Skip to content

Commit e7d8bc9

Browse files
committed
chore(release): add script to automate commit, tag and release
1 parent 4d31632 commit e7d8bc9

File tree

4 files changed

+219
-6
lines changed

4 files changed

+219
-6
lines changed

eslint.config.mjs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
// @ts-check
22
import withNuxt from "./.nuxt/eslint.config.mjs";
33

4-
export default withNuxt({
5-
rules: {
6-
"vue/singleline-html-element-content-newline": "off",
7-
"vue/html-self-closing": "off",
4+
export default withNuxt([
5+
// This first object applies your custom Vue rules globally to all files.
6+
{
7+
rules: {
8+
"vue/singleline-html-element-content-newline": "off",
9+
"vue/html-self-closing": "off",
10+
},
811
},
9-
});
12+
// This second object applies its settings ONLY to files matching 'scripts/**/*.mjs'.
13+
{
14+
files: ["scripts/**/*.mjs"],
15+
languageOptions: {
16+
ecmaVersion: "latest",
17+
},
18+
},
19+
]);

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"postinstall": "nuxt prepare",
1515
"test:e2e": "playwright test",
1616
"lint": "eslint .",
17-
"lint:fix": "eslint . --fix"
17+
"lint:fix": "eslint . --fix",
18+
"release": "node scripts/release.mjs"
1819
},
1920
"devDependencies": {
2021
"@nuxt/eslint": "1.7.1",
@@ -25,6 +26,7 @@
2526
"@playwright/test": "1.54.2",
2627
"@types/node": "22.17.0",
2728
"eslint": "9.32.0",
29+
"execa": "9.6.0",
2830
"linter-bundle": "7.7.0",
2931
"nuxt": "4.0.3",
3032
"nuxt-og-image": "5.1.9",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/release.mjs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { createInterface } from "node:readline/promises";
4+
import { execa } from "execa";
5+
import pkg from "../package.json" with { type: "json" };
6+
7+
// --- Configuration ---
8+
const CHANGELOG_PATH = path.resolve(process.cwd(), "CHANGELOG.md");
9+
const DRY_RUN = process.argv.includes("--dry-run");
10+
11+
// --- Helper Functions ---
12+
const log = {
13+
info: (msg) => console.log(`\x1b[34mINFO\x1b[0m: ${msg}`),
14+
prompt: (msg) => `\x1b[33mPROMPT\x1b[0m: ${msg}`,
15+
error: (msg) => console.error(`\x1b[31mERROR\x1b[0m: ${msg}`),
16+
success: (msg) => console.log(`\x1b[32mSUCCESS\x1b[0m: ${msg}`),
17+
};
18+
19+
async function run(command, args, options = {}) {
20+
if (DRY_RUN) {
21+
let commandToLog = `${command} ${args.join(" ")}`;
22+
let additionalLog;
23+
24+
// Special handling for the 'gh release create' command to avoid verbose logging.
25+
if (command === "gh" && args.includes("--notes")) {
26+
const loggedArgs = [...args];
27+
const notesIndex = loggedArgs.indexOf("--notes");
28+
if (notesIndex !== -1 && loggedArgs.length > notesIndex + 1) {
29+
loggedArgs[notesIndex + 1] = "<release notes>";
30+
commandToLog = `${command} ${loggedArgs.join(" ")}`;
31+
additionalLog = " ...and with the release notes as notes.";
32+
}
33+
}
34+
35+
log.info(`[dry-run] Would run: ${commandToLog}`);
36+
if (additionalLog) {
37+
log.info(additionalLog);
38+
}
39+
40+
return { stdout: "", stderr: "" };
41+
}
42+
return await execa(command, args, { stdio: "inherit", ...options });
43+
}
44+
45+
async function askForConfirmation(question) {
46+
const rl = createInterface({
47+
input: process.stdin,
48+
output: process.stdout,
49+
});
50+
const answer = await rl.question(log.prompt(`${question} (y/N) `));
51+
rl.close();
52+
return answer.toLowerCase().trim();
53+
}
54+
55+
// --- Main Script ---
56+
async function main() {
57+
log.info("Starting release process...");
58+
if (DRY_RUN) {
59+
log.info("Running in dry-run mode. No commands will be executed.");
60+
}
61+
62+
// 1. Check for staged changes
63+
const { stdout: stagedFiles } = await execa("git", [
64+
"diff",
65+
"--staged",
66+
"--name-only",
67+
]);
68+
if (!stagedFiles) {
69+
log.error(
70+
"No staged changes to commit. Stage your changes for the release first (e.g., package.json, CHANGELOG.md).",
71+
);
72+
process.exit(1);
73+
}
74+
log.info(`Staged files for release: \n${stagedFiles}`);
75+
76+
// 2. Get version and construct tag
77+
const { version } = pkg;
78+
if (!version) {
79+
log.error("Could not read version from package.json");
80+
process.exit(1);
81+
}
82+
const tag = `v${version}`;
83+
log.info(`Found version: ${version}`);
84+
85+
// 3. Extract release notes from CHANGELOG.md
86+
log.info("Extracting release notes from CHANGELOG.md...");
87+
const changelog = fs.readFileSync(CHANGELOG_PATH, "utf-8");
88+
const lines = changelog.split("\n");
89+
90+
const versionHeader = `## ${tag}`;
91+
const startIndex = lines.findIndex((line) => line.startsWith(versionHeader));
92+
93+
if (startIndex === -1) {
94+
log.error(
95+
`Could not find release notes for version ${tag} in CHANGELOG.md.`,
96+
);
97+
process.exit(1);
98+
}
99+
100+
// Find the end of the section for the current version
101+
let endIndex = lines.findIndex(
102+
(line, index) => index > startIndex && line.startsWith("## v"),
103+
);
104+
if (endIndex === -1) {
105+
endIndex = lines.length;
106+
}
107+
108+
// Find the '[compare changes]' link to start the notes from there
109+
const sectionLines = lines.slice(startIndex, endIndex);
110+
const notesStartIndex = sectionLines.findIndex((line) =>
111+
line.startsWith("[compare changes]"),
112+
);
113+
114+
if (notesStartIndex === -1) {
115+
log.error(
116+
`Could not find '[compare changes]' link for version ${tag} in CHANGELOG.md.`,
117+
);
118+
process.exit(1);
119+
}
120+
121+
const releaseNotes = sectionLines.slice(notesStartIndex).join("\n").trim();
122+
123+
if (!releaseNotes) {
124+
log.error("Extracted release notes are empty.");
125+
process.exit(1);
126+
}
127+
log.success("Successfully extracted release notes.");
128+
129+
if (DRY_RUN) {
130+
log.info("--- Extracted Release Notes (for review) ---");
131+
const indentedNotes = releaseNotes
132+
.split("\n")
133+
.map((line) => ` ${line}`)
134+
.join("\n");
135+
console.log(indentedNotes);
136+
log.info("--------------------------------------------");
137+
}
138+
139+
// 4. Commit the staged changes
140+
const commitMessage = `chore(release): ${tag}`;
141+
log.info(`Committing with message: "${commitMessage}"`);
142+
await run("git", ["commit", "-m", commitMessage]);
143+
144+
// 5. Create an annotated tag
145+
log.info(`Creating annotated tag: ${tag}`);
146+
await run("git", ["tag", "-a", tag, "-m", tag]);
147+
148+
log.success("Local commit and tag created successfully!");
149+
150+
// 6. Ask to push
151+
if (!DRY_RUN) {
152+
const answer = await askForConfirmation("Push commit and tags to remote?");
153+
if (answer !== "y" && answer !== "yes") {
154+
log.info("Push aborted by user.");
155+
const revertAnswer = await askForConfirmation(
156+
"Revert local commit and tag?",
157+
);
158+
if (revertAnswer === "y" || revertAnswer === "yes") {
159+
log.info("Reverting local commit and tag...");
160+
await run("git", ["reset", "--soft", "HEAD~1"]);
161+
await run("git", ["tag", "-d", tag]);
162+
log.success("Local changes have been reverted.");
163+
log.info("Your staged files are preserved.");
164+
} else {
165+
log.info(
166+
'Local commit and tag were not reverted. Run "git push && git push --tags" manually to publish.',
167+
);
168+
}
169+
process.exit(0);
170+
}
171+
}
172+
173+
// 7. Push to remote
174+
log.info("Pushing commit and tags to remote...");
175+
await run("git", ["push"]);
176+
await run("git", ["push", "--tags"]);
177+
log.success("Commit and tags pushed to remote.");
178+
179+
// 8. Create a GitHub release
180+
log.info("Creating GitHub release...");
181+
await run("gh", [
182+
"release",
183+
"create",
184+
tag,
185+
"--title",
186+
tag,
187+
"--notes",
188+
releaseNotes,
189+
]);
190+
191+
log.success("Release process completed and published successfully!");
192+
}
193+
194+
main().catch((err) => {
195+
log.error("An unexpected error occurred:");
196+
console.error(err);
197+
process.exit(1);
198+
});

0 commit comments

Comments
 (0)