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
88 changes: 88 additions & 0 deletions .github/scripts/prepare-ignored-changesets.mjs
Original file line number Diff line number Diff line change
@@ -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] });
}
178 changes: 178 additions & 0 deletions .github/scripts/prepare-ignored-changesets.test.mjs
Original file line number Diff line number Diff line change
@@ -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
);
});
6 changes: 6 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_.

Expand Down
6 changes: 5 additions & 1 deletion adrs/0006-automatic-library-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
- [Changesets repository](https://github.com/changesets/changesets)
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading