Skip to content

Docker Sandboxes prebuilt images #140

Docker Sandboxes prebuilt images

Docker Sandboxes prebuilt images #140

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"