feat(helm): publish the chart to GHCR as a signed OCI artifact #384
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Helm Chart | |
| on: | |
| push: | |
| branches: [main, staging, dev] | |
| paths: | |
| - 'helm/sim/**' | |
| # Repository-level Artifact Hub metadata, republished by the publish job. | |
| - 'helm/artifacthub-repo.yml' | |
| - '.github/workflows/helm.yml' | |
| # The image inventory is generated from the chart and checked here, so a | |
| # change to its generator has to run this workflow too. | |
| - 'scripts/generate-image-manifest.ts' | |
| - 'package.json' | |
| pull_request: | |
| branches: [main, staging, dev] | |
| paths: | |
| - 'helm/sim/**' | |
| # Repository-level Artifact Hub metadata, republished by the publish job. | |
| - 'helm/artifacthub-repo.yml' | |
| - '.github/workflows/helm.yml' | |
| # The image inventory is generated from the chart and checked here, so a | |
| # change to its generator has to run this workflow too. | |
| - 'scripts/generate-image-manifest.ts' | |
| - 'package.json' | |
| concurrency: | |
| group: helm-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| chart: | |
| name: Lint, test, and validate chart | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - name: Set up Helm | |
| uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 | |
| with: | |
| version: v3.16.4 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 | |
| with: | |
| bun-version: 1.4.1 | |
| # Docker Compose and Kubernetes must run the same background jobs on the | |
| # same schedules; this fails the build if the two drift apart. The script | |
| # imports only node builtins, so this job installs no dependencies. | |
| - name: Scheduler parity (docker/crontab vs helm cronjobs) | |
| run: bun run scripts/check-cron-parity.ts | |
| # helm/sim/images.yaml is what an operator mirrors into a disconnected | |
| # registry, so a chart change that adds an image has to update it. Lives | |
| # here rather than in `check:audits` because it renders the chart, and the | |
| # audits job has no Helm. | |
| - name: Image inventory is current | |
| run: bun run images:check | |
| - name: Helm lint | |
| run: helm lint helm/sim --values helm/sim/ci/default-values.yaml | |
| - name: Helm unit tests | |
| run: | | |
| # Official helm-unittest image, pinned by immutable digest (tag 3.17.3-0.8.2). | |
| # Run as the runner's UID so the container can write into the bind | |
| # mount (it creates tests/__snapshot__), with a writable HOME for helm. | |
| docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \ | |
| -v "$PWD/helm/sim:/apps" \ | |
| helmunittest/helm-unittest@sha256:b653db7d5665bc6cec677b15c5eaa1c0377c0de8ac4eb1df58b924478baa21e1 . | |
| - name: Install kubeconform | |
| run: | | |
| curl -sSL -o /tmp/kubeconform.tar.gz \ | |
| https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz | |
| echo "95f14e87aa28c09d5941f11bd024c1d02fdc0303ccaa23f61cef67bc92619d73 /tmp/kubeconform.tar.gz" | sha256sum -c - | |
| tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform | |
| - name: Render and validate manifests (default configuration) | |
| run: | | |
| helm template sim helm/sim --namespace sim \ | |
| --values helm/sim/ci/default-values.yaml \ | |
| | /tmp/kubeconform -strict -summary \ | |
| -kubernetes-version 1.29.0 \ | |
| -schema-location default \ | |
| -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' | |
| - name: Render and validate manifests (all components enabled) | |
| run: | | |
| helm template sim helm/sim --namespace sim \ | |
| --values helm/sim/ci/full-values.yaml \ | |
| | /tmp/kubeconform -strict -summary \ | |
| -kubernetes-version 1.29.0 \ | |
| -schema-location default \ | |
| -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' | |
| - name: Render every example values file | |
| run: | | |
| set -euo pipefail | |
| for f in helm/sim/examples/values-*.yaml; do | |
| echo "--- $f" | |
| # Examples intentionally omit secrets (their headers document the | |
| # required --set flags), so supply the CI dummies alongside each. | |
| helm template sim helm/sim --namespace sim \ | |
| --values "$f" \ | |
| --values helm/sim/ci/default-values.yaml \ | |
| --set copilot.postgresql.auth.password=ci-dummy-password \ | |
| --set copilot.server.env.AGENT_API_DB_ENCRYPTION_KEY=cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici \ | |
| --set copilot.server.env.INTERNAL_API_SECRET=cicicicicicicicicicicicicicicicicicicicicicicicicicicicicicicici \ | |
| --set copilot.server.env.LICENSE_KEY=ci-dummy-license \ | |
| --set copilot.server.env.SIM_BASE_URL=https://ci.example.com \ | |
| --set copilot.server.env.SIM_AGENT_API_KEY=ci-dummy-agent-key \ | |
| --set copilot.server.env.REDIS_URL=redis://ci-redis:6379 \ | |
| --set copilot.server.env.OPENAI_API_KEY_1=ci-dummy-openai-key \ | |
| --set externalDatabase.password=ci-dummy-password > /dev/null | |
| done | |
| version-bump: | |
| name: Chart version bumped | |
| if: github.event_name == 'pull_request' | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Require a Chart.yaml version bump when chart content changes | |
| run: | | |
| set -euo pipefail | |
| base="origin/${{ github.base_ref }}" | |
| git fetch origin "${{ github.base_ref }}" | |
| merge_base=$(git merge-base "$base" HEAD) | |
| changed=$(git diff --name-only "$merge_base" HEAD) | |
| if echo "$changed" | grep -q '^helm/sim/'; then | |
| base_version=$(git show "$merge_base:helm/sim/Chart.yaml" | awk '/^version:/ {print $2}') | |
| head_version=$(awk '/^version:/ {print $2}' helm/sim/Chart.yaml) | |
| echo "base=$base_version head=$head_version" | |
| if [ "$base_version" = "$head_version" ]; then | |
| echo "::error::helm/sim/** changed but Chart.yaml version did not (still $head_version). Bump it per SemVer." | |
| exit 1 | |
| fi | |
| else | |
| echo "No chart changes; skipping." | |
| fi | |
| install: | |
| name: Install on kind and run helm test | |
| needs: chart | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 25 | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - name: Set up Helm | |
| uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 | |
| with: | |
| version: v3.16.4 | |
| - name: Create kind cluster | |
| uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1 | |
| with: | |
| version: v0.24.0 | |
| - name: Install chart | |
| run: | | |
| helm install sim helm/sim \ | |
| --namespace sim --create-namespace \ | |
| --values helm/sim/ci/default-values.yaml \ | |
| --values helm/sim/ci/kind-values.yaml \ | |
| --wait --timeout 15m | |
| - name: Diagnostics on failure | |
| if: failure() | |
| run: | | |
| kubectl -n sim get pods -o wide || true | |
| kubectl -n sim get events --sort-by=.lastTimestamp | tail -40 || true | |
| kubectl -n sim describe pods | tail -100 || true | |
| kubectl -n sim logs deploy/sim-app -c migrations --tail=50 || true | |
| kubectl -n sim logs deploy/sim-app --tail=80 || true | |
| - name: Run helm test | |
| run: helm test sim --namespace sim --timeout 5m | |
| # Publishes the chart to GHCR as an OCI artifact. Self-hosters cannot admit a | |
| # chart pulled from a git checkout — they need an immutable, versioned artifact | |
| # they can pin by digest and mirror into an internal registry — so shipping the | |
| # chart in-repo only is the same as not shipping it. | |
| # | |
| # Lives here rather than in a `publish-*.yml` of its own so it can gate on the | |
| # jobs above: nothing is published unless the chart linted, unit-tested, | |
| # rendered clean under kubeconform, and actually installed on a kind cluster. | |
| # A separate workflow would race those instead of waiting for them. | |
| publish: | |
| name: Publish chart to GHCR | |
| needs: [chart, install] | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'simstudioai/sim' | |
| runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: read | |
| packages: write | |
| # Sigstore signs against the runner's OIDC identity; no key material is stored. | |
| id-token: write | |
| attestations: write | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Helm | |
| uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 | |
| with: | |
| version: v3.16.4 | |
| # oras also reads ~/.docker/config.json, so this one login covers both the | |
| # chart push and the Artifact Hub metadata push below. | |
| - name: Login to GHCR | |
| uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Package chart | |
| id: package | |
| run: | | |
| set -euo pipefail | |
| name=$(helm show chart helm/sim | awk '/^name:/ {print $2}') | |
| version=$(helm show chart helm/sim | awk '/^version:/ {print $2}') | |
| helm package helm/sim --destination dist | |
| echo "name=${name}" >> "$GITHUB_OUTPUT" | |
| echo "version=${version}" >> "$GITHUB_OUTPUT" | |
| echo "path=dist/${name}-${version}.tgz" >> "$GITHUB_OUTPUT" | |
| echo "repository=ghcr.io/${GITHUB_REPOSITORY_OWNER}/charts/${name}" >> "$GITHUB_OUTPUT" | |
| # Chart versions are immutable once published: whoever pinned a version | |
| # must keep resolving the same bytes forever. The PR gate above | |
| # already forces a version bump on every chart change, so a version that | |
| # is already in the registry means this commit changed something outside | |
| # `helm/sim/` — republishing would either fail or silently move a tag. | |
| - name: Skip if this version is already published | |
| id: exists | |
| run: | | |
| set -euo pipefail | |
| if helm show chart "oci://${{ steps.package.outputs.repository }}" \ | |
| --version "${{ steps.package.outputs.version }}" > /dev/null 2>&1; then | |
| echo "already=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::${{ steps.package.outputs.name }} ${{ steps.package.outputs.version }} is already published; skipping." | |
| else | |
| echo "already=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # `helm push` takes the namespace only — it derives the repository | |
| # basename from the chart's name and the tag from its version, so the | |
| # result is ghcr.io/<owner>/charts/sim:<version>. | |
| - name: Push chart | |
| id: push | |
| if: steps.exists.outputs.already == 'false' | |
| run: | | |
| set -euo pipefail | |
| output=$(helm push "${{ steps.package.outputs.path }}" \ | |
| "oci://ghcr.io/${GITHUB_REPOSITORY_OWNER}/charts" 2>&1) | |
| echo "$output" | |
| digest=$(echo "$output" | grep -oE 'sha256:[a-f0-9]{64}' | head -1 || true) | |
| if [ -z "$digest" ]; then | |
| echo "::error::helm push did not report a digest; refusing to sign an unidentified artifact" | |
| exit 1 | |
| fi | |
| echo "digest=${digest}" >> "$GITHUB_OUTPUT" | |
| - name: Install Cosign | |
| if: steps.exists.outputs.already == 'false' | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| # Signed by digest, never by tag: a tag is a mutable pointer, so signing | |
| # one would attest to whatever it happens to reference later. The verify | |
| # is not ceremony — it fails the run if the signature we just wrote cannot | |
| # be read back with the identity we expect, which is the whole point of | |
| # publishing a signature at all. | |
| - name: Sign and verify chart | |
| if: steps.exists.outputs.already == 'false' | |
| run: | | |
| set -euo pipefail | |
| ref="${{ steps.package.outputs.repository }}@${{ steps.push.outputs.digest }}" | |
| cosign sign --yes "$ref" | |
| cosign verify "$ref" \ | |
| --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/" \ | |
| --certificate-oidc-issuer https://token.actions.githubusercontent.com | |
| # Stored alongside the chart so a mirrored registry carries the | |
| # attestation with it, rather than only being retrievable from GitHub. | |
| - name: Attest build provenance | |
| if: steps.exists.outputs.already == 'false' | |
| uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 | |
| with: | |
| subject-name: ${{ steps.package.outputs.repository }} | |
| subject-digest: ${{ steps.push.outputs.digest }} | |
| push-to-registry: true | |
| - name: Set up ORAS | |
| uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 | |
| # Artifact Hub reads repository metadata from the reserved `artifacthub.io` | |
| # tag on the chart's own OCI repository. Pushed on every run, including | |
| # version-skip runs, so an edit to the metadata file alone still lands. | |
| - name: Publish Artifact Hub metadata | |
| run: | | |
| set -euo pipefail | |
| oras push "${{ steps.package.outputs.repository }}:artifacthub.io" \ | |
| --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ | |
| helm/artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml | |
| - name: Summary | |
| run: | | |
| { | |
| if [ "${{ steps.exists.outputs.already }}" = "true" ]; then | |
| echo "### Chart ${{ steps.package.outputs.version }} was already published — nothing to do" | |
| else | |
| echo "### Published chart ${{ steps.package.outputs.version }}" | |
| echo | |
| echo "Digest: \`${{ steps.push.outputs.digest }}\`" | |
| fi | |
| echo | |
| echo '```bash' | |
| echo "helm install sim oci://${{ steps.package.outputs.repository }} --version ${{ steps.package.outputs.version }}" | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" |