diff --git a/.github/workflows/cd-promote.yml b/.github/workflows/cd-promote.yml new file mode 100644 index 0000000..7c02572 --- /dev/null +++ b/.github/workflows/cd-promote.yml @@ -0,0 +1,77 @@ +name: cd-promote + +# Promote an already-built artifact by digest: point an environment tag at an +# image the registry already holds, and prove the tag resolves to exactly that +# digest afterwards. No checkout of deployed source, no build context, no way +# to introduce new bytes -- see docs/artifact-contract.md. The caller grants +# packages: write, which is the entire privilege surface. + +on: + workflow_call: + inputs: + image: + description: 'Image repository without a tag, e.g. ghcr.io/nddev-opennetwork/example-service.' + type: string + required: true + digest: + description: 'Content digest of the artifact to promote, sha256:<64 hex>.' + type: string + required: true + to_tag: + description: 'Environment tag to point at the digest, e.g. staging or production.' + type: string + required: true + +permissions: {} + +jobs: + promote: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + packages: write + steps: + - name: Validate immutable inputs + env: + IMAGE: ${{ inputs.image }} + DIGEST: ${{ inputs.digest }} + TO_TAG: ${{ inputs.to_tag }} + run: | + set -euo pipefail + [[ "$IMAGE" =~ ^ghcr\.io/[a-z0-9][a-z0-9._/-]*$ ]] + [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "$TO_TAG" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] + + - name: Log in to ghcr.io + env: + GHCR_TOKEN: ${{ github.token }} + GHCR_ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + printf '%s' "$GHCR_TOKEN" | docker login ghcr.io --username "$GHCR_ACTOR" --password-stdin + + - name: Point the tag at the digest + env: + IMAGE: ${{ inputs.image }} + DIGEST: ${{ inputs.digest }} + TO_TAG: ${{ inputs.to_tag }} + run: | + set -euo pipefail + # inspect first: promoting a digest the registry does not hold must + # fail here, as a missing artifact, not inside the tag write. + docker buildx imagetools inspect "${IMAGE}@${DIGEST}" >/dev/null + docker buildx imagetools create --tag "${IMAGE}:${TO_TAG}" "${IMAGE}@${DIGEST}" + + - name: Read the tag back + env: + IMAGE: ${{ inputs.image }} + DIGEST: ${{ inputs.digest }} + TO_TAG: ${{ inputs.to_tag }} + run: | + set -euo pipefail + resolved="$(docker buildx imagetools inspect "${IMAGE}:${TO_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')" + if [ "$resolved" != "$DIGEST" ]; then + echo "tag ${TO_TAG} resolves to ${resolved}, expected ${DIGEST}" >&2 + exit 1 + fi + echo "promoted: ${IMAGE}:${TO_TAG} -> ${DIGEST}" diff --git a/docs/artifact-contract.md b/docs/artifact-contract.md new file mode 100644 index 0000000..dc6c7dc --- /dev/null +++ b/docs/artifact-contract.md @@ -0,0 +1,65 @@ +# The artifact contract: build once, promote by digest + +cd-workflows seals *how* a change is planned, approved and applied. This +contract seals *what* moves: a deployable artifact is an OCI image in GitHub +Container Registry, named by content digest, built exactly once, and promoted +through environments without ever being rebuilt. + +## Why ghcr + +The platform already answers every operational question: + +- **storage and bandwidth** for public and internal container images are + currently free, with a month's notice contractually required before that + changes; +- **access** is scoped by `GITHUB_TOKEN` per repository — a workflow can push + only to its own namespace unless explicitly granted more; +- **provenance** attaches where the bytes live: build provenance and cosign + signatures are ghcr artifacts beside the image. + +## The rules + +1. **Build once, on the fleet.** The image is built by + ci-workflows' `docker-build.yml` (registry layer cache, digest output). + The build's digest — `sha256:…` — is the artifact's identity from that + moment on. +2. **Push by digest, tag as evidence.** Tags are pointers for humans; + the digest is the contract. A tag may move only by re-pointing to an + already-pushed digest, never by rebuilding. +3. **Promote the digest, not the source.** Moving an artifact from test to + staging to production is re-tagging the same digest + (`cd-promote.yml`), so every environment provably receives the same + bytes. A rebuild "for production" is a different artifact and starts the + pipeline over. +4. **Retention cleans what nothing names.** Untagged manifests — superseded + build-cache entries and abandoned builds — are deleted by registry + retention policy; promoted digests are always tagged and therefore kept. + +## cd-promote.yml + +The reusable promotion step: given `image`, `digest` and `to_tag`, it +verifies the digest exists in the registry, points the tag at it with +`docker buildx imagetools create`, and reads the tag back to prove it +resolves to exactly that digest. No checkout of the deployed source, no +build context, no way to introduce new bytes. `packages: write` on the +caller's token is the entire privilege surface. + +```yaml +jobs: + promote: + uses: NDDev-OpenNetwork/cd-workflows/.github/workflows/cd-promote.yml@ + permissions: + packages: write + with: + image: ghcr.io/nddev-opennetwork/example-service + digest: sha256:0123…abcd + to_tag: production +``` + +## First consumer + +The almaty registry build-offload is the natural first consumer: its Stage 1 +already builds on the fleet and pushes ghcr, so its images carry digests +from birth. Adopting the contract means its staging and production tags stop +being rebuild triggers and become `cd-promote.yml` calls on the digest +Stage 1 produced. diff --git a/scripts/validate_module.sh b/scripts/validate_module.sh index 6162378..259374b 100755 --- a/scripts/validate_module.sh +++ b/scripts/validate_module.sh @@ -98,6 +98,36 @@ for name in ("apply", "verify", "resume", "rollback", "evidence", "plan"): if name == "verify" and "runs-on: [self-hosted, cd-verify-out-of-band]" not in content: raise SystemExit("cd-verify workflow is not independent of the managed fleet") +# cd-promote moves an environment tag onto an already-pushed digest and +# nothing else. It must never gain a build surface: no checkout (promotion +# needs no source), no build-push, no free-form runner. The read-back is the +# contract -- a promote that cannot prove the tag resolves to the digest it +# was given is a tag write, not a promotion. +promote = Path(".github/workflows/cd-promote.yml").read_text(encoding="utf-8") +for required in ( + "permissions: {}", + "runs-on: ubuntu-latest", + "packages: write", + 'sha256:[0-9a-f]{64}', + "imagetools inspect", + "imagetools create", + "Read the tag back", +): + if required not in promote: + raise SystemExit(f"cd-promote workflow lacks {required!r}") +for forbidden in ( + "pull_request_target", + "secrets:", + "runs-on: ${{", + "actions/checkout", + "build-push-action", + "context:", +): + if forbidden in promote: + raise SystemExit(f"cd-promote workflow exposes forbidden surface {forbidden!r}") +if promote.index("imagetools inspect") > promote.index("imagetools create"): + raise SystemExit("cd-promote must prove the digest exists before writing the tag") + declared_labels = { line.removeprefix(" - ").strip() for line in Path(".github/actionlint.yaml").read_text(encoding="utf-8").splitlines()