aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-08-21 20:18:56 +0100
committerNikolay Govorov <me@govorov.online>2026-08-21 20:18:56 +0100
commit8bc35fe8be889c50db2d1fb4425cc1b4097dc6b8 (patch)
tree3282b45fc35556109fcf99f953e42862fc6e49a6
parent4a4589fdb968686a7c91b380ff369aba23bd8662 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Reusable tasks for building containers and helm charts
Diffstat
-rw-r--r--.github/workflows/ci.yml10+7 −3
-rw-r--r--README.md27+27 −0
-rwxr-xr-xtasks/chart111+111 −0
-rwxr-xr-xtasks/container173+173 −0
-rwxr-xr-xtests/oci-tasks87+87 −0
5 files changed, 405 insertions, 3 deletions
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
--- /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
--- /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
--- /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'