diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 49794c4..f4cb823 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -88,7 +88,19 @@ jobs: test "$(jq -er '.head.ref' <<<"$resolved_pull")" = "$HEAD_BRANCH" test "$(jq -er '.head.sha' <<<"$resolved_pull")" = "$HEAD_SHA" test "$(jq -er '.merged_at | type' <<<"$resolved_pull")" = "string" - merge_commit="$(jq -er '.merge_commit_sha' <<<"$resolved_pull")" + merged_pr="$( + gh pr view "$pr_number" \ + --repo "$GITHUB_REPOSITORY" \ + --json number,state,mergedAt,mergeCommit,baseRefName,headRefName,headRefOid,isCrossRepository + )" + test "$(jq -er '.number' <<<"$merged_pr")" = "$pr_number" + test "$(jq -er '.state' <<<"$merged_pr")" = "MERGED" + test "$(jq -er '.baseRefName' <<<"$merged_pr")" = "$base_branch" + test "$(jq -er '.headRefName' <<<"$merged_pr")" = "$HEAD_BRANCH" + test "$(jq -er '.headRefOid' <<<"$merged_pr")" = "$HEAD_SHA" + test "$(jq -er '.isCrossRepository' <<<"$merged_pr")" = "false" + test "$(jq -er '.mergedAt | type' <<<"$merged_pr")" = "string" + merge_commit="$(jq -er '.mergeCommit.oid' <<<"$merged_pr")" [[ "$merge_commit" =~ ^[0-9a-f]{40}$ ]] fi @@ -151,8 +163,9 @@ jobs: newly_merged=1 fi pull="" + merged_pr="" merge_observed=0 - for attempt in 1 2 3 4 5 6; do + for attempt in $(seq 1 15); do pull="$( gh api \ -H 'Accept: application/vnd.github+json' \ @@ -165,12 +178,33 @@ jobs: '.merged == true and .base.ref == $base_branch and .head.sha == $head_sha and - (.merge_commit_sha | strings | test("^[0-9a-f]{40}$"))' \ + (.merged_at | type) == "string"' \ <<<"$pull" >/dev/null; then - merge_observed=1 - break + if ! merged_pr="$( + gh pr view "$PR_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json number,state,mergedAt,mergeCommit,baseRefName,headRefName,headRefOid,isCrossRepository + )"; then + merged_pr="" + elif jq -e \ + --argjson pr_number "$PR_NUMBER" \ + --arg base_branch "$BASE_BRANCH" \ + --arg head_branch "$HEAD_BRANCH" \ + --arg head_sha "$HEAD_SHA" \ + '.number == $pr_number and + .state == "MERGED" and + .baseRefName == $base_branch and + .headRefName == $head_branch and + .headRefOid == $head_sha and + .isCrossRepository == false and + (.mergedAt | type) == "string" and + (.mergeCommit.oid | strings | test("^[0-9a-f]{40}$"))' \ + <<<"$merged_pr" >/dev/null; then + merge_observed=1 + break + fi fi - if ((attempt < 6)); then + if ((attempt < 15)); then sleep 2 fi done @@ -178,7 +212,7 @@ jobs: test "$(jq -er '.merged' <<<"$pull")" = "true" test "$(jq -er '.base.ref' <<<"$pull")" = "$BASE_BRANCH" test "$(jq -er '.head.sha' <<<"$pull")" = "$HEAD_SHA" - merge_commit="$(jq -er '.merge_commit_sha' <<<"$pull")" + merge_commit="$(jq -er '.mergeCommit.oid' <<<"$merged_pr")" [[ "$merge_commit" =~ ^[0-9a-f]{40}$ ]] if [[ "$PR_STATE" == "closed" ]]; then test "$merge_commit" = "$RECOVERED_COMMIT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74bea8..f01bda6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,11 @@ permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + # Metadata edits still get a cheap routing run, but must never cancel an + # in-flight source validation for the same PR head. Base-ref edits remain + # independently fail-closed because the merge verifier rechecks the exact + # current base identity before accepting either run. + group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && github.event.action == 'edited' && format('edited-{0}', github.run_id) || 'validation' }} cancel-in-progress: true env: diff --git a/script/tests/test_release_distribution_contracts.py b/script/tests/test_release_distribution_contracts.py index d6f7ff4..c58f91a 100755 --- a/script/tests/test_release_distribution_contracts.py +++ b/script/tests/test_release_distribution_contracts.py @@ -1117,6 +1117,14 @@ class CIWorkflowContractTests(unittest.TestCase): self.assertIn('"$GITHUB_SHA" != "$EXPECTED_SHA"', source) self.assertIn("MAIN_PARENT: ${{ needs.scope.outputs.base }}", source) + def test_ci_metadata_edits_do_not_cancel_source_validation(self) -> None: + source = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") + concurrency = source[source.index("concurrency:") : source.index("env:")] + self.assertIn("github.event.action == 'edited'", concurrency) + self.assertIn("format('edited-{0}', github.run_id)", concurrency) + self.assertIn("|| 'validation'", concurrency) + self.assertIn("cancel-in-progress: true", concurrency) + def test_successful_protected_pr_ci_merges_and_dispatches_main_without_pr_code(self) -> None: source = (ROOT / ".github/workflows/auto-merge.yml").read_text( encoding="utf-8" @@ -1141,9 +1149,13 @@ class CIWorkflowContractTests(unittest.TestCase): ) ] self.assertIn("merge_observed=0", merge_step) - self.assertIn("for attempt in 1 2 3 4 5 6", merge_step) + self.assertIn("for attempt in $(seq 1 15)", merge_step) self.assertIn('test "$merge_observed" = "1"', merge_step) self.assertIn(".merged == true", merge_step) + self.assertIn("gh pr view \"$PR_NUMBER\"", merge_step) + self.assertIn('if ! merged_pr="$(', merge_step) + self.assertIn(".mergeCommit.oid", merge_step) + self.assertNotIn(".merge_commit_sha", merge_step) self.assertLess(merge_step.index("gh pr merge"), merge_step.index("merge_observed=0")) cleanup = source.index("./script/ci_proof_promotion.py delete-merged-head") self.assertGreater(cleanup, source.index("Squash or recover only the PR proven")) @@ -1181,10 +1193,9 @@ class CIWorkflowContractTests(unittest.TestCase): 'test "$(jq -er \'.head.sha\' <<<"$resolved_pull")" = "$HEAD_SHA"', closed_replay, ) - self.assertIn( - 'merge_commit="$(jq -er \'.merge_commit_sha\' <<<"$resolved_pull")"', - closed_replay, - ) + self.assertIn('gh pr view "$pr_number"', closed_replay) + self.assertIn(".mergeCommit.oid", closed_replay) + self.assertNotIn(".merge_commit_sha", closed_replay) def test_ci_prepares_producer_image_dependencies_and_pins_actions(self) -> None: source = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")