Skip to content

Release

Release #2

Workflow file for this run

name: Release
on:
# Traditional flow: push a bare tag `vX` (a human/PAT push triggers the gates
# + build below). Do NOT create the release from the GitHub Releases UI — that
# creates a published (immutable) release the build then can't attach to.
push:
tags:
- "v*"
# UI button (Actions → Release → Run workflow). Pick the branch to release and
# optionally a version; the tag is created only AFTER the gates pass, so a
# failing gate never leaves an orphan immutable tag. The tag is pushed with
# GITHUB_TOKEN, which does not re-trigger this workflow (no double run).
workflow_dispatch:
inputs:
version:
description: "Version without leading v (blank = auto CalVer YYYYMMDD.NN)"
required: false
type: string
permissions:
contents: write
jobs:
# Resolve the version, tag and commit to release. On a tag push the tag already
# exists; on dispatch we compute the version and validate the tag is free, but
# DO NOT tag yet (the `tag` job below tags only after the gates pass).
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
sha: ${{ steps.resolve.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: resolve
# Untrusted dispatch input passed via env (never interpolated into the
# script) to avoid shell injection.
env:
EVENT_NAME: ${{ github.event_name }}
VERSION_INPUT: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
VERSION="${VERSION_INPUT#v}"
if [ -z "$VERSION" ]; then
# Auto CalVer: YYYYMMDD.<next build number for today>.
DATE="$(date -u +%Y%m%d)"
N="$(git tag -l "v${DATE}.*" | wc -l | tr -d ' ')"
VERSION="${DATE}.$(printf '%02d' "$((N + 1))")"
fi
# Reject anything that is not a plain version token (defense in depth:
# this value later reaches git/docker command lines).
case "$VERSION" in
*[!0-9A-Za-z.+-]*|"")
echo "::error::invalid version '${VERSION}': use only [0-9A-Za-z.+-]"; exit 1 ;;
esac
TAG="v${VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::tag ${TAG} already exists — releases are immutable, bump the build number"
exit 1
fi
SHA="$(git rev-parse HEAD)"
else
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
SHA="${GITHUB_SHA}"
fi
echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
echo "tag=${TAG}" >>"$GITHUB_OUTPUT"
echo "sha=${SHA}" >>"$GITHUB_OUTPUT"
echo "Releasing ${TAG} (version ${VERSION}) from ${SHA}" >>"$GITHUB_STEP_SUMMARY"
# Hermetic gates — the same task targets CI runs on every push/PR.
# The release is blocked unless these pass.
gate:
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.sha }}
submodules: recursive
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: "20"
- uses: go-task/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: task lint
- run: task spec:compliance
- run: task vuln
- run: task test:race
- run: task coverage
- run: task test:integration
- run: task reference
- run: task parity:contract
- run: task parity:network
# Runtime lane (real Docker containers). ubuntu-latest ships Docker.
gate-runtime:
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.sha }}
submodules: recursive
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: "20"
- uses: go-task/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
# parity:runtime exports build cache (build.cache-to-*, build.output-oci-*),
# which the default docker driver cannot do — its precondition fails ~1s in.
# Reuse the same runner prep as the go-cli.yml runtime job, or a tagged
# release stalls on a default-driver runner.
- run: task ci:prepare-runner
- run: task reference
- run: task test:e2e
- run: task parity:runtime
# OCI publish parity (features/templates publish into a throwaway registry).
# Runs daily in go-cli.yml; gate it on release too so a tag cannot ship a
# regression in the publish path that daily CI would only catch afterwards.
- run: task parity:publish
- if: always()
run: task clean
# Create the tag ONLY on the dispatch path and ONLY after the gates pass, so a
# failing gate never leaves an orphan (immutable) tag. On a tag push this job is
# skipped (the tag already exists). The GITHUB_TOKEN push does not re-trigger the
# workflow, so there is no second run.
tag:
runs-on: ubuntu-latest
needs: [prepare, gate, gate-runtime]
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.sha }}
fetch-depth: 0
- run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "${{ needs.prepare.outputs.tag }}" -m "Release ${{ needs.prepare.outputs.version }}"
git push origin "${{ needs.prepare.outputs.tag }}"
goreleaser:
runs-on: ubuntu-latest
needs: [prepare, gate, gate-runtime, tag]
# `tag` is skipped on the push path, so gate on explicit success/skip rather
# than the default all-succeeded semantics.
if: |
always() &&
needs.gate.result == 'success' &&
needs.gate-runtime.result == 'success' &&
(needs.tag.result == 'success' || needs.tag.result == 'skipped')
# Scoped least-privilege for the publishing job:
# contents: write -> create the draft GitHub Release
# packages: write -> push images to GHCR (ghcr.io/spin-stack/devcontainer-cli)
# id-token: write -> cosign keyless (OIDC) signing of the pushed image
permissions:
contents: write
packages: write
id-token: write
steps:
- uses: actions/checkout@v7
with:
# Build at the tag so GoReleaser runs in release (not snapshot) mode.
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
# syft powers the sboms: block in .goreleaser.yml and the image SBOM below.
- uses: anchore/sbom-action/download-syft@v0
# Multi-arch image build (arm64 emulated) + buildx driver for the
# dockers:/docker_manifests: blocks in .goreleaser.yml.
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
# GHCR login. Only reached on the tag path (this workflow is push.tags),
# never on PRs, so images are pushed exclusively from an approved tag run.
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: sigstore/cosign-installer@v3
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# --- Post-publish: smoke test, sign, and record the image digest. ---
- name: Compute image ref
id: img
run: |
VERSION="${{ needs.prepare.outputs.version }}"
IMAGE="ghcr.io/spin-stack/devcontainer-cli:${VERSION}"
echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
echo "image=${IMAGE}" >>"$GITHUB_OUTPUT"
- name: Smoke test (docker run --version)
run: |
set -euo pipefail
IMAGE="${{ steps.img.outputs.image }}"
EXPECTED="${{ steps.img.outputs.version }}"
GOT="$(docker run --rm "$IMAGE" --version)"
echo "image reported version: $GOT (expected: $EXPECTED)"
test "$GOT" = "$EXPECTED"
- name: Record image digest
id: digest
run: |
set -euo pipefail
IMAGE="${{ steps.img.outputs.image }}"
DIGEST="$(docker buildx imagetools inspect "$IMAGE" --format '{{ json .Manifest.Digest }}' | tr -d '\"')"
echo "digest=${DIGEST}" >>"$GITHUB_OUTPUT"
{
echo "## Published image"
echo ""
echo "- \`${IMAGE}\`"
echo "- digest: \`${DIGEST}\`"
} >>"$GITHUB_STEP_SUMMARY"
docker buildx imagetools inspect "$IMAGE"
# Move the mutable :latest tag to this release's digest. Best-effort: a
# `latest` tag is incompatible with GHCR immutable tags, so if the registry
# rejects the update the versioned release still succeeds.
- name: Update :latest (best-effort)
continue-on-error: true
run: |
set -euo pipefail
docker buildx imagetools create \
-t ghcr.io/spin-stack/devcontainer-cli:latest \
"ghcr.io/spin-stack/devcontainer-cli@${{ steps.digest.outputs.digest }}"
# Image-level SBOM + keyless provenance signature against the immutable
# digest (not the mutable tag). This is the cosign/syft path chosen over
# inline buildx --provenance/--sbom, which would break docker_manifests.
- name: Sign image and attest SBOM (cosign keyless)
run: |
set -euo pipefail
REF="ghcr.io/spin-stack/devcontainer-cli@${{ steps.digest.outputs.digest }}"
cosign sign --yes "$REF"
syft "$REF" -o spdx-json=image.sbom.spdx.json
cosign attest --yes --predicate image.sbom.spdx.json --type spdxjson "$REF"
# Non-gating: perf/distribution metrics recorded per release, never blocks.
metrics:
runs-on: ubuntu-latest
needs: [prepare, goreleaser]
continue-on-error: true
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
submodules: recursive
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: "20"
- uses: go-task/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: task build:cross
- run: task reference
- run: task metrics
- uses: actions/upload-artifact@v7
if: always()
with:
name: metrics
path: artifacts/metrics.json
if-no-files-found: warn