Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/cd-promote.yml
Original file line number Diff line number Diff line change
@@ -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}"
65 changes: 65 additions & 0 deletions docs/artifact-contract.md
Original file line number Diff line number Diff line change
@@ -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@<pinned-sha>
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.
30 changes: 30 additions & 0 deletions scripts/validate_module.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down