GitHub Actions
Three workflows per app, each with one job of its kind. The template's .github/workflows/{pr,main,release}.yaml are the source of truth; they're reproduced with notes in references/workflows.md.
pull request ──► pr.yaml checks only, nothing published
merge to main ─► main.yaml image built once → ghcr.io/nuvraxis/<app>:sha-<short>, :main
tag vX.Y.Z ────► release.yaml same image retagged :vX.Y.Z, :latest · chart X.Y.Z → OCI · GitHub Release
Rules for every workflow
-
permissions: contents: readat the top; widen per job only as needed.packages: writefor jobs that push to GHCR,contents: writeonly for the job that creates the Release. Noid-token: writeorattestations: write: provenance and SBOM come from BuildKit, notactions/attest*(which needs GitHub Enterprise for private repos). -
GITHUB_TOKENonly. GHCR login uses${{ github.actor }}/${{ secrets.GITHUB_TOKEN }};ghusesGH_TOKEN: ${{ github.token }}. No personal access tokens in CI. (Pulling private packages from the cluster is a different credential; seehelm-nextjs.) -
Pin action majors (
@v7) and keep the SHA table current. The template uses major tags for readability. If the org requires SHA pinning, replace each@vNwith the SHA from references/action-pins.md and keep the# vN.M.Pcomment. Current majors: checkout v7, setup-node v7, pnpm/action-setup v6, setup-qemu v4, setup-buildx v4, login v4, metadata v6, build-push v7, azure/setup-helm v5. -
Toolchain:
pnpm/action-setupbeforeactions/setup-node. action-setup reads the pnpm version frompackageManager; setup-node reads.node-versionand caches the pnpm store (cache: pnpmneeds pnpm on PATH first). Thenpnpm install --frozen-lockfile. Nevernpm ci. -
concurrencyper workflow and ref; cancel in progress on PRs only. A new push to a PR cancels the old run. On main and tags runs always finish: release.yaml needs the image of that exact commit. -
Never echo secrets or interpolate untrusted input into
run:. Pass values throughenv:and quote them ("${APP}").${{ github.event.pull_request.title }}and similar never go inside a script.persist-credentials: falseon checkout when the job doesn't push to git. -
Every job has
timeout-minutes. A hung Docker build otherwise burns six hours. -
Names are lowercase. GHCR rejects uppercase repositories.
IMAGE: ghcr.io/nuvraxis/<app>is written lowercase inenv; the scaffold lowercases the app name.
pr.yaml
-
on: pull_requestwithtypes: [opened, synchronize, reopened, ready_for_review]and nobranchesfilter, so stacked PRs against feature branches are checked too. Every job hasif: ${{ !github.event.pull_request.draft }}; marking ready triggers the run. -
Four parallel jobs:
quality(biome ci --reporter=github, stylelint,turbo run typecheck test --affected,pnpm audit --audit-level high),build(turbo run build --affected),docker(build one platform withload: true, run it read-only with capabilities dropped, curl/api/health),helm(helm lint --strictand kubeconform on both values files).--affectedneedsfetch-depth: 0. -
Nothing is pushed from a PR.
push: false, cache read-only (cache-fromonly), no registry login.
main.yaml
-
on: push: branches: [main]builds the image once, for both architectures, and pushes it. Tags fromdocker/metadata-action:type=sha,format=short,prefix=sha-andtype=raw,value=main,flavor: latest=false.latestbelongs to releases. -
provenance: mode=max,sbom: true,cache-from/to: type=gha,scope=<app>,mode=max. Provenance and SBOM are attached to the image index; release.yaml carries them along when it retags.NEXT_DEPLOYMENT_ID=${{ github.sha }}is the build arg for skew protection. -
main.yaml doesn't publish the chart and doesn't re-run PR checks. Branch protection requires pr.yaml; main only produces the artefact.
release.yaml
-
on: push: tags: ['v*.*.*'], then in order:- Fail unless the tag is exactly
vMAJOR.MINOR.PATCHand its commit is an ancestor oforigin/main. - Don't rebuild. If
ghcr.io/nuvraxis/<app>:sha-<short>doesn't exist, fail with "let main.yaml finish, then re-run". No fallback build. docker buildx imagetools create --tag :vX.Y.Z --tag :latest <sha-image>: a manifest-level copy that keeps both architectures, provenance and SBOM, at the same digest.helm package --version X.Y.Z --app-version vX.Y.Z,helm pushtooci://ghcr.io/nuvraxis/charts.gh release create vX.Y.Z --verify-tag --generate-noteswith the chart.tgz,image-digest.txtandsbom.spdx.jsonattached.
- Fail unless the tag is exactly
-
The release ritual: merge to main → wait for main.yaml to go green →
git tag vX.Y.Z && git push origin vX.Y.Z. Tagging before main.yaml finishes fails step 2 on purpose; re-run the release job once the image exists. A bad release is fixed with a new patch tag, never by moving a tag.
Speeding things up (only when needed)
- arm64 under QEMU is the slow part of main.yaml. Replace with a matrix on
ubuntu-24.04-arm+ubuntu-latestpushing by digest, then a merge job withimagetools create. Keep the tags and the single index. - Turborepo remote cache for pr.yaml: see
pnpm-turborepo.
Verify
docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest # lint all workflows
gh workflow list && gh run list --limit 5
gh run view <run-id> --log-failed
After a release: docker buildx imagetools inspect ghcr.io/nuvraxis/<app>:vX.Y.Z shows both platforms, and helm show chart oci://ghcr.io/nuvraxis/charts/<app> --version X.Y.Z shows appVersion: vX.Y.Z.
Sources
- https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
- https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
- https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token
- https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry
- https://github.com/pnpm/action-setup
- https://github.com/actions/setup-node
- https://github.com/docker/metadata-action
- https://github.com/docker/build-push-action
- https://docs.docker.com/build/ci/github-actions/cache/#github-cache
- https://docs.docker.com/build/metadata/attestations/
- https://docs.docker.com/reference/cli/docker/buildx/imagetools/create/
- https://helm.sh/docs/topics/registries/
- https://cli.github.com/manual/gh_release_create
- https://turborepo.dev/docs/reference/run#--affected