From a0b06d736b30b557bf4fb1e554180a807d96b89b Mon Sep 17 00:00:00 2001 From: Dan Cormier Date: Mon, 24 Aug 2026 16:05:21 -0400 Subject: [PATCH] fix(release): publish with ignored-only changesets --- .../scripts/prepare-ignored-changesets.mjs | 88 +++++++++ .../prepare-ignored-changesets.test.mjs | 178 ++++++++++++++++++ .github/workflows/main.yml | 6 + README.md | 1 + adrs/0006-automatic-library-release.md | 6 +- package-lock.json | 1 + package.json | 4 +- 7 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/prepare-ignored-changesets.mjs create mode 100644 .github/scripts/prepare-ignored-changesets.test.mjs diff --git a/.github/scripts/prepare-ignored-changesets.mjs b/.github/scripts/prepare-ignored-changesets.mjs new file mode 100644 index 0000000000..929100a6fd --- /dev/null +++ b/.github/scripts/prepare-ignored-changesets.mjs @@ -0,0 +1,88 @@ +import { mkdtemp, readFile, rename } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import readChangesets from "@changesets/read"; + +const log = (message) => process.stdout.write(`${message}\n`); + +async function filterConsumedPrereleaseChangesets(cwd, changesets) { + try { + const preState = JSON.parse( + await readFile(path.join(cwd, ".changeset", "pre.json"), "utf8") + ); + + if (preState.mode === "pre") { + const consumedChangesets = new Set(preState.changesets); + return changesets.filter(({ id }) => !consumedChangesets.has(id)); + } + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + + return changesets; +} + +export async function prepareIgnoredChangesets({ + cwd = process.cwd(), + tempDirectory, +} = {}) { + const changesets = await filterConsumedPrereleaseChangesets( + cwd, + await readChangesets(cwd) + ); + + if (changesets.length === 0) { + log("No pending changesets found."); + return []; + } + + const configPath = path.join(cwd, ".changeset", "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + const ignoredPackages = new Set(config.ignore ?? []); + const pendingReleases = changesets.flatMap( + (changeset) => changeset.releases + ); + + if ( + pendingReleases.length === 0 || + pendingReleases.some(({ name }) => !ignoredPackages.has(name)) + ) { + log("Actionable changesets found; leaving all changesets in place."); + return []; + } + + if (!tempDirectory) { + throw new Error( + "RUNNER_TEMP is required when temporarily excluding ignored changesets." + ); + } + + const destination = await mkdtemp( + path.join(tempDirectory, "stacks-ignored-changesets-") + ); + const movedChangesets = []; + + for (const { id } of changesets) { + const source = path.join(cwd, ".changeset", `${id}.md`); + const target = path.join(destination, `${id}.md`); + await rename(source, target); + movedChangesets.push({ source, target }); + } + + log( + `Temporarily excluded ${movedChangesets.length} ignored-only changeset(s) so Changesets can publish prepared package versions.` + ); + + return movedChangesets; +} + +const isMainModule = + process.argv[1] && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; + +if (isMainModule) { + await prepareIgnoredChangesets({ tempDirectory: process.argv[2] }); +} diff --git a/.github/scripts/prepare-ignored-changesets.test.mjs b/.github/scripts/prepare-ignored-changesets.test.mjs new file mode 100644 index 0000000000..184ea4aea6 --- /dev/null +++ b/.github/scripts/prepare-ignored-changesets.test.mjs @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { prepareIgnoredChangesets } from "./prepare-ignored-changesets.mjs"; + +async function createFixture( + t, + { ignore = [], changesets = [], preState } = {} +) { + const cwd = await mkdtemp(path.join(os.tmpdir(), "stacks-changesets-")); + const tempDirectory = await mkdtemp( + path.join(os.tmpdir(), "stacks-runner-temp-") + ); + const changesetDirectory = path.join(cwd, ".changeset"); + + t.after(async () => { + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(tempDirectory, { recursive: true, force: true }), + ]); + }); + + await mkdir(changesetDirectory, { recursive: true }); + await writeFile( + path.join(changesetDirectory, "config.json"), + JSON.stringify({ ignore }) + ); + + if (preState) { + await writeFile( + path.join(changesetDirectory, "pre.json"), + JSON.stringify(preState) + ); + } + + for (const { id, releases } of changesets) { + const frontmatter = releases + .map(({ name, type }) => `"${name}": ${type}`) + .join("\n"); + await writeFile( + path.join(changesetDirectory, `${id}.md`), + `---\n${frontmatter}\n---\n\n${id}\n` + ); + } + + return { cwd, tempDirectory }; +} + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +test("does nothing when there are no pending changesets", async (t) => { + const fixture = await createFixture(t); + + const moved = await prepareIgnoredChangesets(fixture); + + assert.deepEqual(moved, []); +}); + +test("temporarily moves changesets when every release is ignored", async (t) => { + const fixture = await createFixture(t, { + ignore: ["@stackoverflow/stacks-email"], + changesets: [ + { + id: "email-release", + releases: [ + { name: "@stackoverflow/stacks-email", type: "major" }, + ], + }, + ], + }); + + const moved = await prepareIgnoredChangesets(fixture); + + assert.equal(moved.length, 1); + assert.equal(await exists(moved[0].source), false); + assert.equal(await exists(moved[0].target), true); +}); + +test("leaves actionable changesets in place", async (t) => { + const fixture = await createFixture(t, { + ignore: ["@stackoverflow/stacks-email"], + changesets: [ + { + id: "classic-release", + releases: [{ name: "@stackoverflow/stacks", type: "patch" }], + }, + ], + }); + + const moved = await prepareIgnoredChangesets(fixture); + + assert.deepEqual(moved, []); + assert.equal( + await exists( + path.join(fixture.cwd, ".changeset", "classic-release.md") + ), + true + ); +}); + +test("leaves mixed ignored and actionable changesets in place", async (t) => { + const fixture = await createFixture(t, { + ignore: ["@stackoverflow/stacks-email"], + changesets: [ + { + id: "email-release", + releases: [ + { name: "@stackoverflow/stacks-email", type: "major" }, + ], + }, + { + id: "utils-release", + releases: [ + { + name: "@stackoverflow/stacks-utils", + type: "patch", + }, + ], + }, + ], + }); + + const moved = await prepareIgnoredChangesets(fixture); + + assert.deepEqual(moved, []); + assert.equal( + await exists(path.join(fixture.cwd, ".changeset", "email-release.md")), + true + ); + assert.equal( + await exists(path.join(fixture.cwd, ".changeset", "utils-release.md")), + true + ); +}); + +test("ignores changesets already consumed in prerelease mode", async (t) => { + const fixture = await createFixture(t, { + ignore: ["@stackoverflow/stacks-email"], + preState: { + mode: "pre", + tag: "beta", + initialVersions: {}, + changesets: ["consumed-classic-release"], + }, + changesets: [ + { + id: "consumed-classic-release", + releases: [{ name: "@stackoverflow/stacks", type: "patch" }], + }, + { + id: "email-release", + releases: [ + { name: "@stackoverflow/stacks-email", type: "major" }, + ], + }, + ], + }); + + const moved = await prepareIgnoredChangesets(fixture); + + assert.equal(moved.length, 1); + assert.match(moved[0].source, /email-release\.md$/); + assert.equal( + await exists( + path.join(fixture.cwd, ".changeset", "consumed-classic-release.md") + ), + true + ); +}); diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b68a61ceaa..7cfb54e526 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -100,6 +100,10 @@ jobs: command: npm run test -w packages/stacks-email needs_lfs: false needs_playwright: false + - command_description: Release Configuration Tests + command: npm run test:release + needs_lfs: false + needs_playwright: false with: command: ${{ matrix.command }} command_description: ${{ matrix.command_description }} @@ -141,6 +145,8 @@ jobs: run: | npm ci npm run build + - name: Prepare ignored-only changesets + run: node .github/scripts/prepare-ignored-changesets.mjs "$RUNNER_TEMP" - name: 🚀 Create/Update Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 diff --git a/README.md b/README.md index 3e2e417920..e8a45f5036 100755 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ We use [changesets](https://github.com/changesets/changesets) to automatize the - After CI succeeds on `main`, the [release GitHub workflow](.github/workflows/main.yml) creates or updates the `chore(new-release)` PR as pending changesets are merged. - Prerelease mode and its npm tag are controlled by [`.changeset/pre.json`](.changeset/pre.json), not by a separate release branch. - When the `chore(new-release)` PR is merged, the workflow publishes the prepared package versions to npm and creates GitHub releases. Review the generated versions and changelogs before merging it. +- A changeset for a package listed in Changesets' `ignore` configuration remains tracked until that package is ready. When every pending changeset targets an ignored package, the release workflow temporarily excludes those files in its runner so `changesets/action` can publish any already-prepared package versions. If any actionable changeset exists, all files remain in place for normal release-PR generation. _The release github workflow only run if the CI workflow (running linter, formatter and tests) is successful: CI is blocking accidental releases_. diff --git a/adrs/0006-automatic-library-release.md b/adrs/0006-automatic-library-release.md index 89c0b44e4e..caa4e6bacf 100644 --- a/adrs/0006-automatic-library-release.md +++ b/adrs/0006-automatic-library-release.md @@ -42,6 +42,10 @@ The automated release PRs created by changesets include a changelog entry for ea Release PRs generated by changesets can be used in conjunction with GitHub Actions to automatically publish a package to npm when the PR is merged. See the [GitHub Action currently in use in the axe-apca repository](https://github.com/StackExchange/apca-check/blob/main/.github/workflows/release.yml) for a real-world example. +#### Ignored package changesets + +`changesets/action` decides whether to publish by counting raw changeset files before Changesets applies the repository's `ignore` configuration. An ignored-only changeset can therefore block publication of versions already prepared by a merged release PR. The release workflow temporarily excludes changeset files in its ephemeral runner only when every pending release targets an ignored package. The tracked files remain on `main`, and any actionable changeset keeps the normal release-PR flow intact. + ### Other tools considered #### [standard-version](https://github.com/conventional-changelog/standard-version) @@ -58,4 +62,4 @@ Release PRs generated by changesets can be used in conjunction with GitHub Actio ## Additional info -- [Changesets repository](https://github.com/changesets/changesets) \ No newline at end of file +- [Changesets repository](https://github.com/changesets/changesets) diff --git a/package-lock.json b/package-lock.json index 93381cf3df..03eeccf67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.31.0", + "@changesets/read": "^0.6.7", "@eslint/js": "^10.0.1", "@open-wc/testing": "^5.0.0", "@remcovaes/web-test-runner-vite-plugin": "^1.4.0", diff --git a/package.json b/package.json index 037bdf58bb..5472b40db7 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,13 @@ "format": "npm run format -workspaces -if-present", "lint": "npm run lint -workspaces -if-present", "start": "npm run dev -w packages/stacks-docs", - "test": "npm run test -workspaces -if-present" + "test": "npm run test -workspaces -if-present", + "test:release": "node --test .github/scripts/prepare-ignored-changesets.test.mjs" }, "devDependencies": { "@changesets/changelog-github": "^0.5.2", "@changesets/cli": "^2.31.0", + "@changesets/read": "^0.6.7", "@eslint/js": "^10.0.1", "@open-wc/testing": "^5.0.0", "@remcovaes/web-test-runner-vite-plugin": "^1.4.0",