diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 661ea4b181..e39e0beefc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/README.md b/README.md index 5d9878cf40..517f80c9dd 100755 --- a/README.md +++ b/README.md @@ -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_. diff --git a/adrs/0006-automatic-library-release.md b/adrs/0006-automatic-library-release.md index 00062494d6..abadad2a45 100644 --- a/adrs/0006-automatic-library-release.md +++ b/adrs/0006-automatic-library-release.md @@ -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 diff --git a/package-lock.json b/package-lock.json index a23695c742..cc26116ad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,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", @@ -14747,9 +14748,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index b9ddb582f3..911fddbd02 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/scripts/validate-npm-dist-tag.mjs b/scripts/validate-npm-dist-tag.mjs new file mode 100644 index 0000000000..387dfb215c --- /dev/null +++ b/scripts/validate-npm-dist-tag.mjs @@ -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; + } +} diff --git a/scripts/validate-npm-dist-tag.test.mjs b/scripts/validate-npm-dist-tag.test.mjs new file mode 100644 index 0000000000..c4f19df99b --- /dev/null +++ b/scripts/validate-npm-dist-tag.test.mjs @@ -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"); + }); +});