From a0aa54f901e86635cf320138077cb0060e33b1a8 Mon Sep 17 00:00:00 2001 From: Ravi <103035973+raviships@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:50:27 +0900 Subject: [PATCH 1/2] ci: skip releases from dormant next --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 13 ++++ RELEASING.md | 8 ++ package.json | 1 + scripts/release.test.mjs | 135 ++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 scripts/release.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0f26b48..290c133e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,9 @@ jobs: - name: Check dependency versions run: pnpm check:dependencies + - name: Release workflow regression tests + run: pnpm release:test + - name: Check formatting run: pnpm format --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a76adf2..843db0d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,9 +51,20 @@ jobs: run: pnpm install --frozen-lockfile - name: Validate release mode + id: release-mode shell: bash run: | if [[ "$GITHUB_REF_NAME" == "next" ]]; then + if [[ ! -e .changeset/pre.json && ! -L .changeset/pre.json ]]; then + if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then + echo 'should-release=false' >> "$GITHUB_OUTPUT" + echo 'Skipping release: next is dormant until a new prerelease cycle starts.' + echo 'Release skipped: `next` has no `.changeset/pre.json`. Start a new prerelease cycle as described in RELEASING.md before releasing.' >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo '::error::Cannot release from dormant next. Run pnpm changeset pre enter next, commit .changeset/pre.json, and push to start a new prerelease cycle. See RELEASING.md.' + exit 1 + fi jq -e \ '.mode == "pre" and (.tag == "next" or .tag == "rc")' \ .changeset/pre.json @@ -65,8 +76,10 @@ jobs: echo 'Normal releases must run from main or next.' exit 1 fi + echo 'should-release=true' >> "$GITHUB_OUTPUT" - name: Create Version PR or Publish + if: steps.release-mode.outputs.should-release == 'true' id: changesets uses: changesets/action@v1 with: diff --git a/RELEASING.md b/RELEASING.md index c29588d9..ec1a3d0e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -133,6 +133,14 @@ git commit -m "chore: start next prerelease cycle" git push ``` +Between release cycles, `next` may be dormant with no `.changeset/pre.json`. +Automatic pushes to dormant `next` succeed with a release-skipped summary and +skip the entire Changesets version/publish action, including for forward merges +from `main`. Manually selecting the `release` operation on dormant `next` fails +with instructions to start the new prerelease cycle using the commands above. +An existing prerelease file must still contain valid JSON with `"mode": "pre"` +and a supported `next` or `rc` tag; invalid state fails the release workflow. + If `next` advanced after the promotion PR was created, resolve the merge and prerelease-state conflict while preserving those later changes. diff --git a/package.json b/package.json index 9cfdf853..32edfa40 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:types": "turbo run test:types --filter=edgestore-tests", "test:smoke": "turbo run test:smoke --filter=@edgestore/server", "check:dependencies": "node scripts/checkDependencyVersions.ts", + "release:test": "node --test scripts/release.test.mjs", "lint": "turbo lint", "typecheck": "turbo run typecheck --filter=@edgestore/*", "lint-fix": "turbo lint -- --fix && manypkg fix && pnpm format:write", diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 00000000..882f9ef0 --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { parse } from 'yaml'; + +const workflow = parse( + readFileSync( + new URL('../.github/workflows/release.yml', import.meta.url), + 'utf8', + ), +); +const steps = workflow.jobs.release.steps; +const validation = steps.find((step) => step.name === 'Validate release mode'); +const changesets = steps.find((step) => step.id === 'changesets'); + +test('the entire Changesets action requires a validated release mode', () => { + assert.equal(validation.id, 'release-mode'); + assert.equal(validation.shell, 'bash'); + assert.equal(changesets.uses, 'changesets/action@v1'); + assert.equal( + changesets.if, + "steps.release-mode.outputs.should-release == 'true'", + ); +}); + +const pre = (tag, mode = 'pre') => JSON.stringify({ mode, tag }); +const states = [ + { name: 'stable main', branch: 'main', allowed: true }, + { name: 'main RC', branch: 'main', contents: pre('rc'), allowed: true }, + { + name: 'next prerelease', + branch: 'next', + contents: pre('next'), + allowed: true, + }, + { name: 'next RC', branch: 'next', contents: pre('rc'), allowed: true }, + { name: 'dormant next', branch: 'next' }, + { name: 'main next tag', branch: 'main', contents: pre('next') }, + { name: 'main exited RC', branch: 'main', contents: pre('rc', 'exit') }, + { + name: 'next exited prerelease', + branch: 'next', + contents: pre('next', 'exit'), + }, + { name: 'next unsupported tag', branch: 'next', contents: pre('beta') }, + { name: 'main malformed JSON', branch: 'main', contents: '{' }, + { name: 'next malformed JSON', branch: 'next', contents: '{' }, + { name: 'next empty file', branch: 'next', contents: '' }, + { name: 'next missing mode', branch: 'next', contents: '{"tag":"next"}' }, + { name: 'next null state', branch: 'next', contents: 'null' }, + { name: 'next directory in place of file', branch: 'next', directory: true }, + { name: 'next dangling prerelease symlink', branch: 'next', symlink: true }, + { name: 'maintenance branch', branch: '1.x' }, + { + name: 'feature branch with prerelease', + branch: 'feature', + contents: pre('next'), + }, +]; + +for (const event of ['push', 'workflow_dispatch']) { + for (const state of states) { + test(`${event}: ${state.name}`, () => { + const cwd = mkdtempSync(join(tmpdir(), 'edgestore-release-')); + try { + mkdirSync(join(cwd, '.changeset')); + if (state.contents !== undefined) { + writeFileSync(join(cwd, '.changeset/pre.json'), state.contents); + } + if (state.directory) { + mkdirSync(join(cwd, '.changeset/pre.json')); + } + if (state.symlink) { + symlinkSync('missing.json', join(cwd, '.changeset/pre.json')); + } + const outputPath = join(cwd, 'output'); + const summaryPath = join(cwd, 'summary'); + writeFileSync(outputPath, ''); + writeFileSync(summaryPath, ''); + const result = spawnSync( + 'bash', + ['--noprofile', '--norc', '-eo', 'pipefail', '-c', validation.run], + { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GITHUB_REF_NAME: state.branch, + GITHUB_EVENT_NAME: event, + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: summaryPath, + }, + }, + ); + assert.ifError(result.error); + const output = readFileSync(outputPath, 'utf8'); + const summary = readFileSync(summaryPath, 'utf8'); + if (state.name === 'dormant next' && event === 'push') { + assert.equal(result.status, 0, result.stderr); + assert.equal(output, 'should-release=false\n'); + assert.match(result.stdout, /Skipping release: next is dormant/); + assert.match(summary, /Release skipped.*RELEASING\.md/); + } else if (state.allowed) { + assert.equal(result.status, 0, result.stderr); + assert.equal(output, 'should-release=true\n'); + assert.equal(summary, ''); + } else { + assert.notEqual(result.status, 0, result.stdout); + assert.equal(output, ''); + assert.equal(summary, ''); + if (state.name === 'dormant next') { + assert.match( + result.stdout, + /::error::Cannot release from dormant next/, + ); + assert.match(result.stdout, /pnpm changeset pre enter next/); + assert.match(result.stdout, /commit.*pre\.json.*push/); + } + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + } +} From e666cfee290ef2bd741118782df4d5489ad5cac5 Mon Sep 17 00:00:00 2001 From: Ravi <103035973+raviships@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:16:03 +0900 Subject: [PATCH 2/2] ci: keep dormant next fix focused --- .github/workflows/ci.yml | 3 - package.json | 1 - scripts/release.test.mjs | 135 --------------------------------------- 3 files changed, 139 deletions(-) delete mode 100644 scripts/release.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 290c133e..d0f26b48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,9 +47,6 @@ jobs: - name: Check dependency versions run: pnpm check:dependencies - - name: Release workflow regression tests - run: pnpm release:test - - name: Check formatting run: pnpm format --check diff --git a/package.json b/package.json index 32edfa40..9cfdf853 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "test:types": "turbo run test:types --filter=edgestore-tests", "test:smoke": "turbo run test:smoke --filter=@edgestore/server", "check:dependencies": "node scripts/checkDependencyVersions.ts", - "release:test": "node --test scripts/release.test.mjs", "lint": "turbo lint", "typecheck": "turbo run typecheck --filter=@edgestore/*", "lint-fix": "turbo lint -- --fix && manypkg fix && pnpm format:write", diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs deleted file mode 100644 index 882f9ef0..00000000 --- a/scripts/release.test.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { parse } from 'yaml'; - -const workflow = parse( - readFileSync( - new URL('../.github/workflows/release.yml', import.meta.url), - 'utf8', - ), -); -const steps = workflow.jobs.release.steps; -const validation = steps.find((step) => step.name === 'Validate release mode'); -const changesets = steps.find((step) => step.id === 'changesets'); - -test('the entire Changesets action requires a validated release mode', () => { - assert.equal(validation.id, 'release-mode'); - assert.equal(validation.shell, 'bash'); - assert.equal(changesets.uses, 'changesets/action@v1'); - assert.equal( - changesets.if, - "steps.release-mode.outputs.should-release == 'true'", - ); -}); - -const pre = (tag, mode = 'pre') => JSON.stringify({ mode, tag }); -const states = [ - { name: 'stable main', branch: 'main', allowed: true }, - { name: 'main RC', branch: 'main', contents: pre('rc'), allowed: true }, - { - name: 'next prerelease', - branch: 'next', - contents: pre('next'), - allowed: true, - }, - { name: 'next RC', branch: 'next', contents: pre('rc'), allowed: true }, - { name: 'dormant next', branch: 'next' }, - { name: 'main next tag', branch: 'main', contents: pre('next') }, - { name: 'main exited RC', branch: 'main', contents: pre('rc', 'exit') }, - { - name: 'next exited prerelease', - branch: 'next', - contents: pre('next', 'exit'), - }, - { name: 'next unsupported tag', branch: 'next', contents: pre('beta') }, - { name: 'main malformed JSON', branch: 'main', contents: '{' }, - { name: 'next malformed JSON', branch: 'next', contents: '{' }, - { name: 'next empty file', branch: 'next', contents: '' }, - { name: 'next missing mode', branch: 'next', contents: '{"tag":"next"}' }, - { name: 'next null state', branch: 'next', contents: 'null' }, - { name: 'next directory in place of file', branch: 'next', directory: true }, - { name: 'next dangling prerelease symlink', branch: 'next', symlink: true }, - { name: 'maintenance branch', branch: '1.x' }, - { - name: 'feature branch with prerelease', - branch: 'feature', - contents: pre('next'), - }, -]; - -for (const event of ['push', 'workflow_dispatch']) { - for (const state of states) { - test(`${event}: ${state.name}`, () => { - const cwd = mkdtempSync(join(tmpdir(), 'edgestore-release-')); - try { - mkdirSync(join(cwd, '.changeset')); - if (state.contents !== undefined) { - writeFileSync(join(cwd, '.changeset/pre.json'), state.contents); - } - if (state.directory) { - mkdirSync(join(cwd, '.changeset/pre.json')); - } - if (state.symlink) { - symlinkSync('missing.json', join(cwd, '.changeset/pre.json')); - } - const outputPath = join(cwd, 'output'); - const summaryPath = join(cwd, 'summary'); - writeFileSync(outputPath, ''); - writeFileSync(summaryPath, ''); - const result = spawnSync( - 'bash', - ['--noprofile', '--norc', '-eo', 'pipefail', '-c', validation.run], - { - cwd, - encoding: 'utf8', - env: { - ...process.env, - GITHUB_REF_NAME: state.branch, - GITHUB_EVENT_NAME: event, - GITHUB_OUTPUT: outputPath, - GITHUB_STEP_SUMMARY: summaryPath, - }, - }, - ); - assert.ifError(result.error); - const output = readFileSync(outputPath, 'utf8'); - const summary = readFileSync(summaryPath, 'utf8'); - if (state.name === 'dormant next' && event === 'push') { - assert.equal(result.status, 0, result.stderr); - assert.equal(output, 'should-release=false\n'); - assert.match(result.stdout, /Skipping release: next is dormant/); - assert.match(summary, /Release skipped.*RELEASING\.md/); - } else if (state.allowed) { - assert.equal(result.status, 0, result.stderr); - assert.equal(output, 'should-release=true\n'); - assert.equal(summary, ''); - } else { - assert.notEqual(result.status, 0, result.stdout); - assert.equal(output, ''); - assert.equal(summary, ''); - if (state.name === 'dormant next') { - assert.match( - result.stdout, - /::error::Cannot release from dormant next/, - ); - assert.match(result.stdout, /pnpm changeset pre enter next/); - assert.match(result.stdout, /commit.*pre\.json.*push/); - } - } - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }); - } -}