Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ jobs:
command: npm run lint
needs_lfs: false
needs_playwright: false
- command_description: Release Config Tests
command: npm run test:release-config
needs_lfs: false
needs_playwright: false
- command_description: Less Tests
command: npm run test:less -w packages/stacks-classic
needs_lfs: false
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ We use [changesets](https://github.com/changesets/changesets) to automatize the
- Every time you do work that requires a new release to be published, [add a changesets entry](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md) by running `npx changeset` and follow the instructions on screen. (changes that do not require a new release - e.g. changing a test file - don’t need a changeset).
- When opening a PR without a corresponding changeset the [changesets-bot](https://github.com/apps/changeset-bot) will remind you to do so. It generally makes sense to have one changeset for PR (if the PR changes do not require a new release to be published the bot message can be safely ignored)
- The [CI workflow](.github/workflows/main.yml) continuously checks for pending changesets on the `v2` branch. When changesets are present, it creates or updates the `changeset-release/v2` pull request targeting `v2`.
- When we are ready to cut a V2 maintenance release, merge the release pull request into `v2`. The workflow publishes the packages to npm under the `v2` dist-tag and creates versioned Git tags and GitHub releases. The release pull request also gives us an opportunity to adjust the automatically generated changelog when necessary.
- When we are ready to cut a V2 maintenance release, merge the release pull request into `v2`. The workflow publishes the packages to npm under the `legacy-v2` dist-tag and creates versioned Git tags and GitHub releases. npm rejects `v2` as a dist-tag because it parses as a semantic-version range. The release pull request also gives us an opportunity to adjust the automatically generated changelog when necessary.
- V2 maintenance releases must not move npm's `latest` dist-tag. The current-generation release branch owns `latest`.

_The release github workflow only run if the CI workflow (running linter, formatter and tests) is successful: CI is blocking accidental releases_.
Expand Down
2 changes: 1 addition & 1 deletion adrs/0006-automatic-library-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Release PRs generated by changesets can be used in conjunction with GitHub Actio

#### Maintenance release lines

The `v2` branch remains available for supported Stacks Classic 2.x and Stacks Svelte 0.7.x maintenance releases. Changesets generates release pull requests against `v2`, and published packages use npm's `v2` dist-tag. The current-generation release branch owns npm's `latest` dist-tag, so a later V2 patch release must not replace the current major version for unversioned installs.
The `v2` branch remains available for supported Stacks Classic 2.x and Stacks Svelte 0.7.x maintenance releases. Changesets generates release pull requests against `v2`, and published packages use npm's `legacy-v2` dist-tag. npm rejects `v2` as a dist-tag because it parses as a semantic-version range. The current-generation release branch owns npm's `latest` dist-tag, so a later V2 patch release must not replace the current major version for unversioned installs.

### Other tools considered

Expand Down
7 changes: 4 additions & 3 deletions package-lock.json

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

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
"license": "MIT",
"scripts": {
"build": "npm run build -workspaces -if-present",
"prepublishOnly": "npm run build -workspaces -if-present",
"prepublishOnly": "npm run build -workspaces -if-present",
"version": "changeset version && npm install --package-lock-only",
"release": "npm run build && changeset publish",
"release:v2": "npm run build && changeset publish --tag v2",
"release:v2": "node scripts/validate-npm-dist-tag.mjs legacy-v2 && npm run build && changeset publish --tag legacy-v2",
"test:release-config": "node --test scripts/validate-npm-dist-tag.test.mjs",
"format": "npm run format -workspaces -if-present",
"lint": "npm run lint -workspaces -if-present",
"start": "npm run start -w packages/stacks-docs",
Expand Down Expand Up @@ -80,6 +81,7 @@
"rimraf": "^6.0.1",
"rollup-plugin-postcss": "^4.0.2",
"sass": "^1.93.2",
"semver": "^7.8.5",
"sinon": "^20.0.0",
"storybook": "^9.1.19",
"stylelint": "^16.25.0",
Expand Down
33 changes: 33 additions & 0 deletions scripts/validate-npm-dist-tag.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import process from "node:process";
import { pathToFileURL } from "node:url";
import semver from "semver";

export function validateNpmDistTag(tag) {
if (typeof tag !== "string" || tag.length === 0) {
throw new Error("The npm dist-tag must be a non-empty string.");
}

if (
!/^[a-z][a-z0-9._-]*$/i.test(tag) ||
/^v\d/i.test(tag) ||
semver.validRange(tag) !== null
) {
throw new Error(
`The npm dist-tag "${tag}" is unsafe because it is invalid or SemVer-like.`
);
}

return tag;
}

if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
validateNpmDistTag(process.argv[2]);
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
}
}
52 changes: 52 additions & 0 deletions scripts/validate-npm-dist-tag.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { describe, test } from "node:test";
import { URL } from "node:url";

import { validateNpmDistTag } from "./validate-npm-dist-tag.mjs";

describe("validateNpmDistTag", () => {
test("accepts descriptive non-SemVer tags", () => {
for (const tag of ["legacy-v2", "beta", "next"]) {
assert.equal(validateNpmDistTag(tag), tag);
}
});

test("rejects invalid and SemVer-like tags", () => {
for (const tag of [
"",
"2",
"2.x",
"v2",
"V6",
"x",
"x.x",
"x.x.x",
"^2",
"latest tag",
]) {
assert.throws(() => validateNpmDistTag(tag));
}
});

test("validates the same tag that the V2 release publishes", async () => {
const packageJson = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8")
);
const releaseScript = packageJson.scripts["release:v2"];
const validationTags = [
...releaseScript.matchAll(/validate-npm-dist-tag\.mjs (\S+)/g),
].map((match) => match[1]);
const publishInvocations = [
...releaseScript.matchAll(
/\bchangeset publish\b(?:\s+--tag(?:=|\s+)([^\s&]+))?/g
),
];
const publishTags = publishInvocations.map((match) => match[1]);

assert.deepEqual(validationTags, ["legacy-v2"]);
assert.equal(publishInvocations.length, 1);
assert.deepEqual(publishTags, validationTags);
assert.equal(validateNpmDistTag(publishTags[0]), "legacy-v2");
});
});
Loading