From 769f5441ef5123a19473318372074d37ce62bf4e Mon Sep 17 00:00:00 2001 From: Sil Date: Sat, 24 Oct 2026 11:45:07 +0200 Subject: [PATCH 1/3] ci: Review requires each repository's Netlify previews Review now waits for every netlify-checks preview of the revision, so the organization needs no Netlify rule per site, and CloudCannon's automatic publication needs only the central Check and Review rules. Fulldev Sites joins CloudCannon as a content publisher with auto-merge. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ready.yml | 18 +++++++++++++----- README.md | 4 ++-- tests/ready.test.mjs | 18 +++++++++++++----- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ready.yml b/.github/workflows/ready.yml index bb314bb..cef50a5 100644 --- a/.github/workflows/ready.yml +++ b/.github/workflows/ready.yml @@ -139,14 +139,23 @@ jobs: if (completedRunPending) throw new Error('Successful Test metadata is still incomplete. Rerun Ready to reconcile it.'); return false; } + // Every Netlify preview of the revision built, so no organization rule names a site. + async function previewsPassed() { + const names = JSON.parse(process.env.NETLIFY_CHECKS || '[]'); + if (!names.length) return true; + const {data} = await github.rest.repos.getCombinedStatusForRef({owner, repo, ref: sha, per_page: 100}); + return names.every(name => data.statuses.find(status => status.context === name)?.state === 'success'); + } const approved = await testsPassed(); + const previews = await previewsPassed(); const current = await getPull(); if (current.state !== 'open' || current.base.ref !== pull.base.ref || current.head.sha !== sha || current.draft !== pull.draft) return; - const reviewPassed = !pull.draft && approved && await testsPassed(); + const reviewPassed = !pull.draft && approved && previews && await testsPassed(); await setReview(reviewPassed ? 'success' : 'pending', reviewPassed ? 'Ready requirements passed for this revision.' - : 'Waiting for required tests of this revision.'); + : previews ? 'Waiting for required tests of this revision.' + : 'Waiting for the Netlify previews of this revision.'); if (!reviewPassed) return; // Agents own code merges so they can follow the production deployment. // Keep the customer's CloudCannon Publish button automatic. @@ -169,9 +178,8 @@ 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)); - 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 (![process.env.CHECK_NAME, 'Review'].every(name => required.includes(name))) { + return core.info('The 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(); diff --git a/README.md b/README.md index 4a6787c..4db3d24 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,6 @@ Repositories with `test.yml` require `Test / Test` through the existing `Review` The Ready caller grants `actions: read` so the gate reads the current Test workflow attempt and its jobs, including reruns. -Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. Netlify requirements are managed centrally for repositories with those sites. Live URL tests run after deployment, outside the build command. Product scenarios and fixture writes stay on previews. A repository may pass `live-test-email` and `live-test-password` only from its trusted main workflow for one ordinary login/read/sign-out smoke with its own limited account; browser recordings and signed-in artifacts stay off. +Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. `Review` also requires every `netlify-checks` preview of the revision, so no rule names a site. A preview often finishes after Check, so callers without Test also run Ready on Netlify's `status` events. Live URL tests run after deployment, outside the build command. Product scenarios and fixture writes stay on previews. A repository may pass `live-test-email` and `live-test-password` only from its trusted main workflow for one ordinary login/read/sign-out smoke with its own limited account; browser recordings and signed-in artifacts stay off. -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. +Keep repository auto-merge disabled except for content publishers. CloudCannon's Publish button keeps automatic publication for same-repository `preview` PRs created by `cloudcannon[bot]`, with all required checks and previews. Fulldev Sites publishes a content PR by asking GitHub to merge it once its required checks pass. Code PRs are merged by their agent. diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index 1e9a7d0..dd75ead 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -30,7 +30,8 @@ async function scenario(options = {}) { repos: {get: async () => ({data: {default_branch: 'main', full_name: 'o/r', allow_auto_merge: options.autoMerge !== false}}), getCombinedStatusForRef: async () => { if (options.statusError) throw new Error('Status unavailable'); - return {data: {statuses: options.review ? [{context: 'Review', ...options.review}] : []}}; + return {data: {statuses: [...options.review ? [{context: 'Review', ...options.review}] : [], + ...options.previews ?? (options.netlify ?? []).map(context => ({context, state: 'success'}))]}}; }, createCommitStatus: async status => statuses.push(status)}, checks: {listForRef: 'checks'}, @@ -143,10 +144,17 @@ test('unrequired failures do not strand an otherwise eligible PR', async () => { assert.equal(denied.merges.length, 0); assert.match(denied.errors[0], /Head changed/); }); -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('Review waits for every Netlify preview of the revision', async () => { + const netlify = ['netlify/app/deploy-preview', 'netlify/site/deploy-preview']; + for (const state of ['pending', 'failure', undefined]) { + const previews = [{context: netlify[0], state: 'success'}, ...state ? [{context: netlify[1], state}] : []]; + const result = await scenario({netlify, previews}); + assert.equal(result.statuses.at(-1).state, 'pending'); + assert.equal(result.merges.length, 0); + } + const passed = await scenario({netlify, required: ['Check / Check', 'Review']}); + assert.equal(passed.statuses.at(-1).state, 'success'); + assert.equal(passed.merges.length, 1); }); 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, From 2ca37996d3a0a49f4633af115d71d710175d3880 Mon Sep 17 00:00:00 2001 From: Sil Date: Sat, 24 Oct 2026 11:52:38 +0200 Subject: [PATCH 2/3] ci: every caller with Netlify previews reruns Ready on their statuses Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- tests/ready.test.mjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4db3d24..91f2720 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,6 @@ Repositories with `test.yml` require `Test / Test` through the existing `Review` The Ready caller grants `actions: read` so the gate reads the current Test workflow attempt and its jobs, including reruns. -Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. `Review` also requires every `netlify-checks` preview of the revision, so no rule names a site. A preview often finishes after Check, so callers without Test also run Ready on Netlify's `status` events. Live URL tests run after deployment, outside the build command. Product scenarios and fixture writes stay on previews. A repository may pass `live-test-email` and `live-test-password` only from its trusted main workflow for one ordinary login/read/sign-out smoke with its own limited account; browser recordings and signed-in artifacts stay off. +Organization rules require `Check / Check` and `Review`, without requiring synchronization with main. `Review` also requires every `netlify-checks` preview of the revision, so no rule names a site. A preview can finish after Check and Test, so callers with `netlify-checks` also run Ready on Netlify's `status` events. Live URL tests run after deployment, outside the build command. Product scenarios and fixture writes stay on previews. A repository may pass `live-test-email` and `live-test-password` only from its trusted main workflow for one ordinary login/read/sign-out smoke with its own limited account; browser recordings and signed-in artifacts stay off. Keep repository auto-merge disabled except for content publishers. CloudCannon's Publish button keeps automatic publication for same-repository `preview` PRs created by `cloudcannon[bot]`, with all required checks and previews. Fulldev Sites publishes a content PR by asking GitHub to merge it once its required checks pass. Code PRs are merged by their agent. diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index dd75ead..69d3a55 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -28,8 +28,9 @@ async function scenario(options = {}) { pulls: {get: async ({pull_number}) => ({data: ++reads >= 2 && options.changed ? {...pull, head: {sha: 'new'}} : pulls.find(candidate => candidate.number === pull_number)}), 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', full_name: 'o/r', allow_auto_merge: options.autoMerge !== false}}), - getCombinedStatusForRef: async () => { + getCombinedStatusForRef: async ({ref}) => { if (options.statusError) throw new Error('Status unavailable'); + assert.equal(ref, 'head', 'Statuses are read for the PR head'); return {data: {statuses: [...options.review ? [{context: 'Review', ...options.review}] : [], ...options.previews ?? (options.netlify ?? []).map(context => ({context, state: 'success'}))]}}; }, From e6090fa29eb945b972cfbebca40ffcc159147bda Mon Sep 17 00:00:00 2001 From: Sil Date: Sat, 24 Oct 2026 11:53:14 +0200 Subject: [PATCH 3/3] test: statuses are read for an open PR's head Co-Authored-By: Claude Opus 5.5 --- tests/ready.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ready.test.mjs b/tests/ready.test.mjs index 69d3a55..40b3767 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -30,7 +30,7 @@ async function scenario(options = {}) { repos: {get: async () => ({data: {default_branch: 'main', full_name: 'o/r', allow_auto_merge: options.autoMerge !== false}}), getCombinedStatusForRef: async ({ref}) => { if (options.statusError) throw new Error('Status unavailable'); - assert.equal(ref, 'head', 'Statuses are read for the PR head'); + assert.ok(pulls.some(candidate => candidate.head.sha === ref), 'Statuses are read for a PR head'); return {data: {statuses: [...options.review ? [{context: 'Review', ...options.review}] : [], ...options.previews ?? (options.netlify ?? []).map(context => ({context, state: 'success'}))]}}; },