Skip to content

Commit 2e4aabd

Browse files
committed
fix: create the release commit and tag explicitly
1 parent bb9c70b commit 2e4aabd

4 files changed

Lines changed: 186 additions & 31 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"generate:scenarios": "npm run generate -w css-selector-generator-scenarios",
1919
"generate:readme": "node ./scripts/generate-package-readme.js",
2020
"generate:check": "npm run generate && git diff --exit-code -- packages/css-selector-generator-scenarios/src/generated.ts packages/css-selector-generator/README.md",
21-
"release": "npm version --workspaces=false --prefix packages/css-selector-generator"
21+
"release": "node ./scripts/release.js"
2222
},
2323
"repository": {
2424
"type": "git",

packages/css-selector-generator/config/assert-release-entrypoint.js

Lines changed: 0 additions & 27 deletions
This file was deleted.

packages/css-selector-generator/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,6 @@
4141
"lint": "eslint --config eslint.config.js",
4242
"lint:build": "npm run lint -- --max-warnings 0",
4343
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
44-
"preversion": "node ./config/assert-release-entrypoint.js",
45-
"postversion": "git push && git push --tags && npm publish --access=public",
46-
"version": "npm run build && npm run changelog && npm install --package-lock-only --prefix ../.. && git add -A",
4744
"watch:unit": "chokidar '{src,test}/**/*.ts' '../css-selector-generator-scenarios/**/*.{ts,html}' -c 'npm run test:unit'",
4845
"watch:playwright": "chokidar 'playwright-tests/**/*.ts' -c 'npm run test:playwright'"
4946
},

