Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ready.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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});
Expand All @@ -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});
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 28 additions & 11 deletions tests/ready.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand All @@ -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});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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/);
Expand All @@ -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]);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
}
}
});
Loading