Skip to content

Qualify native releases from the tag and build promotion evidence - #1150

Merged
rmcdaniel merged 1 commit into
mainfrom
feat/server-1123-tag-qualification
Nov 12, 2026
Merged

rmcdaniel merged 1 commit into
mainfrom
feat/server-1123-tag-qualification

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Closes #1123 once the next native release is promoted this way.

New flow

A release is: tag, native-release.yml, both qualifiers dispatched from the tag, a mechanically built evidence manifest, promotion, GitHub Release. The short checklist is in docs/native-release-promotion.md under "Release checklist":

  1. Create the tag and dispatch native-release.yml --ref "$tag".
  2. Dispatch native-image-parity.yml and published-compose.yml with --ref "$tag" and the published index digest.
  3. python3 -I scripts/ci/native_alias_manifest.py --tag … --release-run … --corpus-run … --compose-run …
  4. Dispatch native-alias-promotion.yml --ref "$tag" with that manifest.
  5. Dispatch native-github-release.yml --ref main with the same manifest.

You no longer need a subject commit, the feat/server-native32-qualification-runner branch, a merge to main or a hand-written evidence file.

Design

  • Subject derivation. admit now runs only for a workflow_dispatch from a final tag (refs/tags/<X.Y.Z>, workflow ref at that tag, canonical ghcr.io/durable-workflow/server@sha256: input). It derives the release subject from GITHUB_REF_NAME, GITHUB_SHA and the requested index. It also requires GHCR's exact version tag to name that index, which replaces the old "image equals the committed subject's index" check. scripts/ci/native-qualification-subject.json keeps only the pinned MySQL and QEMU images (schema native-qualification-subject.v2), so no release needs to edit it. A final-tag dispatch must admit. Other dispatches and PRs still produce diagnostic receipts.
  • Receipts. Producers always emit the full native-qualification-receipt.v2. Its runner_ref is now refs/tags/<tag> and runner_commit is the release source. Its subject_blob is the Git blob of the dependency file at the tag. The weaker v1 receipt path (no dependency, scope or local-image binding) is gone.
  • Manifest. The verifier accepts only the closed native-alias-promotion-evidence.v1 shape again, the one the runbook already described: no runner block and both runner_commits equal to the release source. The runner and the dependency subject are derived from the release (release_subject). The subject is read at the release commit through the contents API, its blob hash is checked, and it is read again before verify returns.
  • Builder. scripts/ci/native_alias_manifest.py reads the publication run and both qualifier runs, which must all be dispatched from the tag at one source commit. It takes the index from the trusted qualifier run titles, which must agree. It reads the current-attempt job IDs with the verifier's own jobs() and required-step set. It selects each receipt artifact by exact name and refuses zero or several matches. Its output goes through manifest(). It only selects. Promotion still proves everything.
  • Removed. RUNNER_REF, RUNNER_PATHS, the v2 manifest, runner_selection / runner_subject (frozen-delta compare and runner-on-main reads) and the transport's two compare shapes those reads used. Only <sha>...main is still allowed. I dropped the runner path rather than keeping both because it only made sense when tag workflows could not qualify. Promotion runs from the tag's own code, so 3.5.0 and earlier keep their v2 verifier. The only main-side consumer is native-github-release.yml, and every release up to 3.5.0 already has its GitHub Release.

Preserved checks

Promotion and Release creation still verify all of the following:

  • Closed manifest: final tag, positive IDs and attempts, distinct runs and artifacts, the exact 2×2 Compose cell set, runner_commit equal to the release source, and workflow blobs read at that commit.
  • Each run's repository and head-repository IDs, numeric workflow identity and path, workflow_dispatch, head SHA, head branch equal to the tag, exact current attempt, and completed success. Titles bind the canonical image input.
  • Every attempt's jobs are enumerated, newer attempts refuse, and every critical step must succeed. That includes "Admit published qualification subject" and, for arm64, "Set up QEMU".
  • Publication run, release jobs and steps, tag resolution, protected-main ancestry, exact version tags in both registries, and provenance for the exact publication attempt. Qualifiers must start after publication completes.
  • Artifacts: exact IDs, names, provider digests, not expired, owner run and head SHA, creation inside the attempt window. The archive is bounded with one canonical receipt.json, and the SHA-256 is checked before parsing.
  • Receipts: repository, run, attempt and job, runner commit and workflow blob, requested and index digest, child manifest, config and actual config, architecture and OS, source and release labels. Also runner ref and subject blob, owned resource scope, local image identity, each MySQL and QEMU image, observations and verified cleanup.
  • Run, subject and artifact metadata are read again before verify returns. The no-prior-Release check still runs before alias writes.

The workflows are unchanged except for one comment line: same permissions, same pinned actions, and no new secrets or inputs. I updated the check-docker-workflow-isolation.py reviewed-byte hashes for the three changed modules.

Tests

