Docker Sandboxes prebuilt images #140
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Docker Sandboxes prebuilt images | |
| on: | |
| schedule: | |
| - cron: '37 23 */7 * *' | |
| - cron: '57 23 */7 * *' | |
| push: | |
| branches: | |
| - main | |
| paths: | |
| - '.github/workflows/docker-sandboxes-images.yml' | |
| - '.github/scripts/fetch-prebuilt-catalog.sh' | |
| - 'templates/docker-sandboxes/**' | |
| - 'scripts/docker-sandboxes/**' | |
| - 'internal/prebuilt/**' | |
| - 'cmd/epar-prebuilt-publisher/**' | |
| pull_request: | |
| branches: | |
| - develop | |
| - main | |
| paths: | |
| - '.github/workflows/docker-sandboxes-images.yml' | |
| - '.github/scripts/fetch-prebuilt-catalog.sh' | |
| - 'templates/docker-sandboxes/**' | |
| - 'scripts/docker-sandboxes/**' | |
| - 'internal/prebuilt/**' | |
| - 'cmd/epar-prebuilt-publisher/**' | |
| workflow_dispatch: | |
| inputs: | |
| profile: | |
| description: 'Compatible v1 builds automatically advance this profile after its upstream and hosted gates pass.' | |
| required: true | |
| type: choice | |
| options: | |
| - act | |
| - full | |
| default: act | |
| force_candidate: | |
| description: 'Keep the result as a candidate even when compatible-v1 automatic promotion is eligible.' | |
| required: true | |
| type: boolean | |
| default: false | |
| promote_candidate: | |
| description: 'Promote one already-published candidate digest without rebuilding; requires protected environment approval and PROMOTE confirmation.' | |
| required: true | |
| type: boolean | |
| default: false | |
| candidate_digest: | |
| description: 'Exact candidate package index digest (sha256:<64 lowercase hex>) for manual promotion.' | |
| required: false | |
| type: string | |
| default: '' | |
| candidate_catalog_reference: | |
| description: 'Exact immutable signed candidate catalog reference used for protected promotion.' | |
| required: false | |
| type: string | |
| default: '' | |
| acceptance_evidence_json: | |
| description: 'Reviewed JSON containing four run IDs, two receipt SHA-256 values, and the exact ephemeral runner name for every run.' | |
| required: false | |
| type: string | |
| default: '' | |
| promotion_confirmation: | |
| description: 'Type PROMOTE exactly when promoting the supplied candidate digest.' | |
| required: false | |
| type: string | |
| default: '' | |
| permissions: | |
| contents: read | |
| packages: write | |
| attestations: write | |
| id-token: write | |
| concurrency: | |
| group: docker-sandboxes-prebuilt-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'publisher' }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| env: | |
| SOURCE_REPOSITORY: ghcr.io/catthehacker/ubuntu | |
| PACKAGE_REPOSITORY: ghcr.io/solutionforest/ephemeral-action-runner/docker-sandboxes-template | |
| CATALOG_ARTIFACT_TYPE: application/vnd.epar.prebuilt.catalog.v1 | |
| CATALOG_CONFIG_MEDIA_TYPE: application/vnd.epar.prebuilt.catalog.config.v1+json | |
| CATALOG_LAYER_MEDIA_TYPE: application/vnd.epar.prebuilt.catalog.v1+json | |
| RUNTIME_CONTRACT: docker-sandboxes-v1 | |
| TEMPLATE_SCHEMA: '2' | |
| DOCKER_BUILD_RECORD_UPLOAD: 'false' | |
| jobs: | |
| validate: | |
| name: Validate prebuilt publication contract without publishing | |
| if: github.event_name == 'pull_request' | |
| permissions: | |
| contents: read | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Verify publisher and signed-evidence contracts | |
| run: | | |
| go test ./internal/prebuilt ./cmd/epar-prebuilt-publisher -count=1 | |
| bash -n .github/scripts/fetch-prebuilt-catalog.sh | |
| - name: Validate committed Docker Sandboxes assets | |
| shell: pwsh | |
| run: | | |
| scripts/docker-sandboxes/validate-assets.ps1 -Platform linux/amd64 | |
| scripts/docker-sandboxes/validate-assets.ps1 -Platform linux/arm64 | |
| scripts/docker-sandboxes/validate-prebuilt.ps1 -Platform linux/amd64 | |
| scripts/docker-sandboxes/validate-prebuilt.ps1 -Platform linux/arm64 | |
| upstream-gate: | |
| name: Verify fresh upstream tag-producing workflow evidence | |
| if: github.event_name != 'pull_request' && inputs.promote_candidate != true | |
| permissions: | |
| contents: read | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| profile: ${{ steps.gate.outputs.profile }} | |
| eligible: ${{ steps.gate.outputs.eligible }} | |
| evidence: ${{ steps.gate.outputs.evidence }} | |
| reason: ${{ steps.gate.outputs.reason }} | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Evaluate upstream workflow, job, and step evidence | |
| id: gate | |
| shell: bash | |
| env: | |
| DISPATCH_PROFILE: ${{ inputs.profile }} | |
| SCHEDULE_EXPRESSION: ${{ github.event.schedule }} | |
| UPSTREAM_ACTIONS_READ_TOKEN: ${{ secrets.UPSTREAM_ACTIONS_READ_TOKEN || github.token }} | |
| run: | | |
| set -euo pipefail | |
| profile="${DISPATCH_PROFILE:-act}" | |
| if [[ "$GITHUB_EVENT_NAME" == schedule ]]; then | |
| case "$SCHEDULE_EXPRESSION" in | |
| '37 23 */7 * *') profile=full ;; | |
| '57 23 */7 * *') profile=act ;; | |
| *) echo "unsupported schedule expression: $SCHEDULE_EXPRESSION" >&2; exit 1 ;; | |
| esac | |
| fi | |
| case "$profile" in | |
| act|full) ;; | |
| *) echo "unsupported profile: $profile" >&2; exit 1 ;; | |
| esac | |
| result="$RUNNER_TEMP/upstream-gate.json" | |
| if ! go run ./cmd/epar-prebuilt-publisher upstream-gate --profile "$profile" --output "$result"; then | |
| jq -n --arg reason 'GitHub Actions metadata was unavailable or invalid; publication skipped without building or writing GHCR/catalog state' '{eligible:false,reason:$reason}' > "$result" | |
| fi | |
| eligible="$(jq -r 'if (.eligible | type) == "boolean" then (.eligible | tostring) else error("eligible must be boolean") end' "$result")" | |
| reason="$(jq -er '.reason' "$result")" | |
| evidence="$(jq -c '.evidence // {}' "$result")" | |
| { | |
| echo "profile=$profile" | |
| echo "eligible=$eligible" | |
| echo "evidence=$evidence" | |
| echo "reason=$reason" | |
| } >> "$GITHUB_OUTPUT" | |
| { | |
| echo '## Docker Sandboxes upstream publication gate' | |
| echo | |
| echo "- Profile: \`$profile\`" | |
| echo "- Eligible: \`$eligible\`" | |
| echo "- Reason: $reason" | |
| if [[ "$eligible" == true ]]; then | |
| echo "- Upstream run: \`$(jq -r '.evidence.repository + "/actions/runs/" + (.evidence.runId | tostring)' "$result")\`" | |
| echo "- Completed: \`$(jq -r '.evidence.completedAt' "$result")\`" | |
| else | |
| echo '- No source resolution, build, package push, catalog publication, or profile-tag movement was attempted.' | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| resolve: | |
| name: Resolve immutable upstream and publication state | |
| needs: upstream-gate | |
| if: needs.upstream-gate.outputs.eligible == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| outputs: | |
| profile: ${{ steps.select.outputs.profile }} | |
| source_tag: ${{ steps.select.outputs.source_tag }} | |
| source_reference: ${{ steps.select.outputs.source_reference }} | |
| source_index_digest: ${{ steps.source.outputs.index_digest }} | |
| source_amd64_digest: ${{ steps.source.outputs.amd64_digest }} | |
| source_arm64_digest: ${{ steps.source.outputs.arm64_digest }} | |
| recipe_digest: ${{ steps.recipe.outputs.recipe_digest }} | |
| source_lock_digest: ${{ steps.recipe.outputs.source_lock_digest }} | |
| tool_digest: ${{ steps.recipe.outputs.tool_digest }} | |
| runner_version: ${{ steps.runner.outputs.version }} | |
| runner_amd64_url: ${{ steps.runner.outputs.amd64_url }} | |
| 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 }} | |
| upstream_evidence: ${{ needs.upstream-gate.outputs.evidence }} | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Set up ORAS | |
| uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 | |
| with: | |
| version: 1.3.3 | |
| - name: Log in to GHCR for idempotent pointer reconciliation | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ github.token }} | |
| - name: Set up Go for signed catalog verification | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Select profile and immutable source selector | |
| id: select | |
| shell: bash | |
| env: | |
| DISPATCH_PROFILE: ${{ inputs.profile }} | |
| SCHEDULE_EXPRESSION: ${{ github.event.schedule }} | |
| run: | | |
| set -euo pipefail | |
| profile="${DISPATCH_PROFILE:-act}" | |
| if [[ "$GITHUB_EVENT_NAME" == schedule ]]; then | |
| case "$SCHEDULE_EXPRESSION" in | |
| '37 23 */7 * *') profile=full ;; | |
| '57 23 */7 * *') profile=act ;; | |
| *) echo "unsupported schedule expression: $SCHEDULE_EXPRESSION" >&2; exit 1 ;; | |
| esac | |
| fi | |
| case "$profile" in | |
| act|full) ;; | |
| *) echo "unsupported profile: $profile" >&2; exit 1 ;; | |
| esac | |
| if [[ "$profile" == full && "$GITHUB_EVENT_NAME" != workflow_dispatch && "$GITHUB_EVENT_NAME" != schedule ]]; then | |
| echo 'full may be selected only by its dedicated schedule or an explicit workflow dispatch' >&2 | |
| exit 1 | |
| fi | |
| source_tag="${profile}-latest" | |
| source_reference="${SOURCE_REPOSITORY}:${source_tag}" | |
| { | |
| echo "profile=$profile" | |
| echo "source_tag=$source_tag" | |
| echo "source_reference=$source_reference" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Resolve OCI source index and platform descriptors | |
| id: source | |
| shell: bash | |
| env: | |
| SOURCE_REFERENCE: ${{ steps.select.outputs.source_reference }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/epar-source" | |
| oras manifest fetch "$SOURCE_REFERENCE" --format json > "$RUNNER_TEMP/epar-source/source.json" | |
| index_digest="$(oras resolve "$SOURCE_REFERENCE")" | |
| [[ "$index_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| platform_count="$(jq '[(.content.manifests // .manifests)[] | select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64"))] | length' "$RUNNER_TEMP/epar-source/source.json")" | |
| [[ "$platform_count" == 2 ]] | |
| platform_names="$(jq -r '(.content.manifests // .manifests)[] | select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64")) | .platform.architecture' "$RUNNER_TEMP/epar-source/source.json" | sort -u | paste -sd, -)" | |
| [[ "$platform_names" == 'amd64,arm64' ]] | |
| amd64_digest="$(jq -er '(.content.manifests // .manifests)[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' "$RUNNER_TEMP/epar-source/source.json" | head -n 1)" | |
| arm64_digest="$(jq -er '(.content.manifests // .manifests)[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest' "$RUNNER_TEMP/epar-source/source.json" | head -n 1)" | |
| [[ "$amd64_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$arm64_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| { | |
| echo "index_digest=$index_digest" | |
| echo "amd64_digest=$amd64_digest" | |
| echo "arm64_digest=$arm64_digest" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Resolve the exact Actions runner release assets | |
| id: runner | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| release_json="$RUNNER_TEMP/actions-runner-release.json" | |
| curl --fail --silent --show-error --retry 4 --retry-delay 2 \ | |
| -H 'Accept: application/vnd.github+json' \ | |
| -H "Authorization: Bearer ${GH_TOKEN}" \ | |
| -H 'X-GitHub-Api-Version: 2022-11-28' \ | |
| https://api.github.com/repos/actions/runner/releases/latest > "$release_json" | |
| version="$(jq -er '.tag_name | ltrimstr("v")' "$release_json")" | |
| amd64_name="actions-runner-linux-x64-${version}.tar.gz" | |
| arm64_name="actions-runner-linux-arm64-${version}.tar.gz" | |
| amd64_url="$(jq -er --arg name "$amd64_name" '.assets[] | select(.name == $name) | .browser_download_url' "$release_json")" | |
| arm64_url="$(jq -er --arg name "$arm64_name" '.assets[] | select(.name == $name) | .browser_download_url' "$release_json")" | |
| amd64_digest="$(jq -er --arg name "$amd64_name" '.assets[] | select(.name == $name) | .digest' "$release_json")" | |
| arm64_digest="$(jq -er --arg name "$arm64_name" '.assets[] | select(.name == $name) | .digest' "$release_json")" | |
| [[ "$amd64_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$arm64_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| { | |
| echo "version=$version" | |
| echo "amd64_url=$amd64_url" | |
| echo "amd64_digest=$amd64_digest" | |
| echo "arm64_url=$arm64_url" | |
| echo "arm64_digest=$arm64_digest" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Compute immutable recipe and tool identities | |
| id: recipe | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| source_lock_digest="sha256:$(sha256sum templates/docker-sandboxes/sources.lock.json | awk '{print $1}')" | |
| tool_digest="sha256:$(jq -cS '{dockerfileFrontend,sbomGenerator,goBuilder,emulation,tini}' templates/docker-sandboxes/sources.lock.json | sha256sum | awk '{print $1}')" | |
| mapfile -t recipe_files < <( | |
| git ls-files --cached -- \ | |
| 'templates/docker-sandboxes/.dockerignore' \ | |
| 'templates/docker-sandboxes/Dockerfile.prebuilt' \ | |
| 'templates/docker-sandboxes/helpers.sha256' \ | |
| 'templates/docker-sandboxes/prebuilt/**' \ | |
| 'templates/docker-sandboxes/guest/**' \ | |
| 'templates/docker-sandboxes/hook-launcher/main.go' \ | |
| 'templates/docker-sandboxes/egress-bridge/main.go' \ | |
| 'templates/docker-sandboxes/profiles/prebuilt.compatibility.json' | sort | |
| ) | |
| ((${#recipe_files[@]} > 0)) | |
| recipe_digest="sha256:$(printf '%s\n' "${recipe_files[@]}" | xargs sha256sum | sha256sum | awk '{print $1}')" | |
| { | |
| echo "source_lock_digest=$source_lock_digest" | |
| echo "tool_digest=$tool_digest" | |
| echo "recipe_digest=$recipe_digest" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Fetch current signed catalog state or initialize an empty ledger | |
| id: catalog | |
| shell: bash | |
| env: | |
| PROFILE: ${{ steps.select.outputs.profile }} | |
| RUNNER_VERSION: ${{ steps.runner.outputs.version }} | |
| RUNNER_AMD64_DIGEST: ${{ steps.runner.outputs.amd64_digest }} | |
| RUNNER_ARM64_DIGEST: ${{ steps.runner.outputs.arm64_digest }} | |
| SOURCE_INDEX_DIGEST: ${{ steps.source.outputs.index_digest }} | |
| RECIPE_DIGEST: ${{ steps.recipe.outputs.recipe_digest }} | |
| RECIPE_REVISION: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| catalog_dir="$RUNNER_TEMP/epar-catalog" | |
| mkdir -p "$catalog_dir" | |
| catalog_reference="${PACKAGE_REPOSITORY}:catalog-v1" | |
| catalog_error="$catalog_dir/catalog-fetch.error" | |
| 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 | |
| exit 1 | |
| fi | |
| cat > "$catalog_dir/catalog-state.json" <<'JSON' | |
| { | |
| "schemaVersion": 1, | |
| "artifactKind": "docker-sandboxes-template", | |
| "packageRepository": "ghcr.io/solutionforest/ephemeral-action-runner/docker-sandboxes-template", | |
| "policies": { | |
| "act": {"enabled": true, "wizardDefault": false, "autoAdvance": true}, | |
| "full": {"enabled": true, "wizardDefault": true, "autoAdvance": true} | |
| }, | |
| "entries": [], | |
| "aliases": {}, | |
| "transitions": [] | |
| } | |
| JSON | |
| echo 'catalog_manifest_digest=' >> "$GITHUB_OUTPUT" | |
| fi | |
| jq '.policies.act = {enabled:true,wizardDefault:false,autoAdvance:true} | .policies.full = {enabled:true,wizardDefault:true,autoAdvance:true}' "$catalog_dir/catalog-state.json" > "$catalog_dir/catalog-state.tmp" | |
| mv "$catalog_dir/catalog-state.tmp" "$catalog_dir/catalog-state.json" | |
| jq -e . "$catalog_dir/catalog-state.json" >/dev/null | |
| - name: Reconcile an interrupted catalog-first alias promotion | |
| if: github.ref == 'refs/heads/main' | |
| 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" | |
| if observed="$(oras resolve "$alias_ref" 2>"$alias_error")"; then | |
| [[ "$observed" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| elif ! grep -Eqi 'manifest unknown|not found|404' "$alias_error"; then | |
| cat "$alias_error" >&2 | |
| exit 1 | |
| fi | |
| plan="$RUNNER_TEMP/epar-catalog/alias-reconciliation.json" | |
| go run ./cmd/epar-prebuilt-publisher reconcile-alias --catalog "$catalog" --profile "$PROFILE" --observed-digest "$observed" --output "$plan" | |
| 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" | |
| fi | |
| - name: Determine whether the immutable tuple is already active | |
| id: noop | |
| shell: bash | |
| env: | |
| PROFILE: ${{ steps.select.outputs.profile }} | |
| SOURCE_INDEX_DIGEST: ${{ steps.source.outputs.index_digest }} | |
| SOURCE_AMD64_DIGEST: ${{ steps.source.outputs.amd64_digest }} | |
| SOURCE_ARM64_DIGEST: ${{ steps.source.outputs.arm64_digest }} | |
| RECIPE_DIGEST: ${{ steps.recipe.outputs.recipe_digest }} | |
| RECIPE_REVISION: ${{ github.sha }} | |
| SOURCE_LOCK_DIGEST: ${{ steps.recipe.outputs.source_lock_digest }} | |
| TOOL_DIGEST: ${{ steps.recipe.outputs.tool_digest }} | |
| RUNNER_VERSION: ${{ steps.runner.outputs.version }} | |
| RUNNER_AMD64_DIGEST: ${{ steps.runner.outputs.amd64_digest }} | |
| RUNNER_ARM64_DIGEST: ${{ steps.runner.outputs.arm64_digest }} | |
| FORCE_CANDIDATE: ${{ inputs.force_candidate }} | |
| 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 | |
| # Only an active entry can suppress a build. A matching candidate | |
| # must be re-evaluated so a later fresh upstream gate can promote it. | |
| 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" \ | |
| '. as $catalog | [ | |
| $catalog.entries[] as $entry | |
| | (([$catalog.transitions[]? | select(.packageIndexDigest == $entry.packageIndexDigest) | .toStatus] | last) // $entry.status) as $effectiveStatus | |
| | select( | |
| $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" | |
| - name: Preserve catalog state for the publication job | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: epar-prebuilt-catalog-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/epar-catalog/catalog-state.json | |
| if-no-files-found: error | |
| retention-days: 14 | |
| build: | |
| name: Build immutable ${{ matrix.platform }} package layer | |
| needs: resolve | |
| if: needs.resolve.outputs.noop != 'true' | |
| runs-on: ${{ matrix.runner }} | |
| timeout-minutes: 180 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: linux/amd64 | |
| slug: amd64 | |
| runner: ubuntu-latest | |
| source_digest: ${{ needs.resolve.outputs.source_amd64_digest }} | |
| runner_url: ${{ needs.resolve.outputs.runner_amd64_url }} | |
| runner_digest: ${{ needs.resolve.outputs.runner_amd64_digest }} | |
| - platform: linux/arm64 | |
| slug: arm64 | |
| runner: ubuntu-24.04-arm | |
| source_digest: ${{ needs.resolve.outputs.source_arm64_digest }} | |
| runner_url: ${{ needs.resolve.outputs.runner_arm64_url }} | |
| runner_digest: ${{ needs.resolve.outputs.runner_arm64_digest }} | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Prepare hosted capacity for the Full profile | |
| if: needs.resolve.outputs.profile == 'full' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo 'Full requires substantially more unpacked Docker storage than the standard 14 GB hosted-runner allowance.' | |
| df -h / | |
| free -h | |
| sudo rm -rf \ | |
| /usr/local/lib/android \ | |
| /usr/share/dotnet \ | |
| /opt/ghc \ | |
| /usr/local/.ghcup \ | |
| /opt/hostedtoolcache/CodeQL | |
| docker system prune --all --force --volumes | |
| available_kib="$(df --output=avail / | tail -n 1 | tr -d '[:space:]')" | |
| minimum_kib="$((40 * 1024 * 1024))" | |
| if (( available_kib < minimum_kib )); then | |
| echo "Full publication requires at least 40 GiB free before its 8 GiB swap allocation; observed $((available_kib / 1024 / 1024)) GiB" >&2 | |
| exit 1 | |
| fi | |
| swap_path=/mnt/epar-full-build.swap | |
| sudo fallocate -l 8G "$swap_path" | |
| sudo chmod 0600 "$swap_path" | |
| sudo mkswap "$swap_path" | |
| sudo swapon "$swap_path" | |
| df -h / | |
| free -h | |
| - name: Log in to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ github.token }} | |
| - name: Set up QEMU for the declared foreign platform | |
| uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 | |
| with: | |
| image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 | |
| platforms: all | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 | |
| with: | |
| buildkitd-config-inline: | | |
| [worker.oci] | |
| max-parallelism = 1 | |
| - name: Fetch immutable runner, Tini, and emulation license inputs | |
| id: inputs | |
| shell: bash | |
| env: | |
| RUNNER_URL: ${{ matrix.runner_url }} | |
| RUNNER_DIGEST: ${{ matrix.runner_digest }} | |
| PLATFORM: ${{ matrix.platform }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p templates/docker-sandboxes/inputs/emulation-licenses | |
| curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error --retry 4 --retry-delay 2 "$RUNNER_URL" -o templates/docker-sandboxes/inputs/actions-runner.tar.gz | |
| echo "${RUNNER_DIGEST#sha256:} templates/docker-sandboxes/inputs/actions-runner.tar.gz" | sha256sum --check - | |
| tini_url="$(jq -er --arg platform "$PLATFORM" '.platforms[$platform].tini.url' templates/docker-sandboxes/sources.lock.json)" | |
| tini_digest="$(jq -er --arg platform "$PLATFORM" '.platforms[$platform].tini.sha256' templates/docker-sandboxes/sources.lock.json)" | |
| curl --fail --location --proto '=https' --proto-redir '=https' --silent --show-error --retry 4 --retry-delay 2 "$tini_url" -o templates/docker-sandboxes/inputs/tini | |
| echo "${tini_digest#sha256:} templates/docker-sandboxes/inputs/tini" | sha256sum --check - | |
| chmod 0555 templates/docker-sandboxes/inputs/tini | |
| licenses=( | |
| 'tonistiigi-binfmt-MIT.txt|https://raw.githubusercontent.com/tonistiigi/binfmt/e29e7d72c9672c8c8bf846655ab149b50e1a62bd/LICENSE|bba3332a1e2ec03031b587452cd9254bd7ab6ec701aef20b12e642f47f423dd6' | |
| 'qemu-COPYING.txt|https://raw.githubusercontent.com/qemu/qemu/v10.2.3/COPYING|dd3ce02338c3a48abb6ba59b48809f7108a8bd242cb0cc8be90daafa30707c28' | |
| ) | |
| for item in "${licenses[@]}"; do | |
| IFS='|' read -r name url digest <<< "$item" | |
| curl --fail --silent --show-error --retry 4 --retry-delay 2 "$url" -o "templates/docker-sandboxes/inputs/emulation-licenses/$name" | |
| echo "$digest templates/docker-sandboxes/inputs/emulation-licenses/$name" | sha256sum --check - | |
| done | |
| go_builder="$(jq -er --arg platform "$PLATFORM" '.platforms[$platform].goBuilderReference' templates/docker-sandboxes/sources.lock.json)" | |
| binfmt="$(jq -er --arg platform "$PLATFORM" '.emulation.platforms[$platform].sourceReference' templates/docker-sandboxes/sources.lock.json)" | |
| tini_sha="$(jq -er --arg platform "$PLATFORM" '.platforms[$platform].tini.sha256' templates/docker-sandboxes/sources.lock.json)" | |
| hook_sha="$(jq -er '.hookLauncher.sha256' templates/docker-sandboxes/sources.lock.json)" | |
| egress_sha="$(jq -er '.egressBridge.sha256' templates/docker-sandboxes/sources.lock.json)" | |
| echo "go_builder=$go_builder" >> "$GITHUB_OUTPUT" | |
| echo "binfmt=$binfmt" >> "$GITHUB_OUTPUT" | |
| echo "tini_sha=$tini_sha" >> "$GITHUB_OUTPUT" | |
| echo "hook_sha=$hook_sha" >> "$GITHUB_OUTPUT" | |
| echo "egress_sha=$egress_sha" >> "$GITHUB_OUTPUT" | |
| - name: Build and push platform candidate | |
| id: push | |
| uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| with: | |
| context: templates/docker-sandboxes | |
| file: templates/docker-sandboxes/Dockerfile.prebuilt | |
| target: runner-template | |
| platforms: ${{ matrix.platform }} | |
| push: true | |
| pull: true | |
| tags: ${{ env.PACKAGE_REPOSITORY }}:candidate-${{ needs.resolve.outputs.profile }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.slug }} | |
| build-args: | | |
| TEMPLATE_PLATFORM=${{ matrix.platform }} | |
| SOURCE_IMAGE=${{ env.SOURCE_REPOSITORY }}@${{ matrix.source_digest }} | |
| SOURCE_PROFILE=${{ needs.resolve.outputs.profile }} | |
| SOURCE_INDEX_DIGEST=${{ needs.resolve.outputs.source_index_digest }} | |
| SOURCE_MANIFEST_DIGEST=${{ matrix.source_digest }} | |
| GO_BUILDER_IMAGE=${{ steps.inputs.outputs.go_builder }} | |
| BINFMT_IMAGE=${{ steps.inputs.outputs.binfmt }} | |
| EPAR_RECIPE_DIGEST=${{ needs.resolve.outputs.recipe_digest }} | |
| EPAR_RUNTIME_CONTRACT=${{ env.RUNTIME_CONTRACT }} | |
| TEMPLATE_VERSION=${{ github.sha }}-${{ matrix.slug }} | |
| COMPATIBILITY_FILE=prebuilt.compatibility.json | |
| HOOK_LAUNCHER_SHA256=${{ steps.inputs.outputs.hook_sha }} | |
| EGRESS_BRIDGE_SHA256=${{ steps.inputs.outputs.egress_sha }} | |
| TINI_SHA256=${{ steps.inputs.outputs.tini_sha }} | |
| RUNNER_SELECTOR=latest | |
| RUNNER_VERSION=${{ needs.resolve.outputs.runner_version }} | |
| RUNNER_ASSET_DIGEST=${{ matrix.runner_digest }} | |
| ACTIONS_RUNNER_VERSION=${{ needs.resolve.outputs.runner_version }} | |
| ACTIONS_RUNNER_SHA256=${{ matrix.runner_digest }} | |
| # The signed SLSA and SPDX referrers are generated for the completed | |
| # multi-platform index below. Disabling BuildKit's additional | |
| # per-platform attestations avoids duplicate evidence indexes while | |
| # retaining the exact runnable platform manifests in that index. | |
| provenance: false | |
| sbom: false | |
| - name: Record platform digest and runner tuple | |
| shell: bash | |
| env: | |
| PLATFORM: ${{ matrix.platform }} | |
| PLATFORM_SLUG: ${{ matrix.slug }} | |
| CANDIDATE_DESCRIPTOR_DIGEST: ${{ steps.push.outputs.digest }} | |
| PACKAGE_REPOSITORY: ${{ env.PACKAGE_REPOSITORY }} | |
| RUNNER_VERSION: ${{ needs.resolve.outputs.runner_version }} | |
| RUNNER_DIGEST: ${{ matrix.runner_digest }} | |
| run: | | |
| set -euo pipefail | |
| [[ "$CANDIDATE_DESCRIPTOR_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| platform_candidate_ref="${PACKAGE_REPOSITORY}@${CANDIDATE_DESCRIPTOR_DIGEST}" | |
| docker buildx imagetools inspect "$platform_candidate_ref" --raw > "$RUNNER_TEMP/platform-candidate-descriptor.json" | |
| media_type="$(jq -er '.mediaType' "$RUNNER_TEMP/platform-candidate-descriptor.json")" | |
| case "$media_type" in | |
| application/vnd.oci.image.manifest.v1+json|application/vnd.docker.distribution.manifest.v2+json) | |
| package_manifest_digest="$CANDIDATE_DESCRIPTOR_DIGEST" | |
| ;; | |
| application/vnd.oci.image.index.v1+json|application/vnd.docker.distribution.manifest.list.v2+json) | |
| architecture="${PLATFORM#linux/}" | |
| package_manifest_digest="$(jq -er --arg architecture "$architecture" \ | |
| '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == $architecture)] | if length == 1 then .[0].digest else error("expected exactly one runnable platform manifest") end' \ | |
| "$RUNNER_TEMP/platform-candidate-descriptor.json")" | |
| ;; | |
| *) | |
| echo "unsupported platform candidate media type: $media_type" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| [[ "$package_manifest_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| jq -n --arg platform "$PLATFORM" --arg slug "$PLATFORM_SLUG" --arg digest "$package_manifest_digest" --arg candidateDescriptorDigest "$CANDIDATE_DESCRIPTOR_DIGEST" --arg runner "$RUNNER_VERSION" --arg runnerDigest "$RUNNER_DIGEST" \ | |
| '{platform:$platform,slug:$slug,packageManifestDigest:$digest,candidateDescriptorDigest:$candidateDescriptorDigest,runnerVersion:$runner,runnerAssetDigest:$runnerDigest}' > "$RUNNER_TEMP/platform-result.json" | |
| - name: Upload platform digest | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: epar-prebuilt-platform-${{ needs.resolve.outputs.profile }}-${{ matrix.slug }}-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: ${{ runner.temp }}/platform-result.json | |
| if-no-files-found: error | |
| retention-days: 14 | |
| publish: | |
| name: Verify, attest, and publish catalog state | |
| needs: | |
| - resolve | |
| - build | |
| if: needs.resolve.outputs.noop != 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| outputs: | |
| profile: ${{ needs.resolve.outputs.profile }} | |
| index_digest: ${{ steps.index.outputs.index_digest }} | |
| package_ref: ${{ steps.index.outputs.package_ref }} | |
| source_rechecked: ${{ steps.index.outputs.source_rechecked }} | |
| publication_artifact: epar-prebuilt-publication-${{ needs.resolve.outputs.profile }}-${{ github.run_id }}-${{ github.run_attempt }} | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up ORAS | |
| uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 | |
| with: | |
| version: 1.3.3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ github.token }} | |
| - name: Set up QEMU and Buildx for readback | |
| uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 | |
| with: | |
| image: docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 | |
| platforms: all | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 | |
| - name: Download catalog and platform results | |
| uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 | |
| with: | |
| pattern: epar-prebuilt-* | |
| path: ${{ runner.temp }}/epar-artifacts | |
| merge-multiple: false | |
| - name: Merge platform manifests and verify immutable index | |
| id: index | |
| shell: bash | |
| env: | |
| PROFILE: ${{ needs.resolve.outputs.profile }} | |
| SOURCE_REFERENCE: ${{ needs.resolve.outputs.source_reference }} | |
| SOURCE_INDEX_DIGEST: ${{ needs.resolve.outputs.source_index_digest }} | |
| run: | | |
| set -euo pipefail | |
| amd64_result="$(find "$RUNNER_TEMP/epar-artifacts" -path '*amd64*/platform-result.json' -print -quit)" | |
| arm64_result="$(find "$RUNNER_TEMP/epar-artifacts" -path '*arm64*/platform-result.json' -print -quit)" | |
| [[ -f "$amd64_result" && -f "$arm64_result" ]] | |
| amd64_digest="$(jq -er '.packageManifestDigest' "$amd64_result")" | |
| arm64_digest="$(jq -er '.packageManifestDigest' "$arm64_result")" | |
| [[ "$amd64_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$arm64_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| staging_ref="${PACKAGE_REPOSITORY}:candidate-${PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| docker buildx imagetools create --tag "$staging_ref" \ | |
| "${PACKAGE_REPOSITORY}@${amd64_digest}" \ | |
| "${PACKAGE_REPOSITORY}@${arm64_digest}" | |
| index_digest="$(oras resolve "$staging_ref")" | |
| [[ "$index_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| oras manifest fetch "$staging_ref" --format json > "$RUNNER_TEMP/epar-index.json" | |
| jq -e --arg amd64 "$amd64_digest" --arg arm64 "$arm64_digest" \ | |
| '[(.content.manifests // .manifests)[]] as $manifests | ($manifests | length) == 2 and ($manifests | map(select(.platform.os == "linux" and (.platform.architecture == "amd64" or .platform.architecture == "arm64"))) | length) == 2 and ($manifests | map(.platform.architecture) | sort == ["amd64","arm64"]) and ($manifests | map(.digest) | sort == ([$amd64,$arm64] | sort))' "$RUNNER_TEMP/epar-index.json" >/dev/null | |
| current_source_digest="$(oras resolve "$SOURCE_REFERENCE")" | |
| [[ "$current_source_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| source_rechecked=true | |
| if [[ "$current_source_digest" != "$SOURCE_INDEX_DIGEST" ]]; then | |
| source_rechecked=false | |
| echo "upstream source moved from $SOURCE_INDEX_DIGEST to $current_source_digest; keeping candidate only" >&2 | |
| fi | |
| package_tag="${PROFILE}-latest-pkg-${index_digest#sha256:}" | |
| package_ref="${PACKAGE_REPOSITORY}@${index_digest}" | |
| immutable_tag_ref="${PACKAGE_REPOSITORY}:${package_tag}" | |
| tag_error="$RUNNER_TEMP/epar-package-tag-resolve.error" | |
| if existing_digest="$(oras resolve "$immutable_tag_ref" 2>"$tag_error")"; then | |
| [[ "$existing_digest" == "$index_digest" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$tag_error"; then | |
| cat "$tag_error" >&2 | |
| exit 1 | |
| fi | |
| docker buildx imagetools create --tag "$immutable_tag_ref" "$staging_ref" | |
| [[ "$(oras resolve "$immutable_tag_ref")" == "$index_digest" ]] | |
| fi | |
| { | |
| echo "index_digest=$index_digest" | |
| echo "package_tag=$package_tag" | |
| echo "package_ref=$package_ref" | |
| echo "immutable_tag_ref=$immutable_tag_ref" | |
| echo "amd64_digest=$amd64_digest" | |
| echo "arm64_digest=$arm64_digest" | |
| echo "source_rechecked=$source_rechecked" | |
| echo "source_observed_digest=$current_source_digest" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Run hosted two-platform template smoke checks | |
| shell: bash | |
| env: | |
| PACKAGE_AMD64_DIGEST: ${{ steps.index.outputs.amd64_digest }} | |
| PACKAGE_ARM64_DIGEST: ${{ steps.index.outputs.arm64_digest }} | |
| run: | | |
| set -euo pipefail | |
| for platform_and_digest in "linux/amd64|$PACKAGE_AMD64_DIGEST" "linux/arm64|$PACKAGE_ARM64_DIGEST"; do | |
| IFS='|' read -r platform platform_digest <<< "$platform_and_digest" | |
| platform_ref="${PACKAGE_REPOSITORY}@${platform_digest}" | |
| docker pull --platform "$platform" "$platform_ref" >/dev/null | |
| docker run --rm --platform "$platform" --entrypoint /bin/bash "$platform_ref" -lc ' | |
| set -euo pipefail | |
| test -x /opt/actions-runner/bin/Runner.Listener | |
| test -x /usr/local/bin/tini | |
| test -x /opt/epar/verify-template.sh | |
| /opt/actions-runner/bin/Runner.Listener --version >/dev/null | |
| /usr/local/bin/tini --version 2>&1 | grep -qi tini | |
| docker --version >/dev/null | |
| dockerd --version >/dev/null | |
| ' | |
| done | |
| - name: Prepare EPAR SLSA predicate and SPDX SBOM | |
| id: evidence | |
| shell: bash | |
| env: | |
| SOURCE_INDEX_DIGEST: ${{ needs.resolve.outputs.source_index_digest }} | |
| SOURCE_AMD64_DIGEST: ${{ needs.resolve.outputs.source_amd64_digest }} | |
| SOURCE_ARM64_DIGEST: ${{ needs.resolve.outputs.source_arm64_digest }} | |
| PACKAGE_INDEX_DIGEST: ${{ steps.index.outputs.index_digest }} | |
| PACKAGE_AMD64_DIGEST: ${{ steps.index.outputs.amd64_digest }} | |
| PACKAGE_ARM64_DIGEST: ${{ steps.index.outputs.arm64_digest }} | |
| RECIPE_DIGEST: ${{ needs.resolve.outputs.recipe_digest }} | |
| RECIPE_REVISION: ${{ github.sha }} | |
| TOOL_DIGEST: ${{ needs.resolve.outputs.tool_digest }} | |
| RUNNER_VERSION: ${{ needs.resolve.outputs.runner_version }} | |
| RUNNER_AMD64_DIGEST: ${{ needs.resolve.outputs.runner_amd64_digest }} | |
| RUNNER_ARM64_DIGEST: ${{ needs.resolve.outputs.runner_arm64_digest }} | |
| REPOSITORY_ID: ${{ github.repository_id }} | |
| REPOSITORY_OWNER_ID: ${{ github.repository_owner_id }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/epar-evidence" | |
| resolved_dependencies="$(jq -cn --arg gitUri "git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY@$GITHUB_REF" --arg revision "$RECIPE_REVISION" --arg source "$SOURCE_INDEX_DIGEST" --arg sourceAmd64 "$SOURCE_AMD64_DIGEST" --arg sourceArm64 "$SOURCE_ARM64_DIGEST" --arg runnerAmd64 "$RUNNER_AMD64_DIGEST" --arg runnerArm64 "$RUNNER_ARM64_DIGEST" --arg tool "$TOOL_DIGEST" '[{uri:$gitUri,digest:{gitCommit:$revision}}] + ([ $source,$sourceAmd64,$sourceArm64,$runnerAmd64,$runnerArm64,$tool ] | map({uri:"oci",digest:{sha256:(sub("^sha256:";""))}}))')" | |
| jq -n \ | |
| --arg source "$SOURCE_INDEX_DIGEST" --arg sourceAmd64 "$SOURCE_AMD64_DIGEST" --arg sourceArm64 "$SOURCE_ARM64_DIGEST" \ | |
| --arg recipe "$RECIPE_DIGEST" --arg runtime "$RUNTIME_CONTRACT" --arg runner "$RUNNER_VERSION" \ | |
| --arg runnerAmd64 "$RUNNER_AMD64_DIGEST" --arg runnerArm64 "$RUNNER_ARM64_DIGEST" --arg tool "$TOOL_DIGEST" \ | |
| --arg package "$PACKAGE_INDEX_DIGEST" --arg packageAmd64 "$PACKAGE_AMD64_DIGEST" --arg packageArm64 "$PACKAGE_ARM64_DIGEST" \ | |
| --arg revision "$RECIPE_REVISION" --arg workflowRef "$GITHUB_REF" --arg workflowRepository "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ | |
| --arg eventName "$GITHUB_EVENT_NAME" --arg repositoryId "$REPOSITORY_ID" --arg repositoryOwnerId "$REPOSITORY_OWNER_ID" \ | |
| --arg builderId "$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" --arg invocationId "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT" \ | |
| --argjson dependencies "$resolved_dependencies" \ | |
| '{buildDefinition:{buildType:"https://actions.github.io/buildtypes/workflow/v1",externalParameters:{workflow:{ref:$workflowRef,repository:$workflowRepository,path:".github/workflows/docker-sandboxes-images.yml"},source:{indexDigest:$source,platformDigests:{"linux/amd64":$sourceAmd64,"linux/arm64":$sourceArm64}},recipe:{digest:$recipe,runtimeContract:$runtime,templateSchema:2,revision:$revision},runner:{version:$runner,assetDigests:{"linux/amd64":$runnerAmd64,"linux/arm64":$runnerArm64}},tools:{"locked-build-inputs":{digest:$tool}},platforms:{"linux/amd64":{packageManifestDigest:$packageAmd64},"linux/arm64":{packageManifestDigest:$packageArm64}},subjectDigest:$package},internalParameters:{github:{event_name:$eventName,repository_id:$repositoryId,repository_owner_id:$repositoryOwnerId,runner_environment:"github-hosted"}},resolvedDependencies:$dependencies},runDetails:{builder:{id:$builderId},metadata:{invocationId:$invocationId}}}' > "$RUNNER_TEMP/epar-evidence/slsa-predicate.json" | |
| jq -n --arg package "$PACKAGE_INDEX_DIGEST" --arg recipe "$RECIPE_DIGEST" --arg amd64 "$PACKAGE_AMD64_DIGEST" --arg arm64 "$PACKAGE_ARM64_DIGEST" \ | |
| '{spdxVersion:"SPDX-2.3",dataLicense:"CC0-1.0",SPDXID:"SPDXRef-DOCUMENT",documentNamespace:("https://solutionforest.dev/epar/docker-sandboxes/" + $package),packages:[{name:"epar-package-index",SPDXID:"SPDXRef-Package-index",checksums:[{algorithm:"SHA256",checksumValue:($package | sub("^sha256:";""))}]},{name:"epar-runtime-config",SPDXID:"SPDXRef-Package-runtime",checksums:[{algorithm:"SHA256",checksumValue:($recipe | sub("^sha256:";""))}]},{name:"epar-platform-linux-amd64",SPDXID:"SPDXRef-Package-amd64",checksums:[{algorithm:"SHA256",checksumValue:($amd64 | sub("^sha256:";""))}]},{name:"epar-platform-linux-arm64",SPDXID:"SPDXRef-Package-arm64",checksums:[{algorithm:"SHA256",checksumValue:($arm64 | sub("^sha256:";""))}]}]}' > "$RUNNER_TEMP/epar-evidence/sbom.spdx.json" | |
| - name: Detect existing index evidence for idempotent reruns | |
| id: existing-evidence | |
| shell: bash | |
| env: | |
| PACKAGE_REPOSITORY: ${{ env.PACKAGE_REPOSITORY }} | |
| INDEX_DIGEST: ${{ steps.index.outputs.index_digest }} | |
| run: | | |
| set -euo pipefail | |
| referrers="$RUNNER_TEMP/epar-evidence/existing-index-referrers.json" | |
| oras discover "${PACKAGE_REPOSITORY}@${INDEX_DIGEST}" --format json > "$referrers" | |
| jq -e '.referrers | type == "array"' "$referrers" >/dev/null | |
| reuse=false | |
| expected_provenance_digest='' | |
| expected_sbom_digest='' | |
| catalog="$(find "$RUNNER_TEMP/epar-artifacts" -path '*epar-prebuilt-catalog*' -name catalog-state.json -print -quit || true)" | |
| if [[ -n "$catalog" && -f "$catalog" ]]; then | |
| existing_entry="$(jq -c --arg digest "$INDEX_DIGEST" '[.entries[]? | select(.packageIndexDigest == $digest)] | last // empty' "$catalog")" | |
| if [[ -n "$existing_entry" ]]; then | |
| existing_entry_path="$RUNNER_TEMP/epar-evidence/existing-entry.json" | |
| printf '%s\n' "$existing_entry" > "$existing_entry_path" | |
| expected_provenance_digest="$(jq -r '.evidence.provenanceDigest // empty' <<< "$existing_entry")" | |
| expected_sbom_digest="$(jq -r '.evidence.sbomDigest // empty' <<< "$existing_entry")" | |
| expected_attestation_digest="$(jq -r '.evidence.attestationDigest // empty' <<< "$existing_entry")" | |
| if [[ "$expected_provenance_digest" =~ ^sha256:[0-9a-f]{64}$ && "$expected_sbom_digest" =~ ^sha256:[0-9a-f]{64}$ && "$expected_attestation_digest" == "$expected_provenance_digest" ]]; then | |
| if go run ./cmd/epar-prebuilt-publisher verify-package \ | |
| --reference "${PACKAGE_REPOSITORY}@${INDEX_DIGEST}" \ | |
| --entry "$existing_entry_path" \ | |
| --repository "$PACKAGE_REPOSITORY" \ | |
| --ref refs/heads/main \ | |
| --allowed-events schedule,workflow_dispatch,push \ | |
| > "$RUNNER_TEMP/epar-evidence/existing-package-verification.json"; then | |
| if jq -e --arg provenance "$expected_provenance_digest" --arg sbom "$expected_sbom_digest" \ | |
| '[.referrers[].digest] | index($provenance) != null and index($sbom) != null' "$referrers" >/dev/null; then | |
| reuse=true | |
| fi | |
| fi | |
| fi | |
| fi | |
| fi | |
| { | |
| echo "reuse=$reuse" | |
| echo "provenance_digest=$expected_provenance_digest" | |
| echo "sbom_digest=$expected_sbom_digest" | |
| echo "baseline_referrers=$referrers" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Generate signed index SLSA referrer | |
| id: attest-provenance | |
| if: steps.existing-evidence.outputs.reuse != 'true' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ${{ env.PACKAGE_REPOSITORY }} | |
| subject-digest: ${{ steps.index.outputs.index_digest }} | |
| predicate-type: https://slsa.dev/provenance/v1 | |
| predicate-path: ${{ runner.temp }}/epar-evidence/slsa-predicate.json | |
| push-to-registry: true | |
| create-storage-record: false | |
| - name: Record the provenance referrer before publishing the SBOM | |
| id: provenance-referrer | |
| if: steps.existing-evidence.outputs.reuse != 'true' | |
| shell: bash | |
| env: | |
| PACKAGE_REPOSITORY: ${{ env.PACKAGE_REPOSITORY }} | |
| INDEX_DIGEST: ${{ steps.index.outputs.index_digest }} | |
| BASELINE_REFERRERS: ${{ steps.existing-evidence.outputs.baseline_referrers }} | |
| run: | | |
| set -euo pipefail | |
| [[ -f "$BASELINE_REFERRERS" ]] | |
| after_provenance="$RUNNER_TEMP/epar-evidence/after-provenance-referrers.json" | |
| oras discover "${PACKAGE_REPOSITORY}@${INDEX_DIGEST}" --format json > "$after_provenance" | |
| provenance_digest="$(jq -er --slurpfile baseline "$BASELINE_REFERRERS" ' | |
| [ .referrers[] as $r | |
| | select((($baseline[0].referrers // []) | map(.digest) | index($r.digest)) == null) | |
| | $r.digest ] | |
| | if length == 1 then .[0] else error("expected exactly one new provenance referrer") end' "$after_provenance")" | |
| [[ "$provenance_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| echo "digest=$provenance_digest" >> "$GITHUB_OUTPUT" | |
| echo "referrers=$after_provenance" >> "$GITHUB_OUTPUT" | |
| - name: Generate signed index SPDX SBOM referrer | |
| id: attest-sbom | |
| if: steps.existing-evidence.outputs.reuse != 'true' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ${{ env.PACKAGE_REPOSITORY }} | |
| subject-digest: ${{ steps.index.outputs.index_digest }} | |
| sbom-path: ${{ runner.temp }}/epar-evidence/sbom.spdx.json | |
| push-to-registry: true | |
| create-storage-record: false | |
| - name: Record actual signed OCI referrer descriptors | |
| id: gates | |
| shell: bash | |
| env: | |
| PACKAGE_REPOSITORY: ${{ env.PACKAGE_REPOSITORY }} | |
| INDEX_DIGEST: ${{ steps.index.outputs.index_digest }} | |
| SOURCE_RECHECKED: ${{ steps.index.outputs.source_rechecked }} | |
| REUSE_EVIDENCE: ${{ steps.existing-evidence.outputs.reuse }} | |
| EXPECTED_PROVENANCE_DIGEST: ${{ steps.existing-evidence.outputs.provenance_digest }} | |
| EXPECTED_SBOM_DIGEST: ${{ steps.existing-evidence.outputs.sbom_digest }} | |
| FRESH_PROVENANCE_DIGEST: ${{ steps.provenance-referrer.outputs.digest }} | |
| AFTER_PROVENANCE_REFERRERS: ${{ steps.provenance-referrer.outputs.referrers }} | |
| run: | | |
| set -euo pipefail | |
| referrers="$RUNNER_TEMP/epar-evidence/index-referrers.json" | |
| oras discover "${PACKAGE_REPOSITORY}@${INDEX_DIGEST}" --format json > "$referrers" | |
| jq -e '.referrers | length >= 2' "$referrers" >/dev/null | |
| if [[ "$REUSE_EVIDENCE" == true ]]; then | |
| provenance_digest="$EXPECTED_PROVENANCE_DIGEST" | |
| sbom_digest="$EXPECTED_SBOM_DIGEST" | |
| jq -e --arg provenance "$provenance_digest" --arg sbom "$sbom_digest" \ | |
| '[.referrers[].digest] | index($provenance) != null and index($sbom) != null' "$referrers" >/dev/null | |
| else | |
| [[ -f "$AFTER_PROVENANCE_REFERRERS" ]] | |
| provenance_digest="$FRESH_PROVENANCE_DIGEST" | |
| # Each attestation action must add exactly one descriptor relative | |
| # to the immediately preceding snapshot. This identifies the | |
| # evidence by publication order instead of optional OCI annotations. | |
| sbom_digest="$(jq -er --slurpfile baseline "$AFTER_PROVENANCE_REFERRERS" ' | |
| [ .referrers[] as $r | |
| | select((($baseline[0].referrers // []) | map(.digest) | index($r.digest)) == null) | |
| | $r.digest ] | |
| | if length == 1 then .[0] else error("expected exactly one new SBOM referrer") end' "$referrers")" | |
| fi | |
| [[ "$provenance_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$sbom_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| echo "source_rechecked=$SOURCE_RECHECKED" >> "$GITHUB_OUTPUT" | |
| echo 'provenance_generated=true' >> "$GITHUB_OUTPUT" | |
| echo 'sbom_generated=true' >> "$GITHUB_OUTPUT" | |
| echo 'attestation_verified=false' >> "$GITHUB_OUTPUT" | |
| echo 'runtime_validated=false' >> "$GITHUB_OUTPUT" | |
| echo 'import_readback=false' >> "$GITHUB_OUTPUT" | |
| echo "provenance_digest=$provenance_digest" >> "$GITHUB_OUTPUT" | |
| echo "sbom_digest=$sbom_digest" >> "$GITHUB_OUTPUT" | |
| - name: Build the immutable publisher input (live gates remain closed) | |
| id: input | |
| shell: bash | |
| env: | |
| PROFILE: ${{ needs.resolve.outputs.profile }} | |
| SOURCE_TAG: ${{ needs.resolve.outputs.source_tag }} | |
| SOURCE_REFERENCE: ${{ needs.resolve.outputs.source_reference }} | |
| SOURCE_INDEX_DIGEST: ${{ needs.resolve.outputs.source_index_digest }} | |
| SOURCE_AMD64_DIGEST: ${{ needs.resolve.outputs.source_amd64_digest }} | |
| SOURCE_ARM64_DIGEST: ${{ needs.resolve.outputs.source_arm64_digest }} | |
| OBSERVED_SOURCE_DIGEST: ${{ steps.index.outputs.source_observed_digest }} | |
| PACKAGE_REF: ${{ steps.index.outputs.package_ref }} | |
| PACKAGE_INDEX_DIGEST: ${{ steps.index.outputs.index_digest }} | |
| PACKAGE_AMD64_DIGEST: ${{ steps.index.outputs.amd64_digest }} | |
| PACKAGE_ARM64_DIGEST: ${{ steps.index.outputs.arm64_digest }} | |
| RECIPE_DIGEST: ${{ needs.resolve.outputs.recipe_digest }} | |
| RECIPE_REVISION: ${{ github.sha }} | |
| SOURCE_LOCK_DIGEST: ${{ needs.resolve.outputs.source_lock_digest }} | |
| TOOL_DIGEST: ${{ needs.resolve.outputs.tool_digest }} | |
| RUNNER_VERSION: ${{ needs.resolve.outputs.runner_version }} | |
| RUNNER_AMD64_DIGEST: ${{ needs.resolve.outputs.runner_amd64_digest }} | |
| RUNNER_ARM64_DIGEST: ${{ needs.resolve.outputs.runner_arm64_digest }} | |
| SOURCE_RECHECKED: ${{ steps.gates.outputs.source_rechecked }} | |
| PROVENANCE_GENERATED: ${{ steps.gates.outputs.provenance_generated }} | |
| SBOM_GENERATED: ${{ steps.gates.outputs.sbom_generated }} | |
| ATTESTATION_VERIFIED: ${{ steps.gates.outputs.attestation_verified }} | |
| RUNTIME_VALIDATED: ${{ steps.gates.outputs.runtime_validated }} | |
| IMPORT_READBACK: ${{ steps.gates.outputs.import_readback }} | |
| PROVENANCE_DIGEST: ${{ steps.gates.outputs.provenance_digest }} | |
| SBOM_DIGEST: ${{ steps.gates.outputs.sbom_digest }} | |
| UPSTREAM_EVIDENCE: ${{ needs.resolve.outputs.upstream_evidence }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p "$RUNNER_TEMP/epar-publisher" | |
| tools_json="$(jq -cn --arg digest "$TOOL_DIGEST" '[{name:"locked-build-inputs",digest:$digest}]')" | |
| package_platforms="$(jq -cn --arg amd64 "$PACKAGE_AMD64_DIGEST" --arg arm64 "$PACKAGE_ARM64_DIGEST" --arg samd64 "$SOURCE_AMD64_DIGEST" --arg sarm64 "$SOURCE_ARM64_DIGEST" '[{platform:"linux/amd64",packageManifestDigest:$amd64,sourceManifestDigest:$samd64,validated:false},{platform:"linux/arm64",packageManifestDigest:$arm64,sourceManifestDigest:$sarm64,validated:false}]')" | |
| input="$RUNNER_TEMP/epar-publisher/publication-input.json" | |
| jq -n \ | |
| --arg profile "$PROFILE" --arg sourceTag "$SOURCE_TAG" --arg sourceReference "$SOURCE_REFERENCE" \ | |
| --arg packageRepository "$PACKAGE_REPOSITORY" --arg packageReference "$PACKAGE_REF" --arg packageDigest "$PACKAGE_INDEX_DIGEST" \ | |
| --arg recipeDigest "$RECIPE_DIGEST" --arg recipeRevision "$RECIPE_REVISION" --arg sourceLockDigest "$SOURCE_LOCK_DIGEST" --arg toolDigest "$TOOL_DIGEST" --arg runtime "$RUNTIME_CONTRACT" \ | |
| --arg runnerVersion "$RUNNER_VERSION" --arg runnerAmd64 "$RUNNER_AMD64_DIGEST" --arg runnerArm64 "$RUNNER_ARM64_DIGEST" \ | |
| --arg provenanceDigest "$PROVENANCE_DIGEST" --arg sbomDigest "$SBOM_DIGEST" --arg attestationDigest "$PROVENANCE_DIGEST" \ | |
| --arg candidateId "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${PROFILE}-${PACKAGE_INDEX_DIGEST#sha256:}" --argjson platforms "$package_platforms" --argjson tools "$tools_json" --argjson upstream "$UPSTREAM_EVIDENCE" \ | |
| --arg sourceRechecked "$SOURCE_RECHECKED" --arg provenanceGenerated "$PROVENANCE_GENERATED" --arg sbomGenerated "$SBOM_GENERATED" --arg attestationVerified "$ATTESTATION_VERIFIED" --arg runtimeValidated "$RUNTIME_VALIDATED" --arg importReadback "$IMPORT_READBACK" \ | |
| '{profile:$profile,channel:"stable",sourceReference:$sourceReference,sourceTag:$sourceTag,packageRepository:$packageRepository,packageReference:$packageReference,packageIndexDigest:$packageDigest,packagePlatforms:$platforms,recipe:{digest:$recipeDigest,runtimeContract:$runtime,templateSchema:2,recipeRevision:$recipeRevision,sourceLockDigest:$sourceLockDigest,toolDigest:$toolDigest},runner:{selector:"latest",version:$runnerVersion,assetDigests:{"linux/amd64":$runnerAmd64,"linux/arm64":$runnerArm64},overlayRequired:false},tools:$tools,evidence:{provenanceDigest:$provenanceDigest,sbomDigest:$sbomDigest,attestationDigest:$attestationDigest},gates:{sourceResolved:true,sourceRechecked:($sourceRechecked == "true"),buildSucceeded:true,platformsValidated:true,importReadback:($importReadback == "true"),runtimeValidated:($runtimeValidated == "true"),provenanceGenerated:($provenanceGenerated == "true"),sbomGenerated:($sbomGenerated == "true"),attestationVerified:($attestationVerified == "true")},upstream:$upstream,candidateId:$candidateId}' > "$input" | |
| if [[ "$SOURCE_RECHECKED" != true ]]; then | |
| jq --arg immutable "${SOURCE_REPOSITORY}@${SOURCE_INDEX_DIGEST}" '.sourceReference = $immutable' "$input" > "$input.tmp" | |
| mv "$input.tmp" "$input" | |
| fi | |
| echo "input=$input" >> "$GITHUB_OUTPUT" | |
| echo "index_digest=${{ steps.index.outputs.index_digest }}" >> "$GITHUB_OUTPUT" | |
| echo "package_ref=${{ steps.index.outputs.package_ref }}" >> "$GITHUB_OUTPUT" | |
| echo "source_rechecked=$SOURCE_RECHECKED" >> "$GITHUB_OUTPUT" | |
| - name: Upload publication input and evidence receipts | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: epar-prebuilt-publication-${{ needs.resolve.outputs.profile }}-${{ github.run_id }}-${{ github.run_attempt }} | |
| path: | | |
| ${{ runner.temp }}/epar-publisher/publication-input.json | |
| ${{ runner.temp }}/epar-evidence/index-referrers.json | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Publish candidate summary and explicit no-Git-mutation proof | |
| shell: bash | |
| env: | |
| PACKAGE_REF: ${{ steps.index.outputs.package_ref }} | |
| INDEX_DIGEST: ${{ steps.index.outputs.index_digest }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo '## Docker Sandboxes prebuilt candidate' | |
| echo | |
| echo "- Profile: \`${{ needs.resolve.outputs.profile }}\`" | |
| echo "- Package: \`${PACKAGE_REF}\`" | |
| echo "- Index digest: \`${INDEX_DIGEST}\`" | |
| echo '- Candidate assembly, immutable package tagging, hosted two-platform smoke checks, and OCI evidence completed. Compatible v1 packages may be promoted automatically; real Sandbox acceptance remains factual only when separately performed.' | |
| echo '- This workflow has contents:read and does not commit, tag, release, open a pull request, or push Git refs.' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| promote: | |
| name: Publish signed candidate catalog and move only authorized aliases | |
| needs: | |
| - resolve | |
| - publish | |
| if: always() && needs.resolve.result == 'success' && needs.resolve.outputs.noop != 'true' && needs.publish.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up ORAS | |
| uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 | |
| with: | |
| version: 1.3.3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ github.token }} | |
| - name: Download catalog and publication input | |
| uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 | |
| with: | |
| pattern: epar-prebuilt-* | |
| path: ${{ runner.temp }}/epar-promotion-artifacts | |
| merge-multiple: false | |
| - name: Verify hosted package evidence and plan immutable catalog publication | |
| id: plan | |
| shell: bash | |
| env: | |
| PROFILE: ${{ needs.resolve.outputs.profile }} | |
| PACKAGE_REF: ${{ needs.publish.outputs.package_ref }} | |
| SOURCE_RECHECKED: ${{ needs.publish.outputs.source_rechecked }} | |
| FORCE_CANDIDATE: ${{ inputs.force_candidate }} | |
| ALLOW_ALIAS: ${{ github.ref == 'refs/heads/main' }} | |
| run: | | |
| set -euo pipefail | |
| catalog="$(find "$RUNNER_TEMP/epar-promotion-artifacts" -path '*epar-prebuilt-catalog*' -name catalog-state.json -print -quit)" | |
| input="$(find "$RUNNER_TEMP/epar-promotion-artifacts" -path '*epar-prebuilt-publication*' -name publication-input.json -print -quit)" | |
| [[ -f "$catalog" && -f "$input" ]] | |
| mkdir -p "$RUNNER_TEMP/epar-promotion" | |
| jq '.gates.platformsValidated=true | .packagePlatforms |= map(.validated=true)' "$input" > "$input.tmp" | |
| mv "$input.tmp" "$input" | |
| go run ./cmd/epar-prebuilt-publisher plan --catalog "$catalog" --input "$input" --output "$RUNNER_TEMP/epar-promotion/pre-plan.json" | |
| jq '.entry' "$RUNNER_TEMP/epar-promotion/pre-plan.json" > "$RUNNER_TEMP/epar-promotion/package-entry.json" | |
| go run ./cmd/epar-prebuilt-publisher verify-package --reference "$PACKAGE_REF" --entry "$RUNNER_TEMP/epar-promotion/package-entry.json" --repository "$PACKAGE_REPOSITORY" --ref "$GITHUB_REF" --allowed-events schedule,workflow_dispatch,push | |
| jq '.gates.attestationVerified=true' "$input" > "$input.tmp" | |
| mv "$input.tmp" "$input" | |
| go run ./cmd/epar-prebuilt-publisher plan --catalog "$catalog" --input "$input" --output "$RUNNER_TEMP/epar-promotion/plan.json" | |
| if [[ "$FORCE_CANDIDATE" == true || "$ALLOW_ALIAS" != true || "$SOURCE_RECHECKED" != true ]]; then | |
| jq '.action="candidate" | .reason="protected acceptance or trusted-main gate is incomplete; candidate retained"' "$RUNNER_TEMP/epar-promotion/plan.json" > "$RUNNER_TEMP/epar-promotion/plan.tmp" | |
| mv "$RUNNER_TEMP/epar-promotion/plan.tmp" "$RUNNER_TEMP/epar-promotion/plan.json" | |
| fi | |
| echo "catalog=$catalog" >> "$GITHUB_OUTPUT" | |
| echo "input=$input" >> "$GITHUB_OUTPUT" | |
| echo "plan=$RUNNER_TEMP/epar-promotion/plan.json" >> "$GITHUB_OUTPUT" | |
| - name: Append candidate or protected active alias state locally | |
| id: promote-catalog | |
| shell: bash | |
| env: | |
| CATALOG: ${{ steps.plan.outputs.catalog }} | |
| PLAN: ${{ steps.plan.outputs.plan }} | |
| run: | | |
| set -euo pipefail | |
| go run ./cmd/epar-prebuilt-publisher promote --catalog "$CATALOG" --plan "$PLAN" | |
| echo "action=$(jq -r '.action' "$PLAN")" >> "$GITHUB_OUTPUT" | |
| - name: Canonicalize and publish immutable catalog tag with collision guard | |
| id: catalog | |
| shell: bash | |
| env: | |
| CATALOG: ${{ steps.plan.outputs.catalog }} | |
| run: | | |
| set -euo pipefail | |
| catalog_json="$RUNNER_TEMP/epar-promotion/catalog.json" | |
| catalog_output="$(go run ./cmd/epar-prebuilt-publisher catalog --catalog "$CATALOG" --output "$catalog_json")" | |
| [[ -s "$catalog_json" ]] | |
| catalog_digest="$(printf '%s\n' "$catalog_output" | grep -Eo 'sha256:[0-9a-f]{64}' | tail -n 1 || true)" | |
| [[ "$catalog_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| immutable_ref="${PACKAGE_REPOSITORY}:catalog-v1-pkg-${catalog_digest#sha256:}" | |
| catalog_tag_error="$RUNNER_TEMP/epar-promotion/catalog-tag-resolve.error" | |
| if existing_manifest="$(oras resolve "$immutable_ref" 2>"$catalog_tag_error")"; then | |
| [[ "$existing_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| existing_json="$RUNNER_TEMP/epar-promotion/existing-catalog.json" | |
| bash .github/scripts/fetch-prebuilt-catalog.sh "$PACKAGE_REPOSITORY" "${PACKAGE_REPOSITORY}@${existing_manifest}" "$existing_json" | |
| existing_canonical="$RUNNER_TEMP/epar-promotion/existing-catalog.canonical.json" | |
| existing_output="$(go run ./cmd/epar-prebuilt-publisher catalog --catalog "$existing_json" --output "$existing_canonical")" | |
| existing_digest="$(printf '%s\n' "$existing_output" | grep -Eo 'sha256:[0-9a-f]{64}' | tail -n 1 || true)" | |
| [[ "$existing_digest" == "$catalog_digest" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$catalog_tag_error"; then | |
| cat "$catalog_tag_error" >&2 | |
| exit 1 | |
| fi | |
| config_file="$RUNNER_TEMP/epar-promotion/catalog-config.json" | |
| printf '%s\n' '{}' > "$config_file" | |
| ( | |
| cd "$RUNNER_TEMP/epar-promotion" | |
| oras push "$immutable_ref" --artifact-type "$CATALOG_ARTIFACT_TYPE" --config "catalog-config.json:$CATALOG_CONFIG_MEDIA_TYPE" "catalog.json:$CATALOG_LAYER_MEDIA_TYPE" | |
| ) | |
| existing_manifest="$(oras resolve "$immutable_ref")" | |
| fi | |
| [[ "$existing_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| oras manifest fetch "$immutable_ref" > "$RUNNER_TEMP/epar-promotion/catalog-manifest.json" | |
| jq -e --arg artifact "$CATALOG_ARTIFACT_TYPE" --arg config "$CATALOG_CONFIG_MEDIA_TYPE" --arg layer "$CATALOG_LAYER_MEDIA_TYPE" '(.schemaVersion == 2) and (.mediaType == "application/vnd.oci.image.manifest.v1+json") and (.artifactType == $artifact) and (.config.mediaType == $config) and ((.layers | length) == 1) and (.layers[0].mediaType == $layer)' "$RUNNER_TEMP/epar-promotion/catalog-manifest.json" >/dev/null | |
| echo "catalog_digest=$catalog_digest" >> "$GITHUB_OUTPUT" | |
| echo "manifest_digest=$existing_manifest" >> "$GITHUB_OUTPUT" | |
| echo "immutable_ref=$immutable_ref" >> "$GITHUB_OUTPUT" | |
| - name: Detect existing signed catalog evidence for idempotent reruns | |
| id: catalog-evidence | |
| shell: bash | |
| env: | |
| CATALOG_MANIFEST: ${{ steps.catalog.outputs.manifest_digest }} | |
| IMMUTABLE_REF: ${{ steps.catalog.outputs.immutable_ref }} | |
| run: | | |
| set -euo pipefail | |
| oras discover "${PACKAGE_REPOSITORY}@${CATALOG_MANIFEST}" --format json > "$RUNNER_TEMP/epar-promotion/catalog-referrers.json" | |
| jq -e '.referrers | type == "array"' "$RUNNER_TEMP/epar-promotion/catalog-referrers.json" >/dev/null | |
| if go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --reference "$IMMUTABLE_REF" --ref "$GITHUB_REF" --allowed-events schedule,workflow_dispatch,push >/dev/null; then | |
| echo 'signed=true' >> "$GITHUB_OUTPUT" | |
| else | |
| echo 'signed=false' >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Sign immutable catalog and verify it before moving catalog-v1 | |
| id: sign-catalog | |
| if: steps.catalog-evidence.outputs.signed != 'true' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ${{ env.PACKAGE_REPOSITORY }} | |
| subject-digest: ${{ steps.catalog.outputs.manifest_digest }} | |
| push-to-registry: true | |
| create-storage-record: false | |
| - name: Verify signed catalog and move only authorized aliases | |
| shell: bash | |
| env: | |
| CATALOG_MANIFEST: ${{ steps.catalog.outputs.manifest_digest }} | |
| CATALOG_DIGEST: ${{ steps.catalog.outputs.catalog_digest }} | |
| PLAN: ${{ steps.plan.outputs.plan }} | |
| PROFILE: ${{ needs.resolve.outputs.profile }} | |
| 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:}" | |
| [[ "$(oras resolve "$immutable_catalog_ref")" == "$CATALOG_MANIFEST" ]] | |
| go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --profile "$PROFILE" --reference "$immutable_catalog_ref" --ref "$GITHUB_REF" --allowed-events schedule,workflow_dispatch,push >/dev/null | |
| catalog_moving="${PACKAGE_REPOSITORY}:catalog-v1" | |
| catalog_pointer_error="$RUNNER_TEMP/epar-promotion/catalog-pointer-resolve.error" | |
| old_catalog_digest='' | |
| if old_catalog_digest="$(oras resolve "$catalog_moving" 2>"$catalog_pointer_error")"; then | |
| [[ "$old_catalog_digest" =~ ^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 | |
| 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" | |
| old_alias_digest='' | |
| if [[ "$plan_action" == advance-alias && "$ALLOW_ALIAS" == true && "$SOURCE_RECHECKED" == true ]]; then | |
| move_alias=true | |
| expected_alias="$(jq -r '.expectedAliasDigest // empty' "$PLAN")" | |
| alias_pointer_error="$RUNNER_TEMP/epar-promotion/alias-pointer-resolve.error" | |
| if old_alias_digest="$(oras resolve "$alias_ref" 2>"$alias_pointer_error")"; then | |
| [[ "$old_alias_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ -z "$expected_alias" || "$old_alias_digest" == "$expected_alias" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$alias_pointer_error"; then | |
| cat "$alias_pointer_error" >&2 | |
| exit 1 | |
| fi | |
| [[ -z "$expected_alias" ]] | |
| old_alias_digest='' | |
| fi | |
| fi | |
| catalog_moved=false | |
| alias_moved=false | |
| rollback_pointers() { | |
| rc=$? | |
| trap - EXIT | |
| set +e | |
| if [[ "$alias_moved" == true ]]; 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 | |
| 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 ]]; 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 [[ "$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" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$alias_cas_error"; then | |
| cat "$alias_cas_error" >&2 | |
| exit 1 | |
| fi | |
| [[ -z "$expected_alias" ]] | |
| fi | |
| oras tag "$PACKAGE_REF" "${PROFILE}-latest" | |
| alias_moved=true | |
| [[ "$(oras resolve "$alias_ref")" == "${PACKAGE_REF##*@}" ]] | |
| trap - EXIT | |
| echo 'Signed catalog was moved before the authorized compatible-v1 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" | |
| fi | |
| prepare-promotion-review: | |
| name: Prepare protected promotion review | |
| if: inputs.promote_candidate == true && github.ref == 'refs/heads/main' && inputs.promotion_confirmation == 'PROMOTE' | |
| permissions: | |
| contents: read | |
| packages: read | |
| 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 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up ORAS | |
| uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 | |
| with: | |
| version: 1.3.3 | |
| - name: Verify candidate identities and prepare the reviewer checklist | |
| id: review | |
| shell: bash | |
| env: | |
| PROFILE: ${{ inputs.profile }} | |
| CANDIDATE_DIGEST: ${{ inputs.candidate_digest }} | |
| CANDIDATE_CATALOG_REFERENCE: ${{ inputs.candidate_catalog_reference }} | |
| ACCEPTANCE_EVIDENCE_JSON: ${{ inputs.acceptance_evidence_json }} | |
| run: | | |
| set -euo pipefail | |
| [[ "$PROFILE" == act || "$PROFILE" == full ]] | |
| [[ "$CANDIDATE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$CANDIDATE_CATALOG_REFERENCE" == "${PACKAGE_REPOSITORY}:catalog-v1-pkg-"* || "$CANDIDATE_CATALOG_REFERENCE" == "${PACKAGE_REPOSITORY}@sha256:"* ]] | |
| evidence_input="$RUNNER_TEMP/promotion-review-input.json" | |
| printf '%s' "$ACCEPTANCE_EVIDENCE_JSON" > "$evidence_input" | |
| jq -e 'type == "object" and (keys | sort) == ["amd64DockerHubRunId","amd64DockerHubRunnerName","amd64PlaywrightRunId","amd64PlaywrightRunnerName","amd64ReceiptSha256","arm64DockerHubRunId","arm64DockerHubRunnerName","arm64PlaywrightRunId","arm64PlaywrightRunnerName","arm64ReceiptSha256"]' "$evidence_input" >/dev/null | |
| amd64_playwright_run_id="$(jq -er '.amd64PlaywrightRunId | tostring' "$evidence_input")" | |
| amd64_dockerhub_run_id="$(jq -er '.amd64DockerHubRunId | tostring' "$evidence_input")" | |
| arm64_playwright_run_id="$(jq -er '.arm64PlaywrightRunId | tostring' "$evidence_input")" | |
| arm64_dockerhub_run_id="$(jq -er '.arm64DockerHubRunId | tostring' "$evidence_input")" | |
| amd64_receipt="$(jq -er '.amd64ReceiptSha256' "$evidence_input")" | |
| arm64_receipt="$(jq -er '.arm64ReceiptSha256' "$evidence_input")" | |
| amd64_playwright_runner="$(jq -er '.amd64PlaywrightRunnerName' "$evidence_input")" | |
| amd64_dockerhub_runner="$(jq -er '.amd64DockerHubRunnerName' "$evidence_input")" | |
| arm64_playwright_runner="$(jq -er '.arm64PlaywrightRunnerName' "$evidence_input")" | |
| arm64_dockerhub_runner="$(jq -er '.arm64DockerHubRunnerName' "$evidence_input")" | |
| for run_id in "$amd64_playwright_run_id" "$amd64_dockerhub_run_id" "$arm64_playwright_run_id" "$arm64_dockerhub_run_id"; do | |
| [[ "$run_id" =~ ^[1-9][0-9]*$ ]] | |
| done | |
| [[ "$amd64_receipt" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$arm64_receipt" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| digest_prefix="${CANDIDATE_DIGEST#sha256:}" | |
| digest_prefix="${digest_prefix:0:12}" | |
| [[ "$amd64_playwright_runner" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-amd64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$amd64_dockerhub_runner" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-amd64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$arm64_playwright_runner" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-arm64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$arm64_dockerhub_runner" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-arm64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$amd64_playwright_runner" != "$amd64_dockerhub_runner" ]] | |
| [[ "$arm64_playwright_runner" != "$arm64_dockerhub_runner" ]] | |
| candidate_catalog_manifest="$(oras resolve "$CANDIDATE_CATALOG_REFERENCE")" | |
| [[ "$candidate_catalog_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| exact_catalog_reference="${PACKAGE_REPOSITORY}@${candidate_catalog_manifest}" | |
| catalog="$RUNNER_TEMP/promotion-review-catalog.json" | |
| go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --reference "$exact_catalog_reference" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push --output "$catalog" >/dev/null | |
| jq -e --arg digest "$CANDIDATE_DIGEST" '([.entries[] | select(.packageIndexDigest == $digest and .status == "candidate")] | length) == 1 and ([.transitions[]? | select(.packageIndexDigest == $digest)] | length) == 0' "$catalog" >/dev/null | |
| entry="$RUNNER_TEMP/promotion-review-entry.json" | |
| jq -e --arg digest "$CANDIDATE_DIGEST" --arg profile "$PROFILE" '.entries[] | select(.packageIndexDigest == $digest and .profile == $profile)' "$catalog" > "$entry" | |
| jq -e --arg repo "$PACKAGE_REPOSITORY" --arg digest "$CANDIDATE_DIGEST" '.packageReference == ($repo + "@" + $digest) and (.platforms | length == 2) and ([.platforms[] | select(.validated != true)] | length == 0)' "$entry" >/dev/null | |
| go run ./cmd/epar-prebuilt-publisher verify-package --reference "${PACKAGE_REPOSITORY}@${CANDIDATE_DIGEST}" --entry "$entry" --repository "$PACKAGE_REPOSITORY" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push >/dev/null | |
| source_reference="$(jq -er '.source.reference' "$entry")" | |
| source_digest="$(jq -er '.source.indexDigest' "$entry")" | |
| [[ "$(oras resolve "$source_reference")" == "$source_digest" ]] | |
| recipe_digest="$(jq -er '.recipe.digest' "$entry")" | |
| recipe_revision="$(jq -er '.recipe.recipeRevision' "$entry")" | |
| runtime_contract="$(jq -er '.recipe.runtimeContract' "$entry")" | |
| template_schema="$(jq -er '.recipe.templateSchema' "$entry")" | |
| runner_version="$(jq -er '.runner.version' "$entry")" | |
| package_amd64="$(jq -er '.platforms[] | select(.platform == "linux/amd64") | .packageManifestDigest' "$entry")" | |
| 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 | |
| echo '> This unprotected job verified the signed candidate catalog and package evidence but did not approve a deployment or move any package tag. Review the private test runs below before approving the `epar-prebuilt-promotion` environment.' | |
| echo | |
| echo '### Candidate identity' | |
| echo | |
| echo '| Field | Verified value |' | |
| echo '| --- | --- |' | |
| 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}\` |" | |
| echo "| Runtime contract | \`${runtime_contract}\`, template schema \`${template_schema}\` |" | |
| echo "| Actions runner | \`${runner_version}\` |" | |
| echo "| amd64 package/source | \`${package_amd64}\` / \`${source_amd64}\` |" | |
| echo "| arm64 package/source | \`${package_arm64}\` / \`${source_arm64}\` |" | |
| echo | |
| echo '### Human-reviewed acceptance evidence' | |
| echo | |
| echo '| Platform | Workflow run | EPAR runner | Receipt |' | |
| echo '| --- | --- | --- | --- |' | |
| echo "| linux/amd64 | [playwright-docker.yml run ${amd64_playwright_run_id}](https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/${amd64_playwright_run_id}) | \`${amd64_playwright_runner}\` | \`${amd64_receipt}\` |" | |
| echo "| linux/amd64 | [dockerhub-private-pull.yml run ${amd64_dockerhub_run_id}](https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/${amd64_dockerhub_run_id}) | \`${amd64_dockerhub_runner}\` | \`${amd64_receipt}\` |" | |
| echo "| linux/arm64 | [playwright-docker.yml run ${arm64_playwright_run_id}](https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/${arm64_playwright_run_id}) | \`${arm64_playwright_runner}\` | \`${arm64_receipt}\` |" | |
| echo "| linux/arm64 | [dockerhub-private-pull.yml run ${arm64_dockerhub_run_id}](https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/${arm64_dockerhub_run_id}) | \`${arm64_dockerhub_runner}\` | \`${arm64_receipt}\` |" | |
| echo | |
| echo '### Reviewer checklist' | |
| echo | |
| echo '- [ ] Open all four authenticated private-repository links and confirm each conclusion is `success`.' | |
| echo '- [ ] Confirm every job used the listed unique EPAR runner and the `epar-dev-test` runner group.' | |
| echo '- [ ] Confirm the two receipt hashes match the accepted amd64 and arm64 EPAR controllers.' | |
| echo '- [ ] Confirm both EPAR controllers stopped with no owned Sandbox, runner, or staging residue.' | |
| echo "- [ ] Confirm this exact package digest and catalog are the artifacts intended for \`${PROFILE}-latest\`." | |
| echo | |
| echo "After this review, approve the waiting protected deployment. The protected job repeats all machine-verifiable checks before it can move \`catalog-v1\` and then \`${PROFILE}-latest\`." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| manual-promote: | |
| name: Manually promote one verified candidate digest | |
| needs: prepare-promotion-review | |
| environment: epar-prebuilt-promotion | |
| if: inputs.promote_candidate == true && github.ref == 'refs/heads/main' && inputs.promotion_confirmation == 'PROMOTE' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| steps: | |
| - name: Check out source | |
| uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 | |
| - name: Set up Go | |
| uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 | |
| with: | |
| go-version-file: go.mod | |
| cache: true | |
| - name: Set up ORAS | |
| uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 | |
| with: | |
| version: 1.3.3 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ github.token }} | |
| - name: Verify exact candidate catalog and record human-reviewed acceptance | |
| id: verify | |
| shell: bash | |
| env: | |
| PROFILE: ${{ inputs.profile }} | |
| CANDIDATE_DIGEST: ${{ inputs.candidate_digest }} | |
| CANDIDATE_CATALOG_REFERENCE: ${{ inputs.candidate_catalog_reference }} | |
| ACCEPTANCE_EVIDENCE_JSON: ${{ inputs.acceptance_evidence_json }} | |
| PACKAGE_REPOSITORY: ${{ env.PACKAGE_REPOSITORY }} | |
| run: | | |
| set -euo pipefail | |
| [[ "$PROFILE" == act || "$PROFILE" == full ]] | |
| [[ "$CANDIDATE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$CANDIDATE_CATALOG_REFERENCE" == "${PACKAGE_REPOSITORY}:catalog-v1-pkg-"* || "$CANDIDATE_CATALOG_REFERENCE" == "${PACKAGE_REPOSITORY}@sha256:"* ]] | |
| evidence_input="$RUNNER_TEMP/manual-acceptance-input.json" | |
| printf '%s' "$ACCEPTANCE_EVIDENCE_JSON" > "$evidence_input" | |
| jq -e 'type == "object" and (keys | sort) == ["amd64DockerHubRunId","amd64DockerHubRunnerName","amd64PlaywrightRunId","amd64PlaywrightRunnerName","amd64ReceiptSha256","arm64DockerHubRunId","arm64DockerHubRunnerName","arm64PlaywrightRunId","arm64PlaywrightRunnerName","arm64ReceiptSha256"]' "$evidence_input" >/dev/null | |
| AMD64_PLAYWRIGHT_RUN_ID="$(jq -er '.amd64PlaywrightRunId | tostring' "$evidence_input")" | |
| AMD64_DOCKERHUB_RUN_ID="$(jq -er '.amd64DockerHubRunId | tostring' "$evidence_input")" | |
| ARM64_PLAYWRIGHT_RUN_ID="$(jq -er '.arm64PlaywrightRunId | tostring' "$evidence_input")" | |
| ARM64_DOCKERHUB_RUN_ID="$(jq -er '.arm64DockerHubRunId | tostring' "$evidence_input")" | |
| AMD64_RECEIPT_SHA256="$(jq -er '.amd64ReceiptSha256' "$evidence_input")" | |
| ARM64_RECEIPT_SHA256="$(jq -er '.arm64ReceiptSha256' "$evidence_input")" | |
| AMD64_PLAYWRIGHT_RUNNER_NAME="$(jq -er '.amd64PlaywrightRunnerName' "$evidence_input")" | |
| AMD64_DOCKERHUB_RUNNER_NAME="$(jq -er '.amd64DockerHubRunnerName' "$evidence_input")" | |
| ARM64_PLAYWRIGHT_RUNNER_NAME="$(jq -er '.arm64PlaywrightRunnerName' "$evidence_input")" | |
| ARM64_DOCKERHUB_RUNNER_NAME="$(jq -er '.arm64DockerHubRunnerName' "$evidence_input")" | |
| for run_id in "$AMD64_PLAYWRIGHT_RUN_ID" "$AMD64_DOCKERHUB_RUN_ID" "$ARM64_PLAYWRIGHT_RUN_ID" "$ARM64_DOCKERHUB_RUN_ID"; do | |
| [[ "$run_id" =~ ^[1-9][0-9]*$ ]] | |
| done | |
| [[ "$AMD64_RECEIPT_SHA256" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ "$ARM64_RECEIPT_SHA256" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| digest_prefix="${CANDIDATE_DIGEST#sha256:}" | |
| digest_prefix="${digest_prefix:0:12}" | |
| [[ "$AMD64_PLAYWRIGHT_RUNNER_NAME" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-amd64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$AMD64_DOCKERHUB_RUNNER_NAME" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-amd64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$ARM64_PLAYWRIGHT_RUNNER_NAME" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-arm64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$ARM64_DOCKERHUB_RUNNER_NAME" =~ ^epar-prebuilt-${PROFILE}-${digest_prefix}-arm64-[0-9]{8}-[0-9]{6}-[0-9]{3}$ ]] | |
| [[ "$AMD64_PLAYWRIGHT_RUNNER_NAME" != "$AMD64_DOCKERHUB_RUNNER_NAME" ]] | |
| [[ "$ARM64_PLAYWRIGHT_RUNNER_NAME" != "$ARM64_DOCKERHUB_RUNNER_NAME" ]] | |
| candidate_catalog_manifest="$(oras resolve "$CANDIDATE_CATALOG_REFERENCE")" | |
| [[ "$candidate_catalog_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| exact_catalog_reference="${PACKAGE_REPOSITORY}@${candidate_catalog_manifest}" | |
| catalog="$RUNNER_TEMP/manual-catalog.json" | |
| go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --reference "$exact_catalog_reference" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push --output "$catalog" >/dev/null | |
| jq -e --arg digest "$CANDIDATE_DIGEST" '([.entries[] | select(.packageIndexDigest == $digest and .status == "candidate")] | length) == 1 and ([.transitions[]? | select(.packageIndexDigest == $digest)] | length) == 0' "$catalog" >/dev/null | |
| jq -e --arg digest "$CANDIDATE_DIGEST" --arg profile "$PROFILE" '.entries[] | select(.packageIndexDigest == $digest and .profile == $profile)' "$catalog" > "$RUNNER_TEMP/manual-entry.json" | |
| jq -e --arg repo "$PACKAGE_REPOSITORY" --arg digest "$CANDIDATE_DIGEST" '.packageReference == ($repo + "@" + $digest) and (.platforms | length == 2) and ([.platforms[] | select(.validated != true)] | length == 0)' "$RUNNER_TEMP/manual-entry.json" >/dev/null | |
| go run ./cmd/epar-prebuilt-publisher verify-package --reference "${PACKAGE_REPOSITORY}@${CANDIDATE_DIGEST}" --entry "$RUNNER_TEMP/manual-entry.json" --repository "$PACKAGE_REPOSITORY" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push | |
| source_reference="$(jq -er '.source.reference' "$RUNNER_TEMP/manual-entry.json")" | |
| source_digest="$(jq -er '.source.indexDigest' "$RUNNER_TEMP/manual-entry.json")" | |
| [[ "$(oras resolve "$source_reference")" == "$source_digest" ]] | |
| accepted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| jq -n \ | |
| --arg digest "$CANDIDATE_DIGEST" --arg profile "$PROFILE" --arg acceptedAt "$accepted_at" --arg reviewer "$GITHUB_ACTOR" \ | |
| --arg amd64Receipt "$AMD64_RECEIPT_SHA256" --arg arm64Receipt "$ARM64_RECEIPT_SHA256" \ | |
| --arg amd64PlaywrightRunner "$AMD64_PLAYWRIGHT_RUNNER_NAME" --arg amd64DockerHubRunner "$AMD64_DOCKERHUB_RUNNER_NAME" \ | |
| --arg arm64PlaywrightRunner "$ARM64_PLAYWRIGHT_RUNNER_NAME" --arg arm64DockerHubRunner "$ARM64_DOCKERHUB_RUNNER_NAME" \ | |
| --arg amd64Label "epar-prebuilt-${PROFILE}-${digest_prefix}-amd64" --arg arm64Label "epar-prebuilt-${PROFILE}-${digest_prefix}-arm64" \ | |
| --argjson amd64Playwright "$AMD64_PLAYWRIGHT_RUN_ID" --argjson amd64DockerHub "$AMD64_DOCKERHUB_RUN_ID" \ | |
| --argjson arm64Playwright "$ARM64_PLAYWRIGHT_RUN_ID" --argjson arm64DockerHub "$ARM64_DOCKERHUB_RUN_ID" \ | |
| '[ | |
| {schemaVersion:3,profile:$profile,packageIndexDigest:$digest,platform:"linux/amd64",runnerGroup:"epar-dev-test",runnerLabel:$amd64Label,receiptSha256:$amd64Receipt,importReadback:true,runtimeValidated:true,cleanupValidated:true,reviewedBy:$reviewer,acceptedAt:$acceptedAt,workflowRuns:[ | |
| {repository:"solutionforest/ephemeral-action-runner-test",workflow:"playwright-docker.yml",runId:$amd64Playwright,url:("https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/"+($amd64Playwright|tostring)),conclusion:"success",runnerName:$amd64PlaywrightRunner}, | |
| {repository:"solutionforest/ephemeral-action-runner-test",workflow:"dockerhub-private-pull.yml",runId:$amd64DockerHub,url:("https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/"+($amd64DockerHub|tostring)),conclusion:"success",runnerName:$amd64DockerHubRunner} | |
| ]}, | |
| {schemaVersion:3,profile:$profile,packageIndexDigest:$digest,platform:"linux/arm64",runnerGroup:"epar-dev-test",runnerLabel:$arm64Label,receiptSha256:$arm64Receipt,importReadback:true,runtimeValidated:true,cleanupValidated:true,reviewedBy:$reviewer,acceptedAt:$acceptedAt,workflowRuns:[ | |
| {repository:"solutionforest/ephemeral-action-runner-test",workflow:"playwright-docker.yml",runId:$arm64Playwright,url:("https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/"+($arm64Playwright|tostring)),conclusion:"success",runnerName:$arm64PlaywrightRunner}, | |
| {repository:"solutionforest/ephemeral-action-runner-test",workflow:"dockerhub-private-pull.yml",runId:$arm64DockerHub,url:("https://github.com/solutionforest/ephemeral-action-runner-test/actions/runs/"+($arm64DockerHub|tostring)),conclusion:"success",runnerName:$arm64DockerHubRunner} | |
| ]} | |
| ]' > "$RUNNER_TEMP/manual-acceptance.json" | |
| go run ./cmd/epar-prebuilt-publisher accept --catalog "$catalog" --input "$RUNNER_TEMP/manual-acceptance.json" | |
| alias_digest="$(jq -r --arg profile "$PROFILE" '.aliases[$profile].packageIndexDigest // empty' "$catalog")" | |
| jq -n --slurpfile entry "$RUNNER_TEMP/manual-entry.json" --arg source "$source_digest" --arg sourceReference "$source_reference" --arg alias "$alias_digest" '{action:"candidate",reason:"protected human-reviewed two-platform EPAR acceptance",expectedSourceDigest:$source,expectedAliasDigest:$alias,sourceReference:$sourceReference,entry:$entry[0]}' > "$RUNNER_TEMP/manual-plan.json" | |
| echo "catalog=$catalog" >> "$GITHUB_OUTPUT" | |
| echo "plan=$RUNNER_TEMP/manual-plan.json" >> "$GITHUB_OUTPUT" | |
| - name: Promote the exact candidate with publisher CAS | |
| shell: bash | |
| env: | |
| CATALOG: ${{ steps.verify.outputs.catalog }} | |
| PLAN: ${{ steps.verify.outputs.plan }} | |
| run: | | |
| set -euo pipefail | |
| go run ./cmd/epar-prebuilt-publisher promote --protected --catalog "$CATALOG" --plan "$PLAN" | |
| - name: Canonicalize, sign, verify, and expose the promoted catalog | |
| id: manual-catalog | |
| shell: bash | |
| env: | |
| CATALOG: ${{ steps.verify.outputs.catalog }} | |
| run: | | |
| set -euo pipefail | |
| catalog_json="$RUNNER_TEMP/manual-catalog.canonical.json" | |
| catalog_output="$(go run ./cmd/epar-prebuilt-publisher catalog --catalog "$CATALOG" --output "$catalog_json")" | |
| catalog_digest="$(printf '%s\n' "$catalog_output" | grep -Eo 'sha256:[0-9a-f]{64}' | tail -n 1 || true)" | |
| [[ "$catalog_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| immutable_ref="${PACKAGE_REPOSITORY}:catalog-v1-pkg-${catalog_digest#sha256:}" | |
| config_file="$RUNNER_TEMP/manual-catalog-config.json" | |
| printf '%s\n' '{}' > "$config_file" | |
| catalog_tag_error="$RUNNER_TEMP/manual-catalog-tag-resolve.error" | |
| if existing_manifest="$(oras resolve "$immutable_ref" 2>"$catalog_tag_error")"; then | |
| [[ "$existing_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| existing_json="$RUNNER_TEMP/manual-existing-catalog.json" | |
| bash .github/scripts/fetch-prebuilt-catalog.sh "$PACKAGE_REPOSITORY" "${PACKAGE_REPOSITORY}@${existing_manifest}" "$existing_json" | |
| existing_output="$(go run ./cmd/epar-prebuilt-publisher catalog --catalog "$existing_json" --output "$RUNNER_TEMP/manual-existing-canonical.json")" | |
| existing_digest="$(printf '%s\n' "$existing_output" | grep -Eo 'sha256:[0-9a-f]{64}' | tail -n 1 || true)" | |
| [[ "$existing_digest" == "$catalog_digest" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$catalog_tag_error"; then | |
| cat "$catalog_tag_error" >&2 | |
| exit 1 | |
| fi | |
| ( | |
| cd "$RUNNER_TEMP" | |
| oras push "$immutable_ref" --artifact-type "$CATALOG_ARTIFACT_TYPE" --config "manual-catalog-config.json:$CATALOG_CONFIG_MEDIA_TYPE" "manual-catalog.canonical.json:$CATALOG_LAYER_MEDIA_TYPE" | |
| ) | |
| existing_manifest="$(oras resolve "$immutable_ref")" | |
| fi | |
| [[ "$existing_manifest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| oras manifest fetch "$immutable_ref" > "$RUNNER_TEMP/manual-catalog-manifest.json" | |
| jq -e --arg artifact "$CATALOG_ARTIFACT_TYPE" --arg config "$CATALOG_CONFIG_MEDIA_TYPE" --arg layer "$CATALOG_LAYER_MEDIA_TYPE" '(.schemaVersion == 2) and (.mediaType == "application/vnd.oci.image.manifest.v1+json") and (.artifactType == $artifact) and (.config.mediaType == $config) and ((.layers | length) == 1) and (.layers[0].mediaType == $layer)' "$RUNNER_TEMP/manual-catalog-manifest.json" >/dev/null | |
| echo "catalog_digest=$catalog_digest" >> "$GITHUB_OUTPUT" | |
| echo "manifest_digest=$existing_manifest" >> "$GITHUB_OUTPUT" | |
| echo "immutable_ref=$immutable_ref" >> "$GITHUB_OUTPUT" | |
| - name: Detect existing manual catalog evidence for idempotent reruns | |
| id: manual-catalog-evidence | |
| shell: bash | |
| env: | |
| CATALOG_MANIFEST: ${{ steps.manual-catalog.outputs.manifest_digest }} | |
| IMMUTABLE_REF: ${{ steps.manual-catalog.outputs.immutable_ref }} | |
| run: | | |
| set -euo pipefail | |
| oras discover "${PACKAGE_REPOSITORY}@${CATALOG_MANIFEST}" --format json > "$RUNNER_TEMP/manual-catalog-referrers.json" | |
| jq -e '.referrers | type == "array"' "$RUNNER_TEMP/manual-catalog-referrers.json" >/dev/null | |
| if go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --reference "$IMMUTABLE_REF" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push >/dev/null; then | |
| echo 'signed=true' >> "$GITHUB_OUTPUT" | |
| else | |
| echo 'signed=false' >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Sign the manually promoted catalog | |
| if: steps.manual-catalog-evidence.outputs.signed != 'true' | |
| uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 | |
| with: | |
| subject-name: ${{ env.PACKAGE_REPOSITORY }} | |
| subject-digest: ${{ steps.manual-catalog.outputs.manifest_digest }} | |
| push-to-registry: true | |
| create-storage-record: false | |
| - name: Verify signed manual catalog and move exact aliases | |
| shell: bash | |
| env: | |
| PROFILE: ${{ inputs.profile }} | |
| CATALOG_DIGEST: ${{ steps.manual-catalog.outputs.catalog_digest }} | |
| CATALOG_MANIFEST: ${{ steps.manual-catalog.outputs.manifest_digest }} | |
| IMMUTABLE_REF: ${{ steps.manual-catalog.outputs.immutable_ref }} | |
| 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" ]] | |
| go run ./cmd/epar-prebuilt-publisher verify-catalog --repository "$PACKAGE_REPOSITORY" --profile "$PROFILE" --reference "$IMMUTABLE_REF" --ref refs/heads/main --allowed-events schedule,workflow_dispatch,push >/dev/null | |
| catalog_moving="${PACKAGE_REPOSITORY}:catalog-v1" | |
| catalog_pointer_error="$RUNNER_TEMP/manual-catalog-pointer-resolve.error" | |
| old_catalog_digest='' | |
| if old_catalog_digest="$(oras resolve "$catalog_moving" 2>"$catalog_pointer_error")"; then | |
| [[ "$old_catalog_digest" =~ ^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 | |
| 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")" | |
| alias_pointer_error="$RUNNER_TEMP/manual-alias-pointer-resolve.error" | |
| old_alias_digest='' | |
| if old_alias_digest="$(oras resolve "$alias_ref" 2>"$alias_pointer_error")"; then | |
| [[ "$old_alias_digest" =~ ^sha256:[0-9a-f]{64}$ ]] | |
| [[ -z "$expected_alias" || "$old_alias_digest" == "$expected_alias" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$alias_pointer_error"; then | |
| cat "$alias_pointer_error" >&2 | |
| exit 1 | |
| fi | |
| [[ -z "$expected_alias" ]] | |
| old_alias_digest='' | |
| fi | |
| catalog_moved=false | |
| alias_moved=false | |
| rollback_manual_pointers() { | |
| rc=$? | |
| trap - EXIT | |
| set +e | |
| if [[ "$alias_moved" == true ]]; 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 | |
| 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 ]]; 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" ]] | |
| alias_cas_error="$RUNNER_TEMP/manual-alias-cas-resolve.error" | |
| if actual_alias="$(oras resolve "$alias_ref" 2>"$alias_cas_error")"; then | |
| [[ -z "$expected_alias" || "$actual_alias" == "$expected_alias" ]] | |
| else | |
| if ! grep -Eqi 'manifest unknown|not found|404' "$alias_cas_error"; then | |
| cat "$alias_cas_error" >&2 | |
| exit 1 | |
| fi | |
| [[ -z "$expected_alias" ]] | |
| fi | |
| oras tag "${PACKAGE_REPOSITORY}@${CANDIDATE_DIGEST}" "$alias_tag" | |
| alias_moved=true | |
| [[ "$(oras resolve "$alias_ref")" == "$CANDIDATE_DIGEST" ]] | |
| trap - EXIT | |
| echo 'Manual candidate promotion completed only after exact digest, package evidence, source recheck, catalog media, alias CAS, and pointer rollback checks.' >> "$GITHUB_STEP_SUMMARY" | |
| noop: | |
| name: Report unchanged immutable tuple | |
| needs: resolve | |
| if: needs.resolve.outputs.noop == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Report no-op | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| 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" |