From 3845c28eebf204d76a95eaf8537aa67b48eb42b9 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Fri, 14 Aug 2026 13:59:05 -0400 Subject: [PATCH] fix(ci): stop trusting refs/pull/N/merge for triggered-integration-test resolve-source's "Verify PR merge checkout" step compares the checked-out merge commit's parents against a live-resolved base_sha (fixed in #62), but the checked-out commit itself still came from GitHub's refs/pull//merge -- a commit GitHub computes and caches asynchronously. Per GitHub's own changelog ("Changes to test merge commit generation for pull requests", 2026-02-19), that ref is only regenerated on a push to the PR branch, a merge-base change, or a 12h max-age timer, and viewing the PR no longer forces a refresh. Live reproduction against PR #63 right after #62 merged: the ref stayed stale for 49+ minutes, including after an explicit mergeable recheck, which would fail "Verify PR merge checkout" with a "base changed" error for a transient, unrelated-to-the-PR reason (see grid#66). Stop depending on that ref. resolve-source already resolves head_sha (PR API) and base_sha (live refs/heads/main) as pinned commit SHAs -- construct the merge locally from those two SHAs with `git merge --no-ff` instead, in both resolve-source (to fail fast on real conflicts before the 120-minute glb-e2e job) and glb-e2e's checkout (to get the actual tested tree). Pinning author/committer identity and date makes the merge commit fully deterministic, so both jobs independently arrive at the identical SHA despite running on separate runners with no shared git state -- verified live: two independent fresh-clone reconstructions of PR #63's merge (base 7ebabdbc.., head 50720c9d..) produced the exact same commit SHA (6df4a70a..). fetch-depth changes from 2 to 0 (full clone) for the shared checkout step, since `git merge` needs enough history to find the true merge-base between base_sha and head_sha, which a shallow clone cannot guarantee for a PR that is many commits behind main. Fixes grid#66 Signed-off-by: Jordi Gil --- .../workflows/triggered-integration-test.yaml | 99 ++++++++++++++----- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/.github/workflows/triggered-integration-test.yaml b/.github/workflows/triggered-integration-test.yaml index f1635af..d0469d3 100644 --- a/.github/workflows/triggered-integration-test.yaml +++ b/.github/workflows/triggered-integration-test.yaml @@ -42,6 +42,17 @@ env: # docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/praxis-proxy/grid-ai-rollup: GATEWAY_IMAGE_REF: ghcr.io/praxis-proxy/grid-ai-rollup:v0.1.3 GATEWAY_IMAGE_DIGEST: sha256:e5bdea9071f76533ab61714eeef6067c7449d0e9d811c221aa940cf3cdf9719f + # Pinned identity/date/message-suffix for the local PR-merge reconstruction + # in resolve-source's "Resolve source" step and glb-e2e's "Reconstruct PR + # merge" step (see grid#66). Both jobs build a merge commit from the same + # base_sha/head_sha pair and must produce a byte-identical SHA, which + # requires byte-identical author/committer identity, date, and message -- + # a fixed, meaningless-but-consistent value is fine since this commit is a + # CI-internal integrity check, never published or citable. + MERGE_COMMIT_NAME: grid-ci-merge-check + MERGE_COMMIT_EMAIL: grid-ci-merge-check@users.noreply.github.com + MERGE_COMMIT_DATE: "1970-01-01T00:00:00Z" + MERGE_COMMIT_MESSAGE_SUFFIX: grid-ci deterministic merge check jobs: # ============================================================================ @@ -57,6 +68,7 @@ jobs: source_kind: ${{ steps.source.outputs.source_kind }} source_label: ${{ steps.source.outputs.source_label }} tested_sha: ${{ steps.source.outputs.tested_sha }} + checkout_sha: ${{ steps.source.outputs.checkout_sha }} pr_number: ${{ steps.pr.outputs.pr_number }} title: ${{ steps.pr.outputs.title }} url: ${{ steps.pr.outputs.url }} @@ -121,10 +133,10 @@ jobs: # verified base — GitHub only refreshes this REST field on PR # synchronize events (e.g. a push to the head branch), so it can # lag `main`'s actual tip by hours if the PR sits idle while main - # moves forward. `refs/pull//merge`, resolved in the next step, - # IS kept continuously current against `main`, so the base used - # for verification is instead re-resolved live from - # refs/heads/main in the "Resolve source" step below. + # moves forward. The base used for verification is instead + # re-resolved live from refs/heads/main in the "Resolve source" + # step below (see grid#66 for why that step also stopped trusting + # GitHub's precomputed refs/pull//merge for the same reason). echo "${PR_JSON}" | jq '{ number: .number, title: .title, @@ -147,40 +159,56 @@ jobs: id: source env: PR_NUM: ${{ inputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | set -euo pipefail REPO_URL="https://github.com/${{ github.repository }}.git" if [[ -n "${PR_NUM}" ]]; then - # Resolve the merge ref and refs/heads/main in a single - # `git ls-remote` call, instead of trusting the PR REST - # resource's `.base.sha` (see note in "Query PR metadata") and - # instead of two sequential round-trips, which would leave a - # window for main to advance between them and make BASE_SHA - # point past the merge ref's actual first parent. One call - # resolves both refs from the same server-side view, making - # them atomically consistent with each other. - LS_OUTPUT=$(git ls-remote "${REPO_URL}" "refs/pull/${PR_NUM}/merge" refs/heads/main) - MERGE_SHA=$(echo "${LS_OUTPUT}" | awk '/refs\/pull\//{print $1}') - BASE_SHA=$(echo "${LS_OUTPUT}" | awk '/refs\/heads\/main/{print $1}') - if [[ -z "${MERGE_SHA}" ]]; then - echo "::error::Cannot resolve refs/pull/${PR_NUM}/merge" - echo "::error::The PR may have merge conflicts or the merge ref may not exist" - exit 1 - fi + # Resolve refs/heads/main live instead of trusting the PR REST + # resource's `.base.sha` (see note in "Query PR metadata"), + # which can lag main's actual tip by hours while a PR sits idle. + BASE_SHA=$(git ls-remote "${REPO_URL}" refs/heads/main | awk '{print $1}') if [[ -z "${BASE_SHA}" ]]; then echo "::error::Cannot resolve refs/heads/main for base verification" exit 1 fi + # Construct the merge locally from the two pinned commit SHAs + # instead of trusting GitHub's asynchronously-regenerated + # refs/pull//merge: that ref is only recomputed on a push to + # the PR branch, a merge-base change, or a 12h max-age timer, + # and can be observed lagging main's live tip well past the + # window either of those give (see grid#66 for a live-reproduced + # example -- unchanged 49+ minutes after main advanced). A full + # (unshallow) clone guarantees `git merge` can find the true + # merge-base between base and head regardless of how far apart + # they are; glb-e2e's "Reconstruct PR merge" step repeats this + # exact reconstruction (same inputs, same pinned identity/date/ + # message below) to independently arrive at the identical SHA + # this step verifies it against. + git clone --quiet "${REPO_URL}" merge-check + git -C merge-check fetch --quiet origin "${BASE_SHA}" "${HEAD_SHA}" + git -C merge-check checkout --quiet "${BASE_SHA}" + + MERGE_COMMIT_MESSAGE="Merge PR #${PR_NUM} head ${HEAD_SHA} into base ${BASE_SHA} (${MERGE_COMMIT_MESSAGE_SUFFIX})" + if ! GIT_AUTHOR_NAME="${MERGE_COMMIT_NAME}" GIT_AUTHOR_EMAIL="${MERGE_COMMIT_EMAIL}" GIT_AUTHOR_DATE="${MERGE_COMMIT_DATE}" \ + GIT_COMMITTER_NAME="${MERGE_COMMIT_NAME}" GIT_COMMITTER_EMAIL="${MERGE_COMMIT_EMAIL}" GIT_COMMITTER_DATE="${MERGE_COMMIT_DATE}" \ + git -C merge-check merge --no-ff --quiet -m "${MERGE_COMMIT_MESSAGE}" "${HEAD_SHA}"; then + echo "::error::PR #${PR_NUM} (head ${HEAD_SHA}) does not merge cleanly onto main (${BASE_SHA})" + exit 1 + fi + MERGE_SHA=$(git -C merge-check rev-parse HEAD) + { echo "source_kind=pr" echo "source_label=PR #${PR_NUM}" echo "tested_sha=${MERGE_SHA}" + echo "checkout_sha=${BASE_SHA}" echo "base_sha=${BASE_SHA}" } >> "${GITHUB_OUTPUT}" - echo "Resolved refs/pull/${PR_NUM}/merge -> ${MERGE_SHA}" - echo "Resolved refs/heads/main -> ${BASE_SHA} (expected base)" + echo "Resolved refs/heads/main -> ${BASE_SHA} (base)" + echo "Constructed deterministic merge of PR #${PR_NUM} head ${HEAD_SHA} onto ${BASE_SHA} -> ${MERGE_SHA}" else MAIN_SHA=$(git ls-remote "${REPO_URL}" refs/heads/main | awk '{print $1}') if [[ -z "${MAIN_SHA}" ]]; then @@ -191,6 +219,7 @@ jobs: echo "source_kind=main" echo "source_label=main" echo "tested_sha=${MAIN_SHA}" + echo "checkout_sha=${MAIN_SHA}" } >> "${GITHUB_OUTPUT}" echo "Resolved refs/heads/main -> ${MAIN_SHA}" fi @@ -242,15 +271,35 @@ jobs: # Checkout # ---------------------------------------------------------------------- - - name: Checkout Grid at tested SHA + - name: Checkout Grid at base revision uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: repository: ${{ github.repository }} - ref: ${{ needs.resolve-source.outputs.tested_sha }} + ref: ${{ needs.resolve-source.outputs.checkout_sha }} path: grid - fetch-depth: 2 + fetch-depth: 0 persist-credentials: false + - name: Reconstruct PR merge + if: needs.resolve-source.outputs.source_kind == 'pr' + run: | + set -euo pipefail + REPO="${GITHUB_WORKSPACE}/grid" + git -C "${REPO}" fetch --quiet origin "${HEAD_SHA}" + + # Must reproduce resolve-source's "Resolve source" step exactly + # (same base_sha/head_sha, same pinned identity/date/message) so + # the resulting commit SHA matches TESTED_SHA byte-for-byte -- + # that match is what "Verify PR merge checkout" below asserts. + MERGE_COMMIT_MESSAGE="Merge PR #${PR_NUMBER} head ${HEAD_SHA} into base ${BASE_SHA} (${MERGE_COMMIT_MESSAGE_SUFFIX})" + if ! GIT_AUTHOR_NAME="${MERGE_COMMIT_NAME}" GIT_AUTHOR_EMAIL="${MERGE_COMMIT_EMAIL}" GIT_AUTHOR_DATE="${MERGE_COMMIT_DATE}" \ + GIT_COMMITTER_NAME="${MERGE_COMMIT_NAME}" GIT_COMMITTER_EMAIL="${MERGE_COMMIT_EMAIL}" GIT_COMMITTER_DATE="${MERGE_COMMIT_DATE}" \ + git -C "${REPO}" merge --no-ff --quiet -m "${MERGE_COMMIT_MESSAGE}" "${HEAD_SHA}"; then + echo "::error::Reconstructing the merge of PR #${PR_NUMBER} head ${HEAD_SHA} onto base ${BASE_SHA} failed" + echo "::error::This mirrors resolve-source's own merge, which already succeeded earlier in this run" + exit 1 + fi + - name: Verify PR merge checkout if: needs.resolve-source.outputs.source_kind == 'pr' run: |