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
18 changes: 13 additions & 5 deletions .github/workflows/ready.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 15 additions & 6 deletions tests/ready.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down Expand Up @@ -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,
Expand Down