From 7ab355f072b46b191986c0477b70d41d300f4c1a Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 21 Aug 2026 21:16:14 +0800 Subject: [PATCH] feat: make prebuilt image updates metadata-first --- .github/workflows/docker-sandboxes-images.yml | 215 +++++++++++++++--- .../workflow_contract_test.go | 98 +++++++- docs/development/docker-sandboxes-prebuilt.md | 10 +- 3 files changed, 288 insertions(+), 35 deletions(-) diff --git a/.github/workflows/docker-sandboxes-images.yml b/.github/workflows/docker-sandboxes-images.yml index a85253f..c532a98 100644 --- a/.github/workflows/docker-sandboxes-images.yml +++ b/.github/workflows/docker-sandboxes-images.yml @@ -2,8 +2,8 @@ name: Docker Sandboxes prebuilt images on: schedule: - - cron: '37 */6 * * *' - - cron: '7 1,7,13,19 * * *' + - cron: '37 23 */7 * *' + - cron: '57 23 */7 * *' push: branches: - main @@ -137,6 +137,7 @@ jobs: runner_amd64_digest: ${{ steps.runner.outputs.amd64_digest }} runner_arm64_url: ${{ steps.runner.outputs.arm64_url }} runner_arm64_digest: ${{ steps.runner.outputs.arm64_digest }} + catalog_manifest_digest: ${{ steps.catalog.outputs.catalog_manifest_digest }} noop: ${{ steps.noop.outputs.noop }} steps: - name: Check out source @@ -171,8 +172,8 @@ jobs: profile="${DISPATCH_PROFILE:-act}" if [[ "$GITHUB_EVENT_NAME" == schedule ]]; then case "$SCHEDULE_EXPRESSION" in - '37 */6 * * *') profile=act ;; - '7 1,7,13,19 * * *') profile=full ;; + '37 23 */7 * *') profile=full ;; + '57 23 */7 * *') profile=act ;; *) echo "unsupported schedule expression: $SCHEDULE_EXPRESSION" >&2; exit 1 ;; esac fi @@ -276,6 +277,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Fetch current signed catalog state or initialize an empty ledger + id: catalog shell: bash env: PROFILE: ${{ steps.select.outputs.profile }} @@ -294,6 +296,7 @@ jobs: if catalog_digest="$(oras resolve "$catalog_reference" 2>"$catalog_error")"; then [[ "$catalog_digest" =~ ^sha256:[0-9a-f]{64}$ ]] go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --profile act --reference "${PACKAGE_REPOSITORY}@${catalog_digest}" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push --output "$catalog_dir/catalog-state.json" + echo "catalog_manifest_digest=$catalog_digest" >> "$GITHUB_OUTPUT" else if ! grep -Eqi 'manifest unknown|not found|404' "$catalog_error"; then cat "$catalog_error" >&2 @@ -313,6 +316,7 @@ jobs: "transitions": [] } JSON + echo 'catalog_manifest_digest=' >> "$GITHUB_OUTPUT" fi jq -e . "$catalog_dir/catalog-state.json" >/dev/null @@ -321,9 +325,26 @@ jobs: shell: bash env: PROFILE: ${{ steps.select.outputs.profile }} + EXPECTED_CATALOG_MANIFEST: ${{ steps.catalog.outputs.catalog_manifest_digest }} run: | set -euo pipefail catalog="$RUNNER_TEMP/epar-catalog/catalog-state.json" + expected_catalog_manifest="${EXPECTED_CATALOG_MANIFEST:-}" + current_catalog_manifest='' + catalog_cas_error="$RUNNER_TEMP/epar-catalog/catalog-reconcile-cas.error" + if current_catalog_manifest="$(oras resolve "${PACKAGE_REPOSITORY}:catalog-v1" 2>"$catalog_cas_error")"; then + [[ "$current_catalog_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] + else + if ! grep -Eqi 'manifest unknown|not found|404' "$catalog_cas_error"; then + cat "$catalog_cas_error" >&2 + exit 1 + fi + current_catalog_manifest='' + fi + [[ "$current_catalog_manifest" == "$expected_catalog_manifest" ]] || { + echo "catalog-v1 changed while preparing alias reconciliation (expected ${expected_catalog_manifest:-missing}, observed ${current_catalog_manifest:-missing})" >&2 + exit 1 + } alias_ref="${PACKAGE_REPOSITORY}:${PROFILE}-latest" observed='' alias_error="$RUNNER_TEMP/epar-catalog/alias-reconcile.error" @@ -338,6 +359,21 @@ jobs: if [[ "$(jq -r '.needsRepair' "$plan")" == true ]]; then target="$(jq -er '.targetDigest' "$plan")" [[ "$(oras resolve "${PACKAGE_REPOSITORY}@${target}")" == "$target" ]] + alias_cas_error="$RUNNER_TEMP/epar-catalog/alias-reconcile-cas.error" + current_alias_digest='' + if current_alias_digest="$(oras resolve "$alias_ref" 2>"$alias_cas_error")"; then + [[ "$current_alias_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + else + if ! grep -Eqi 'manifest unknown|not found|404' "$alias_cas_error"; then + cat "$alias_cas_error" >&2 + exit 1 + fi + current_alias_digest='' + fi + [[ "$current_alias_digest" == "$observed" ]] || { + echo "${PROFILE}-latest changed while preparing alias reconciliation (expected ${observed:-missing}, observed ${current_alias_digest:-missing})" >&2 + exit 1 + } oras tag "${PACKAGE_REPOSITORY}@${target}" "${PROFILE}-latest" [[ "$(oras resolve "$alias_ref")" == "$target" ]] echo "Recovered interrupted ${PROFILE} alias promotion to ${target}." >> "$GITHUB_STEP_SUMMARY" @@ -362,18 +398,75 @@ jobs: run: | set -euo pipefail catalog="$RUNNER_TEMP/epar-catalog/catalog-state.json" + matching_entries="$RUNNER_TEMP/epar-catalog/matching-entries.json" + matching_entry="$RUNNER_TEMP/epar-catalog/matching-entry.json" noop=false if [[ "$FORCE_CANDIDATE" != true ]]; then - # A retained candidate is also a no-op: rebuilding it can produce a - # different index digest and make the immutable catalog reject the - # same complete tuple. Only revoked/critical entries are ignored. - jq -e --arg profile "$PROFILE" --arg source "$SOURCE_INDEX_DIGEST" \ + # Only a complete candidate or active entry can suppress a build. + # Superseded entries must not suppress a build because their package + # is no longer the catalog's current accepted result. + jq -c --arg profile "$PROFILE" --arg source "$SOURCE_INDEX_DIGEST" \ --arg sourceAmd64 "$SOURCE_AMD64_DIGEST" --arg sourceArm64 "$SOURCE_ARM64_DIGEST" \ --arg recipe "$RECIPE_DIGEST" --arg revision "$RECIPE_REVISION" --arg sourceLock "$SOURCE_LOCK_DIGEST" --arg tool "$TOOL_DIGEST" \ --arg runtime "$RUNTIME_CONTRACT" --arg schema "$TEMPLATE_SCHEMA" --arg runner "$RUNNER_VERSION" \ --arg amd64 "$RUNNER_AMD64_DIGEST" --arg arm64 "$RUNNER_ARM64_DIGEST" \ - '.entries[] as $entry | (([.transitions[]? | select(.packageIndexDigest == $entry.packageIndexDigest) | .toStatus] | last) // $entry.status) as $effectiveStatus | select(($effectiveStatus == "candidate" or $effectiveStatus == "active" or $effectiveStatus == "superseded") and $entry.profile == $profile and $entry.source.indexDigest == $source and $entry.source.platformDigests["linux/amd64"] == $sourceAmd64 and $entry.source.platformDigests["linux/arm64"] == $sourceArm64 and $entry.recipe.digest == $recipe and $entry.recipe.recipeRevision == $revision and $entry.recipe.sourceLockDigest == $sourceLock and $entry.recipe.toolDigest == $tool and $entry.recipe.runtimeContract == $runtime and ($entry.recipe.templateSchema | tostring) == $schema and $entry.runner.version == $runner and $entry.runner.assetDigests["linux/amd64"] == $amd64 and $entry.runner.assetDigests["linux/arm64"] == $arm64)' \ - "$catalog" >/dev/null && noop=true || true + '. as $catalog | [ + $catalog.entries[] as $entry + | (([$catalog.transitions[]? | select(.packageIndexDigest == $entry.packageIndexDigest) | .toStatus] | last) // $entry.status) as $effectiveStatus + | select( + ($effectiveStatus == "candidate" or $effectiveStatus == "active") + and $entry.profile == $profile + and $entry.source.indexDigest == $source + and $entry.source.platformDigests["linux/amd64"] == $sourceAmd64 + and $entry.source.platformDigests["linux/arm64"] == $sourceArm64 + and $entry.recipe.digest == $recipe + and $entry.recipe.recipeRevision == $revision + and $entry.recipe.sourceLockDigest == $sourceLock + and $entry.recipe.toolDigest == $tool + and $entry.recipe.runtimeContract == $runtime + and ($entry.recipe.templateSchema | tostring) == $schema + and $entry.runner.version == $runner + and $entry.runner.assetDigests["linux/amd64"] == $amd64 + and $entry.runner.assetDigests["linux/arm64"] == $arm64 + and $entry.gates.sourceResolved == true + and $entry.gates.sourceRechecked == true + and $entry.gates.buildSucceeded == true + and $entry.gates.platformsValidated == true + and $entry.gates.provenanceGenerated == true + and $entry.gates.sbomGenerated == true + and $entry.gates.attestationVerified == true + and ($effectiveStatus != "active" or $catalog.aliases[$profile].packageIndexDigest == $entry.packageIndexDigest) + ) + | $entry + ]' "$catalog" > "$matching_entries" + + match_count="$(jq 'length' "$matching_entries")" + case "$match_count" in + 0) + echo 'No complete matching catalog entry; the hosted build will proceed.' + ;; + 1) + jq -e '.[0]' "$matching_entries" > "$matching_entry" + package_reference="$(jq -er '.packageReference' "$matching_entry")" + [[ "$package_reference" == "${PACKAGE_REPOSITORY}@sha256:"* ]] + # Verify the immutable package and its signed evidence before + # suppressing work. This is metadata-only and does not pull + # image layers. A failed verification is fail-closed. + go run ./cmd/epar-prebuilt-publisher verify-package \ + --reference "$package_reference" \ + --entry "$matching_entry" \ + --repository "$PACKAGE_REPOSITORY" \ + --ref refs/heads/main \ + --allowed-events schedule,workflow_dispatch,push \ + > "$RUNNER_TEMP/epar-catalog/package-verification.json" + noop=true + echo "Verified immutable package metadata for $package_reference; skipping hosted builds." + ;; + *) + echo "Catalog contains $match_count complete matching entries; refusing an ambiguous no-op." >&2 + exit 1 + ;; + esac fi echo "noop=$noop" >> "$GITHUB_OUTPUT" @@ -1118,6 +1211,7 @@ jobs: PACKAGE_REF: ${{ needs.publish.outputs.package_ref }} SOURCE_RECHECKED: ${{ needs.publish.outputs.source_rechecked }} ALLOW_ALIAS: ${{ github.ref == 'refs/heads/main' }} + EXPECTED_CATALOG_MANIFEST: ${{ needs.resolve.outputs.catalog_manifest_digest }} run: | set -euo pipefail immutable_catalog_ref="${PACKAGE_REPOSITORY}:catalog-v1-pkg-${CATALOG_DIGEST#sha256:}" @@ -1135,6 +1229,11 @@ jobs: fi old_catalog_digest='' fi + expected_catalog_manifest="${EXPECTED_CATALOG_MANIFEST:-}" + if [[ "$old_catalog_digest" != "$expected_catalog_manifest" ]]; then + echo "catalog-v1 moved since resolve (expected ${expected_catalog_manifest:-missing}, observed ${old_catalog_digest:-missing}); refusing to publish stale catalog state" >&2 + exit 1 + fi plan_action="$(jq -r '.action' "$PLAN")" move_alias=false alias_ref="${PACKAGE_REPOSITORY}:${PROFILE}-latest" @@ -1162,26 +1261,36 @@ jobs: trap - EXIT set +e if [[ "$alias_moved" == true ]]; then - if [[ -n "$old_alias_digest" ]]; then + actual_alias_digest="$(oras resolve "$alias_ref" 2>/dev/null || true)" + if [[ "$actual_alias_digest" == "${PACKAGE_REF##*@}" && -n "$old_alias_digest" ]]; then oras tag "${PACKAGE_REPOSITORY}@${old_alias_digest}" "${PROFILE}-latest" >/dev/null [[ "$(oras resolve "$alias_ref")" == "$old_alias_digest" ]] || echo 'alias rollback readback failed' >&2 - else + elif [[ "$actual_alias_digest" == "${PACKAGE_REF##*@}" ]]; then echo 'alias rollback cannot remove a first-publication alias; catalog rollback remains authoritative' >&2 + else + echo 'alias rollback skipped because the alias changed after this publication' >&2 fi fi - if [[ "$catalog_moved" == true && -n "$old_catalog_digest" ]]; then - oras tag "${PACKAGE_REPOSITORY}@${old_catalog_digest}" catalog-v1 >/dev/null - [[ "$(oras resolve "$catalog_moving")" == "$old_catalog_digest" ]] || echo 'catalog rollback readback failed' >&2 - elif [[ "$catalog_moved" == true ]]; then - echo 'catalog rollback cannot remove a first-publication catalog pointer; it remains signed with no trusted alias' >&2 + if [[ "$catalog_moved" == true ]]; then + actual_catalog_digest="$(oras resolve "$catalog_moving" 2>/dev/null || true)" + if [[ "$actual_catalog_digest" == "$CATALOG_MANIFEST" && -n "$old_catalog_digest" ]]; then + oras tag "${PACKAGE_REPOSITORY}@${old_catalog_digest}" catalog-v1 >/dev/null + [[ "$(oras resolve "$catalog_moving")" == "$old_catalog_digest" ]] || echo 'catalog rollback readback failed' >&2 + elif [[ "$actual_catalog_digest" == "$CATALOG_MANIFEST" ]]; then + echo 'catalog rollback cannot remove a first-publication catalog pointer; it remains signed with no trusted alias' >&2 + else + echo 'catalog rollback skipped because the catalog pointer changed after this publication' >&2 + fi fi exit "$rc" } - if [[ "$move_alias" == true ]]; then + if [[ "$ALLOW_ALIAS" == true ]]; then trap rollback_pointers EXIT oras tag "${PACKAGE_REPOSITORY}:catalog-v1-pkg-${CATALOG_DIGEST#sha256:}" catalog-v1 catalog_moved=true [[ "$(oras resolve "$catalog_moving")" == "$CATALOG_MANIFEST" ]] + fi + if [[ "$move_alias" == true ]]; then alias_cas_error="$RUNNER_TEMP/epar-promotion/alias-cas-resolve.error" if actual_alias_digest="$(oras resolve "$alias_ref" 2>"$alias_cas_error")"; then [[ -z "$expected_alias" || "$actual_alias_digest" == "$expected_alias" ]] @@ -1197,6 +1306,10 @@ jobs: [[ "$(oras resolve "$alias_ref")" == "${PACKAGE_REF##*@}" ]] trap - EXIT echo 'Signed catalog was moved before the authorized source-only profile alias; pointer rollback was armed until both readbacks passed.' >> "$GITHUB_STEP_SUMMARY" + elif [[ "$ALLOW_ALIAS" == true ]]; then + trap - EXIT + echo "Signed immutable candidate catalog: ${immutable_catalog_ref}" >> "$GITHUB_STEP_SUMMARY" + echo 'catalog-v1 was moved; the profile alias was not moved; protected EPAR acceptance remains required.' >> "$GITHUB_STEP_SUMMARY" else echo "Signed immutable candidate catalog: ${immutable_catalog_ref}" >> "$GITHUB_STEP_SUMMARY" echo 'catalog-v1 and the profile alias were not moved; protected EPAR acceptance remains required.' >> "$GITHUB_STEP_SUMMARY" @@ -1211,6 +1324,8 @@ jobs: attestations: read runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + catalog_manifest: ${{ steps.review.outputs.catalog_manifest }} steps: - name: Check out source uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 @@ -1227,6 +1342,7 @@ jobs: version: 1.3.3 - name: Verify candidate identities and prepare the reviewer checklist + id: review shell: bash env: PROFILE: ${{ inputs.profile }} @@ -1288,6 +1404,23 @@ jobs: package_arm64="$(jq -er '.platforms[] | select(.platform == "linux/arm64") | .packageManifestDigest' "$entry")" source_amd64="$(jq -er '.source.platformDigests["linux/amd64"]' "$entry")" source_arm64="$(jq -er '.source.platformDigests["linux/arm64"]' "$entry")" + catalog_moving="${PACKAGE_REPOSITORY}:catalog-v1" + catalog_pointer_error="$RUNNER_TEMP/promotion-review-catalog-pointer.error" + expected_catalog_manifest='' + if expected_catalog_manifest="$(oras resolve "$catalog_moving" 2>"$catalog_pointer_error")"; then + [[ "$expected_catalog_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] + else + if ! grep -Eqi 'manifest unknown|not found|404' "$catalog_pointer_error"; then + cat "$catalog_pointer_error" >&2 + exit 1 + fi + expected_catalog_manifest='' + fi + [[ -n "$expected_catalog_manifest" && "$candidate_catalog_manifest" == "$expected_catalog_manifest" ]] || { + echo "candidate catalog ${candidate_catalog_manifest} is not the current catalog-v1 head (${expected_catalog_manifest:-missing}); refusing to promote a stale ledger snapshot" >&2 + exit 1 + } + echo "catalog_manifest=$expected_catalog_manifest" >> "$GITHUB_OUTPUT" { echo '## EPAR prebuilt promotion review' echo @@ -1300,6 +1433,7 @@ jobs: echo "| Package index | \`${PACKAGE_REPOSITORY}@${CANDIDATE_DIGEST}\` |" echo "| Candidate catalog | \`${CANDIDATE_CATALOG_REFERENCE}\` |" echo "| Catalog manifest | \`${candidate_catalog_manifest}\` |" + echo "| Current catalog-v1 manifest at review | \`${expected_catalog_manifest:-missing}\` |" echo "| Upstream source | \`${source_reference}\` |" echo "| Upstream index | \`${source_digest}\` |" echo "| Recipe | \`${recipe_digest}\` at \`${recipe_revision}\` |" @@ -1518,6 +1652,7 @@ jobs: PACKAGE_REPOSITORY: ${{ env.PACKAGE_REPOSITORY }} CANDIDATE_DIGEST: ${{ inputs.candidate_digest }} PLAN: ${{ steps.verify.outputs.plan }} + EXPECTED_CATALOG_MANIFEST: ${{ needs.prepare-promotion-review.outputs.catalog_manifest }} run: | set -euo pipefail [[ "$(oras resolve "$IMMUTABLE_REF")" == "$CATALOG_MANIFEST" ]] @@ -1534,6 +1669,11 @@ jobs: fi old_catalog_digest='' fi + expected_catalog_manifest="${EXPECTED_CATALOG_MANIFEST:-}" + if [[ "$old_catalog_digest" != "$expected_catalog_manifest" ]]; then + echo "catalog-v1 changed after reviewer preparation (expected ${expected_catalog_manifest:-missing}, observed ${old_catalog_digest:-missing}); refusing stale manual promotion" >&2 + exit 1 + fi alias_tag="${PROFILE}-latest" alias_ref="${PACKAGE_REPOSITORY}:${alias_tag}" expected_alias="$(jq -r '.expectedAliasDigest // empty' "$PLAN")" @@ -1557,22 +1697,45 @@ jobs: trap - EXIT set +e if [[ "$alias_moved" == true ]]; then - if [[ -n "$old_alias_digest" ]]; then + actual_alias_digest="$(oras resolve "$alias_ref" 2>/dev/null || true)" + if [[ "$actual_alias_digest" == "$CANDIDATE_DIGEST" && -n "$old_alias_digest" ]]; then oras tag "${PACKAGE_REPOSITORY}@${old_alias_digest}" "$alias_tag" >/dev/null [[ "$(oras resolve "$alias_ref")" == "$old_alias_digest" ]] || echo 'manual alias rollback readback failed' >&2 - else + elif [[ "$actual_alias_digest" == "$CANDIDATE_DIGEST" ]]; then echo 'manual alias rollback cannot remove a first-publication alias; catalog rollback remains authoritative' >&2 + else + echo 'manual alias rollback skipped because the alias changed after this promotion' >&2 fi fi - if [[ "$catalog_moved" == true && -n "$old_catalog_digest" ]]; then - oras tag "${PACKAGE_REPOSITORY}@${old_catalog_digest}" catalog-v1 >/dev/null - [[ "$(oras resolve "$catalog_moving")" == "$old_catalog_digest" ]] || echo 'manual catalog rollback readback failed' >&2 - elif [[ "$catalog_moved" == true ]]; then - echo 'manual catalog rollback cannot remove a first-publication catalog pointer; it remains signed with no trusted alias' >&2 + if [[ "$catalog_moved" == true ]]; then + actual_catalog_digest="$(oras resolve "$catalog_moving" 2>/dev/null || true)" + if [[ "$actual_catalog_digest" == "$CATALOG_MANIFEST" && -n "$old_catalog_digest" ]]; then + oras tag "${PACKAGE_REPOSITORY}@${old_catalog_digest}" catalog-v1 >/dev/null + [[ "$(oras resolve "$catalog_moving")" == "$old_catalog_digest" ]] || echo 'manual catalog rollback readback failed' >&2 + elif [[ "$actual_catalog_digest" == "$CATALOG_MANIFEST" ]]; then + echo 'manual catalog rollback cannot remove a first-publication catalog pointer; it remains signed with no trusted alias' >&2 + else + echo 'manual catalog rollback skipped because the catalog pointer changed after this promotion' >&2 + fi fi exit "$rc" } trap rollback_manual_pointers EXIT + manual_catalog_cas_error="$RUNNER_TEMP/manual-catalog-cas-resolve.error" + current_catalog_digest='' + if current_catalog_digest="$(oras resolve "$catalog_moving" 2>"$manual_catalog_cas_error")"; then + [[ "$current_catalog_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + else + if ! grep -Eqi 'manifest unknown|not found|404' "$manual_catalog_cas_error"; then + cat "$manual_catalog_cas_error" >&2 + exit 1 + fi + current_catalog_digest='' + fi + [[ "$current_catalog_digest" == "$old_catalog_digest" ]] || { + echo "catalog-v1 changed while preparing manual promotion (expected ${old_catalog_digest:-missing}, observed ${current_catalog_digest:-missing})" >&2 + exit 1 + } oras tag "$IMMUTABLE_REF" catalog-v1 catalog_moved=true [[ "$(oras resolve "$catalog_moving")" == "$CATALOG_MANIFEST" ]] @@ -1602,4 +1765,4 @@ jobs: shell: bash run: | set -euo pipefail - echo 'Upstream source, recipe, runner, and locked-tool tuple is unchanged; no package or catalog mutation was attempted.' >> "$GITHUB_STEP_SUMMARY" + echo 'Upstream source, recipe, runner, and locked-tool tuple is unchanged; the matching immutable package and signed evidence were verified from the catalog, so no hosted build or package mutation was attempted.' >> "$GITHUB_STEP_SUMMARY" diff --git a/cmd/epar-prebuilt-publisher/workflow_contract_test.go b/cmd/epar-prebuilt-publisher/workflow_contract_test.go index eafe37d..3346cf3 100644 --- a/cmd/epar-prebuilt-publisher/workflow_contract_test.go +++ b/cmd/epar-prebuilt-publisher/workflow_contract_test.go @@ -23,6 +23,94 @@ func TestWorkflowRepairsCatalogFirstPromotionBeforeNoop(t *testing.T) { } } +func TestWorkflowVerifiesMatchingPackageBeforeNoop(t *testing.T) { + workflow := strings.ReplaceAll(readPublisherWorkflow(t), "\r\n", "\n") + noop := strings.Index(workflow, " - name: Determine whether the immutable tuple is already active\n") + build := strings.Index(workflow, "\n build:\n") + if noop < 0 || build <= noop { + t.Fatalf("cannot isolate metadata-first no-op step: noop=%d build=%d", noop, build) + } + noopStep := workflow[noop:build] + for _, required := range []string{ + `matching_entries="$RUNNER_TEMP/epar-catalog/matching-entries.json"`, + `matching_entry="$RUNNER_TEMP/epar-catalog/matching-entry.json"`, + `($effectiveStatus == "candidate" or $effectiveStatus == "active")`, + `$entry.gates.sourceRechecked == true`, + `$entry.gates.attestationVerified == true`, + `go run ./cmd/epar-prebuilt-publisher verify-package`, + `--reference "$package_reference"`, + `> "$RUNNER_TEMP/epar-catalog/package-verification.json"`, + `Catalog contains $match_count complete matching entries; refusing an ambiguous no-op.`, + } { + if !strings.Contains(noopStep, required) { + t.Fatalf("metadata-first no-op contract is missing %q", required) + } + } + if strings.Contains(noopStep, `$effectiveStatus == "superseded"`) { + t.Fatal("superseded catalog entries must not suppress a rebuild") + } + verify := strings.Index(noopStep, `go run ./cmd/epar-prebuilt-publisher verify-package`) + noOpAssignment := strings.Index(noopStep, "noop=true") + if verify < 0 || noOpAssignment < 0 || verify >= noOpAssignment { + t.Fatalf("immutable package verification must precede noop=true: verify=%d noop=%d", verify, noOpAssignment) + } +} + +func TestWorkflowGuardsCatalogPointerAndPublishesCandidateLedgerOnMain(t *testing.T) { + workflow := strings.ReplaceAll(readPublisherWorkflow(t), "\r\n", "\n") + promote := strings.Index(workflow, " - name: Verify signed catalog and move only authorized aliases\n") + manual := strings.Index(workflow, "\n prepare-promotion-review:\n") + if promote < 0 || manual <= promote { + t.Fatalf("cannot isolate automatic catalog publication step: promote=%d manual=%d", promote, manual) + } + promoteStep := workflow[promote:manual] + for _, required := range []string{ + `EXPECTED_CATALOG_MANIFEST: ${{ needs.resolve.outputs.catalog_manifest_digest }}`, + `old_catalog_digest" != "$expected_catalog_manifest"`, + `if [[ "$ALLOW_ALIAS" == true ]]; then`, + `catalog-v1 was moved; the profile alias was not moved`, + `catalog rollback skipped because the catalog pointer changed after this publication`, + } { + if !strings.Contains(promoteStep, required) { + t.Fatalf("catalog publication safety contract is missing %q", required) + } + } + reconcile := strings.Index(workflow, " - name: Reconcile an interrupted catalog-first alias promotion\n") + noop := strings.Index(workflow, " - name: Determine whether the immutable tuple is already active\n") + if reconcile < 0 || noop <= reconcile || !strings.Contains(workflow[reconcile:noop], `current_alias_digest" == "$observed"`) { + t.Fatal("alias reconciliation must compare the observed alias head again before repair") + } +} + +func TestWorkflowCarriesManualCatalogHeadThroughProtectedPromotion(t *testing.T) { + workflow := strings.ReplaceAll(readPublisherWorkflow(t), "\r\n", "\n") + prepare := strings.Index(workflow, " prepare-promotion-review:\n") + manual := strings.Index(workflow, "\n manual-promote:\n") + if prepare < 0 || manual <= prepare { + t.Fatalf("cannot isolate protected promotion preparation: prepare=%d manual=%d", prepare, manual) + } + prepareJob := workflow[prepare:manual] + manualJob := workflow[manual:] + for _, required := range []string{ + `catalog_manifest: ${{ steps.review.outputs.catalog_manifest }}`, + `echo "catalog_manifest=$expected_catalog_manifest" >> "$GITHUB_OUTPUT"`, + `Current catalog-v1 manifest at review`, + } { + if !strings.Contains(prepareJob, required) { + t.Fatalf("protected review must record the catalog head: missing %q", required) + } + } + for _, required := range []string{ + `EXPECTED_CATALOG_MANIFEST: ${{ needs.prepare-promotion-review.outputs.catalog_manifest }}`, + `catalog-v1 changed after reviewer preparation`, + `[[ "$current_catalog_digest" == "$old_catalog_digest" ]]`, + } { + if !strings.Contains(manualJob, required) { + t.Fatalf("protected promotion must guard the reviewed catalog head: missing %q", required) + } + } +} + func TestWorkflowForceCandidatePreservesVerifiedEvidence(t *testing.T) { workflow := readPublisherWorkflow(t) for _, required := range []string{ @@ -62,7 +150,7 @@ func TestWorkflowUsesHostedBuildsAndExternalEPARAcceptance(t *testing.T) { `runnerName:$amd64DockerHubRunner`, `runnerName:$arm64PlaywrightRunner`, `runnerName:$arm64DockerHubRunner`, - `catalog-v1 and the profile alias were not moved`, + `catalog-v1 was moved; the profile alias was not moved`, } { if !strings.Contains(workflow, required) { t.Fatalf("publisher candidate acceptance contract is missing %q", required) @@ -217,10 +305,10 @@ func TestWorkflowPreparesReviewSummaryBeforeProtectedPromotion(t *testing.T) { func TestWorkflowBuildsAndPromotesFullWithoutPersistentNativeRunners(t *testing.T) { workflow := readPublisherWorkflow(t) for _, required := range []string{ - `- cron: '37 */6 * * *'`, - `- cron: '7 1,7,13,19 * * *'`, - `'37 */6 * * *') profile=act`, - `'7 1,7,13,19 * * *') profile=full`, + `- cron: '37 23 */7 * *'`, + `- cron: '57 23 */7 * *'`, + `'37 23 */7 * *') profile=full`, + `'57 23 */7 * *') profile=act`, `act|full) ;;`, `if: needs.resolve.outputs.profile == 'full'`, `Full publication requires at least 40 GiB free`, diff --git a/docs/development/docker-sandboxes-prebuilt.md b/docs/development/docker-sandboxes-prebuilt.md index de7a835..417a82d 100644 --- a/docs/development/docker-sandboxes-prebuilt.md +++ b/docs/development/docker-sandboxes-prebuilt.md @@ -4,11 +4,13 @@ EPAR publishes its Docker Sandboxes template as an immutable multi-platform OCI The canonical source is `ghcr.io/catthehacker/ubuntu`. The public package is `ghcr.io/solutionforest/ephemeral-action-runner/docker-sandboxes-template`. Docker Hub is never used as a source fallback because its OCI identities may differ from GHCR even when the logical image content matches. -Act (`act-latest`) is the first accepted profile. Full (`full-latest`) uses the same publication, verification, and runtime contracts but remains candidate-only until its independent amd64 and arm64 acceptance cycle completes; its first protected promotion enables the Full stable policy atomically with the alias move. +Act (`act-latest`) and Full (`full-latest`) use the same publication, verification, and runtime contracts. The signed catalog records candidates before acceptance; runtime resolution follows only an active profile alias. ## Workflow triggers and hosted build gates -`.github/workflows/docker-sandboxes-images.yml` polls Act every six hours at minute 37 and Full on an offset six-hour schedule at minute 7, supports manual dispatch, and publishes for recipe-related pushes only on `main`. GitHub executes both cron expressions only from the default branch. Pull requests to `develop` or `main` that change a publisher, recipe, or committed-asset path run publisher, signed-evidence, and asset validation without logging in to GHCR, building a package, or pushing any manifest. This prevents a normal `develop` to `main` promotion from publishing the same source change twice. +`.github/workflows/docker-sandboxes-images.yml` checks the Catthehacker day-of-month cadence at 23:37 UTC for Full and 23:57 UTC for Act, approximately 6–12 hours after the upstream 12:00 UTC schedule, supports manual dispatch, and publishes for recipe-related pushes only on `main`. The `*/7` day-of-month expression mirrors the upstream calendar pattern rather than one fixed weekday. GitHub executes both cron expressions only from the default branch and may delay scheduled starts under load. Pull requests to `develop` or `main` that change a publisher, recipe, or committed-asset path run publisher, signed-evidence, and asset validation without logging in to GHCR, building a package, or pushing any manifest. This prevents a normal `develop` to `main` promotion from publishing the same source change twice. + +Before allocating hosted build runners, the resolve job reads the signed moving catalog and compares the complete source, recipe, runtime, schema, runner, and locked-tool tuple. A candidate or active entry must also have all hosted build and signed-evidence gates set, and an active entry must still be the selected profile alias. When exactly one entry matches, the job runs `verify-package` against that entry's immutable package reference; this checks registry metadata, signed referrers, and claims without pulling image layers. Only a successful verification produces a no-op. No match starts a hosted build, an ambiguous match fails closed, and a package or evidence verification failure never silently falls back to rebuilding. The amd64 build runs on GitHub-hosted `ubuntu-latest`; the arm64 build runs on GitHub-hosted `ubuntu-24.04-arm`. The workflow has no persistent self-hosted runner dependency and no `EPAR_PREBUILT_LIVE` switch. Full jobs first reclaim disposable hosted-runner tool caches, require at least 40 GiB free before allocating 8 GiB of swap, serialize BuildKit execution, and allow a three-hour timeout; failing that capacity gate leaves Full unpublished rather than silently changing its recipe or dropping a platform. Hosted jobs resolve the GHCR source descriptor, build from its immutable digest, inspect both runnable platform manifests by digest, assemble an index from those exact digests, require exactly two descriptors, run package smoke checks, generate and sign one index-level SLSA provenance statement and one index-level SPDX SBOM, verify their referrers, and publish an immutable signed candidate catalog. Platform builds disable BuildKit's additional per-platform SBOM/provenance indexes because EPAR's trust decision uses the separately signed index-level evidence and catalog. The index-level SPDX document records the package, recipe, and exact platform identities rather than reproducing BuildKit's more detailed component inventory; this deliberate narrower audit scope avoids four additional per-publication GHCR version rows without weakening the evidence enforced by EPAR. @@ -23,7 +25,7 @@ Every new package is first recorded as `candidate`. Before manual acceptance, pu - signed SLSA provenance and SPDX SBOM referrers; - an immutable catalog object and canonical tag such as `catalog-v1-pkg-<64 hex catalog digest>`. -Candidate publication does not move `catalog-v1` or the profile's `*-latest` alias. This permits first-catalog bootstrap: a candidate can be acquired through its exact signed immutable catalog even when no moving catalog exists yet. +On trusted `main`, candidate publication moves the signed `catalog-v1` ledger pointer but does not move the profile's `*-latest` alias. This makes a complete candidate discoverable to later metadata checks without activating it. Non-main candidate publication remains immutable-only, and first-catalog bootstrap still uses the exact signed immutable catalog. Catalog readers resolve an exact catalog manifest, validate its artifact/config/single-layer media contract, and fetch that layer by descriptor digest into a caller-chosen file. They never extract the publisher-supplied OCI layer title as a filesystem path. New catalogs are published from controlled relative filenames, while this descriptor path remains compatible with the initial catalogs that recorded absolute runner-temporary titles. @@ -121,7 +123,7 @@ The evidence input is one JSON object so the workflow remains below GitHub's ten {"amd64PlaywrightRunId":123,"amd64PlaywrightRunnerName":"","amd64DockerHubRunId":124,"amd64DockerHubRunnerName":"","amd64ReceiptSha256":"sha256:<64 hex>","arm64PlaywrightRunId":125,"arm64PlaywrightRunnerName":"","arm64DockerHubRunId":126,"arm64DockerHubRunnerName":"","arm64ReceiptSha256":"sha256:<64 hex>"} ``` -Before GitHub requests approval for the protected environment, an unprotected `Prepare protected promotion review` job verifies the signed candidate catalog and package evidence and writes a job summary containing the exact package, catalog, source, recipe, runtime, runner, platform, four acceptance-run links, runner names, and receipt hashes. The reviewer opens that completed job summary, follows the four authenticated private-repository links, completes its checklist, and only then approves the waiting `epar-prebuilt-promotion` deployment. The prepare job cannot approve a deployment or move a package tag. +Before GitHub requests approval for the protected environment, an unprotected `Prepare protected promotion review` job verifies the signed candidate catalog and package evidence and requires that the candidate catalog is the current `catalog-v1` head; this prevents an older ledger snapshot from overwriting newer candidates. It writes a job summary containing the exact package, catalog, source, recipe, runtime, runner, platform, four acceptance-run links, runner names, receipt hashes, and reviewed catalog head. The reviewer opens that completed job summary, follows the four authenticated private-repository links, completes its checklist, and only then approves the waiting `epar-prebuilt-promotion` deployment. The prepare job cannot approve a deployment or move a package tag. The `epar-prebuilt-promotion` environment must require an authorized reviewer. After approval, the protected job independently repeats the immutable catalog and package checks, rechecks the upstream source, appends two profile-bound platform acceptance records, requires exactly the two approved workflows per platform, and performs protected catalog compare-and-swap. It then signs and verifies the promoted catalog, moves `catalog-v1`, and moves the matching `act-latest` or `full-latest` alias last. Incomplete, failed, misrouted, single-platform, wrong-profile, wrong-workflow, alias-raced, or source-raced evidence cannot promote.