From e555c13b67b48f1dae6c8bf7aa4acb2643f4667c Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 19:03:54 +0200 Subject: [PATCH 1/8] ci: share Ready checks and guarded automatic merge --- .github/workflows/check.yml | 47 ++++++++++++++++++ .github/workflows/ready.yml | 81 +++++++++++++++++++++++++++++++ README.md | 10 +++- tests/ready.test.mjs | 95 +++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/ready.yml create mode 100644 tests/ready.test.mjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..d74a649 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,47 @@ +name: Check + +on: + workflow_call: + inputs: + command: + type: string + default: vp run check + install: + type: boolean + default: true + node-version-file: + type: string + default: .node-version + +permissions: + contents: read + +jobs: + check: + name: Check + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + pnpm_config_ignore_scripts: "true" + pnpm_config_ignore_pnpmfile: "true" + steps: + - name: Checkout PR merge result + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Vite+ and install dependencies + if: ${{ inputs.install }} + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 + with: + version: "1.1.0" + node-version-file: ${{ inputs.node-version-file }} + cache: true + run-install: | + args: + - --frozen-lockfile + - --ignore-scripts + - name: Check + env: + CHECK_COMMAND: ${{ inputs.command }} + run: bash -e -o pipefail -c "$CHECK_COMMAND" diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml new file mode 100644 index 0000000..6bd2394 --- /dev/null +++ b/.github/workflows/ready.yml @@ -0,0 +1,81 @@ +name: Ready + +on: + workflow_call: + inputs: + check-name: + type: string + default: Check / Check + +permissions: + contents: write + pull-requests: write + statuses: write + checks: read + +jobs: + ready: + name: Ready + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + # Only GitHub metadata is read. Never check out or execute PR code here. + - name: Require selected review and enable auto-merge + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + CHECK_NAME: ${{ inputs.check-name }} + with: + script: | + const {owner, repo} = context.repo; + const number = context.payload.pull_request?.number + ?? context.payload.workflow_run?.pull_requests?.[0]?.number; + if (!number) return core.info('No associated pull request.'); + const getPull = async () => (await github.rest.pulls.get({owner, repo, pull_number: number})).data; + const pull = await getPull(); + const repository = (await github.rest.repos.get({owner, repo})).data; + if (pull.state !== 'open' || pull.base.ref !== repository.default_branch) return; + const sha = pull.head.sha; + const selected = pull.labels.some(label => label.name === 'review-ready'); + let approved = !selected; + if (selected) { + const reviews = await github.paginate(github.rest.pulls.listReviews, {owner, repo, pull_number: number, per_page: 100}); + const decisions = reviews.filter(review => review.user?.type === 'Bot' + && review.user.login === 'coderabbitai[bot]' + && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)); + const latest = decisions.sort((a, b) => b.id - a.id)[0]; + approved = latest?.state === 'APPROVED' && latest.commit_id === sha; + } + const current = await getPull(); + if (current.head.sha !== sha || current.draft !== pull.draft + || current.labels.some(label => label.name === 'review-ready') !== selected) return; + const reviewPassed = !pull.draft && approved; + await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', + state: reviewPassed ? 'success' : 'pending', + description: pull.draft ? 'Draft: publication is not authorized.' + : approved ? (selected ? 'CodeRabbit approved this revision.' : 'No CodeRabbit review selected.') + : 'Waiting for CodeRabbit approval of this revision.', + target_url: pull.html_url}); + if (pull.draft || !approved) return; + // A skipped draft Check must never authorize automatic publication. + const checks = await github.paginate(github.rest.checks.listForRef, {owner, repo, ref: sha, per_page: 100}); + const check = checks.filter(run => run.name === process.env.CHECK_NAME && run.app?.id === 15368) + .sort((a, b) => b.id - a.id)[0]; + if (check?.conclusion !== 'success') return core.info('Waiting for a successful Ready Check.'); + // Rollout must install central requirements before enabling auto-merge. + const {data: rules} = await github.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: pull.base.ref}); + const required = rules.filter(rule => rule.type === 'required_status_checks') + .flatMap(rule => rule.parameters.required_status_checks.map(status => status.context)); + if (!required.includes(process.env.CHECK_NAME) || !required.includes('Review')) { + return core.info('Central Check and Review requirements are not active yet.'); + } + if (!repository.allow_auto_merge) return core.info('Repository auto-merge is not enabled yet.'); + const final = await getPull(); + if (final.state !== 'open' || final.draft || final.head.sha !== sha + || final.labels.some(label => label.name === 'review-ready') !== selected) return; + if (final.auto_merge) return core.info('Auto-merge is already enabled.'); + await github.graphql(`mutation($id: ID!) { + enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: SQUASH}) { + pullRequest { number } + } + }`, {id: final.node_id}); diff --git a/README.md b/README.md index cef1f36..6c61165 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # fulldotdev/.github -Organization-wide GitHub defaults. CI workflows belong to each repository; workflows here are not inherited automatically. +Organization-wide GitHub defaults and reusable workflows. Repository callers select their own check command; workflows here are not inherited automatically. -New projects take their CI from [astro-template](https://github.com/fulldotdev/astro-template) or [vite-template](https://github.com/fulldotdev/vite-template). [fulldev](https://github.com/fulldotdev/fulldev) owns the monorepo workflows. +New projects take their callers from [astro-template](https://github.com/fulldotdev/astro-template) or [vite-template](https://github.com/fulldotdev/vite-template). + +- **Finalize (Prepare)** leaves a complete draft PR. Previews build; full checks wait for Ready. +- **Dispatch (Release)** validates the affected behavior and marks the PR Ready, authorizing automatic merge once the required checks and previews pass. Choose `review-ready` before marking Ready when CodeRabbit approval is needed. +- **Settle (Close)** follows production verification and preserves the result before closing the task. + +Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. Netlify requirements are managed centrally for the repositories with those sites. Production keeps its scoped check and build. A skipped or outdated CodeRabbit review cannot satisfy a selected review. diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs new file mode 100644 index 0000000..383eed1 --- /dev/null +++ b/tests/ready.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('../.github/workflows/ready.yml', import.meta.url), 'utf8') + .split(' script: |\n')[1].split('\n').map(line => line.slice(12)).join('\n'); +const execute = new (Object.getPrototypeOf(async function () {}).constructor)('github', 'context', 'core', 'process', source); + +async function scenario(options = {}) { + const pull = {number: 1, node_id: 'PR1', state: 'open', draft: false, head: {sha: 'head'}, + base: {ref: 'main'}, labels: options.selected ? [{name: 'review-ready'}] : [], html_url: 'https://github.com/o/r/pull/1', + ...options.pull}; + const statuses = []; + const merges = []; + let reads = 0; + const github = { + rest: { + pulls: {get: async () => ({data: ++reads >= 2 && options.changed ? {...pull, head: {sha: 'new'}} : pull}), listReviews: 'reviews'}, + repos: {get: async () => ({data: {default_branch: 'main', allow_auto_merge: options.autoMerge !== false}}), + createCommitStatus: async status => statuses.push(status)}, + checks: {listForRef: 'checks'}, + }, + paginate: async endpoint => endpoint === 'reviews' ? (options.reviews ?? []) + : (options.checks ?? [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success'}]), + request: async () => ({data: [{type: 'required_status_checks', parameters: { + required_status_checks: (options.required ?? ['Check / Check', 'Review']).map(context => ({context})), + }}]}), + graphql: async (_, args) => merges.push(args), + }; + await execute(github, {repo: {owner: 'o', repo: 'r'}, payload: options.event ?? {pull_request: {number: 1}}}, + {info: () => {}}, {env: {CHECK_NAME: 'Check / Check'}}); + return {statuses, merges}; +} + +const approval = {id: 1, user: {type: 'Bot', login: 'coderabbitai[bot]'}, state: 'APPROVED', commit_id: 'head'}; + +test('Ready with real checks and no selected review enables auto-merge', async () => { + const result = await scenario(); + assert.equal(result.statuses[0].state, 'success'); + assert.equal(result.merges.length, 1); +}); +test('draft remains pending and cannot auto-merge', async () => { + const result = await scenario({pull: {draft: true}}); + assert.equal(result.statuses[0].state, 'pending'); + assert.equal(result.merges.length, 0); +}); +test('selected but missing, skipped or rate-limited review stays pending', async () => { + const result = await scenario({selected: true}); + assert.equal(result.statuses[0].state, 'pending'); + assert.equal(result.merges.length, 0); +}); +test('selected approval must be from CodeRabbit at the current revision', async () => { + for (const review of [{...approval, commit_id: 'old'}, {...approval, user: {type: 'User', login: 'coderabbitai[bot]'}}, + {...approval, user: {type: 'Bot', login: 'another[bot]'}}]) { + const result = await scenario({selected: true, reviews: [review]}); + assert.equal(result.statuses[0].state, 'pending'); + assert.equal(result.merges.length, 0); + } + assert.equal((await scenario({selected: true, reviews: [approval]})).merges.length, 1); +}); +test('requested changes or dismissed approval supersede an earlier approval', async () => { + for (const state of ['CHANGES_REQUESTED', 'DISMISSED']) { + const result = await scenario({selected: true, reviews: [approval, {...approval, id: 2, state}]}); + assert.equal(result.statuses[0].state, 'pending'); + assert.equal(result.merges.length, 0); + } +}); +test('skipped, failed or absent static checks never authorize publication', async () => { + for (const checks of [[], [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'skipped'}], + [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'failure'}]]) { + assert.equal((await scenario({checks})).merges.length, 0); + } +}); +test('a newer pending Check supersedes an older successful one', async () => { + const checks = [ + {id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success'}, + {id: 2, name: 'Check / Check', app: {id: 15368}, conclusion: null}, + ]; + assert.equal((await scenario({checks})).merges.length, 0); +}); +test('missing central requirements or disabled repository auto-merge prevents rollout races', async () => { + assert.equal((await scenario({required: ['Check / Check']})).merges.length, 0); + assert.equal((await scenario({autoMerge: false})).merges.length, 0); +}); +test('changed revision, closed PR or another target does not publish', async () => { + for (const options of [{changed: true}, {pull: {state: 'closed'}}, {pull: {base: {ref: 'preview'}}}]) { + const result = await scenario(options); + assert.equal(result.statuses.length, 0); + assert.equal(result.merges.length, 0); + } +}); +test('successful Check workflow completion resumes the associated PR', async () => { + assert.equal((await scenario({event: {workflow_run: {pull_requests: [{number: 1}]}}})).merges.length, 1); + assert.equal((await scenario({event: {workflow_run: {pull_requests: []}}})).merges.length, 0); +}); From 2ed33959ecafa9ada915bdd4250d9844799d6331 Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 19:16:59 +0200 Subject: [PATCH 2/8] ci: bind release decisions to trusted events and exact heads --- .github/workflows/ready.yml | 54 ++++++++++++++++++++++++++----------- tests/ready.test.mjs | 30 +++++++++++++++++---- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index 6bd2394..35f8520 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -3,6 +3,9 @@ name: Ready on: workflow_call: inputs: + netlify-checks: + type: string + default: "[]" check-name: type: string default: Check / Check @@ -13,6 +16,10 @@ permissions: statuses: write checks: read +concurrency: + group: ready-policy-${{ github.repository }} + cancel-in-progress: false + jobs: ready: name: Ready @@ -23,39 +30,42 @@ jobs: - name: Require selected review and enable auto-merge uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: + NETLIFY_CHECKS: ${{ inputs.netlify-checks }} CHECK_NAME: ${{ inputs.check-name }} with: script: | const {owner, repo} = context.repo; - const number = context.payload.pull_request?.number - ?? context.payload.workflow_run?.pull_requests?.[0]?.number; - if (!number) return core.info('No associated pull request.'); + const repository = (await github.rest.repos.get({owner, repo})).data; + const candidates = context.payload.pull_request ? [context.payload.pull_request] + : await github.paginate(github.rest.pulls.list, {owner, repo, state: 'open', + base: repository.default_branch, per_page: 100}); + async function processPull(number) { const getPull = async () => (await github.rest.pulls.get({owner, repo, pull_number: number})).data; const pull = await getPull(); - const repository = (await github.rest.repos.get({owner, repo})).data; if (pull.state !== 'open' || pull.base.ref !== repository.default_branch) return; const sha = pull.head.sha; const selected = pull.labels.some(label => label.name === 'review-ready'); - let approved = !selected; - if (selected) { + async function hasApproval() { + if (!selected) return true; const reviews = await github.paginate(github.rest.pulls.listReviews, {owner, repo, pull_number: number, per_page: 100}); const decisions = reviews.filter(review => review.user?.type === 'Bot' && review.user.login === 'coderabbitai[bot]' && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)); const latest = decisions.sort((a, b) => b.id - a.id)[0]; - approved = latest?.state === 'APPROVED' && latest.commit_id === sha; + return latest?.state === 'APPROVED' && latest.commit_id === sha; } + const approved = await hasApproval(); const current = await getPull(); - if (current.head.sha !== sha || current.draft !== pull.draft + if (current.base.ref !== pull.base.ref || current.head.sha !== sha || current.draft !== pull.draft || current.labels.some(label => label.name === 'review-ready') !== selected) return; - const reviewPassed = !pull.draft && approved; + const reviewPassed = !pull.draft && approved && await hasApproval(); await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', state: reviewPassed ? 'success' : 'pending', description: pull.draft ? 'Draft: publication is not authorized.' : approved ? (selected ? 'CodeRabbit approved this revision.' : 'No CodeRabbit review selected.') : 'Waiting for CodeRabbit approval of this revision.', target_url: pull.html_url}); - if (pull.draft || !approved) return; + if (!reviewPassed) return; // A skipped draft Check must never authorize automatic publication. const checks = await github.paginate(github.rest.checks.listForRef, {owner, repo, ref: sha, per_page: 100}); const check = checks.filter(run => run.name === process.env.CHECK_NAME && run.app?.id === 15368) @@ -66,16 +76,28 @@ jobs: {owner, repo, branch: pull.base.ref}); const required = rules.filter(rule => rule.type === 'required_status_checks') .flatMap(rule => rule.parameters.required_status_checks.map(status => status.context)); - if (!required.includes(process.env.CHECK_NAME) || !required.includes('Review')) { - return core.info('Central Check and Review requirements are not active yet.'); + const expected = [process.env.CHECK_NAME, 'Review', ...JSON.parse(process.env.NETLIFY_CHECKS)]; + if (!expected.every(name => required.includes(name))) { + return core.info('The complete central Check, Review and Netlify requirements are not active yet.'); } if (!repository.allow_auto_merge) return core.info('Repository auto-merge is not enabled yet.'); const final = await getPull(); - if (final.state !== 'open' || final.draft || final.head.sha !== sha + if (final.state !== 'open' || final.draft || final.base.ref !== pull.base.ref || final.head.sha !== sha || final.labels.some(label => label.name === 'review-ready') !== selected) return; + if (selected && !await hasApproval()) { + await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', + state: 'pending', description: 'CodeRabbit approval changed; waiting for review.', target_url: pull.html_url}); + return; + } + if (final.mergeable_state === 'clean') { + await github.rest.pulls.merge({owner, repo, pull_number: number, sha, merge_method: 'squash'}); + return; + } if (final.auto_merge) return core.info('Auto-merge is already enabled.'); - await github.graphql(`mutation($id: ID!) { - enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: SQUASH}) { + await github.graphql(`mutation($id: ID!, $sha: GitObjectID!) { + enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: SQUASH}) { pullRequest { number } } - }`, {id: final.node_id}); + }`, {id: final.node_id, sha}); + } + for (const pull of candidates) await processPull(pull.number); diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index 383eed1..c3c5c57 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -15,20 +15,21 @@ async function scenario(options = {}) { let reads = 0; const github = { rest: { - pulls: {get: async () => ({data: ++reads >= 2 && options.changed ? {...pull, head: {sha: 'new'}} : pull}), listReviews: 'reviews'}, + pulls: {get: async () => ({data: ++reads >= 2 && options.changed ? {...pull, head: {sha: 'new'}} : pull}), + listReviews: 'reviews', list: 'pulls', merge: async args => merges.push(args)}, repos: {get: async () => ({data: {default_branch: 'main', allow_auto_merge: options.autoMerge !== false}}), createCommitStatus: async status => statuses.push(status)}, checks: {listForRef: 'checks'}, }, - paginate: async endpoint => endpoint === 'reviews' ? (options.reviews ?? []) - : (options.checks ?? [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success'}]), + paginate: async endpoint => endpoint === 'reviews' ? (options.reviewRead ? options.reviewRead() : options.reviews ?? []) + : endpoint === 'pulls' ? [pull] : (options.checks ?? [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success'}]), request: async () => ({data: [{type: 'required_status_checks', parameters: { required_status_checks: (options.required ?? ['Check / Check', 'Review']).map(context => ({context})), }}]}), graphql: async (_, args) => merges.push(args), }; await execute(github, {repo: {owner: 'o', repo: 'r'}, payload: options.event ?? {pull_request: {number: 1}}}, - {info: () => {}}, {env: {CHECK_NAME: 'Check / Check'}}); + {info: () => {}}, {env: {CHECK_NAME: 'Check / Check', NETLIFY_CHECKS: JSON.stringify(options.netlify ?? [])}}); return {statuses, merges}; } @@ -91,5 +92,24 @@ test('changed revision, closed PR or another target does not publish', async () }); test('successful Check workflow completion resumes the associated PR', async () => { assert.equal((await scenario({event: {workflow_run: {pull_requests: [{number: 1}]}}})).merges.length, 1); - assert.equal((await scenario({event: {workflow_run: {pull_requests: []}}})).merges.length, 0); + assert.equal((await scenario({event: {workflow_run: {pull_requests: []}}})).merges.length, 1); +}); +test('already-green PR merges immediately with the validated SHA', async () => { + const result = await scenario({pull: {mergeable_state: 'clean'}}); + assert.deepEqual(result.merges, [{owner: 'o', repo: 'r', pull_number: 1, sha: 'head', merge_method: 'squash'}]); +}); +test('automatic merge is bound to the validated head', async () => { + assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head'}]); +}); +test('every expected Netlify context must be centrally required', async () => { + const result = await scenario({netlify: ['netlify/app/deploy-preview', 'netlify/site/deploy-preview'], + required: ['Check / Check', 'Review', 'netlify/app/deploy-preview']}); + assert.equal(result.merges.length, 0); +}); +test('dismissal during processing never writes stale approval', async () => { + let reads = 0; + const result = await scenario({selected: true, + reviewRead: () => ++reads === 1 ? [approval] : [{...approval, state: 'DISMISSED'}]}); + assert.equal(result.statuses[0].state, 'pending'); + assert.equal(result.merges.length, 0); }); From e9519a34feab673e248dd3ff33b6187de7d2baba Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 19:26:27 +0200 Subject: [PATCH 3/8] fix: reconcile all PRs and protect review notifications --- .github/workflows/ready.yml | 140 +++++++++++++++++++++--------------- tests/ready.test.mjs | 39 ++++++++-- 2 files changed, 115 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index 35f8520..85306bb 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -36,68 +36,90 @@ jobs: script: | const {owner, repo} = context.repo; const repository = (await github.rest.repos.get({owner, repo})).data; - const candidates = context.payload.pull_request ? [context.payload.pull_request] - : await github.paginate(github.rest.pulls.list, {owner, repo, state: 'open', + const candidates = await github.paginate(github.rest.pulls.list, {owner, repo, state: 'open', base: repository.default_branch, per_page: 100}); async function processPull(number) { - const getPull = async () => (await github.rest.pulls.get({owner, repo, pull_number: number})).data; - const pull = await getPull(); - if (pull.state !== 'open' || pull.base.ref !== repository.default_branch) return; - const sha = pull.head.sha; - const selected = pull.labels.some(label => label.name === 'review-ready'); - async function hasApproval() { - if (!selected) return true; - const reviews = await github.paginate(github.rest.pulls.listReviews, {owner, repo, pull_number: number, per_page: 100}); - const decisions = reviews.filter(review => review.user?.type === 'Bot' - && review.user.login === 'coderabbitai[bot]' - && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)); - const latest = decisions.sort((a, b) => b.id - a.id)[0]; - return latest?.state === 'APPROVED' && latest.commit_id === sha; - } - const approved = await hasApproval(); - const current = await getPull(); - if (current.base.ref !== pull.base.ref || current.head.sha !== sha || current.draft !== pull.draft - || current.labels.some(label => label.name === 'review-ready') !== selected) return; - const reviewPassed = !pull.draft && approved && await hasApproval(); - await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', - state: reviewPassed ? 'success' : 'pending', - description: pull.draft ? 'Draft: publication is not authorized.' - : approved ? (selected ? 'CodeRabbit approved this revision.' : 'No CodeRabbit review selected.') - : 'Waiting for CodeRabbit approval of this revision.', - target_url: pull.html_url}); - if (!reviewPassed) return; - // A skipped draft Check must never authorize automatic publication. - const checks = await github.paginate(github.rest.checks.listForRef, {owner, repo, ref: sha, per_page: 100}); - const check = checks.filter(run => run.name === process.env.CHECK_NAME && run.app?.id === 15368) - .sort((a, b) => b.id - a.id)[0]; - if (check?.conclusion !== 'success') return core.info('Waiting for a successful Ready Check.'); - // Rollout must install central requirements before enabling auto-merge. - const {data: rules} = await github.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', - {owner, repo, branch: pull.base.ref}); - const required = rules.filter(rule => rule.type === 'required_status_checks') - .flatMap(rule => rule.parameters.required_status_checks.map(status => status.context)); - const expected = [process.env.CHECK_NAME, 'Review', ...JSON.parse(process.env.NETLIFY_CHECKS)]; - if (!expected.every(name => required.includes(name))) { - return core.info('The complete central Check, Review and Netlify requirements are not active yet.'); - } - if (!repository.allow_auto_merge) return core.info('Repository auto-merge is not enabled yet.'); - const final = await getPull(); - if (final.state !== 'open' || final.draft || final.base.ref !== pull.base.ref || final.head.sha !== sha - || final.labels.some(label => label.name === 'review-ready') !== selected) return; - if (selected && !await hasApproval()) { + const getPull = async () => (await github.rest.pulls.get({owner, repo, pull_number: number})).data; + const pull = await getPull(); + if (pull.state !== 'open' || pull.base.ref !== repository.default_branch) return; + const sha = pull.head.sha; + const selected = pull.labels.some(label => label.name === 'review-ready'); + async function hasApproval() { + if (!selected) return true; + const reviews = await github.paginate(github.rest.pulls.listReviews, {owner, repo, pull_number: number, per_page: 100}); + const decisions = reviews.filter(review => review.user?.type === 'Bot' + && review.user.login === 'coderabbitai[bot]' + && ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)); + const latest = decisions.sort((a, b) => b.id - a.id)[0]; + return latest?.state === 'APPROVED' && latest.commit_id === sha; + } + async function hasTrustedNotifier() { + if (!selected) return true; + try { + const path = '.github/workflows/review-event.yml'; + const trusted = (await github.rest.repos.getContent({owner, repo, path, + ref: repository.default_branch})).data; + const merged = (await github.rest.repos.getContent({owner, repo, path, + ref: `refs/pull/${number}/merge`})).data; + return trusted.type === 'file' && merged.type === 'file' && trusted.sha === merged.sha; + } catch (error) { + if (error.status === 404) return false; + throw error; + } + } + const approved = await hasApproval() && await hasTrustedNotifier(); + const current = await getPull(); + if (current.base.ref !== pull.base.ref || current.head.sha !== sha || current.draft !== pull.draft + || current.labels.some(label => label.name === 'review-ready') !== selected) return; + const reviewPassed = !pull.draft && approved && await hasApproval() && await hasTrustedNotifier(); await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', - state: 'pending', description: 'CodeRabbit approval changed; waiting for review.', target_url: pull.html_url}); - return; - } - if (final.mergeable_state === 'clean') { - await github.rest.pulls.merge({owner, repo, pull_number: number, sha, merge_method: 'squash'}); - return; + state: reviewPassed ? 'success' : 'pending', + description: pull.draft ? 'Draft: publication is not authorized.' + : approved ? (selected ? 'CodeRabbit approved this revision.' : 'No CodeRabbit review selected.') + : 'Waiting for CodeRabbit approval of this revision.', + target_url: pull.html_url}); + if (!reviewPassed) return; + // A skipped draft Check must never authorize automatic publication. + const checks = await github.paginate(github.rest.checks.listForRef, {owner, repo, ref: sha, per_page: 100}); + const check = checks.filter(run => run.name === process.env.CHECK_NAME && run.app?.id === 15368) + .sort((a, b) => b.id - a.id)[0]; + if (check?.conclusion !== 'success') return core.info('Waiting for a successful Ready Check.'); + // Rollout must install central requirements before enabling auto-merge. + const {data: rules} = await github.request('GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: pull.base.ref}); + const required = rules.filter(rule => rule.type === 'required_status_checks') + .flatMap(rule => rule.parameters.required_status_checks.map(status => status.context)); + const expected = [process.env.CHECK_NAME, 'Review', ...JSON.parse(process.env.NETLIFY_CHECKS)]; + if (!expected.every(name => required.includes(name))) { + return core.info('The complete central Check, Review and Netlify requirements are not active yet.'); + } + if (!repository.allow_auto_merge) return core.info('Repository auto-merge is not enabled yet.'); + const final = await getPull(); + if (final.state !== 'open' || final.draft || final.base.ref !== pull.base.ref || final.head.sha !== sha + || final.labels.some(label => label.name === 'review-ready') !== selected) return; + if (selected && (!await hasApproval() || !await hasTrustedNotifier())) { + await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', + state: 'pending', description: 'CodeRabbit approval changed; waiting for review.', target_url: pull.html_url}); + return; + } + if (final.mergeable_state === 'clean') { + await github.rest.pulls.merge({owner, repo, pull_number: number, sha, merge_method: 'squash'}); + return; + } + if (final.auto_merge) return core.info('Auto-merge is already enabled.'); + await github.graphql(`mutation($id: ID!, $sha: GitObjectID!) { + enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: SQUASH}) { + pullRequest { number } + } + }`, {id: final.node_id, sha}); } - if (final.auto_merge) return core.info('Auto-merge is already enabled.'); - await github.graphql(`mutation($id: ID!, $sha: GitObjectID!) { - enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: SQUASH}) { - pullRequest { number } + let failed = false; + for (const pull of candidates) { + try { + await processPull(pull.number); + } catch (error) { + failed = true; + core.error(`PR #${pull.number}: ${error.message}`); } - }`, {id: final.node_id, sha}); } - for (const pull of candidates) await processPull(pull.number); + if (failed) core.setFailed('Some PRs could not be reconciled; see their errors above.'); diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index c3c5c57..fbb0286 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -10,27 +10,33 @@ async function scenario(options = {}) { const pull = {number: 1, node_id: 'PR1', state: 'open', draft: false, head: {sha: 'head'}, base: {ref: 'main'}, labels: options.selected ? [{name: 'review-ready'}] : [], html_url: 'https://github.com/o/r/pull/1', ...options.pull}; + const pulls = options.pulls ?? [pull]; + const errors = []; const statuses = []; const merges = []; let reads = 0; const github = { rest: { - pulls: {get: async () => ({data: ++reads >= 2 && options.changed ? {...pull, head: {sha: 'new'}} : pull}), - listReviews: 'reviews', list: 'pulls', merge: async args => merges.push(args)}, + pulls: {get: async ({pull_number}) => ({data: ++reads >= 2 && options.changed ? {...pull, head: {sha: 'new'}} : pulls.find(candidate => candidate.number === pull_number)}), + listReviews: 'reviews', list: 'pulls', merge: async args => { if (options.rejectPull === args.pull_number) throw Object.assign(new Error('Head changed'), {status: 409}); merges.push(args); }}, repos: {get: async () => ({data: {default_branch: 'main', allow_auto_merge: options.autoMerge !== false}}), + getContent: async ({ref}) => { + if (options.missingNotifier && ref.startsWith('refs/pull/')) throw Object.assign(new Error('Not found'), {status: 404}); + return {data: {type: 'file', sha: options.changedNotifier && ref.startsWith('refs/pull/') ? 'modified' : 'trusted'}}; + }, createCommitStatus: async status => statuses.push(status)}, checks: {listForRef: 'checks'}, }, paginate: async endpoint => endpoint === 'reviews' ? (options.reviewRead ? options.reviewRead() : options.reviews ?? []) - : endpoint === 'pulls' ? [pull] : (options.checks ?? [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success'}]), + : endpoint === 'pulls' ? pulls : (options.checks ?? [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success'}]), request: async () => ({data: [{type: 'required_status_checks', parameters: { required_status_checks: (options.required ?? ['Check / Check', 'Review']).map(context => ({context})), }}]}), graphql: async (_, args) => merges.push(args), }; await execute(github, {repo: {owner: 'o', repo: 'r'}, payload: options.event ?? {pull_request: {number: 1}}}, - {info: () => {}}, {env: {CHECK_NAME: 'Check / Check', NETLIFY_CHECKS: JSON.stringify(options.netlify ?? [])}}); - return {statuses, merges}; + {info: () => {}, error: message => errors.push(message), setFailed: message => errors.push(message)}, {env: {CHECK_NAME: 'Check / Check', NETLIFY_CHECKS: JSON.stringify(options.netlify ?? [])}}); + return {statuses, merges, errors}; } const approval = {id: 1, user: {type: 'Bot', login: 'coderabbitai[bot]'}, state: 'APPROVED', commit_id: 'head'}; @@ -113,3 +119,26 @@ test('dismissal during processing never writes stale approval', async () => { assert.equal(result.statuses[0].state, 'pending'); assert.equal(result.merges.length, 0); }); + +test('selected review cannot remove or modify the trusted review notifier', async () => { + for (const options of [{changedNotifier: true}, {missingNotifier: true}]) { + const result = await scenario({...options, selected: true, reviews: [approval]}); + assert.equal(result.statuses[0].state, 'pending'); + assert.equal(result.merges.length, 0); + } +}); +test('every event reconciles other open PRs, even when concurrency coalesces events', async () => { + const pulls = [1, 2].map(number => ({number, node_id: `PR${number}`, state: 'open', draft: false, + head: {sha: `head${number}`}, base: {ref: 'main'}, labels: [], html_url: `https://github.com/o/r/pull/${number}`})); + const result = await scenario({pulls, event: {pull_request: {number: 2}}}); + assert.deepEqual(result.merges.map(merge => merge.id), ['PR1', 'PR2']); +}); +test('a rejected merge does not stop reconciliation of later PRs', async () => { + const pulls = [1, 2].map(number => ({number, node_id: `PR${number}`, state: 'open', draft: false, + head: {sha: `head${number}`}, base: {ref: 'main'}, labels: [], mergeable_state: 'clean', + html_url: `https://github.com/o/r/pull/${number}`})); + const result = await scenario({pulls, rejectPull: 1}); + assert.deepEqual(result.merges.map(merge => merge.pull_number), [2]); + assert.match(result.errors[0], /PR #1: Head changed/); + assert.equal(result.errors.length, 2); +}); From 48f12b79520c984ca7bcf75095d05ec1cf63fef2 Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 19:30:52 +0200 Subject: [PATCH 4/8] fix: read current approval last and check shared workflows on Ready --- .github/workflows/ci-check.yml | 21 +++++++++++++++++++++ .github/workflows/ci-ready.yml | 19 +++++++++++++++++++ .github/workflows/ready.yml | 6 +++--- .github/workflows/review-event.yml | 14 ++++++++++++++ tests/ready.test.mjs | 14 ++++++++++++++ 5 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci-check.yml create mode 100644 .github/workflows/ci-ready.yml create mode 100644 .github/workflows/review-event.yml diff --git a/.github/workflows/ci-check.yml b/.github/workflows/ci-check.yml new file mode 100644 index 0000000..c91660a --- /dev/null +++ b/.github/workflows/ci-check.yml @@ -0,0 +1,21 @@ +name: Check + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + +permissions: + contents: read + +concurrency: + group: check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check: + name: Check + uses: ./.github/workflows/check.yml + with: + install: false + command: node --test tests/ready.test.mjs diff --git a/.github/workflows/ci-ready.yml b/.github/workflows/ci-ready.yml new file mode 100644 index 0000000..351cfe6 --- /dev/null +++ b/.github/workflows/ci-ready.yml @@ -0,0 +1,19 @@ +name: Ready + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled, unlabeled] + workflow_run: + workflows: [Check, Review event] + types: [completed] + +permissions: + contents: write + pull-requests: write + statuses: write + checks: read + +jobs: + ready: + uses: ./.github/workflows/ready.yml diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index 85306bb..e23967e 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -67,11 +67,11 @@ jobs: throw error; } } - const approved = await hasApproval() && await hasTrustedNotifier(); + const approved = await hasTrustedNotifier() && await hasApproval(); const current = await getPull(); if (current.base.ref !== pull.base.ref || current.head.sha !== sha || current.draft !== pull.draft || current.labels.some(label => label.name === 'review-ready') !== selected) return; - const reviewPassed = !pull.draft && approved && await hasApproval() && await hasTrustedNotifier(); + const reviewPassed = !pull.draft && approved && await hasTrustedNotifier() && await hasApproval(); await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', state: reviewPassed ? 'success' : 'pending', description: pull.draft ? 'Draft: publication is not authorized.' @@ -97,7 +97,7 @@ jobs: const final = await getPull(); if (final.state !== 'open' || final.draft || final.base.ref !== pull.base.ref || final.head.sha !== sha || final.labels.some(label => label.name === 'review-ready') !== selected) return; - if (selected && (!await hasApproval() || !await hasTrustedNotifier())) { + if (selected && (!await hasTrustedNotifier() || !await hasApproval())) { await github.rest.repos.createCommitStatus({owner, repo, sha, context: 'Review', state: 'pending', description: 'CodeRabbit approval changed; waiting for review.', target_url: pull.html_url}); return; diff --git a/.github/workflows/review-event.yml b/.github/workflows/review-event.yml new file mode 100644 index 0000000..ec42697 --- /dev/null +++ b/.github/workflows/review-event.yml @@ -0,0 +1,14 @@ +name: Review event + +on: + pull_request_review: + types: [submitted, dismissed] + +permissions: {} + +jobs: + notify: + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - run: echo "Review changed; the trusted Ready workflow will inspect GitHub metadata." diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index fbb0286..19aeb51 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -21,6 +21,7 @@ async function scenario(options = {}) { listReviews: 'reviews', list: 'pulls', merge: async args => { if (options.rejectPull === args.pull_number) throw Object.assign(new Error('Head changed'), {status: 409}); merges.push(args); }}, repos: {get: async () => ({data: {default_branch: 'main', allow_auto_merge: options.autoMerge !== false}}), getContent: async ({ref}) => { + options.notifierRead?.(); if (options.missingNotifier && ref.startsWith('refs/pull/')) throw Object.assign(new Error('Not found'), {status: 404}); return {data: {type: 'file', sha: options.changedNotifier && ref.startsWith('refs/pull/') ? 'modified' : 'trusted'}}; }, @@ -142,3 +143,16 @@ test('a rejected merge does not stop reconciliation of later PRs', async () => { assert.match(result.errors[0], /PR #1: Head changed/); assert.equal(result.errors.length, 2); }); + +test('dismissal during notifier reads blocks both status and merge gates', async () => { + for (const dismissAt of [3, 5]) { + let reads = 0; + let dismissed = false; + const result = await scenario({selected: true, pull: {mergeable_state: 'clean'}, + notifierRead: () => { if (++reads === dismissAt) dismissed = true; }, + reviewRead: () => [{...approval, state: dismissed ? 'DISMISSED' : 'APPROVED'}]}); + assert.equal(result.merges.length, 0); + assert.equal(result.statuses.at(-1).state, 'pending'); + if (dismissAt === 3) assert.equal(result.statuses[0].state, 'pending'); + } +}); From f08f667ca97d0ce50261dbfd4649d0b6e980f1a3 Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 19:48:29 +0200 Subject: [PATCH 5/8] fix: preserve CMS ancestry and existing package releases --- .github/workflows/ready.yml | 46 +++++++++++++++++++++++++++++++++---- tests/ready.test.mjs | 9 +++++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index e23967e..830f994 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -10,6 +10,13 @@ on: type: string default: Check / Check + merge-app-client-id: + type: string + default: "" + secrets: + merge-app-private-key: + required: false + permissions: contents: write pull-requests: write @@ -26,18 +33,40 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: + - name: Create merge token for repositories with release workflows + if: ${{ inputs.merge-app-client-id != '' }} + id: merge-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.merge-app-client-id }} + private-key: ${{ secrets.merge-app-private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + # Only the merge uses this token, so existing push-triggered releases still run. # Only GitHub metadata is read. Never check out or execute PR code here. - name: Require selected review and enable auto-merge uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: NETLIFY_CHECKS: ${{ inputs.netlify-checks }} CHECK_NAME: ${{ inputs.check-name }} + MERGE_TOKEN: ${{ steps.merge-token.outputs.token }} with: script: | const {owner, repo} = context.repo; const repository = (await github.rest.repos.get({owner, repo})).data; const candidates = await github.paginate(github.rest.pulls.list, {owner, repo, state: 'open', base: repository.default_branch, per_page: 100}); + async function mergeRequest(path, method, body) { + const response = await fetch(`${process.env.GITHUB_API_URL}${path}`, { + method, headers: {Authorization: `Bearer ${process.env.MERGE_TOKEN}`, + Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28'}, body: JSON.stringify(body)}); + const result = await response.json(); + if (!response.ok || result.errors) { + throw new Error(`Merge request failed (${response.status}): ${result.message ?? 'GraphQL error'}`); + } + return result; + } async function processPull(number) { const getPull = async () => (await github.rest.pulls.get({owner, repo, pull_number: number})).data; const pull = await getPull(); @@ -102,16 +131,25 @@ jobs: state: 'pending', description: 'CodeRabbit approval changed; waiting for review.', target_url: pull.html_url}); return; } + const method = final.head.ref === 'preview' && final.head.repo?.full_name === final.base.repo?.full_name + ? 'merge' : 'squash'; if (final.mergeable_state === 'clean') { - await github.rest.pulls.merge({owner, repo, pull_number: number, sha, merge_method: 'squash'}); + if (process.env.MERGE_TOKEN) { + await mergeRequest(`/repos/${owner}/${repo}/pulls/${number}/merge`, 'PUT', {sha, merge_method: method}); + } else { + await github.rest.pulls.merge({owner, repo, pull_number: number, sha, merge_method: method}); + } return; } if (final.auto_merge) return core.info('Auto-merge is already enabled.'); - await github.graphql(`mutation($id: ID!, $sha: GitObjectID!) { - enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: SQUASH}) { + const mutation = `mutation($id: ID!, $sha: GitObjectID!, $method: PullRequestMergeMethod!) { + enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: $method}) { pullRequest { number } } - }`, {id: final.node_id, sha}); + }`; + const variables = {id: final.node_id, sha, method: method.toUpperCase()}; + if (process.env.MERGE_TOKEN) await mergeRequest('/graphql', 'POST', {query: mutation, variables}); + else await github.graphql(mutation, variables); } let failed = false; for (const pull of candidates) { diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index 19aeb51..d1c2389 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -106,7 +106,7 @@ test('already-green PR merges immediately with the validated SHA', async () => { assert.deepEqual(result.merges, [{owner: 'o', repo: 'r', pull_number: 1, sha: 'head', merge_method: 'squash'}]); }); test('automatic merge is bound to the validated head', async () => { - assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head'}]); + assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head', method: 'SQUASH'}]); }); test('every expected Netlify context must be centrally required', async () => { const result = await scenario({netlify: ['netlify/app/deploy-preview', 'netlify/site/deploy-preview'], @@ -156,3 +156,10 @@ test('dismissal during notifier reads blocks both status and merge gates', async if (dismissAt === 3) assert.equal(result.statuses[0].state, 'pending'); } }); + +test('CloudCannon preview merges preserve ancestry', async () => { + const result = await scenario({pull: {mergeable_state: 'clean', + head: {sha: 'head', ref: 'preview', repo: {full_name: 'o/r'}}, + base: {ref: 'main', repo: {full_name: 'o/r'}}}}); + assert.equal(result.merges[0].merge_method, 'merge'); +}); From 948803c9868e80bee10fe96162fab75f53a64e2a Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 19:49:01 +0200 Subject: [PATCH 6/8] test: verify release app merge authorization --- tests/ready.test.mjs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index d1c2389..dae539b 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; const source = readFileSync(new URL('../.github/workflows/ready.yml', import.meta.url), 'utf8') .split(' script: |\n')[1].split('\n').map(line => line.slice(12)).join('\n'); -const execute = new (Object.getPrototypeOf(async function () {}).constructor)('github', 'context', 'core', 'process', source); +const execute = new (Object.getPrototypeOf(async function () {}).constructor)('github', 'context', 'core', 'process', 'fetch', source); async function scenario(options = {}) { const pull = {number: 1, node_id: 'PR1', state: 'open', draft: false, head: {sha: 'head'}, @@ -36,7 +36,9 @@ async function scenario(options = {}) { graphql: async (_, args) => merges.push(args), }; await execute(github, {repo: {owner: 'o', repo: 'r'}, payload: options.event ?? {pull_request: {number: 1}}}, - {info: () => {}, error: message => errors.push(message), setFailed: message => errors.push(message)}, {env: {CHECK_NAME: 'Check / Check', NETLIFY_CHECKS: JSON.stringify(options.netlify ?? [])}}); + {info: () => {}, error: message => errors.push(message), setFailed: message => errors.push(message)}, {env: {CHECK_NAME: 'Check / Check', NETLIFY_CHECKS: JSON.stringify(options.netlify ?? []), + GITHUB_API_URL: 'https://api.github.com', MERGE_TOKEN: options.mergeToken}}, + options.fetch ?? (() => { throw new Error('Unexpected network request'); })); return {statuses, merges, errors}; } @@ -163,3 +165,25 @@ test('CloudCannon preview merges preserve ancestry', async () => { base: {ref: 'main', repo: {full_name: 'o/r'}}}}); assert.equal(result.merges[0].merge_method, 'merge'); }); + +test('release app handles only the merge while Review remains an Actions status', async () => { + for (const mergeable_state of ['clean', 'blocked']) { + const requests = []; + const result = await scenario({mergeToken: 'synthetic-token', pull: {mergeable_state}, + fetch: async (url, options) => { requests.push({url, options}); + return {ok: true, status: 200, json: async () => ({merged: true})}; }}); + assert.equal(result.statuses[0].state, 'success'); + assert.equal(result.merges.length, 0); + assert.equal(requests.length, 1); + assert.equal(requests[0].options.headers.Authorization, 'Bearer synthetic-token'); + const body = JSON.parse(requests[0].options.body); + if (mergeable_state === 'clean') assert.deepEqual(body, {sha: 'head', merge_method: 'squash'}); + else assert.deepEqual(body.variables, {id: 'PR1', sha: 'head', method: 'SQUASH'}); + } +}); +test('release app rejection is reported without treating the PR as merged', async () => { + const result = await scenario({mergeToken: 'synthetic-token', pull: {mergeable_state: 'clean'}, + fetch: async () => ({ok: false, status: 409, json: async () => ({message: 'Head changed'})})}); + assert.equal(result.merges.length, 0); + assert.match(result.errors[0], /Merge request failed \(409\)/); +}); From ddd6fca817c980ce0802a5107f442b47e4b2f75b Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 20:32:25 +0200 Subject: [PATCH 7/8] docs: pin shared workflows to merged commits --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6c61165..a2a0af9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Organization-wide GitHub defaults and reusable workflows. Repository callers select their own check command; workflows here are not inherited automatically. +Callers pin a full commit SHA from `main` after the shared workflow PR is merged. + New projects take their callers from [astro-template](https://github.com/fulldotdev/astro-template) or [vite-template](https://github.com/fulldotdev/vite-template). - **Finalize (Prepare)** leaves a complete draft PR. Previews build; full checks wait for Ready. From 6bb501192347973dae5f0002cbcb27d60600145e Mon Sep 17 00:00:00 2001 From: Sil Veltman Date: Wed, 14 Oct 2026 20:40:45 +0200 Subject: [PATCH 8/8] Fix auto-merge GraphQL variable reserved by Octokit --- .github/workflows/ready.yml | 6 +++--- tests/ready.test.mjs | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index 830f994..089a399 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -142,12 +142,12 @@ jobs: return; } if (final.auto_merge) return core.info('Auto-merge is already enabled.'); - const mutation = `mutation($id: ID!, $sha: GitObjectID!, $method: PullRequestMergeMethod!) { - enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: $method}) { + const mutation = `mutation($id: ID!, $sha: GitObjectID!, $mergeMethod: PullRequestMergeMethod!) { + enablePullRequestAutoMerge(input: {pullRequestId: $id, expectedHeadOid: $sha, mergeMethod: $mergeMethod}) { pullRequest { number } } }`; - const variables = {id: final.node_id, sha, method: method.toUpperCase()}; + const variables = {id: final.node_id, sha, mergeMethod: method.toUpperCase()}; if (process.env.MERGE_TOKEN) await mergeRequest('/graphql', 'POST', {query: mutation, variables}); else await github.graphql(mutation, variables); } diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index dae539b..e392d69 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -33,7 +33,14 @@ async function scenario(options = {}) { request: async () => ({data: [{type: 'required_status_checks', parameters: { required_status_checks: (options.required ?? ['Check / Check', 'Review']).map(context => ({context})), }}]}), - graphql: async (_, args) => merges.push(args), + graphql: async (query, args) => { + // Octokit reserves request options instead of treating them as GraphQL variables. + for (const reserved of ['method', 'url', 'baseUrl', 'headers', 'request']) { + assert.equal(Object.hasOwn(args, reserved), false, `Reserved GraphQL variable: ${reserved}`); + } + assert.match(query, /mergeMethod: \$mergeMethod/); + merges.push(args); + }, }; await execute(github, {repo: {owner: 'o', repo: 'r'}, payload: options.event ?? {pull_request: {number: 1}}}, {info: () => {}, error: message => errors.push(message), setFailed: message => errors.push(message)}, {env: {CHECK_NAME: 'Check / Check', NETLIFY_CHECKS: JSON.stringify(options.netlify ?? []), @@ -108,7 +115,7 @@ test('already-green PR merges immediately with the validated SHA', async () => { assert.deepEqual(result.merges, [{owner: 'o', repo: 'r', pull_number: 1, sha: 'head', merge_method: 'squash'}]); }); test('automatic merge is bound to the validated head', async () => { - assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head', method: 'SQUASH'}]); + assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head', mergeMethod: 'SQUASH'}]); }); test('every expected Netlify context must be centrally required', async () => { const result = await scenario({netlify: ['netlify/app/deploy-preview', 'netlify/site/deploy-preview'], @@ -178,7 +185,7 @@ test('release app handles only the merge while Review remains an Actions status' assert.equal(requests[0].options.headers.Authorization, 'Bearer synthetic-token'); const body = JSON.parse(requests[0].options.body); if (mergeable_state === 'clean') assert.deepEqual(body, {sha: 'head', merge_method: 'squash'}); - else assert.deepEqual(body.variables, {id: 'PR1', sha: 'head', method: 'SQUASH'}); + else assert.deepEqual(body.variables, {id: 'PR1', sha: 'head', mergeMethod: 'SQUASH'}); } }); test('release app rejection is reported without treating the PR as merged', async () => {