From cfe16b465db0812bcf2b368c6893f32b6bbc2044 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Thu, 8 Oct 2026 07:17:09 +0000 Subject: [PATCH 1/2] ci(release): deploy only after CI and a real mutation run on the same commit The release gate deployed when the plan and mutation jobs passed, without waiting for CI or journeys, and accepted a skipped mutation job: a main with no *.workflow.ts planned an empty set and deployed unmutated (R23). The gate now calls ci.yml as a reusable workflow on the same commit, and deploy needs ci, plan and mutation to succeed. The planner refuses a workspace with no *.workflow.ts instead of returning an empty plan. Judgment surfaces changed: .github/workflows/ci.yml, .github/workflows/release-gate.yml, scripts/mutation-shards.ts (GATE1, conductor Q7) Conductor ruling 4 --- .github/workflows/ci.yml | 1 + .github/workflows/release-gate.yml | 8 +++++--- scripts/mutation-shards.test.ts | 8 ++++++-- scripts/mutation-shards.ts | 11 +++++++---- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b4a285..cd73f04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 7c00d57..41a71c8 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -12,6 +12,9 @@ concurrency: cancel-in-progress: false jobs: + ci: + uses: ./.github/workflows/ci.yml + plan: name: plan · mutation shards runs-on: ubuntu-latest @@ -33,7 +36,6 @@ jobs: mutation: name: mutation · ${{ matrix.package }} needs: [plan] - if: ${{ needs.plan.outputs.packages != '[]' }} runs-on: ubuntu-latest timeout-minutes: 75 strategy: @@ -68,8 +70,8 @@ jobs: deploy: name: deploy · production - needs: [plan, mutation] - if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.mutation.result == 'success' || needs.mutation.result == 'skipped') && !github.event.repository.is_template }} + needs: [ci, plan, mutation] + if: ${{ !github.event.repository.is_template }} runs-on: ubuntu-latest timeout-minutes: 45 env: diff --git a/scripts/mutation-shards.test.ts b/scripts/mutation-shards.test.ts index bf97051..3770ecf 100644 --- a/scripts/mutation-shards.test.ts +++ b/scripts/mutation-shards.test.ts @@ -68,12 +68,16 @@ Deno.test('a mutation script without declared mutate globs is refused', async () }) }) -Deno.test('a workspace without a single *.workflow.ts file has no decisions to mutate', async () => { +Deno.test('a workspace without a single *.workflow.ts file is refused as an empty set', async () => { const root = await workspaceOf([ { name: 'site', mutate: ['src/**/*.workflow.ts'], files: ['src/page.tsx'] }, { name: 'tools', mutation: false, files: ['src/cli.ts'] }, ]) - assertEquals(await planMutationShards(root), { packages: [], refusals: [], decisions: 0 }) + assertEquals(await planMutationShards(root), { + packages: [], + refusals: ['no workspace package has a *.workflow.ts file; the release gate refuses an empty set'], + decisions: 0, + }) }) Deno.test('decisions that no package mutates are refused as an empty set', async () => { diff --git a/scripts/mutation-shards.ts b/scripts/mutation-shards.ts index bda3043..a3531ac 100755 --- a/scripts/mutation-shards.ts +++ b/scripts/mutation-shards.ts @@ -42,7 +42,13 @@ export const planMutationShards = async (root: string): Promise => { } } } - if (decisions === 0) return { packages: [], refusals: [], decisions } + if (decisions === 0) { + return { + packages: [], + refusals: ['no workspace package has a *.workflow.ts file; the release gate refuses an empty set'], + decisions, + } + } if (packages.length === 0 && refusals.length === 0) { refusals.push( `${decisions} *.workflow.ts file(s) but no workspace package declares a \`mutation\` script; the release gate refuses an empty set`, @@ -58,9 +64,6 @@ if (import.meta.main) { for (const refusal of plan.refusals) console.error(`mutation-shards: ${refusal}`) Deno.exit(1) } - if (plan.decisions === 0) { - console.log('::notice title=Release gate::No decisions to mutate: no workspace package has a *.workflow.ts file.') - } const line = `packages=${JSON.stringify(plan.packages)}` console.error(`mutation-shards: ${line}`) if (output) await Deno.writeTextFile(output, `${line}\n`, { append: true }) From 9be4e58f548939e89ecfb36d7312bcd963b405a9 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Thu, 8 Oct 2026 20:37:13 +0000 Subject: [PATCH 2/2] ci: run main's CI once, inside the Release gate ci.yml no longer triggers on push to main. The Release gate calls ci.yml as a reusable workflow on every push to main, so the standalone push trigger ran the full suite twice per merge. pull_request, workflow_dispatch (release.yml dispatches it on the release PR branch) and workflow_call remain, per conductor ruling 6 --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd73f04..4e0efb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [main] pull_request: workflow_dispatch: workflow_call: