Add a declarative file-ingest capability for browser demos (with DOOM WAD upload) #474
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 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 |