All of these ran in a bwrap sandbox with no network, a cleared env, no /run, /var or /home, and a tripwire over /usr/bin/docker. The tripwire was never hit.

  • python3 -m unittest discover -s tests/Unit/Support -p native_qualification_test.py: 43 OK
  • python3 -m unittest discover -s tests/Unit/Support -p 'native_alias_*test.py': 28 OK (8 new builder tests)
  • python3 -m unittest discover -s tests/Unit/Support -p docker_workflow_isolation_policy_test.py: 84 OK
  • python3 scripts/ci/check-docker-workflow-isolation.py: passed
  • bash scripts/ci/check-native-release-tags.sh: passed

New tests cover admission from the tag, exact-tag index mismatch, runner-branch, candidate, fork and push contexts, a local and committed subject mismatch, legacy v1 subject, receipt and v2-manifest refusals, a subject change during verification, the exact-tag transport read, and builder selection and refusals.

Live read-only check (gh api only, no Docker): on the real 3.5.0 runs the builder refuses with selection_corpus_run, because those qualifiers ran from the runner branch. With only that branch identity rewritten to the tag, it selected exactly the five artifact IDs that promotion run 43117894863 verified, in the same order.

Review questions

  • The v2 path bound the release run ID and attempt into the committed subject. Now the qualifier binds tag, source and index, checked against GHCR's exact tag at admission. The run ID and attempt are bound only at promotion, through the publication run, provenance for that attempt and "qualifiers start after publication". Is that acceptable?
  • A workflow_dispatch of either qualifier from a final tag now fails if the image input is missing or not a canonical digest, where it used to produce a diagnostic run.
  • The first real proof is the next release. If a tag's qualifier workflow turns out broken, there is no runner-branch fallback. A fix would need a new tag.

🤖 Generated with Claude Code

Qualifiers now admit only when dispatched from the final release tag.
The release part of the qualification subject is derived from that tag,
its commit and the requested index, which must be the index GHCR's exact
version tag names. The subject file keeps only the pinned MySQL and QEMU
images, so a release no longer needs a hand-edited commit, the
feat/server-native32-qualification-runner branch or a merge to main.

The verifier accepts only the v1 manifest shape again, where both
qualifier runner commits equal the release source, while still requiring
the full v2 receipt: tag runner ref, subject blob read at the release
commit, owned resource scope, local image identity and every dependency
image. The runner-branch v2 manifest, its frozen-delta and ancestry reads
and the transport's compare shapes for them are removed.

scripts/ci/native_alias_manifest.py builds the manifest from the release,
corpus and Compose run IDs by reading the provider API. It selects each
receipt artifact by exact name and refuses ambiguity. Promotion and
GitHub Release creation verify it as before.

Refs #1123.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Owner review (server): the design is accepted.

  • Qualification now binds the tag dispatch: repository, workflow ref at refs/tags/<tag>, final tag, GITHUB_SHA. The requested image must be the index that GHCR's exact version tag names.
  • The release run ID and attempt are bound at promotion through the publication run, provenance for that exact attempt, and qualifiers starting after publication. That replaces the committed subject's run binding, so open question 1 is acceptable.
  • Dropping the runner-branch path is fine: promotion runs the tag's own code, and every release through 3.5.0 already has its GitHub Release.
  • Questions 2 and 3 (strict tag dispatch, a fix needs a new tag) are acceptable. Question 4 (copying the index from the release summary) is fine for now.

Requesting a Security review because this changes promotion verification. It merges only after that and green CI. The first real end-to-end use will be 3.6.0.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security review: PASS (head 4dad60d7). Merge after green CI.

Deriving the release subject from the tag makes the tag the trust anchor. I checked it holds end to end:

  • Tags are immutable and restricted: the ruleset Protect published tags blocks update and deletion on all tags with no bypass, and Restrict published tag creation limits creation to one bypass actor. A release tag can't be moved after qualification.
  • The tagged commit must be on protected main: native-release.yml runs verify-native-release-dispatch.sh, which requires github.ref == refs/tags/<tag> and compare/<release_commit>...main to show main contains the commit. That closes the risk that tag-ref qualifiers carry workflow code main never accepted.
  • Qualifiers are bound to that release: admission requires a workflow_dispatch with GITHUB_REF=refs/tags/<tag>, GITHUB_WORKFLOW_REF at the tag, a canonical ghcr.io/durable-workflow/server@sha256: input, and GHCR's exact tag naming that index. The verifier requires both qualifier runner_commits to equal the release commit, so main ancestry carries over. Receipts are always the full v2 form; the weaker v1 receipt path is removed.
  • Promotion stays gated: a no-write verify job runs first, and promote (packages: write, protected dockerhub-native environment) depends on it and only runs from the tag. Promotion acts on the manifest's digest, not a mutable tag.

Optional: have native_alias_manifest.py also print the compare status for the release commit into the manifest's provenance, so the evidence file records main ancestry itself.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 11, 2026 23:40
@rmcdaniel
rmcdaniel enabled auto-merge November 11, 2026 23:41
@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit aa176ae Nov 12, 2026
69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Native release: dispatch qualifiers from the tag and let them emit their own promotion evidence

1 participant