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..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. 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 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 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..40b3767 100644 --- a/tests/ready.test.mjs +++ b/tests/ready.test.mjs @@ -28,9 +28,11 @@ 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'); - return {data: {statuses: options.review ? [{context: 'Review', ...options.review}] : []}}; + 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'}))]}}; }, createCommitStatus: async status => statuses.push(status)}, checks: {listForRef: 'checks'}, @@ -143,10 +145,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,