@@ -2,31 +2,33 @@ name: Merge gate
22
33# Single required status context (`ci/merge-gate`) that both internal and fork
44# PRs to `release` can satisfy. Branch protection on `release` requires
5- # `ci/merge-gate`; the per-flow checks it aggregates (qc-result,
6- # `perform-test / ci-test-result`, external-ci-result, approved Cypress) are no
7- # longer individually required — each PR type only ever produces one of the two
8- # sets, and a raw required context that is skipped/absent on the other type
9- # stays "Expected" forever and blocks the merge (this is exactly the bug that
10- # left fork PRs unmergeable after the external-contributor flow shipped).
5+ # `ci/merge-gate`; the per-flow checks it aggregates (`qc-result`,
6+ # `perform-test / ci-test-result`, `external-ci-result`, approved Cypress) are
7+ # no longer individually required — each PR type only ever produces one of the
8+ # two sets, and a raw required context that is skipped/absent on the other type
9+ # stays "Expected" forever and blocks the merge (the bug this fixes).
1110#
12- # Ownership of the write is split by which flow knows the PR head SHA:
13- # - Internal PRs: THIS workflow, triggered by the internal PR workflows'
14- # completion (their head_sha is the PR head).
15- # - Fork PRs: build-client-server.yml sets `ci/merge-gate` on `/approve-ci`,
16- # where the approved head SHA is known. This workflow skips forks.
17- # A PR is exactly one of the two, so the context is written by exactly one path
18- # and the two setters never race.
11+ # This workflow recomputes the gate for the PR associated with a feeding
12+ # workflow's run, on both rerun start (`in_progress`) and `completed`:
13+ # - `in_progress` forces the feeding workflow's own input to `pending`, so an
14+ # earlier success cannot linger while that workflow re-runs on the same SHA.
15+ # - `completed` reads the head SHA's checks/statuses and writes success /
16+ # failure / pending (fail-closed: skipped/neutral/absent => pending).
17+ # It owns internal PRs outright and recomputes the fork credential-free side;
18+ # the fork Cypress side is written by build-client-server.yml on `/approve-ci`.
1919#
20- # Security: this workflow never checks out or runs PR code — it only calls the
21- # GitHub API — and runs from the default branch in the base-repo context, so
22- # holding `statuses: write` is safe even when the associated PR is from a fork.
20+ # Security: it never checks out or runs PR code — it only checks out the base
21+ # repo for the shared script and calls the GitHub API — and runs from the
22+ # default branch in the base-repo context, so holding `statuses: write` is safe
23+ # even when the associated PR is from a fork.
2324
2425on :
2526 workflow_run :
2627 workflows :
2728 - " Quality checks"
2829 - " PR Automation test suite"
29- types : [completed]
30+ - " External PR credential-free validation"
31+ types : [in_progress, completed]
3032
3133permissions :
3234 contents : read
@@ -36,106 +38,40 @@ permissions:
3638
3739concurrency :
3840 # Serialize evaluations per head SHA so concurrent triggers don't race on the
39- # commit- status write; queue rather than cancel so the latest state wins.
41+ # status write; queue rather than cancel so the latest state always wins.
4042 group : merge-gate-${{ github.event.workflow_run.head_sha }}
4143 cancel-in-progress : false
4244
4345jobs :
4446 evaluate :
4547 runs-on : ubuntu-latest
4648 steps :
47- - name : Evaluate internal merge gate
49+ - name : Checkout base repo (trusted — for the shared gate script only)
50+ uses : actions/checkout@v4
51+
52+ - name : Evaluate merge gate
4853 uses : actions/github-script@v7
4954 env :
50- HEAD_SHA : ${{ github.event.workflow_run.head_sha }}
55+ NODE_PATH : ${{ github.workspace }}/.github/workflows/scripts
5156 with :
5257 script : |
53- const GATE = "ci/ merge-gate" ;
58+ const gate = require(" merge-gate.js") ;
5459 const { owner, repo } = context.repo;
55- const sha = process.env.HEAD_SHA;
56- if (!sha) {
57- core.info("No head SHA on the triggering run; skipping.");
58- return;
59- }
60+ const run = context.payload.workflow_run;
61+ const sha = run.head_sha;
62+ const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
6063
61- // Resolve the open PR whose head is this SHA.
62- const assoc = await github.paginate(
63- github.rest.repos.listPullRequestsAssociatedWithCommit,
64- { owner, repo, commit_sha: sha, per_page: 100 },
65- );
66- const pr =
67- assoc.find((p) => p.state === "open" && p.head.sha === sha) ??
68- assoc.find((p) => p.state === "open");
64+ const pr = await gate.resolvePr({ github, owner, repo, sha, workflowRun: run });
6965 if (!pr) {
70- core.info(`No open PR found for ${sha}; skipping.`);
71- return;
72- }
73-
74- // Only gate PRs targeting release — the branch whose protection
75- // requires ci/merge-gate.
76- if (pr.base.ref !== "release") {
77- core.info(`PR #${pr.number} targets ${pr.base.ref}, not release; skipping.`);
66+ core.info(`No unique open PR found for ${sha}; skipping.`);
7867 return;
7968 }
8069
81- // Fork PRs get ci/merge-gate from build-client-server.yml on
82- // /approve-ci; this workflow owns internal PRs only.
83- if (pr.head.repo.full_name !== `${owner}/${repo}`) {
84- core.info(`PR #${pr.number} is a fork; build-client-server.yml owns its status. Skipping.`);
85- return;
86- }
87-
88- // Read the latest run of each required internal check on the head SHA.
89- const checks = await github.paginate(github.rest.checks.listForRef, {
90- owner,
91- repo,
92- ref: sha,
93- per_page: 100,
94- });
95- const latest = new Map();
96- for (const c of checks) {
97- const prev = latest.get(c.name);
98- if (!prev || new Date(c.started_at ?? 0) >= new Date(prev.started_at ?? 0)) {
99- latest.set(c.name, c);
100- }
101- }
102- // A skipped/neutral required check means "not satisfied yet" (e.g. an
103- // internal PR without ok-to-test never ran Cypress) — treat as
104- // pending so the gate blocks, matching pre-existing behavior.
105- const outcome = (name) => {
106- const c = latest.get(name);
107- if (!c || c.status !== "completed") return "pending";
108- if (c.conclusion === "success") return "success";
109- if (c.conclusion === "skipped" || c.conclusion === "neutral") return "pending";
110- return "failure";
111- };
112-
113- const parts = {
114- "qc-result": outcome("qc-result"),
115- "perform-test / ci-test-result": outcome("perform-test / ci-test-result"),
116- };
117- const failed = Object.entries(parts).filter(([, v]) => v === "failure").map(([k]) => k);
118- const pending = Object.entries(parts).filter(([, v]) => v === "pending").map(([k]) => k);
119-
120- let state, description;
121- if (failed.length) {
122- state = "failure";
123- description = `Failed: ${failed.join(", ")}`.slice(0, 140);
124- } else if (pending.length) {
125- state = "pending";
126- description = `Waiting: ${pending.join(", ")}`.slice(0, 140);
127- } else {
128- state = "success";
129- description = "Quality checks + Cypress passed";
70+ // A rerun that just started means its result is in flight — force
71+ // that input to `pending` so an existing success can't linger.
72+ const pendingChecks = new Set();
73+ if (run.status === "in_progress" && gate.WORKFLOW_TO_CHECK[run.name]) {
74+ pendingChecks.add(gate.WORKFLOW_TO_CHECK[run.name]);
13075 }
13176
132- core.info(`Setting ${GATE}=${state} on ${sha} for internal PR #${pr.number} (${description})`);
133- await github.rest.repos.createCommitStatus({
134- owner,
135- repo,
136- sha,
137- state,
138- context: GATE,
139- description,
140- target_url: `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
141- });
77+ await gate.evaluate({ github, core, owner, repo, sha, pr, pendingChecks, runUrl });
0 commit comments