Release and Publishing¶
Audience: Maintainer
How a maintainer cuts a release: tag → publish (build, push, sign, attest, and package + push + sign the chart) → verify → record digests. This is the maintainer runbook for producing a release. Operators consuming a release pin the published digests at install time — see tenant-onboarding.md and the chart README.
What a release produces¶
Operators installing a release pin the published digests at install time — see install.md § Pin images by digest.
A release is a vX.Y.Z git tag plus its outputs:
- The four first-party images —
gmc,agc,proxy,worker— pushed to GHCR (ghcr.io/actions-gateway/<name>), each taggedvX.Y.Zand by long commit SHA. Each is multi-arch (linux/amd64+linux/arm64): the pushed artifact is an OCI image index, and the digest recorded everywhere (run summary, release notes, chart pins) is the index digest — the kubelet resolves the per-arch manifest from it at pull time, so one pinned digest schedules on both amd64 and arm64 (e.g. Graviton) nodes. - A keyless cosign signature on every image (sigstore/Fulcio via GitHub Actions OIDC — no signing key, no stored secret), signed recursively — the index and each per-arch manifest — and an SPDX-JSON SBOM per architecture attached as a keyless cosign attestation to that architecture's manifest.
- The Helm chart, packaged and pushed as an OCI artifact to
oci://ghcr.io/actions-gateway/charts/actions-gateway, with itsversionandappVersionset to the release tag and a keyless cosign signature from the same Fulcio/Rekor flow as the images. Operators install it straight from the registry (helm install … oci://…) with the published image digests pinned — nogit cloneof the chart. OCI (over agh-pageschart repo) is chosen so the chart reuses the images' registry, login, and keyless-signing path; Artifact Hub (seeChart.yamlannotations) indexes the OCI ref for discoverability.
Both the image and chart work are automated by the
publish.yml workflow, which triggers on
the v* tag push (the chart-publish job runs after every image leg succeeds).
The maintainer's job is to cut the tag and verify the result.
Signing and chart publish are exercised for the first time on the first real
v*tag. Pull-request CI builds each image and generates its SBOM, but it does not push, sign, attest, or publish the chart (those need a registry push and the publish workflow's OIDC identity). The verification step below is therefore not optional on the first release — it is the only thing that proves the signing and chart-publish paths work.
One-time setup (first release only)¶
- GHCR package visibility. The first publish creates the
ghcr.io/actions-gateway/{gmc,agc,proxy,worker}image packages and theghcr.io/actions-gateway/charts/actions-gatewaychart package. They inherit the repository's visibility and may start private. For third parties to runcosign verify/helm pull(and for an air-gapped operator to pull), set each package to public in the org's GHCR package settings, or keep them private and distribute pull credentials. Verification by this project's CI and by anyone with pull access works either way. - Workflow permissions are already declared in
publish.yml(packages: writeto push,id-token: writefor keyless cosign). No repo secret is required — that is the point of keyless signing.
Release sequence¶
1. Pre-flight¶
mainis green: unit/integration/e2e andsecurity-scan.ymlall passing on the commit you are about to tag. Runmake checklocally as a final gate.- Choose the version
vX.Y.Z(semver). The tag must matchv*orpublish.ymlwill refuse to publish.
2. Tag and push¶
Pushing the tag starts publish.yml. Watch it:
gh run watch "$(gh run list --workflow=publish.yml --branch=vX.Y.Z -L1 --json databaseId -q '.[0].databaseId')"
A
workflow_dispatchrun with ataginput publishes the same way without a git tag — use it to dry-run the pipeline against a throwawayvX.Y.Z-rc1tag.
3. Verify the publish¶
Confirm every image and the chart was signed by this workflow before
announcing the release. The one-command check uses the pinned cosign
(make downloads COSIGN_VERSION — the same version publish.yml signs with —
into .build/):
This verifies the four image signatures plus the chart (whose tag is X.Y.Z,
without the leading v) against the publish workflow's keyless identity. It
needs no credentials once the GHCR packages are public. The equivalent explicit
commands (and SBOM attestation retrieval) live in
security-operations.md § Image provenance;
each is a cosign verify --certificate-identity-regexp '…/publish\.yml@refs/tags/v.*$' --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' <ref>.
A cosign verify failure is a stop-ship: do not announce the release until it
passes. Spot-check one SBOM attestation too so the attestation path is exercised —
SBOM attestations are bound to the per-arch manifest digests, not the index,
so resolve one first (the full command set is in
security-operations.md § Retrieve and inspect the SBOM):
digest="$(docker buildx imagetools inspect ghcr.io/actions-gateway/gmc:vX.Y.Z --raw \
| jq -r '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest')"
cosign verify-attestation --type spdxjson \
--certificate-identity-regexp '^https://github.com/actions-gateway/github-actions-gateway/\.github/workflows/publish\.yml@refs/tags/v.*$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"ghcr.io/actions-gateway/gmc@${digest}" >/dev/null && echo OK
Also spot-check that the index actually carries both platforms
(docker buildx imagetools inspect ghcr.io/actions-gateway/gmc:vX.Y.Z should
list linux/amd64 and linux/arm64 manifests).
4. Record the published digests¶
publish.yml writes each image's immutable ghcr.io/.../<name>@sha256:… ref to
the run summary (the "Record published digest" step). These are the
multi-arch index digests — the single ref that serves both amd64 and arm64
nodes. Copy those four refs — operators pin the workload to the digest, not the
mutable vX.Y.Z tag. You can also resolve a digest directly:
docker buildx imagetools inspect ghcr.io/actions-gateway/gmc:vX.Y.Z \
--format '{{json .Manifest.Digest}}'
5. Cut the GitHub Release¶
Create the GitHub Release for the tag. In the notes, include the four
name@sha256:… digests from step 4 and the cosign verify command from step 3,
so a consumer can verify provenance and pin digests without reading this runbook.
6. Chart version & metadata¶
The chart-publish job sets the published chart's version and appVersion to
the release tag (with the leading v stripped, since chart SemVer forbids it), so
there is no manual Chart.yaml version bump to remember — the in-repo
version/appVersion are dev placeholders the pipeline overrides at package time.
Two things still need a maintainer's eye, in a normal PR (not on the tag):
- Prerelease annotation.
Chart.yamlcarriesartifacthub.io/prerelease.publish.ymldoes not derive this from the tag — it packages the chart fromcharts/actions-gateway/as is, so the committed value is baked into the published chart at tag time and is immutable once tagged. Keep it"true"while cutting0.x/-rctags; it was flipped to"false"for thev1.0.0GA cut (Q98) so Artifact Hub no longer flags the listing as a prerelease. This flip must land in a normal PR before the stable tag is pushed. - Artifact Hub listing. Discoverability metadata (description, keywords,
prerelease flag) ships in the chart's own annotations. Ownership verification
uses
artifacthub-repo.ymlat the repo root — register the OCI repository in the Artifact Hub control panel, copy the assignedrepositoryIDinto that file, and push it to the registry as the repository-metadata OCI artifact (the file's header documents the exact steps). This is a one-time control-panel action, not part ofpublish.yml. - Empty
values.yamldigests. Do not commit realsha256:…digests intovalues.yaml. The emptydigestfields are the secure default: an unconfigured install fails closed (the GMC rejects floating AGC/proxy tags at startup) until the operator pins a real digest at install time. Baking a digest into the shipped chart would defeat that fail-closed posture and immediately go stale. The published digests belong in the release notes (step 5), which is where the operator copies them from.
7. Hand off to operators¶
Operators install/upgrade straight from the published OCI chart with the
digests pinned via --set, exactly as
install.md § Pin images by digest and
upgrade.md document (X.Y.Z is the release tag without the v):
helm install gag oci://ghcr.io/actions-gateway/charts/actions-gateway --version X.Y.Z \
--set gmc.image.digest=sha256:<gmc> \
--set agc.image.digest=sha256:<agc> \
--set proxy.image.digest=sha256:<proxy>
Rollback¶
A release is just a tag and a set of immutable, digest-addressed images — nothing
is destructive. To roll an installed release back, re-pin the previous digests
and helm rollback/helm upgrade; the procedure and post-rollback validation are
in upgrade.md. A bad tag can be superseded by a higher patch
release; do not retag an existing vX.Y.Z (it would break the digest↔tag binding
consumers rely on).
The worker image and DefaultWorkerImage¶
publish.yml builds and signs ghcr.io/actions-gateway/worker (the wrapper that
feeds the job payload into Runner.Worker). Note that the AGC's
DefaultWorkerImage
(provisioner.go) currently
defaults to the upstream ghcr.io/actions/actions-runner image
(digest-pinned, with its runner version locked to the agent.version the AGC
registers — see building.md),
not this signed first-party worker — so a default install does not run the signed
worker unless a tenant sets RunnerGroup.Spec.WorkerImage to it. Signing it is still
correct supply-chain hygiene; whether the default should point at the signed
first-party worker is a separate decision tracked on the backlog.
PR CI vs publish — what runs where¶
| Stage | Build image | Generate SBOM | Push to GHCR | Sign + attest |
|---|---|---|---|---|
Pull request (security-scan.yml) |
✅ | ✅ (artifact) | — | — |
Release tag (publish.yml) |
✅ | ✅ (attached) | ✅ | ✅ keyless |
PR CI proves the image builds and the SBOM generates so those paths can't silently
break; signing and attestation are first exercised on a real v* tag, which is
why step 3 verification matters on every release.
Supply-chain integrity of the pipeline itself¶
The publish job holds packages: write + id-token: write: its ambient OIDC
identity is the release trust root. A hijacked upstream action tag executing in
that job could push and keyless-sign malicious images as the legitimate publish
identity. Three controls keep the pipeline itself trustworthy.
Actions are pinned to full commit SHAs¶
Every uses: across .github/workflows/ is pinned to a full 40-char commit SHA
with a trailing # vX.Y.Z comment for readability — never a floating tag (@v4)
or branch. A tag is mutable: whoever controls the upstream repo can repoint it at
new code, which would then run inside the privileged publish job. A SHA is
immutable. The runtime tool downloads in the publish path are pinned the same way:
cosign via sigstore/cosign-installer with an explicit cosign-release
(kept in step with COSIGN_VERSION in the Makefile so a local make
verify-release uses the same version that signed), and syft via the
syft-version input on anchore/sbom-action/download-syft (the action is
SHA-pinned, but syft itself is a runtime download).
Bumping a pinned action. Dependabot's github-actions ecosystem
(.github/dependabot.yml) opens weekly PRs that
bump both the SHA and the # vX.Y.Z comment, so the pins don't rot — review and
merge those like any dependency PR. To pin or bump by hand, resolve the tag to its
commit SHA and keep the comment in sync:
syft-version is not Dependabot-managed (it's a tool download, not an action
ref) — bump it by hand in publish.yml when you bump the anchore/sbom-action
SHA. actionlint (CI lint job) keeps SHA-pinned uses: lint-clean.
Signing identity is tags-only¶
Releases are cut by pushing a v* tag, so a legitimate keyless signature's Fulcio
certificate records publish.yml running from refs/tags/vX.Y.Z. Two layers
enforce that a signature can only ever be a tag signature:
- publish.yml refuses to run from a non-tag ref. Both publish jobs' "Resolve
publish tag" step rejects any
GITHUB_REFthat isn'trefs/tags/…, so aworkflow_dispatchrun from a branch can't even reach the sign step. make verify-releaseonly accepts a tags identity. The--certificate-identity-regexpis anchored to…/publish\.yml@refs/tags/v.*$(sourced fromrelease_identity_regexpinscripts/lib/common.sh), so a signature minted fromrefs/heads/…is rejected even if one were somehow produced. Thescripts/verify-release-test.shassertions (run bymake checkand CI) guard that the regexp stays tags-only.
Together these close the hole where repo-write could dispatch publish.yml from a
scratch branch, overwrite a released GHCR version tag, and still pass
verification.
Build inputs and the signer binary are integrity-checked¶
The first two controls protect who runs the pipeline and how signatures are trusted; this one protects what goes into the signed artifacts and the tool that verifies them.
- Vendored dependencies are gated against
go.sum. Images build withgo build -mod=vendor, but-mod=vendoronly checksvendor/modules.txtconsistency — it never verifies that the vendored source matches the hashes ingo.sum. A malicious or accidental edit undervendor/(ortools/vendor/) would otherwise compile straight into the signed release images. Thevendor-checkjob (inunit-test.yml, single source of truthmake vendor-check→scripts/vendor-check.sh) re-runs the workspace-vendor flow — which re-fetches every module verified againstgo.sum— and fails on any diff against the committed trees. A Dependabotgo.modbump legitimately fails this gate until a follow-up vendor sync lands; that is the intended signal (see go-workspaces.md § Changing dependencies). - The cosign verify binary is checksummed. GitHub release assets are mutable
for an existing tag, so a raw download of the release verifier can't be trusted
on its own. The publish pipeline obtains cosign via the SHA-pinned
sigstore/cosign-installeraction (which performs its own signature verification); the local verify path (make verify-release→scripts/download-cosign.sh) pins the expected SHA256 per platform in-repo and refuses to install a binary whose bytes don't match. BumpingCOSIGN_VERSIONmust add the new digests to that script (it fails closed on an unpinned version) — the same deliberate-pin discipline asKIND_BINARY_SHA256ine2e-test.yml.