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/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 new file mode 100644 index 0000000..e23967e --- /dev/null +++ b/.github/workflows/ready.yml @@ -0,0 +1,125 @@ +name: Ready + +on: + workflow_call: + inputs: + netlify-checks: + type: string + default: "[]" + check-name: + type: string + default: Check / Check + +permissions: + contents: write + pull-requests: write + statuses: write + checks: read + +concurrency: + group: ready-policy-${{ github.repository }} + cancel-in-progress: false + +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: + NETLIFY_CHECKS: ${{ inputs.netlify-checks }} + CHECK_NAME: ${{ inputs.check-name }} + 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 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; + } + 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 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 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.' + : 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 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; + } + 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}); + } + let failed = false; + for (const pull of candidates) { + try { + await processPull(pull.number); + } catch (error) { + failed = true; + core.error(`PR #${pull.number}: ${error.message}`); + } + } + if (failed) core.setFailed('Some PRs could not be reconciled; see their errors above.'); 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/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..19aeb51 --- /dev/null +++ b/tests/ready.test.mjs @@ -0,0 +1,158 @@ +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 pulls = options.pulls ?? [pull]; + const errors = []; + const statuses = []; + const merges = []; + let reads = 0; + const github = { + rest: { + 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}) => { + 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'}}; + }, + createCommitStatus: async status => statuses.push(status)}, + checks: {listForRef: 'checks'}, + }, + paginate: async endpoint => endpoint === 'reviews' ? (options.reviewRead ? options.reviewRead() : options.reviews ?? []) + : 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: () => {}, 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'}; + +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, 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); +}); + +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); +}); + +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'); + } +});