diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index 79cddd0..d708ee8 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -44,7 +44,7 @@ jobs: 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 + - name: Require selected review and publish CloudCannon changes uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: NETLIFY_CHECKS: ${{ inputs.netlify-checks }} @@ -111,6 +111,14 @@ jobs: : 'Waiting for CodeRabbit approval of this revision.', target_url: pull.html_url}); if (!reviewPassed) return; + // Agents own code merges so they can follow the production deployment. + // Keep the customer's CloudCannon Publish button automatic. + const isCloudCannonPublication = candidate => candidate.user?.type === 'Bot' + && candidate.user.login === 'cloudcannon[bot]' + && candidate.head.ref === 'preview' + && candidate.head.repo?.full_name === repository.full_name + && candidate.base.repo?.full_name === repository.full_name; + if (!isCloudCannonPublication(pull)) return core.info('Ready for the agent to merge and verify production.'); // Workflow events can create jobs out of order. Compare the originating run first. // 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}); @@ -132,6 +140,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 (!isCloudCannonPublication(final)) 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}); diff --git a/README.md b/README.md index a2a0af9..188b3b6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ 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. -- **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. +- **Dispatch (Release)** validates the affected behavior and marks the PR Ready. After the required checks and previews pass, the agent merges the validated head and follows its production deployment. 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. + +Keep repository auto-merge disabled except for CloudCannon publishers. CloudCannon's Publish button keeps automatic publication for same-repository `preview` PRs created by `cloudcannon[bot]`, with all required checks and previews. Code PRs are merged by their agent. diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index 5c3bd95..d698800 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -7,8 +7,8 @@ const source = readFileSync(new URL('../.github/workflows/ready.yml', import.met 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'}, - base: {ref: 'main'}, labels: options.selected ? [{name: 'review-ready'}] : [], html_url: 'https://github.com/o/r/pull/1', + const pull = {number: 1, node_id: 'PR1', state: 'open', draft: false, head: {sha: 'head', ref: 'preview', repo: {full_name: 'o/r'}}, + base: {ref: 'main', repo: {full_name: 'o/r'}}, user: {type: 'Bot', login: 'cloudcannon[bot]'}, labels: options.selected ? [{name: 'review-ready'}] : [], html_url: 'https://github.com/o/r/pull/1', ...options.pull}; const pulls = options.pulls ?? [pull]; const errors = []; @@ -19,7 +19,7 @@ async function scenario(options = {}) { 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}}), + repos: {get: async () => ({data: {default_branch: 'main', full_name: 'o/r', 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}); @@ -52,7 +52,7 @@ async function scenario(options = {}) { 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 () => { +test('CloudCannon publication 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); @@ -113,14 +113,14 @@ test('successful Check workflow completion resumes the associated PR', async () }); 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'}]); + assert.deepEqual(result.merges, [{owner: 'o', repo: 'r', pull_number: 1, sha: 'head', merge_method: 'merge'}]); }); test('automatic merge is bound to the validated head', async () => { - assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head', mergeMethod: 'SQUASH'}]); + assert.deepEqual((await scenario()).merges, [{id: 'PR1', sha: 'head', mergeMethod: 'MERGE'}]); }); test('unrequired failures do not strand an otherwise eligible PR', async () => { const result = await scenario({pull: {mergeable_state: 'unstable'}}); - assert.deepEqual(result.merges, [{owner: 'o', repo: 'r', pull_number: 1, sha: 'head', merge_method: 'squash'}]); + assert.deepEqual(result.merges, [{owner: 'o', repo: 'r', pull_number: 1, sha: 'head', merge_method: 'merge'}]); const denied = await scenario({pull: {mergeable_state: 'unstable'}, rejectPull: 1}); assert.equal(denied.merges.length, 0); assert.match(denied.errors[0], /Head changed/); @@ -147,13 +147,13 @@ test('selected review cannot remove or modify the trusted review notifier', asyn }); 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}`})); + head: {sha: `head${number}`, ref: 'preview', repo: {full_name: 'o/r'}}, base: {ref: 'main', repo: {full_name: 'o/r'}}, user: {type: 'Bot', login: 'cloudcannon[bot]'}, 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', + head: {sha: `head${number}`, ref: 'preview', repo: {full_name: 'o/r'}}, base: {ref: 'main', repo: {full_name: 'o/r'}}, user: {type: 'Bot', login: 'cloudcannon[bot]'}, 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]); @@ -192,8 +192,8 @@ test('release app handles only the merge while Review remains an Actions status' 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 !== 'blocked') assert.deepEqual(body, {sha: 'head', merge_method: 'squash'}); - else assert.deepEqual(body.variables, {id: 'PR1', sha: 'head', mergeMethod: 'SQUASH'}); + if (mergeable_state !== 'blocked') assert.deepEqual(body, {sha: 'head', merge_method: 'merge'}); + else assert.deepEqual(body.variables, {id: 'PR1', sha: 'head', mergeMethod: 'MERGE'}); } }); test('release app rejection is reported without treating the PR as merged', async () => { @@ -241,3 +241,20 @@ test('mixed or absent workflow metadata cannot authorize an old successful resul assert.equal((await scenario({checks: [{id: 1, name: 'Check / Check', app: {id: 15368}, conclusion: 'success', details_url: 'invalid'}]})).merges.length, 0); }); + + +test('ordinary Ready code PRs pass Review without any automatic merge', async () => { + for (const pull of [ + {user: {type: 'User', login: 'silveltman'}}, + {user: {type: 'Bot', login: 'dependabot[bot]'}}, + {head: {sha: 'head', ref: 'feature/code', repo: {full_name: 'o/r'}}}, + {head: {sha: 'head', ref: 'preview', repo: {full_name: 'fork/r'}}}, + {head: {sha: 'head', ref: 'preview'}}, + ]) { + for (const mergeable_state of ['clean', 'blocked', 'unstable']) { + const result = await scenario({pull: {...pull, mergeable_state}}); + assert.equal(result.statuses[0].state, 'success'); + assert.equal(result.merges.length, 0); + } + } +});