Skip to content

Add a declarative file-ingest capability for browser demos (with DOOM WAD upload) #474

Add a declarative file-ingest capability for browser demos (with DOOM WAD upload)

Add a declarative file-ingest capability for browser demos (with DOOM WAD upload) #474

Workflow file for this run

name: Prepare merge
# Triggered when a maintainer applies the `ready-to-ship` label to an
# approved PR. Captures the current base branch SHA once, synthesizes a
# PR-head-into-that-base merge commit, and drives the per-package matrix
# flow against that exact synthetic merge (preflight + toolchain-cache +
# matrix-build + test-gate; Phase B-1's pattern, targeting the durable
# `binaries-abi-v<N>` tag). It then posts `merge-gate=success` on the
# original PR head and enables auto-merge. PRs labeled `batched-changes`
# use rebase auto-merge so their granular commits land on main; all other
# PRs keep the default squash auto-merge behavior.
#
# Flow (see docs/plans/2026-05-05-decoupled-package-builds-design.md
# §5.5 + docs/plans/2026-04-29-pr-package-builds-design.md §4.2):
# * targets the undated `binaries-abi-v<N>` tag (per-archive uploads),
# * preflight runs against the synthetic merge commit and narrows the
# matrix to `(package, arch)` pairs whose cache_key_sha differs from
# what's already published in the durable release,
# * builds each remaining pair in parallel via `xtask archive-stage`,
# publishing each archive and index entry atomically, and
# * gates the merge on the test-suite resolving from the durable
# release state.
#
# `merge-gate=success` is posted on the original PR HEAD SHA after the
# durable-release test gate passes against the synthetic merge commit.
# Branch protection on `main` requires this status.
#
# See:
# docs/plans/2026-05-05-decoupled-package-builds-design.md §5.5
# docs/plans/2026-04-29-pr-package-builds-design.md §4.2
on:
pull_request:
types: [labeled]
env:
PREPARE_MERGE_METHOD: ${{ contains(github.event.pull_request.labels.*.name, 'batched-changes') && 'rebase' || 'squash' }}
PREPARE_MERGE_FLAG: ${{ contains(github.event.pull_request.labels.*.name, 'batched-changes') && '--rebase' || '--squash' }}
GIT_FETCH_RETRY_FUNC: |
git_fetch_retry() {
local attempt=1
local max_attempts=5
local delay=2
local rc
while true; do
if git fetch "$@"; then
return 0
fi
rc=$?
if [ "$attempt" -ge "$max_attempts" ]; then
return "$rc"
fi
echo "::warning::git fetch failed with exit code $rc (attempt $attempt/$max_attempts); retrying in ${delay}s" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
# Do not use workflow-level concurrency. GitHub Actions keeps at most
# one pending run per concurrency group, so a third ready PR cancels/
# replaces the second instead of forming a real queue. The
# durable-release-lock (acquired by `merge-gate-finalize`) serializes
# durable-release publishing while preserving every workflow run.
jobs:
# ---------------------------------------------------------------------
# synthesize-merge — capture exactly one base SHA and synthesize the PR
# merge commit used by every package/test job in this run.
#
# Do not consume GitHub's refs/pull/<N>/merge here. That ref can lag
# behind the live base branch on pull_request:labeled events. The
# selected base SHA below is the single base used for change scope,
# package cache keys/builds/promotions, and test-gate. Final posting jobs
# still re-check that the base branch has not advanced since this SHA.
# ---------------------------------------------------------------------
synthesize-merge:
if: |
github.event.label.name == 'ready-to-ship' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
outputs:
base_sha: ${{ steps.synthesize.outputs.base_sha }}
head_sha: ${{ steps.synthesize.outputs.head_sha }}
merge_sha: ${{ steps.synthesize.outputs.merge_sha }}
bundle_artifact: ${{ steps.synthesize.outputs.bundle_artifact }}
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Capture base and synthesize PR merge
id: synthesize
run: |
set -euo pipefail
work="$RUNNER_TEMP/synthesize-merge"
bundle_dir="$RUNNER_TEMP/synthetic-pr-merge"
mkdir -p "$bundle_dir"
git init --quiet "$work"
cd "$work"
git remote add origin "https://${{ github.token }}@github.com/${{ github.repository }}.git"
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
BASE_SHA=$(git rev-parse "refs/remotes/origin/${BASE_REF}")
git_fetch_retry --quiet --no-tags origin "$PR_HEAD_SHA"
if ! git cat-file -e "$PR_HEAD_SHA^{commit}" 2>/dev/null; then
echo "::error::Could not fetch PR head commit $PR_HEAD_SHA"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
export GIT_AUTHOR_NAME="github-actions[bot]"
export GIT_AUTHOR_EMAIL="41898282+github-actions[bot]@users.noreply.github.com"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
head_date=$(git show -s --format=%cI "$PR_HEAD_SHA")
export GIT_AUTHOR_DATE="$head_date"
export GIT_COMMITTER_DATE="$head_date"
git checkout --quiet --detach "$BASE_SHA"
set +e
merge_output=$(git merge --no-ff --no-commit "$PR_HEAD_SHA" 2>&1)
merge_status=$?
set -e
printf '%s\n' "$merge_output"
if [ "$merge_status" -ne 0 ]; then
echo "::error::Could not synthesize PR merge from base $BASE_SHA and head $PR_HEAD_SHA"
exit 1
fi
TREE_SHA=$(git write-tree)
MERGE_SHA=$(printf 'Synthetic prepare-merge for PR #%s\n\nBase: %s\nHead: %s\n' "$PR_NUMBER" "$BASE_SHA" "$PR_HEAD_SHA" |
git commit-tree "$TREE_SHA" -p "$BASE_SHA" -p "$PR_HEAD_SHA")
git update-ref refs/heads/synthesized-merge "$MERGE_SHA"
parents=$(git show --no-patch --format=%P "$MERGE_SHA")
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
git bundle create "$bundle_dir/synthesized-merge.bundle" \
refs/heads/synthesized-merge "^$BASE_SHA" "^$PR_HEAD_SHA"
echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "head_sha=$PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "merge_sha=$MERGE_SHA" >> "$GITHUB_OUTPUT"
echo "bundle_artifact=synthetic-pr-merge" >> "$GITHUB_OUTPUT"
echo "Synthetic merge $MERGE_SHA uses base $BASE_SHA and head $PR_HEAD_SHA."
- name: Upload synthetic merge bundle
uses: actions/upload-artifact@v7
with:
name: synthetic-pr-merge
path: ${{ runner.temp }}/synthetic-pr-merge/synthesized-merge.bundle
retention-days: 1
if-no-files-found: error
- name: Drop label + comment on synthesis failure
if: failure()
run: |
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: prepare-merge could not synthesize and persist a merge of PR head \`$PR_HEAD_SHA\` into the current \`$BASE_REF\` base. Please rebase or merge \`$BASE_REF\` if there are conflicts, then re-apply \`ready-to-ship\`." || true
# ---------------------------------------------------------------------
# gate — review state + required-checks + wait-for-staging-build
# when package staging is required.
# All gates that must pass BEFORE we burn matrix-build compute live
# here so a failed gate is cheap. On success, downstream jobs run.
# ---------------------------------------------------------------------
change-scope:
needs: [synthesize-merge]
if: |
github.event.label.name == 'ready-to-ship' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
kernel: ${{ steps.scope.outputs.kernel }}
package_archive_changed: ${{ steps.scope.outputs.package_archive_changed }}
package_publish_flow_changed: ${{ steps.scope.outputs.package_publish_flow_changed }}
binary_materialization_changed: ${{ steps.scope.outputs.binary_materialization_changed }}
kernel_runtime_changed: ${{ steps.scope.outputs.kernel_runtime_changed }}
test_gate_required: ${{ steps.scope.outputs.test_gate_required }}
docs_only: ${{ steps.scope.outputs.docs_only }}
package_staging_required: ${{ steps.scope.outputs.package_staging_required }}
package_staging_reason: ${{ steps.scope.outputs.package_staging_reason }}
steps:
- uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Detect package staging scope
id: scope
uses: ./.github/actions/detect-change-scope
with:
base-ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-base: 'false'
gate:
needs: [change-scope]
if: |
github.event.label.name == 'ready-to-ship' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
actions: read # gh run list/watch for staging-build
checks: read # gh pr checks reads check runs
contents: read # gh pr view/checks repository metadata
pull-requests: write # drop label / comment on failure
statuses: read
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Verify PR review state + required checks green
id: gate
run: |
gh_retry() {
local attempt=1
local max_attempts=4
local delay=2
local stdout_file
local stderr_file
stdout_file="$(mktemp)"
stderr_file="$(mktemp)"
while true; do
: >"$stdout_file"
: >"$stderr_file"
if "$@" >"$stdout_file" 2>"$stderr_file"; then
cat "$stdout_file"
rm -f "$stdout_file" "$stderr_file"
return 0
fi
local rc=$?
if [ "$attempt" -ge "$max_attempts" ]; then
cat "$stderr_file" >&2
if [ -s "$stdout_file" ]; then
cat "$stdout_file" >&2
fi
rm -f "$stdout_file" "$stderr_file"
return "$rc"
fi
cat "$stderr_file" >&2
echo "::warning::GitHub command failed (attempt ${attempt}/${max_attempts}); retrying in ${delay}s: $*" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
# reviewDecision values:
# APPROVED — at least one approving review, branch
# protection requires reviews
# REVIEW_REQUIRED — branch protection requires reviews,
# none yet
# CHANGES_REQUESTED — at least one CHANGES_REQUESTED review
# ""/null — no review policy on the branch (solo
# maintainer flows, internal repos)
#
# Default policy: block REVIEW_REQUIRED and CHANGES_REQUESTED.
# Maintainer override: a user with admin/maintain permission
# who applies `ready-to-ship` can bypass the review gate (the
# ask was: let maintainers ship without an approving review).
# Required-check failures are NEVER bypassable here — those
# gate on objective signals.
LABELER="${{ github.event.sender.login }}"
PERM=$(gh_retry gh api "/repos/${{ github.repository }}/collaborators/${LABELER}/permission" -q .permission)
MAINTAINER=0
case "$PERM" in
admin|maintain) MAINTAINER=1 ;;
esac
echo "labeler=$LABELER permission=$PERM maintainer=$MAINTAINER"
REVIEW=$(gh_retry gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json reviewDecision -q .reviewDecision)
case "$REVIEW" in
REVIEW_REQUIRED|CHANGES_REQUESTED)
if [ "$MAINTAINER" = "1" ]; then
echo "::notice::review state $REVIEW; bypassed by maintainer ${LABELER}"
else
gh_retry gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh_retry gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: PR review state is ${REVIEW}. (A maintainer — admin/maintain permission — can re-apply \`ready-to-ship\` to override.)" || true
exit 1
fi
;;
esac
# Count failing required checks. Other non-skipping checks
# currently in flight are tolerated. Required-check failure is
# NOT bypassable, even by maintainers — the workflow's own
# post-publish tests gate the merge-gate status, and bypassing
# pre-existing failed checks here would defeat that intent.
REQUIRED_CHECKS_ERR="$RUNNER_TEMP/required-checks.err"
if ! FAILING=$(gh_retry gh pr checks "$PR_NUMBER" --repo ${{ github.repository }} --required --json bucket -q '[.[] | select(.bucket == "fail")] | length' 2>"$REQUIRED_CHECKS_ERR"); then
if grep -qi "no required checks reported" "$REQUIRED_CHECKS_ERR"; then
echo "::notice::no required checks reported yet; continuing"
FAILING=0
else
cat "$REQUIRED_CHECKS_ERR" >&2
exit 1
fi
fi
if [ "$FAILING" -gt 0 ]; then
gh_retry gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh_retry gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: $FAILING required check(s) failing." || true
exit 1
fi
- name: Checkout workflow helpers
if: |
needs.change-scope.outputs.package_staging_required == 'true' ||
needs.change-scope.outputs.test_gate_required == 'true'
uses: actions/checkout@v7.0.0
with:
ref: ${{ github.sha }}
sparse-checkout: |
.github/scripts
- name: Inspect staging-build on PR HEAD
if: |
needs.change-scope.outputs.package_staging_required == 'true' ||
needs.change-scope.outputs.test_gate_required == 'true'
# staging-build runs the same matrix flow on every PR push.
# If a real non-skipped staging-build exists for this HEAD,
# respect it: wait while it is running and block if it failed.
# If the only run is the intentionally skipped label-event run
# produced by ready-to-ship, fall through. prepare-merge's own
# synthetic-merge durable preflight/matrix/test-gate below will
# build or promote exact cache-key matches and validate the
# merge candidate before posting merge-gate=success.
run: |
gh_retry() {
local attempt=1
local max_attempts=4
local delay=2
local stdout_file
local stderr_file
stdout_file="$(mktemp)"
stderr_file="$(mktemp)"
while true; do
: >"$stdout_file"
: >"$stderr_file"
if "$@" >"$stdout_file" 2>"$stderr_file"; then
cat "$stdout_file"
rm -f "$stdout_file" "$stderr_file"
return 0
fi
local rc=$?
if [ "$attempt" -ge "$max_attempts" ]; then
cat "$stderr_file" >&2
if [ -s "$stdout_file" ]; then
cat "$stdout_file" >&2
fi
rm -f "$stdout_file" "$stderr_file"
return "$rc"
fi
cat "$stderr_file" >&2
echo "::warning::GitHub command failed (attempt ${attempt}/${max_attempts}); retrying in ${delay}s: $*" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
classify_runs() {
jq -c -f .github/scripts/select-staging-build-run.jq
}
log_summary() {
jq -r '
"staging-build selection: state=\(.state) total=\(.total_count) real=\(.real_count) skipped=\(.skipped_count) selected=\(.run_id) status=\(.status) conclusion=\(.conclusion) skipped_ids=\(.skipped_run_ids | join(","))"
'
}
# The label event can fire before GitHub registers related
# workflow runs against PR_HEAD_SHA. Retry only the truly
# empty state. A skipped-only state is meaningful: label
# events other than skip-staging-tests intentionally create
# all-skipped staging-build runs.
SUMMARY=""
for attempt in 1 2 3 4 5 6; do
RUNS_JSON=$(gh_retry gh run list \
--repo ${{ github.repository }} \
--workflow=staging-build.yml \
--commit="$PR_HEAD_SHA" \
--limit=100 \
--json databaseId,status,conclusion,createdAt)
SUMMARY=$(printf '%s\n' "$RUNS_JSON" | classify_runs)
printf '%s\n' "$SUMMARY" | log_summary
STATE=$(printf '%s\n' "$SUMMARY" | jq -r '.state')
[ "$STATE" != "no_runs" ] && break
[ "$attempt" = "6" ] && break
sleep 10
done
STATE=$(printf '%s\n' "$SUMMARY" | jq -r '.state')
RUN_ID=$(printf '%s\n' "$SUMMARY" | jq -r '.run_id')
RUN_STATUS=$(printf '%s\n' "$SUMMARY" | jq -r '.status')
RUN_CONCLUSION=$(printf '%s\n' "$SUMMARY" | jq -r '.conclusion')
SKIPPED_IDS=$(printf '%s\n' "$SUMMARY" | jq -r '.skipped_run_ids | join(", ")')
case "$STATE" in
no_runs)
echo "::notice::No staging-build run is registered for HEAD $PR_HEAD_SHA after the retry window; prepare-merge will run synthetic-merge package validation itself."
exit 0
;;
only_skipped)
echo "::notice::Only skipped staging-build run(s) found for HEAD $PR_HEAD_SHA (${SKIPPED_IDS:-none}). These are expected for non-skip-staging-tests label events such as ready-to-ship; prepare-merge will run synthetic-merge package validation itself."
exit 0
;;
real_success)
echo "Latest real staging-build run $RUN_ID for $PR_HEAD_SHA succeeded; continuing."
exit 0
;;
real_failed)
gh_retry gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh_retry gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: latest real non-skipped \`staging-build\` for HEAD \`$PR_HEAD_SHA\` concluded \`${RUN_CONCLUSION:-$RUN_STATUS}\` (run ${{ github.server_url }}/${{ github.repository }}/actions/runs/$RUN_ID). Skipped label-event staging runs are ignored and do not count as validation. Re-apply \`ready-to-ship\` once staging-build is green, or let a new commit trigger a fresh run." || true
exit 1
;;
real_in_progress)
;;
*)
echo "::error::Unknown staging-build selection state: $STATE"
exit 1
;;
esac
if [ -z "$RUN_ID" ]; then
echo "::error::staging-build selector returned state=$STATE without a run_id"
exit 1
fi
echo "Waiting for real staging-build run $RUN_ID on $PR_HEAD_SHA (status: $RUN_STATUS)."
# --exit-status: non-zero on failure/cancelled conclusion.
if ! gh_retry gh run watch "$RUN_ID" --repo ${{ github.repository }} --exit-status; then
gh_retry gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh_retry gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: real non-skipped \`staging-build\` for HEAD \`$PR_HEAD_SHA\` did not succeed (run ${{ github.server_url }}/${{ github.repository }}/actions/runs/$RUN_ID). Skipped label-event staging runs are ignored and do not count as validation. Re-apply \`ready-to-ship\` once staging-build is green." || true
exit 1
fi
- name: Drop label + comment on gate failure
if: failure()
run: |
gh_retry() {
local attempt=1
local max_attempts=4
local delay=2
local stdout_file
local stderr_file
stdout_file="$(mktemp)"
stderr_file="$(mktemp)"
while true; do
: >"$stdout_file"
: >"$stderr_file"
if "$@" >"$stdout_file" 2>"$stderr_file"; then
cat "$stdout_file"
rm -f "$stdout_file" "$stderr_file"
return 0
fi
local rc=$?
if [ "$attempt" -ge "$max_attempts" ]; then
cat "$stderr_file" >&2
if [ -s "$stdout_file" ]; then
cat "$stdout_file" >&2
fi
rm -f "$stdout_file" "$stderr_file"
return "$rc"
fi
cat "$stderr_file" >&2
echo "::warning::GitHub command failed (attempt ${attempt}/${max_attempts}); retrying in ${delay}s: $*" >&2
sleep "$delay"
attempt=$((attempt + 1))
delay=$((delay * 2))
done
}
gh_retry gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh_retry gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge gate failed. Check the workflow run log: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. Re-apply the ready-to-ship label after fixing." || true
# ---------------------------------------------------------------------
# Per-package matrix flow.
#
# Mirrors `staging-build.yml`'s preflight + toolchain-cache +
# matrix-build + test-gate + publish + generate-index, with one
# adjustment: target_tag is always `binaries-abi-v<N>` (durable),
# never `pr-<NNN>-staging`. The rest of the flow is structurally
# identical, including the deterministic-archive guarantees the
# matrix relies on.
#
# A future cleanup PR can extract this shape into a reusable
# `workflow_call` workflow shared between staging-build and
# prepare-merge. For the Phase C cutover we deliberately copy-paste
# to keep the diff localized.
# ---------------------------------------------------------------------
preflight:
needs: [synthesize-merge, gate, change-scope]
if: |
needs.change-scope.outputs.package_staging_required == 'true' ||
needs.change-scope.outputs.test_gate_required == 'true'
runs-on: ubuntu-latest
permissions:
actions: read # download synthesized merge artifact
contents: read # gh release view (target tag probe)
outputs:
library_matrix: ${{ steps.compute.outputs.library_matrix }}
program_matrix: ${{ steps.compute.outputs.program_matrix }}
promote_matrix: ${{ steps.compute.outputs.promote_matrix }}
abi: ${{ steps.compute.outputs.abi }}
target_tag: ${{ steps.compute.outputs.target_tag }}
staging_tag: ${{ steps.compute.outputs.staging_tag }}
merge_base_sha: ${{ steps.compute.outputs.merge_base_sha }}
steps:
- uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Fetch musl submodule
# Hardened submodule fetch via the reusable action (PR #435).
# preflight only needs musl (it builds xtask and computes the
# matrix; tests/libc/libc-test and tests/sortix/os-test materialize in matrix-build / test-gate
# via their own checkouts). Mergeability is proven by
# `synthesize-merge`, and base drift is checked by the final
# merge-gate posting jobs.
uses: ./.github/actions/fetch-submodules
with:
submodules: libc/musl
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
with:
github-token: ""
- name: Cache Nix store + flake eval
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
with:
use-gha-cache: false
use-flakehub: false
- name: Warm Nix dev shell
run: bash scripts/dev-shell.sh true
- name: Cache xtask build
uses: actions/cache@v5.0.5
with:
path: |
~/.cargo/registry
target
key: preflight-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'tools/xtask/**') }}
restore-keys: |
preflight-${{ runner.os }}-
- name: Build xtask
run: |
bash scripts/dev-shell.sh bash -c '
HOST_TARGET=$(rustc -vV | awk "/^host/ {print \$2}")
cargo build --release -p xtask --target "$HOST_TARGET"
'
- name: Compute matrix
id: compute
env:
GH_TOKEN: ${{ github.token }}
SYNTH_BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
SYNTH_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
bash scripts/dev-shell.sh bash -c '
set -euo pipefail
PACKAGE_STAGING_REQUIRED="${{ needs.change-scope.outputs.package_staging_required }}"
if [ "$(git rev-parse HEAD)" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::preflight is not on synthetic merge $SYNTHETIC_MERGE_SHA"
exit 1
fi
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf "%s\n" "$parents" | awk "{print \$1}")
actual_head=$(printf "%s\n" "$parents" | awk "{print \$2}")
MERGE_BASE_SHA="$SYNTH_BASE_SHA"
MERGE_HEAD_SHA="$SYNTH_HEAD_SHA"
if [ "$actual_base" != "$MERGE_BASE_SHA" ] || [ "$actual_head" != "$MERGE_HEAD_SHA" ]; then
echo "::error::prepare-merge synthetic merge parent mismatch: parents=$parents base=$MERGE_BASE_SHA head=$MERGE_HEAD_SHA"
exit 1
fi
echo "merge_base_sha=$MERGE_BASE_SHA" >> "$GITHUB_OUTPUT"
ABI=$(grep -oE "ABI_VERSION: u32 = [0-9]+" crates/shared/src/lib.rs | awk "{print \$4}")
echo "abi=$ABI" >> "$GITHUB_OUTPUT"
# prepare-merge always targets the durable, undated tag.
# Distinct from staging-build, which uses pr-<NNN>-staging
# for PR runs. The two flows can run in parallel without
# conflict because their target tags differ.
TARGET_TAG="binaries-abi-v${ABI}"
echo "target_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT"
STAGING_TAG="pr-${{ github.event.pull_request.number }}-staging"
echo "staging_tag=$STAGING_TAG" >> "$GITHUB_OUTPUT"
if [ "$PACKAGE_STAGING_REQUIRED" != "true" ]; then
echo "library_matrix=[]" >> "$GITHUB_OUTPUT"
echo "program_matrix=[]" >> "$GITHUB_OUTPUT"
echo "promote_matrix=[]" >> "$GITHUB_OUTPUT"
echo "preflight: package staging not required; skipping package drift matrix"
exit 0
fi
existing_assets=$(gh release view "$TARGET_TAG" --repo "${{ github.repository }}" --json assets --jq "[.assets[].name]" 2>/dev/null || echo "[]")
echo "existing-assets:"
echo "$existing_assets" | jq -r ".[0:20][]"
echo "..."
existing_index_dir=$(mktemp -d)
existing_index="$existing_index_dir/index.toml"
if gh release download "$TARGET_TAG" --repo "${{ github.repository }}" --pattern index.toml --dir "$existing_index_dir" --clobber >/dev/null 2>&1; then
echo "existing-index: downloaded $TARGET_TAG/index.toml"
else
echo "existing-index: none"
: > "$existing_index"
fi
staging_assets=$(gh release view "$STAGING_TAG" --repo "${{ github.repository }}" --json assets --jq "[.assets[].name]" 2>/dev/null || echo "[]")
echo "staging-assets:"
echo "$staging_assets" | jq -r ".[0:20][]"
echo "..."
staging_index_dir=$(mktemp -d)
staging_index="$staging_index_dir/index.toml"
if gh release download "$STAGING_TAG" --repo "${{ github.repository }}" --pattern index.toml --dir "$staging_index_dir" --clobber >/dev/null 2>&1; then
echo "staging-index: downloaded $STAGING_TAG/index.toml"
else
echo "staging-index: none"
: > "$staging_index"
fi
HOST_TARGET=$(rustc -vV | awk "/^host/ {print \$2}")
library_matrix="[]"
program_matrix="[]"
promote_matrix="[]"
# Temporarily disabled packages — too slow to rebuild in
# CI while we have no separate prebuilt-package repo to
# offload them to. Each is still consumable via its
# existing `[binary]` pin in package.toml; prepare-merge
# just refuses to redo the work. Remove an entry here
# (and mirror in staging-build.yml) to re-enable rebuilds.
disabled_pkgs=" cpython erlang erlang-vfs perl perl-vfs python-vfs redis ruby texlive "
for pkg_dir in packages/registry/*/; do
pkg=$(basename "$pkg_dir")
if ! grep -q "^\[build\]" "$pkg_dir/package.toml"; then
continue
fi
# Disabled-package skip (see `disabled_pkgs` above).
if [[ "$disabled_pkgs" == *" $pkg "* ]]; then
echo "preflight: skipping $pkg (temporarily disabled)" >&2
continue
fi
kind=$(sed -nE "s/^kind *= *\"([^\"]+)\".*/\\1/p" "$pkg_dir/package.toml" | head -1)
case "$kind" in
library|program) ;;
*) echo "preflight: skipping $pkg (kind=$kind)" >&2; continue ;;
esac
# Version comes from package.toml (recipe). Revision
# comes from build.toml (project view); defaults to 1
# when build.toml omits it.
version=$(sed -nE "s/^version *= *\"([^\"]+)\".*/\\1/p" "$pkg_dir/package.toml" | head -1)
revision=1
if [ -f "$pkg_dir/build.toml" ]; then
revision=$(sed -nE "s/^revision *= *([0-9]+).*/\\1/p" "$pkg_dir/build.toml" | head -1)
revision=${revision:-1}
fi
arches=$(awk -F'"'"'[][]'"'"' '"'"'/^arches *=/ {print $2}'"'"' "$pkg_dir/package.toml" | tr -d '"'"' "'"'"' | tr '"'"','"'"' '"'"' '"'"')
arches=${arches:-wasm32}
for arch in $arches; do
sha=$(cargo run --release -p xtask --target "$HOST_TARGET" --quiet -- \
compute-cache-key-sha --package "$pkg_dir" --arch "$arch" 2>/dev/null) || {
echo "preflight: skipping $pkg/$arch (compute-cache-key-sha failed)" >&2
continue
}
short=${sha:0:8}
prefix="${pkg}-"
suffix="-abi${ABI}-${arch}-${short}.tar.zst"
existing_archive=$(echo "$existing_assets" | jq -r --arg pre "$prefix" --arg suf "$suffix" \
"first(.[] | select(startswith(\$pre) and endswith(\$suf))) // \"\"")
if [ -n "$existing_archive" ] && scripts/index-has-current-entry.sh "$existing_index" "$pkg" "$arch" "$sha" "$revision"; then
echo "preflight: skip $pkg/$arch (sha $short already indexed)"
continue
fi
entry=$(jq -nc --arg pkg "$pkg" --arg arch "$arch" --arg sha "$sha" \
--arg ver "$version" --argjson rev "$revision" \
"{package: \$pkg, arch: \$arch, sha: \$sha, version: \$ver, revision: \$rev}")
staging_archive=$(echo "$staging_assets" | jq -r --arg pre "$prefix" --arg suf "$suffix" \
"first(.[] | select(startswith(\$pre) and endswith(\$suf))) // \"\"")
if [ -n "$staging_archive" ]; then
promote_entry=$(echo "$entry" | jq -c --arg archive "$staging_archive" ". + {archive_name: \$archive}")
promote_matrix=$(echo "$promote_matrix" | jq -c --argjson e "$promote_entry" ". + [\$e]")
echo "preflight: promote $pkg/$arch (sha $short from $STAGING_TAG)"
continue
fi
if [ "$kind" = "library" ]; then
library_matrix=$(echo "$library_matrix" | jq -c --argjson e "$entry" ". + [\$e]")
else
program_matrix=$(echo "$program_matrix" | jq -c --argjson e "$entry" ". + [\$e]")
fi
done
done
program_matrix_file=$(mktemp)
printf "%s" "$program_matrix" > "$program_matrix_file"
program_matrix=$(cargo run --release -p xtask --target "$HOST_TARGET" --quiet -- \
sort-package-matrix \
--registry packages/registry \
--matrix "$program_matrix_file")
rm -f "$program_matrix_file"
echo "library_matrix=$library_matrix" >> "$GITHUB_OUTPUT"
echo "program_matrix=$program_matrix" >> "$GITHUB_OUTPUT"
echo "promote_matrix=$promote_matrix" >> "$GITHUB_OUTPUT"
echo "preflight: promote=$(echo "$promote_matrix" | jq length) library-build=$(echo "$library_matrix" | jq length) program-build=$(echo "$program_matrix" | jq length)"
echo "promote_matrix:"; echo "$promote_matrix" | jq .
echo "library_matrix:"; echo "$library_matrix" | jq .
echo "program_matrix:"; echo "$program_matrix" | jq .
'
toolchain-cache:
needs: [synthesize-merge, gate, change-scope]
if: |
needs.change-scope.outputs.package_staging_required == 'true' ||
needs.change-scope.outputs.test_gate_required == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Prepare package toolchain
uses: ./.github/actions/package-toolchain
# Fast path for prepare-merge: if staging already built the exact
# cache-keyed archive for the merged tree, copy that asset into the
# durable release and update binaries-abi-v<N>/index.toml under the
# same state-lock used by regular matrix publishes. Fall back to
# lib/program matrix builds for entries absent from both durable and
# PR staging releases.
promote-staging:
needs: [synthesize-merge, preflight]
if: needs.preflight.outputs.promote_matrix != '[]'
runs-on: ubuntu-latest
permissions:
contents: write # gh release download/upload + state-lock git push
strategy:
fail-fast: false
max-parallel: 10
matrix:
include: ${{ fromJSON(needs.preflight.outputs.promote_matrix) }}
steps:
- uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Build xtask
run: |
HOST_TARGET=$(rustc -vV | awk "/^host/ {print \$2}")
cargo build --release -p xtask --target "$HOST_TARGET"
- name: Download staged archive
id: archive
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/promote"
gh release download "${{ needs.preflight.outputs.staging_tag }}" \
--repo "$GITHUB_REPOSITORY" \
--pattern "${{ matrix.archive_name }}" \
--dir "$RUNNER_TEMP/promote" \
--clobber
archive_path="$RUNNER_TEMP/promote/${{ matrix.archive_name }}"
if [ ! -f "$archive_path" ]; then
echo "::error::staged archive not downloaded: $archive_path"
exit 1
fi
echo "archive-path=$archive_path" >> "$GITHUB_OUTPUT"
echo "archive-name=${{ matrix.archive_name }}" >> "$GITHUB_OUTPUT"
- name: Promote archive + index entry
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
bash scripts/index-update.sh \
--target-tag "${{ needs.preflight.outputs.target_tag }}" \
--package "${{ matrix.package }}" \
--version "${{ matrix.version }}" \
--revision "${{ matrix.revision }}" \
--arch "${{ matrix.arch }}" \
--status success \
--archive-path "${{ steps.archive.outputs.archive-path }}" \
--archive-name "${{ steps.archive.outputs.archive-name }}" \
--cache-key-sha "${{ matrix.sha }}"
# Library wave — kind=library packages. See staging-build.yml for
# the lib-then-program design rationale.
lib-matrix-build:
needs: [synthesize-merge, preflight, toolchain-cache]
if: ${{ needs.preflight.result == 'success' && needs.toolchain-cache.result == 'success' && needs.preflight.outputs.library_matrix != '[]' }}
runs-on: ubuntu-latest
permissions:
actions: read # download toolchain artifact
contents: write # state-lock git push + gh release create/upload
env:
WASM_POSIX_BINARY_INDEX_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.preflight.outputs.target_tag }}/index.toml
strategy:
fail-fast: false
max-parallel: 10
matrix:
include: ${{ fromJSON(needs.preflight.outputs.library_matrix) }}
steps:
- id: checkout
uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Build ${{ matrix.package }} (${{ matrix.arch }})
id: build
uses: ./.github/actions/package-archive-build
with:
package: ${{ matrix.package }}
arch: ${{ matrix.arch }}
build-host: ${{ github.repository }}@${{ needs.synthesize-merge.outputs.merge_sha }}
expected-cache-key-sha: ${{ matrix.sha }}
# Atomic per-matrix-entry publish: archive upload + index.toml
# mutation under the state-lock acquired for the target tag.
# Different release tags use different lock subjects (durable
# binaries-abi-v<N> vs pr-<N>-staging), so concurrent rebuilds
# to independent tags don't block each other.
#
# On failure: a separate step records status=failed in the
# index so consumers see the rebuild attempt; if a prior
# successful build exists, IndexToml::update_entry_failed
# preserves it in the fallback_* slot (last-green semantics).
- name: Publish archive + index entry
if: success()
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
bash scripts/index-update.sh \
--target-tag "${{ needs.preflight.outputs.target_tag }}" \
--package "${{ matrix.package }}" \
--version "${{ matrix.version }}" \
--revision "${{ matrix.revision }}" \
--arch "${{ matrix.arch }}" \
--status success \
--archive-path "${{ steps.build.outputs.archive-path }}" \
--archive-name "${{ steps.build.outputs.archive-name }}" \
--cache-key-sha "${{ matrix.sha }}"
- name: Record failed build in index
if: ${{ failure() && steps.checkout.outcome == 'success' }}
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
bash scripts/index-update.sh \
--target-tag "${{ needs.preflight.outputs.target_tag }}" \
--package "${{ matrix.package }}" \
--version "${{ matrix.version }}" \
--revision "${{ matrix.revision }}" \
--arch "${{ matrix.arch }}" \
--status failed \
--error "matrix-build failed; see ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|| true
# Program wave — kind=program packages, gated on the library wave.
matrix-build:
needs: [synthesize-merge, preflight, toolchain-cache, promote-staging, lib-matrix-build]
if: |
!cancelled() &&
needs.preflight.result == 'success' &&
needs.toolchain-cache.result == 'success' &&
(needs.promote-staging.result == 'success' || needs.promote-staging.result == 'skipped') &&
(needs.lib-matrix-build.result == 'success' || needs.lib-matrix-build.result == 'skipped') &&
needs.preflight.outputs.program_matrix != '[]'
runs-on: ubuntu-latest
permissions:
actions: read # download toolchain + library artifacts
contents: write # state-lock git push + gh release create/upload
env:
WASM_POSIX_BINARY_INDEX_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.preflight.outputs.target_tag }}/index.toml
strategy:
fail-fast: false
max-parallel: 10
matrix:
include: ${{ fromJSON(needs.preflight.outputs.program_matrix) }}
steps:
- id: checkout
uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Build ${{ matrix.package }} (${{ matrix.arch }})
id: build
uses: ./.github/actions/package-archive-build
with:
package: ${{ matrix.package }}
arch: ${{ matrix.arch }}
build-host: ${{ github.repository }}@${{ needs.synthesize-merge.outputs.merge_sha }}
expected-cache-key-sha: ${{ matrix.sha }}
materialize-library-overlays: ${{ needs.preflight.outputs.library_matrix != '[]' }}
library-matrix: ${{ needs.preflight.outputs.library_matrix }}
dependency-matrix: ${{ needs.preflight.outputs.program_matrix }}
github-token: ${{ github.token }}
# Atomic per-matrix-entry publish — see lib-matrix-build for
# the rationale.
- name: Publish archive + index entry
if: success()
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
bash scripts/index-update.sh \
--target-tag "${{ needs.preflight.outputs.target_tag }}" \
--package "${{ matrix.package }}" \
--version "${{ matrix.version }}" \
--revision "${{ matrix.revision }}" \
--arch "${{ matrix.arch }}" \
--status success \
--archive-path "${{ steps.build.outputs.archive-path }}" \
--archive-name "${{ steps.build.outputs.archive-name }}" \
--cache-key-sha "${{ matrix.sha }}"
- name: Record failed build in index
if: ${{ failure() && steps.checkout.outcome == 'success' }}
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
bash scripts/index-update.sh \
--target-tag "${{ needs.preflight.outputs.target_tag }}" \
--package "${{ matrix.package }}" \
--version "${{ matrix.version }}" \
--revision "${{ matrix.revision }}" \
--arch "${{ matrix.arch }}" \
--status failed \
--error "matrix-build failed; see ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
|| true
test-gate-prepare:
needs: [synthesize-merge, change-scope, preflight, toolchain-cache, promote-staging, lib-matrix-build, matrix-build]
if: |
!cancelled() &&
needs.toolchain-cache.result == 'success' &&
needs.preflight.result == 'success' &&
(needs.promote-staging.result == 'success' || needs.promote-staging.result == 'skipped') &&
(needs.lib-matrix-build.result == 'success' || needs.lib-matrix-build.result == 'skipped') &&
(needs.matrix-build.result == 'success' || needs.matrix-build.result == 'skipped')
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
merge_base_sha: ${{ steps.merge_ref.outputs.merge_base_sha }}
env:
PACKAGE_STAGING_REQUIRED: ${{ needs.change-scope.outputs.package_staging_required }}
PACKAGE_STAGE_OVERLAYS_REQUIRED: ${{ needs.preflight.outputs.library_matrix != '[]' || needs.preflight.outputs.program_matrix != '[]' }}
PACKAGE_PUBLISH_FLOW_CHANGED: ${{ needs.change-scope.outputs.package_publish_flow_changed }}
BINARY_MATERIALIZATION_CHANGED: ${{ needs.change-scope.outputs.binary_materialization_changed }}
PACKAGE_TARGET_TAG: ${{ needs.preflight.outputs.target_tag }}
steps:
- uses: actions/checkout@v7.0.0
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Capture synthesized merge base
id: merge_ref
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
if [ "$(git rev-parse HEAD)" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::test-gate is not on synthetic merge $SYNTHETIC_MERGE_SHA"
exit 1
fi
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf "%s\n" "$parents" | awk "{print \$1}")
actual_head=$(printf "%s\n" "$parents" | awk "{print \$2}")
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
echo "merge_base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT"
- name: Fetch submodules (musl, libc-test, os-test)
uses: ./.github/actions/fetch-submodules
with:
submodules: libc/musl tests/libc/libc-test tests/sortix/os-test
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
with:
github-token: ""
- name: Cache Nix store + flake eval
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
with:
use-gha-cache: false
use-flakehub: false
- name: Download toolchain sysroots artifact
uses: actions/download-artifact@v8
with:
name: toolchain-sysroots
path: ${{ runner.temp }}/toolchain
- name: Extract toolchain sysroots
run: |
tar --zstd -xf "$RUNNER_TEMP/toolchain/toolchain-sysroots.tar.zst"
- name: Download matrix artifacts
if: ${{ needs.preflight.outputs.library_matrix != '[]' || needs.preflight.outputs.program_matrix != '[]' }}
uses: ./.github/actions/download-run-artifacts
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
pattern: '*-wasm*'
path: ${{ runner.temp }}/staged-archives
- name: Install host npm deps
# Keep this before the host typecheck, package-system tests,
# and later test-program/rootfs build steps. Binary
# materialization itself runs fetch-only in CI.
run: |
bash scripts/dev-shell.sh bash -c '
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund
cd host && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund
'
- name: Typecheck host package
run: |
bash scripts/dev-shell.sh bash -c '
cd host && npm run typecheck
'
- name: Test package publish flow
if: env.PACKAGE_PUBLISH_FLOW_CHANGED == 'true'
run: |
bash tests/scripts/index-update.sh
- name: Test binary materialization flow
if: env.BINARY_MATERIALIZATION_CHANGED == 'true'
run: |
bash scripts/dev-shell.sh npx --prefix host vitest run --root . tests/package-system
- name: Materialize binaries
env:
WASM_POSIX_FETCH_SKIP_PKGS: cpython erlang erlang-vfs perl perl-vfs python-vfs redis ruby texlive
# Resolve against the durable release plus local file://
# overlays for newly built archives. Runtime-only PRs can still
# reach this overlay path when preflight detects durable index
# drift from an earlier package-key change.
run: |
set -euo pipefail
if [ "$PACKAGE_STAGE_OVERLAYS_REQUIRED" = "true" ]; then
export WASM_POSIX_BINARY_INDEX_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${PACKAGE_TARGET_TAG}/index.toml"
STAGE="$RUNNER_TEMP/staged-overlay"
bash scripts/materialize-pr-overlays.sh "$RUNNER_TEMP/staged-archives" "$STAGE"
else
ABI=$(grep -oE "ABI_VERSION: u32 = [0-9]+" crates/shared/src/lib.rs | awk '{print $4}')
export WASM_POSIX_BINARY_INDEX_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/binaries-abi-v${ABI}/index.toml"
fi
echo "WASM_POSIX_BINARY_INDEX_URL=$WASM_POSIX_BINARY_INDEX_URL" >> "$GITHUB_ENV"
bash scripts/dev-shell.sh bash scripts/fetch-binaries.sh --fetch-only
- name: Build kernel + test programs + install host deps
# The vitest suite installs Chromium in its own runner because
# browser downloads are not part of the prepared workspace
# artifact.
run: |
bash scripts/dev-shell.sh bash -c '
set -euo pipefail
cargo build --release -p kandelo \
-Z build-std=core,alloc
mkdir -p local-binaries
cp target/wasm32-unknown-unknown/release/kandelo_kernel.wasm \
local-binaries/kernel.wasm
bash scripts/build-programs.sh
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund
cd host && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund && cd ..
bash scripts/build-rootfs.sh
'
- name: Prepare browser assets from package index
run: |
bash scripts/dev-shell.sh bash -c '
set -euo pipefail
./run.sh --fetch-only prepare-browser
bash scripts/ci-check-browser-assets.sh
'
- name: Pack prepared test workspace
run: |
bash scripts/pack-ci-test-workspace.sh "$RUNNER_TEMP/test-workspace.tar.zst"
- name: Upload prepared test workspace
uses: actions/upload-artifact@v7
with:
name: test-workspace
path: ${{ runner.temp }}/test-workspace.tar.zst
retention-days: 1
if-no-files-found: error
test-suite:
name: test-suite (${{ matrix.suite }})
needs: [synthesize-merge, change-scope, test-gate-prepare]
if: |
!cancelled() &&
needs.test-gate-prepare.result == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- suite: cargo-kernel
kernel_only: true
needs_submodules: false
needs_toolchain: false
needs_workspace: false
- suite: fork-instrument
kernel_only: true
needs_submodules: false
needs_toolchain: false
needs_workspace: false
- suite: vitest
kernel_only: false
needs_submodules: false
needs_toolchain: true
needs_workspace: true
- suite: browser
kernel_only: false
needs_submodules: false
needs_toolchain: true
needs_workspace: true
- suite: libc
kernel_only: true
needs_submodules: true
needs_toolchain: true
needs_workspace: true
- suite: posix
kernel_only: true
needs_submodules: false
needs_toolchain: true
needs_workspace: true
- suite: sortix
kernel_only: true
needs_submodules: true
needs_toolchain: true
needs_workspace: true
env:
KERNEL_CHANGED: ${{ needs.change-scope.outputs.kernel }}
KERNEL_ONLY: ${{ matrix.kernel_only }}
SUITE: ${{ matrix.suite }}
steps:
- name: Decide whether to run suite
id: suite
run: |
set -euo pipefail
if [ "$KERNEL_ONLY" = "true" ] && [ "$KERNEL_CHANGED" != "true" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "$SUITE is kernel-only and this diff does not require kernel suites."
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7.0.0
if: steps.suite.outputs.skip != 'true'
with:
ref: ${{ needs.synthesize-merge.outputs.base_sha }}
fetch-depth: 1
- uses: actions/download-artifact@v8
if: steps.suite.outputs.skip != 'true'
with:
name: ${{ needs.synthesize-merge.outputs.bundle_artifact }}
path: ${{ runner.temp }}/synthetic-pr-merge
- name: Checkout synthesized PR merge
if: steps.suite.outputs.skip != 'true'
env:
BASE_SHA: ${{ needs.synthesize-merge.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.synthesize-merge.outputs.head_sha }}
SYNTHETIC_MERGE_SHA: ${{ needs.synthesize-merge.outputs.merge_sha }}
run: |
set -euo pipefail
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$PR_HEAD_SHA"
git fetch --quiet "$RUNNER_TEMP/synthetic-pr-merge/synthesized-merge.bundle" \
"refs/heads/synthesized-merge:refs/remotes/origin/synthesized-merge"
actual=$(git rev-parse refs/remotes/origin/synthesized-merge)
if [ "$actual" != "$SYNTHETIC_MERGE_SHA" ]; then
echo "::error::Synthetic merge bundle resolved to $actual, expected $SYNTHETIC_MERGE_SHA"
exit 1
fi
git checkout --quiet --detach "$SYNTHETIC_MERGE_SHA"
parents=$(git show --no-patch --format=%P HEAD)
actual_base=$(printf '%s\n' "$parents" | awk '{print $1}')
actual_head=$(printf '%s\n' "$parents" | awk '{print $2}')
if [ "$actual_base" != "$BASE_SHA" ] || [ "$actual_head" != "$PR_HEAD_SHA" ]; then
echo "::error::Synthetic merge parent mismatch: parents=$parents base=$BASE_SHA head=$PR_HEAD_SHA"
exit 1
fi
- name: Fetch submodules (musl, libc-test, os-test)
if: steps.suite.outputs.skip != 'true' && matrix.needs_submodules == true
uses: ./.github/actions/fetch-submodules
with:
submodules: libc/musl tests/libc/libc-test tests/sortix/os-test
- name: Install Nix
if: steps.suite.outputs.skip != 'true'
uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
with:
github-token: ""
- name: Cache Nix store + flake eval
if: steps.suite.outputs.skip != 'true'
uses: DeterminateSystems/magic-nix-cache-action@908b263ff629f4cc17666315b7fd3ec127c6244d # v14
with:
use-gha-cache: false
use-flakehub: false
- name: Download toolchain sysroots artifact
if: steps.suite.outputs.skip != 'true' && matrix.needs_toolchain == true
uses: actions/download-artifact@v8
with:
name: toolchain-sysroots
path: ${{ runner.temp }}/toolchain
- name: Extract toolchain sysroots
if: steps.suite.outputs.skip != 'true' && matrix.needs_toolchain == true
run: |
tar --zstd -xf "$RUNNER_TEMP/toolchain/toolchain-sysroots.tar.zst"
- name: Download prepared test workspace
if: steps.suite.outputs.skip != 'true' && matrix.needs_workspace == true
uses: actions/download-artifact@v8
with:
name: test-workspace
path: ${{ runner.temp }}/test-workspace
- name: Extract prepared test workspace
if: steps.suite.outputs.skip != 'true' && matrix.needs_workspace == true
run: |
tar --zstd -xf "$RUNNER_TEMP/test-workspace/test-workspace.tar.zst"
- name: Run ${{ matrix.suite }} suite
if: steps.suite.outputs.skip != 'true'
run: |
bash scripts/dev-shell.sh bash scripts/ci-run-test-suite.sh "$SUITE"
- name: Upload browser test artifacts
if: failure() && steps.suite.outputs.skip != 'true' && matrix.suite == 'browser'
uses: actions/upload-artifact@v7
with:
name: browser-test-artifacts
path: |
apps/browser-demos/test-results
if-no-files-found: ignore
retention-days: 7
test-gate:
needs: [test-gate-prepare, test-suite]
if: |
always() &&
!cancelled() &&
needs.test-gate-prepare.result != 'skipped'
runs-on: ubuntu-latest
outputs:
merge_base_sha: ${{ steps.gate.outputs.merge_base_sha }}
steps:
- name: Check split test suites
id: gate
env:
MERGE_BASE_SHA: ${{ needs.test-gate-prepare.outputs.merge_base_sha }}
PREPARE_RESULT: ${{ needs.test-gate-prepare.result }}
TEST_SUITE_RESULT: ${{ needs.test-suite.result }}
run: |
set -euo pipefail
echo "merge_base_sha=$MERGE_BASE_SHA" >> "$GITHUB_OUTPUT"
if [ "$PREPARE_RESULT" != "success" ]; then
echo "::error::test preparation completed with result: $PREPARE_RESULT"
exit 1
fi
if [ "$TEST_SUITE_RESULT" != "success" ]; then
echo "::error::split test suites completed with result: $TEST_SUITE_RESULT"
exit 1
fi
# publish / generate-index / amend-package-toml — REMOVED.
#
# Per the binary-resolution-via-index-ledger design, each
# matrix-build entry now publishes its archive AND mutates
# index.toml atomically under the workflow-level state-lock
# (see the "Publish archive + index entry" step in
# lib-matrix-build / matrix-build above). The three former jobs
# — separate publish, generate-index, and amend-package-toml —
# are no longer needed:
#
# * publish: per-entry steps upload to the release directly.
# * generate-index: index.toml is mutated per-entry by
# scripts/index-update.sh; no separate aggregation pass.
# * amend-package-toml: the index.toml ledger IS the consumer-
# visible state. There are no [binary] URLs in package.toml
# to amend; the bot-PR mechanism is gone.
# ---------------------------------------------------------------------
# merge-gate-post-noop — package staging was not required.
#
# Browser/docs/workflow-only PRs do not need PR staging archives and
# must not trigger durable package builds on label. After the regular
# review/check/mergeability gate succeeds, post the same merge-gate
# success context and enable auto-merge without touching releases.
# ---------------------------------------------------------------------
merge-gate-post-noop:
needs: [synthesize-merge, gate, change-scope]
if: |
!cancelled() &&
needs.gate.result == 'success' &&
needs.change-scope.outputs.package_staging_required == 'false' &&
needs.change-scope.outputs.test_gate_required == 'false'
runs-on: ubuntu-latest
permissions:
contents: write # gh pr merge --auto (enablePullRequestAutoMerge)
pull-requests: write # auto-merge + comment
statuses: write # post merge-gate=success
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
steps:
- name: Verify prepared base is still current
run: |
set -euo pipefail
PREPARED_BASE="${{ needs.synthesize-merge.outputs.base_sha }}"
if [ -z "$PREPARED_BASE" ]; then
echo "::error::synthesize-merge did not report base_sha"
exit 1
fi
BASE_REF="${{ github.event.pull_request.base.ref }}"
git init --quiet "$RUNNER_TEMP/base-drift"
cd "$RUNNER_TEMP/base-drift"
git remote add origin "https://${{ github.token }}@github.com/${{ github.repository }}.git"
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
CURRENT_BASE=$(git rev-parse "refs/remotes/origin/$BASE_REF")
if [ "$CURRENT_BASE" != "$PREPARED_BASE" ]; then
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: \`$BASE_REF\` advanced while prepare-merge was running (prepared \`$PREPARED_BASE\`, current \`$CURRENT_BASE\`). Re-apply \`ready-to-ship\` so the no-op merge gate is based on the latest base."
exit 1
fi
- name: Post merge-gate=success on original PR HEAD
run: |
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"/repos/${{ github.repository }}/statuses/$PR_HEAD_SHA" \
-f state=success \
-f context=merge-gate \
-f description="No package or runtime-affecting changes; package staging skipped."
- name: Enable selected auto-merge on original PR
id: automerge
run: |
echo "Enabling ${PREPARE_MERGE_METHOD} auto-merge for PR #${PR_NUMBER}."
set +e
output=$(gh pr merge "$PR_NUMBER" \
--repo ${{ github.repository }} \
--auto "$PREPARE_MERGE_FLAG" 2>&1)
status=$?
set -e
if [ "$status" -eq 0 ]; then
printf '%s\n' "$output"
echo "manual_merge_required=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s\n' "$output" >&2
if printf '%s\n' "$output" | grep -q 'without `workflows` permission'; then
echo "manual_merge_required=true" >> "$GITHUB_OUTPUT"
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge passed and posted \`merge-gate=success\`, but GitHub Actions cannot enable ${PREPARE_MERGE_METHOD} auto-merge because this PR changes workflow files. A user with workflow permission must merge this PR manually."
exit 0
fi
echo "manual_merge_required=false" >> "$GITHUB_OUTPUT"
exit "$status"
- name: Comment on original PR
continue-on-error: true
run: |
if [ "${{ steps.automerge.outputs.manual_merge_required }}" = "true" ]; then
BODY="prepare-merge: no package or runtime-affecting changes; package staging and durable package publishing were skipped. \`merge-gate=success\` posted on PR HEAD. Manual merge is required because GitHub Actions could not enable ${PREPARE_MERGE_METHOD} auto-merge for this workflow-changing PR."
else
BODY="prepare-merge: no package or runtime-affecting changes; package staging and durable package publishing were skipped. \`merge-gate=success\` posted on PR HEAD and ${PREPARE_MERGE_METHOD} auto-merge enabled."
fi
set +e
for attempt in 1 2 3; do
if gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY"; then
exit 0
fi
echo "::warning::gh pr comment failed (attempt $attempt/3); retrying in 10s"
sleep 10
done
echo "::warning::gh pr comment failed after 3 attempts; merge-gate was posted but the explanatory comment was not"
- name: Drop label + comment on failure
if: failure()
run: |
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge failed during the no-op merge-gate-post step. Check the workflow run log: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. Re-apply the ready-to-ship label after fixing." || true
# ---------------------------------------------------------------------
# merge-gate-post-runtime-only — package staging was not required, but
# runtime/materialization tests were.
#
# Non-archive runtime/materialization PRs should validate the synthetic
# merge commit without building or publishing package archives. Once
# test-gate passes against durable binaries plus the freshly built
# kernel/test workspace, post the normal merge-gate context and enable
# auto-merge.
# ---------------------------------------------------------------------
merge-gate-post-runtime-only:
needs: [gate, change-scope, test-gate]
if: |
!cancelled() &&
needs.gate.result == 'success' &&
needs.test-gate.result == 'success' &&
needs.change-scope.outputs.package_staging_required == 'false' &&
needs.change-scope.outputs.test_gate_required == 'true'
runs-on: ubuntu-latest
permissions:
contents: write # gh pr merge --auto (enablePullRequestAutoMerge)
pull-requests: write # auto-merge + comment
statuses: write # post merge-gate=success
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
steps:
- name: Verify prepared base is still current
run: |
set -euo pipefail
PREPARED_BASE="${{ needs.test-gate.outputs.merge_base_sha }}"
if [ -z "$PREPARED_BASE" ]; then
echo "::error::test-gate did not report merge_base_sha"
exit 1
fi
BASE_REF="${{ github.event.pull_request.base.ref }}"
git init --quiet "$RUNNER_TEMP/base-drift"
cd "$RUNNER_TEMP/base-drift"
git remote add origin "https://${{ github.token }}@github.com/${{ github.repository }}.git"
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
CURRENT_BASE=$(git rev-parse "refs/remotes/origin/$BASE_REF")
if [ "$CURRENT_BASE" != "$PREPARED_BASE" ]; then
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: \`$BASE_REF\` advanced while prepare-merge was running (prepared \`$PREPARED_BASE\`, current \`$CURRENT_BASE\`). Re-apply \`ready-to-ship\` so runtime/materialization tests run against the latest base."
exit 1
fi
- name: Post merge-gate=success on original PR HEAD
run: |
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"/repos/${{ github.repository }}/statuses/$PR_HEAD_SHA" \
-f state=success \
-f context=merge-gate \
-f description="Runtime test-gate passed; package staging and publishing skipped."
- name: Enable selected auto-merge on original PR
id: automerge
run: |
echo "Enabling ${PREPARE_MERGE_METHOD} auto-merge for PR #${PR_NUMBER}."
set +e
output=$(gh pr merge "$PR_NUMBER" \
--repo ${{ github.repository }} \
--auto "$PREPARE_MERGE_FLAG" 2>&1)
status=$?
set -e
if [ "$status" -eq 0 ]; then
printf '%s\n' "$output"
echo "manual_merge_required=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s\n' "$output" >&2
if printf '%s\n' "$output" | grep -q 'without `workflows` permission'; then
echo "manual_merge_required=true" >> "$GITHUB_OUTPUT"
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge passed and posted \`merge-gate=success\`, but GitHub Actions cannot enable ${PREPARE_MERGE_METHOD} auto-merge because this PR changes workflow files. A user with workflow permission must merge this PR manually."
exit 0
fi
echo "manual_merge_required=false" >> "$GITHUB_OUTPUT"
exit "$status"
- name: Comment on original PR
continue-on-error: true
run: |
if [ "${{ steps.automerge.outputs.manual_merge_required }}" = "true" ]; then
BODY="prepare-merge: runtime/materialization tests passed against the synthetic PR merge; package staging and durable package publishing were skipped. \`merge-gate=success\` posted on PR HEAD. Manual merge is required because GitHub Actions could not enable ${PREPARE_MERGE_METHOD} auto-merge for this workflow-changing PR."
else
BODY="prepare-merge: runtime/materialization tests passed against the synthetic PR merge; package staging and durable package publishing were skipped. \`merge-gate=success\` posted on PR HEAD and ${PREPARE_MERGE_METHOD} auto-merge enabled."
fi
set +e
for attempt in 1 2 3; do
if gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY"; then
exit 0
fi
echo "::warning::gh pr comment failed (attempt $attempt/3); retrying in 10s"
sleep 10
done
echo "::warning::gh pr comment failed after 3 attempts; merge-gate was posted but the explanatory comment was not"
- name: Drop label + comment on failure
if: failure()
run: |
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge failed during the runtime-only merge-gate-post step. Check the workflow run log: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. Re-apply the ready-to-ship label after fixing." || true
# ---------------------------------------------------------------------
# merge-gate-post — single merge-gate poster + auto-merge enabler.
#
# Post binary-resolution-via-index-ledger: there's no bot PR (no
# in-tree URLs to amend), and per-matrix-entry atomic
# archive+index publish happens inline. This job posts
# merge-gate=success on the ORIGINAL PR's HEAD SHA and enables
# auto-merge there.
#
# Fires when test-gate succeeds against the durable release state.
# Prepare may have built missing archives, promoted exact archives
# from pr-<N>-staging, or found every cache_key_sha already on the
# durable tag; in all cases test-gate validates the synthetic PR merge
# against what consumers will fetch from binaries-abi-v<N>.
# ---------------------------------------------------------------------
merge-gate-post:
needs: [gate, change-scope, preflight, test-gate]
if: |
!cancelled() &&
needs.change-scope.outputs.package_staging_required == 'true' &&
needs.preflight.result == 'success' &&
needs.test-gate.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # gh pr merge --auto (enablePullRequestAutoMerge)
pull-requests: write # auto-merge + comment
statuses: write # post merge-gate=success
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
steps:
- name: Verify prepared base is still current
run: |
set -euo pipefail
PREPARED_BASE="${{ needs.preflight.outputs.merge_base_sha }}"
if [ -z "$PREPARED_BASE" ]; then
echo "::error::preflight did not report merge_base_sha"
exit 1
fi
BASE_REF="${{ github.event.pull_request.base.ref }}"
git init --quiet "$RUNNER_TEMP/base-drift"
cd "$RUNNER_TEMP/base-drift"
git remote add origin "https://${{ github.token }}@github.com/${{ github.repository }}.git"
eval "$GIT_FETCH_RETRY_FUNC"
git_fetch_retry --quiet --depth=1 origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
CURRENT_BASE=$(git rev-parse "refs/remotes/origin/$BASE_REF")
if [ "$CURRENT_BASE" != "$PREPARED_BASE" ]; then
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "Cannot prepare merge: \`$BASE_REF\` advanced while prepare-merge was running (prepared \`$PREPARED_BASE\`, current \`$CURRENT_BASE\`). Re-apply \`ready-to-ship\` so packages are validated against the latest base."
exit 1
fi
- name: Post merge-gate=success on original PR HEAD
run: |
DESC="test-gate passed on the synthetic PR merge against durable archives on ${{ needs.preflight.outputs.target_tag }}."
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
"/repos/${{ github.repository }}/statuses/$PR_HEAD_SHA" \
-f state=success \
-f context=merge-gate \
-f description="$DESC"
- name: Enable selected auto-merge on original PR
id: automerge
run: |
echo "Enabling ${PREPARE_MERGE_METHOD} auto-merge for PR #${PR_NUMBER}."
set +e
output=$(gh pr merge "$PR_NUMBER" \
--repo ${{ github.repository }} \
--auto "$PREPARE_MERGE_FLAG" 2>&1)
status=$?
set -e
if [ "$status" -eq 0 ]; then
printf '%s\n' "$output"
echo "manual_merge_required=false" >> "$GITHUB_OUTPUT"
exit 0
fi
printf '%s\n' "$output" >&2
if printf '%s\n' "$output" | grep -q 'without `workflows` permission'; then
echo "manual_merge_required=true" >> "$GITHUB_OUTPUT"
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge passed and posted \`merge-gate=success\`, but GitHub Actions cannot enable ${PREPARE_MERGE_METHOD} auto-merge because this PR changes workflow files. A user with workflow permission must merge this PR manually."
exit 0
fi
echo "manual_merge_required=false" >> "$GITHUB_OUTPUT"
exit "$status"
- name: Comment on original PR
# Purely informational — the merge already succeeded by this
# point. A transient `gh pr comment` failure (e.g. GitHub API
# 504) must NOT trigger the `if: failure()` handler below,
# which would post a misleading "merge-gate failed; re-apply
# ready-to-ship" message and remove the label from an
# already-merged PR. continue-on-error: true contains the
# blast radius; the inner retry recovers most transients.
continue-on-error: true
run: |
if [ "${{ steps.automerge.outputs.manual_merge_required }}" = "true" ]; then
BODY="prepare-merge: test-gate passed against the synthetic PR merge and [\`${{ needs.preflight.outputs.target_tag }}\`](https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.target_tag }}). Missing entries were built or promoted from PR staging only when their merged-tree cache key matched; \`merge-gate=success\` posted on PR HEAD. Manual merge is required because GitHub Actions could not enable ${PREPARE_MERGE_METHOD} auto-merge for this workflow-changing PR."
else
BODY="prepare-merge: test-gate passed against the synthetic PR merge and [\`${{ needs.preflight.outputs.target_tag }}\`](https://github.com/${{ github.repository }}/releases/tag/${{ needs.preflight.outputs.target_tag }}). Missing entries were built or promoted from PR staging only when their merged-tree cache key matched; \`merge-gate=success\` posted on PR HEAD and ${PREPARE_MERGE_METHOD} auto-merge enabled."
fi
set +e
for attempt in 1 2 3; do
if gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "$BODY"; then
exit 0
fi
echo "::warning::gh pr comment failed (attempt $attempt/3); retrying in 10s"
sleep 10
done
echo "::warning::gh pr comment failed after 3 attempts; PR is already merged but the explanatory comment was not posted"
- name: Drop label + comment on failure
if: failure()
run: |
gh pr edit "$PR_NUMBER" --repo ${{ github.repository }} --remove-label ready-to-ship || true
gh pr comment "$PR_NUMBER" --repo ${{ github.repository }} --body "prepare-merge failed during the merge-gate-post step. Check the workflow run log: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. Re-apply the ready-to-ship label after fixing." || true