From 8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Fri, 21 Aug 2026 20:18:56 +0100 Subject: Reusable tasks for building containers and helm charts --- .github/workflows/ci.yml | 10 ++- README.md | 27 ++++++ tasks/chart | 111 +++++++++++++++++++++++++ tasks/container | 173 +++++++++++++++++++++++++++++++++++++++ tests/oci-tasks | 87 ++++++++++++++++++++ 5 files changed, 405 insertions(+), 3 deletions(-) create mode 100755 tasks/chart create mode 100755 tasks/container create mode 100755 tests/oci-tasks diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc0c63e..5835033 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,10 +33,14 @@ jobs: ruff check tasks/_*.py tasks/package tasks/publish tests/fakes/boto3.py ruff format --check tasks/_*.py tasks/package tasks/publish tests/fakes/boto3.py - - name: Check package contract + - name: Check shell + run: sh -n tasks/chart tasks/container tests/oci-tasks + + - name: Tests run: | + tests/oci-tasks + tests/package tests/package-integration - - name: Check publish contract - run: tests/publish-integration + tests/publish-integration diff --git a/README.md b/README.md index 1bffdef..59ac4c4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,33 @@ mise run package -- \ tar.gz zip ``` +## OCI artifacts + +The shared [`container`](tasks/container) task builds one or more tagged OCI +images with Docker Buildx. Registry authentication is deliberately left to the +calling workflow, so the same build can be pushed to GHCR, Cloudflare, or +another OCI registry. The [`chart`](tasks/chart) task strictly lints a Helm +chart, packages an immutable version, and can push it to one or more OCI +repositories. + +```console +mise run container -- \ + --context . --file deploy/Dockerfile \ + --platform linux/amd64,linux/arm64 \ + --target site --build-arg APP=site \ + --tag ghcr.io/example/site:1.2.3 \ + --cache-scope site --push + +mise run chart -- \ + --chart charts/service --version 1.2.3 --app-version 1.2.3 \ + --output dist/charts --push oci://ghcr.io/example/charts +``` + +Container tags, chart versions, credentials, and release policy remain owned by +the consuming project. `--provenance false --sbom false` is available for +registries that do not accept OCI attestation indexes. Without `--push` or +`--load`, Buildx only validates and caches the build result. + ## Package repositories Projects publish beneath a service-owned prefix at diff --git a/tasks/chart b/tasks/chart new file mode 100755 index 0000000..f334209 --- /dev/null +++ b/tasks/chart @@ -0,0 +1,111 @@ +#!/bin/sh -eu +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: 0BSD +#MISE description="Lint, package, and optionally publish a Helm chart" +#MISE tools={"helm"="4.1.1"} + +usage() { + cat <<'EOF' +Usage: mise run chart -- --chart DIR [options] + +Options: + --version VERSION Immutable chart version (required for packaging) + --app-version VERSION Application version written to Chart.yaml + --output DIR Package directory (default: dist/charts) + --push OCI_URL Push package to an OCI registry; repeatable + --lint-only Strictly lint without packaging +EOF +} + +chart= +version= +app_version= +output=dist/charts +push_urls= +lint_only=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --chart) + [ "$#" -ge 2 ] || { echo "chart: --chart requires a value" >&2; exit 2; } + chart=$2 + shift 2 + ;; + --version) + [ "$#" -ge 2 ] || { echo "chart: --version requires a value" >&2; exit 2; } + version=$2 + shift 2 + ;; + --app-version) + [ "$#" -ge 2 ] || { echo "chart: --app-version requires a value" >&2; exit 2; } + app_version=$2 + shift 2 + ;; + --output) + [ "$#" -ge 2 ] || { echo "chart: --output requires a value" >&2; exit 2; } + output=$2 + shift 2 + ;; + --push) + [ "$#" -ge 2 ] || { echo "chart: --push requires a value" >&2; exit 2; } + case $2 in + oci://*) ;; + *) echo "chart: registry must use oci://: $2" >&2; exit 2 ;; + esac + push_urls="${push_urls}${push_urls:+ +}$2" + shift 2 + ;; + --lint-only) + lint_only=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "chart: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +command -v helm >/dev/null 2>&1 || { echo "chart: helm is not installed" >&2; exit 1; } +[ -n "$chart" ] || { echo "chart: --chart is required" >&2; exit 2; } +[ -d "$chart" ] || { echo "chart: directory not found: $chart" >&2; exit 1; } +[ -f "$chart/Chart.yaml" ] || { echo "chart: Chart.yaml not found in $chart" >&2; exit 1; } + +name=$(sed -n 's/^name:[[:space:]]*\([A-Za-z0-9_.-][A-Za-z0-9_.-]*\)[[:space:]]*$/\1/p' "$chart/Chart.yaml" | head -n 1) +[ -n "$name" ] || { echo "chart: cannot read chart name from $chart/Chart.yaml" >&2; exit 1; } + +helm lint "$chart" --strict + +if [ "$lint_only" = true ]; then + if [ -n "$version" ] || [ -n "$app_version" ] || [ -n "$push_urls" ]; then + echo "chart: --lint-only cannot package or push a chart" >&2 + exit 2 + fi + exit 0 +fi + +[ -n "$version" ] || { echo "chart: --version is required unless --lint-only is used" >&2; exit 2; } +mkdir -p "$output" + +set -- helm package "$chart" --destination "$output" --version "$version" +if [ -n "$app_version" ]; then + set -- "$@" --app-version "$app_version" +fi +"$@" + +package=$output/$name-$version.tgz +[ -f "$package" ] || { echo "chart: Helm did not create expected package: $package" >&2; exit 1; } + +old_ifs=$IFS +IFS=' +' +for registry in $push_urls; do + helm push "$package" "${registry%/}" +done +IFS=$old_ifs diff --git a/tasks/container b/tasks/container new file mode 100755 index 0000000..b41a313 --- /dev/null +++ b/tasks/container @@ -0,0 +1,173 @@ +#!/bin/sh -eu +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: 0BSD +#MISE description="Build and optionally publish an OCI container image" + +usage() { + cat <<'EOF' +Usage: mise run container -- --tag IMAGE [options] + +Options: + --context DIR Build context (default: .) + --file FILE Dockerfile relative to the context (default: Dockerfile) + --platform PLATFORMS Target platforms (default: linux/amd64) + --target TARGET Dockerfile build target + --build-arg VALUE Build argument; repeatable + --label VALUE OCI image label; repeatable + --tag IMAGE Image tag; repeatable and required + --cache-scope SCOPE GitHub Actions cache scope + --provenance BOOL BuildKit provenance (default: true) + --sbom BOOL BuildKit SBOM (default: true) + --push Push the resulting image + --load Load a single-platform image locally +EOF +} + +context=. +file=Dockerfile +platform=linux/amd64 +target= +build_args= +labels= +tags= +cache_scope= +provenance=true +sbom=true +push=false +load=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --context) + [ "$#" -ge 2 ] || { echo "container: --context requires a value" >&2; exit 2; } + context=$2 + shift 2 + ;; + --file) + [ "$#" -ge 2 ] || { echo "container: --file requires a value" >&2; exit 2; } + file=$2 + shift 2 + ;; + --platform) + [ "$#" -ge 2 ] || { echo "container: --platform requires a value" >&2; exit 2; } + platform=$2 + shift 2 + ;; + --target) + [ "$#" -ge 2 ] || { echo "container: --target requires a value" >&2; exit 2; } + target=$2 + shift 2 + ;; + --build-arg) + [ "$#" -ge 2 ] || { echo "container: --build-arg requires a value" >&2; exit 2; } + case $2 in *' +'*) echo "container: build arguments cannot contain newlines" >&2; exit 2 ;; esac + build_args="${build_args}${build_args:+ +}$2" + shift 2 + ;; + --label) + [ "$#" -ge 2 ] || { echo "container: --label requires a value" >&2; exit 2; } + case $2 in *' +'*) echo "container: labels cannot contain newlines" >&2; exit 2 ;; esac + labels="${labels}${labels:+ +}$2" + shift 2 + ;; + --tag) + [ "$#" -ge 2 ] || { echo "container: --tag requires a value" >&2; exit 2; } + case $2 in ''|*[[:space:]]*) echo "container: invalid image tag: $2" >&2; exit 2 ;; esac + tags="${tags}${tags:+ +}$2" + shift 2 + ;; + --cache-scope) + [ "$#" -ge 2 ] || { echo "container: --cache-scope requires a value" >&2; exit 2; } + cache_scope=$2 + shift 2 + ;; + --provenance) + [ "$#" -ge 2 ] || { echo "container: --provenance requires a value" >&2; exit 2; } + case $2 in true|false) provenance=$2 ;; *) echo "container: --provenance must be true or false" >&2; exit 2 ;; esac + shift 2 + ;; + --sbom) + [ "$#" -ge 2 ] || { echo "container: --sbom requires a value" >&2; exit 2; } + case $2 in true|false) sbom=$2 ;; *) echo "container: --sbom must be true or false" >&2; exit 2 ;; esac + shift 2 + ;; + --push) + push=true + shift + ;; + --load) + load=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "container: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +command -v docker >/dev/null 2>&1 || { echo "container: docker is not installed" >&2; exit 1; } +[ -d "$context" ] || { echo "container: context directory not found: $context" >&2; exit 1; } +[ -n "$tags" ] || { echo "container: at least one --tag is required" >&2; exit 2; } + +case $file in + /*) dockerfile=$file ;; + *) dockerfile=$context/$file ;; +esac +[ -f "$dockerfile" ] || { echo "container: Dockerfile not found: $dockerfile" >&2; exit 1; } + +if [ "$push" = true ] && [ "$load" = true ]; then + echo "container: --push and --load are mutually exclusive" >&2 + exit 2 +fi +case $platform:$load in + *,*:true) echo "container: --load supports exactly one platform" >&2; exit 2 ;; +esac + +set -- docker buildx build \ + --file "$dockerfile" \ + --platform "$platform" \ + "--provenance=$provenance" \ + "--sbom=$sbom" + +if [ -n "$target" ]; then + set -- "$@" --target "$target" +fi + +old_ifs=$IFS +IFS=' +' +for value in $build_args; do + set -- "$@" --build-arg "$value" +done +for value in $labels; do + set -- "$@" --label "$value" +done +for value in $tags; do + set -- "$@" --tag "$value" +done +IFS=$old_ifs + +if [ -n "$cache_scope" ]; then + set -- "$@" \ + --cache-from "type=gha,scope=$cache_scope" \ + --cache-to "type=gha,mode=max,scope=$cache_scope" +fi +if [ "$push" = true ]; then + set -- "$@" --push +elif [ "$load" = true ]; then + set -- "$@" --load +fi +set -- "$@" "$context" + +exec "$@" diff --git a/tests/oci-tasks b/tests/oci-tasks new file mode 100755 index 0000000..1bc54f2 --- /dev/null +++ b/tests/oci-tasks @@ -0,0 +1,87 @@ +#!/bin/sh -eu +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: 0BSD + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/project/chart/templates" +: > "$work/project/Dockerfile" +cat > "$work/project/chart/Chart.yaml" <<'EOF' +apiVersion: v2 +name: fixture +version: 0.0.0 +EOF +cat > "$work/project/chart/values.yaml" <<'EOF' +image: fixture +EOF +cat > "$work/project/chart/templates/configmap.yaml" <<'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: + name: fixture +EOF + +cat > "$work/bin/docker" <<'EOF' +#!/bin/sh +printf '%s\n' "$@" > "$OCI_TEST_DOCKER_LOG" +EOF +cat > "$work/bin/helm" <<'EOF' +#!/bin/sh +printf '%s\n' "$@" >> "$OCI_TEST_HELM_LOG" +if [ "$1" = package ]; then + destination= + version= + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --destination) destination=$2; shift 2 ;; + --version) version=$2; shift 2 ;; + *) shift ;; + esac + done + : > "$destination/fixture-$version.tgz" +fi +EOF +chmod +x "$work/bin/docker" "$work/bin/helm" +export PATH="$work/bin:$PATH" +export OCI_TEST_DOCKER_LOG="$work/docker.log" +export OCI_TEST_HELM_LOG="$work/helm.log" + +"$root/tasks/container" \ + --context "$work/project" \ + --platform linux/amd64 \ + --target site \ + --build-arg APP=site \ + --build-arg 'TITLE=hello world' \ + --label 'org.example.title=Example site' \ + --tag ghcr.io/example/site:sha-abc \ + --tag ghcr.io/example/site:latest \ + --cache-scope site \ + --provenance false \ + --sbom false \ + --push + +grep -qx -- 'buildx' "$work/docker.log" +grep -qx -- '--target' "$work/docker.log" +grep -qx -- 'site' "$work/docker.log" +grep -qx -- 'TITLE=hello world' "$work/docker.log" +grep -qx -- 'org.example.title=Example site' "$work/docker.log" +grep -qx -- 'ghcr.io/example/site:sha-abc' "$work/docker.log" +grep -qx -- 'ghcr.io/example/site:latest' "$work/docker.log" +grep -qx -- 'type=gha,mode=max,scope=site' "$work/docker.log" +grep -qx -- '--push' "$work/docker.log" + +"$root/tasks/chart" \ + --chart "$work/project/chart" \ + --version 1.2.3 \ + --app-version sha-abc \ + --output "$work/output" \ + --push oci://ghcr.io/example/charts + +grep -qx -- 'lint' "$work/helm.log" +grep -qx -- 'package' "$work/helm.log" +grep -qx -- 'push' "$work/helm.log" +grep -qx -- 'oci://ghcr.io/example/charts' "$work/helm.log" + +echo 'oci tasks: ok' -- Gilti