scripts/release.js

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/* eslint no-console: 0 */
2+
3+
// `npm version` cannot tag a package that is not at the git root: it decides
4+
// whether to touch git by looking for a `.git` directory next to the
5+
// package.json, which in a workspace does not exist. It then skips the commit,
6+
// the tag and the clean-tree check while still running `postversion`, which is
7+
// how a release can get published with nothing recorded in git. So the version
8+
// bump is done with scripts and git disabled, and every step is explicit here.
9+
10+
import { execFileSync } from "node:child_process";
11+
import { readFileSync } from "node:fs";
12+
import { createInterface } from "node:readline/promises";
13+
import path from "node:path";
14+
15+
const PACKAGE = "css-selector-generator";
16+
const INCREMENTS = ["patch", "minor", "major"];
17+
const rootDir = path.resolve(import.meta.dirname, "..");
18+
const packageJsonPath = path.join(rootDir, "packages", PACKAGE, "package.json");
19+
20+
function run(command, args) {
21+
execFileSync(command, args, { cwd: rootDir, stdio: "inherit" });
22+
}
23+
24+
function capture(command, args) {
25+
return execFileSync(command, args, {
26+
cwd: rootDir,
27+
encoding: "utf-8",
28+
}).trim();
29+
}
30+
31+
function readVersion() {
32+
return JSON.parse(readFileSync(packageJsonPath, "utf-8")).version;
33+
}
34+
35+
function fail(message) {
36+
console.error(`\n${message}\n`);
37+
process.exit(1);
38+
}
39+
40+
function preview(current, increment) {
41+
const parts = /^(\d+)\.(\d+)\.(\d+)$/.exec(current);
42+
if (!parts) {
43+
return "";
44+
}
45+
const [major, minor, patch] = parts.slice(1).map(Number);
46+
const next = {
47+
patch: `${String(major)}.${String(minor)}.${String(patch + 1)}`,
48+
minor: `${String(major)}.${String(minor + 1)}.0`,
49+
major: `${String(major + 1)}.0.0`,
50+
}[increment];
51+
return next ? ` ${current} -> ${next}` : "";
52+
}
53+
54+
async function prompt(question) {
55+
const rl = createInterface({ input: process.stdin, output: process.stdout });
56+
try {
57+
return (await rl.question(question)).trim();
58+
} catch {
59+
// Ctrl+C or Ctrl+D. Treated as no answer, which every caller declines on.
60+
return "";
61+
} finally {
62+
rl.close();
63+
}
64+
}
65+
66+
// A dirty tree is checked before anything else, so that a release never starts
67+
// half-way and leaves the repository in a confusing state.
68+
if (capture("git", ["status", "--porcelain"]) !== "") {
69+
fail(
70+
"The working tree has uncommitted changes.\n" +
71+
"Commit or stash them, then run `npm run release` again.",
72+
);
73+
}
74+
75+
const currentVersion = readVersion();
76+
let increment = process.argv[2];
77+
78+
if (increment && !INCREMENTS.includes(increment)) {
79+
fail(
80+
`Unknown release type "${increment}". Use one of: ${INCREMENTS.join(", ")}.`,
81+
);
82+
}
83+
84+
if (!increment) {
85+
if (!process.stdin.isTTY) {
86+
fail(
87+
`Specify a release type: npm run release -- <${INCREMENTS.join("|")}>`,
88+
);
89+
}
90+
console.log(`\nCurrent version is ${currentVersion}.\n`);
91+
INCREMENTS.forEach((name, index) => {
92+
console.log(
93+
` ${String(index + 1)}) ${name}${preview(currentVersion, name)}`,
94+
);
95+
});
96+
const answer = await prompt("\nWhich release? [1-3] ");
97+
increment =
98+
INCREMENTS[Number(answer) - 1] ??
99+
(INCREMENTS.includes(answer) ? answer : undefined);
100+
if (!increment) {
101+
fail("No release type chosen.");
102+
}
103+
}
104+
105+
run("npm", [
106+
"version",
107+
increment,
108+
"--no-git-tag-version",
109+
"--ignore-scripts",
110+
"--workspace",
111+
PACKAGE,
112+
]);
113+
114+
const version = readVersion();
115+
if (version === currentVersion) {
116+
fail(`Version is still ${version}.`);
117+
}
118+
119+
const tag = `v${version}`;
120+
if (capture("git", ["tag", "--list", tag]) !== "") {
121+
fail(`Tag ${tag} already exists.`);
122+
}
123+
124+
// Keeps the root lockfile's record of the workspace version in step, which
125+
// `npm ci` verifies.
126+
run("npm", ["install", "--package-lock-only"]);
127+
run("npm", ["run", "build", "--workspace", PACKAGE]);
128+
run("npm", ["run", "changelog", "--workspace", PACKAGE]);
129+
130+
run("git", ["add", "-A"]);
131+
run("git", ["commit", "-m", version]);
132+
run("git", ["tag", tag]);
133+
134+
console.log(`
135+
${"=".repeat(60)}
136+
Prepared ${version}. Nothing has been pushed or published yet.
137+
138+
Commit: ${capture("git", ["log", "--oneline", "-1"])}
139+
Tag: ${tag}
140+
Files: ${capture("git", ["show", "--stat", "--format=", "HEAD"]).split("\n").length - 1} changed
141+
142+
Changelog entry:
143+
${capture("git", [
144+
"show",
145+
"HEAD",
146+
"--format=",
147+
"-U0",
148+
"--",
149+
`packages/${PACKAGE}/CHANGELOG.md`,
150+
])
151+
.split("\n")
152+
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
153+
.map((line) => ` ${line.slice(1)}`)
154+
.join("\n")}
155+
${"=".repeat(60)}
156+
`);
157+
158+
const undo = ` git tag -d ${tag} && git reset --hard HEAD~1`;
159+
160+
if (!process.stdin.isTTY) {
161+
console.log(
162+
`Not a terminal, so stopping here. To finish:\n\n` +
163+
` git push --follow-tags\n` +
164+
` npm publish --workspace ${PACKAGE} --access=public\n\n` +
165+
`To undo:\n\n${undo}\n`,
166+
);
167+
process.exit(0);
168+
}
169+
170+
const confirmed = await prompt(
171+
"Does this look right? Push and publish? [y/N] ",
172+
);
173+
174+
if (confirmed.toLowerCase() !== "y" && confirmed.toLowerCase() !== "yes") {
175+
console.log(
176+
`\nStopped. The commit and tag exist locally but nothing was published.\n\n` +
177+
`To undo:\n\n${undo}\n`,
178+
);
179+
process.exit(0);
180+
}
181+
182+
run("git", ["push", "--follow-tags"]);
183+
run("npm", ["publish", "--workspace", PACKAGE, "--access=public"]);
184+
185+
console.log(`\nPublished ${version}.\n`);

0 commit comments

Comments
 (0)