Skip to content

Commit 2b59ed3

Browse files
authored
Merge pull request #15 from SahulKola/chore/update-release-workflow
chore: update release workflow automation
2 parents 2a90faa + e984040 commit 2b59ed3

3 files changed

Lines changed: 284 additions & 7 deletions

File tree

handbook/RELEASE_GUIDE.md

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,71 @@
11
# Release Guide
22

3-
A step-by-step reference for shipping a versioned release of git-devkit. Covers the CLI package, the docs-app, and the GitHub release tag. Follow this every time you want to publish something to users.
3+
A step-by-step reference for shipping a versioned release of git-devkit. Covers the CLI, the docs-app, and the GitHub release tag.
4+
5+
---
6+
7+
## Automated release (recommended)
8+
9+
Everything below is automated by `scripts/release.mjs`. Run it from the repo root:
10+
11+
```bash
12+
npm run release
13+
```
14+
15+
The script will:
16+
1. Verify you are on `main` with a clean working tree
17+
2. Ask you to choose a version bump (patch / minor / major / custom)
18+
3. Ask for a one-line release summary
19+
4. Build the CLI (`npm run build`)
20+
5. Build the docs-app (`pnpm run build:ghpages`)
21+
6. Bump `package.json` version
22+
7. Prepend a CHANGELOG entry
23+
8. `git commit` + `git tag -a vX.Y.Z`
24+
9. `git push origin main --tags`
25+
10. Deploy docs to GitHub Pages (`gh-pages`)
26+
11. Print a summary with links
27+
28+
After the script finishes, go to GitHub → Releases, find the new tag, and publish it with the release notes from CHANGELOG.
29+
30+
---
31+
32+
## Release cadence — when to run it
33+
34+
Consistency matters more than frequency. Follow this schedule:
35+
36+
| Release type | When to trigger | Version bump |
37+
|---|---|---|
38+
| **Hotfix** | Immediately after a critical bug is found in production | `patch` |
39+
| **Regular** | After completing a meaningful batch of changes (features, docs, fixes) | `minor` |
40+
| **Milestone** | When a major capability is complete or a breaking change ships | `major` |
41+
42+
**Practical rhythm for this project:**
43+
44+
- Run `npm run release` (patch) whenever you push a set of fixes or documentation updates — aim for every **1–2 weeks** minimum
45+
- Run a minor release when a new page, feature, or workflow is fully complete
46+
- Never let `main` accumulate more than **2 weeks** of untagged commits — untagged changes make it impossible for users to reference a stable version
47+
- Before any release: run `pnpm test` inside `docs-app/` and confirm the build is green
48+
49+
**Checklist before running the script:**
50+
51+
- [ ] All changes committed and pushed to `main`
52+
- [ ] `git status` is clean
53+
- [ ] `pnpm test` passes in `docs-app/`
54+
- [ ] Any new feature has a docs-app page or handbook entry
455

556
---
657

758
## Overview of what a release includes
859

9-
git-devkit has three distinct "artifacts" that can be released independently or together:
60+
git-devkit has three release artifacts:
1061

1162
| Artifact | What it is | Where it lives |
1263
|---|---|---|
13-
| CLI package | The `git-multi-ssh` Node.js tool | Published to npm, bundled from `bin/` |
14-
| Docs app | The Angular documentation site | Deployed as static output to GitHub Pages |
15-
| Git tag + GitHub Release | The version marker on the repo | GitHub Releases page |
64+
| CLI source | The `git-multi-ssh` Node.js tool | Git tag on this repository |
65+
| Docs app | The Angular documentation site | GitHub Pages |
66+
| Git tag + GitHub Release | The version marker | GitHub Releases page |
1667

17-
In most cases you will release all three at once.
68+
In most cases you will release all three at once via `npm run release`.
1869

1970
---
2071

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"start": "git-multi-ssh",
1818
"clean": "rimraf dist",
1919
"build": "npm run clean && node scripts/build.mjs",
20-
"pack:check": "npm run build --dry-run"
20+
"release": "node scripts/release.mjs"
2121
},
2222
"dependencies": {
2323
"chalk": "^5.0.0",

scripts/release.mjs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env node
2+
/**
3+
* git-devkit release automation
4+
* Run: npm run release
5+
*
6+
* Flow:
7+
* 1. Safety checks (clean tree, on main, has remote)
8+
* 2. Choose version bump (patch / minor / major)
9+
* 3. Enter one-line release summary
10+
* 4. Build CLI (npm run build)
11+
* 5. Build docs-app (pnpm run build:ghpages)
12+
* 6. Bump version in package.json
13+
* 7. Prepend CHANGELOG entry
14+
* 8. git commit + annotated tag
15+
* 9. git push origin main --tags
16+
* 10. Deploy docs to GitHub Pages (gh-pages)
17+
* 11. Print release summary
18+
*/
19+
20+
import { execSync } from 'child_process';
21+
import fs from 'fs';
22+
import path from 'path';
23+
import { fileURLToPath } from 'url';
24+
import readline from 'readline';
25+
26+
// ─── resolve paths ────────────────────────────────────────────────────────────
27+
28+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
29+
const ROOT = path.resolve(__dirname, '..');
30+
const DOCS_APP = path.join(ROOT, 'docs-app');
31+
const PKG_PATH = path.join(ROOT, 'package.json');
32+
const CHANGELOG_PATH = path.join(ROOT, 'handbook', 'technical', 'CHANGELOG.md');
33+
34+
// ─── tiny helpers ─────────────────────────────────────────────────────────────
35+
36+
const ESC = '\x1b';
37+
const c = {
38+
bold: s => `${ESC}[1m${s}${ESC}[0m`,
39+
dim: s => `${ESC}[2m${s}${ESC}[0m`,
40+
green: s => `${ESC}[32m${s}${ESC}[0m`,
41+
yellow: s => `${ESC}[33m${s}${ESC}[0m`,
42+
cyan: s => `${ESC}[36m${s}${ESC}[0m`,
43+
red: s => `${ESC}[31m${s}${ESC}[0m`,
44+
};
45+
46+
function log(msg) { console.log(msg); }
47+
function ok(msg) { log(` ${c.green('✔')} ${msg}`); }
48+
function warn(msg) { log(` ${c.yellow('⚠')} ${msg}`); }
49+
function step(n, msg) { log(`\n${c.bold(c.cyan(`[${n}]`))} ${c.bold(msg)}`); }
50+
function fail(msg) { log(`\n ${c.red('✖')} ${c.red(msg)}\n`); process.exit(1); }
51+
52+
function run(cmd, opts = {}) {
53+
return execSync(cmd, { encoding: 'utf8', stdio: opts.silent ? 'pipe' : 'inherit', cwd: opts.cwd || ROOT }).trim();
54+
}
55+
56+
function runSilent(cmd, opts = {}) {
57+
return run(cmd, { ...opts, silent: true });
58+
}
59+
60+
function prompt(question) {
61+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
62+
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
63+
}
64+
65+
function promptChoice(question, choices) {
66+
const lines = choices.map((c, i) => ` ${c.dim(`${i + 1}.`)} ${c.label}`).join('\n');
67+
log(`\n ${question}`);
68+
log(lines);
69+
return (async () => {
70+
while (true) {
71+
const raw = await prompt(`\n Enter number [1-${choices.length}]: `);
72+
const idx = parseInt(raw, 10) - 1;
73+
if (idx >= 0 && idx < choices.length) return choices[idx];
74+
warn('Invalid choice, try again.');
75+
}
76+
})();
77+
}
78+
79+
function bumpVersion(current, type) {
80+
const [maj, min, pat] = current.split('.').map(Number);
81+
if (type === 'major') return `${maj + 1}.0.0`;
82+
if (type === 'minor') return `${maj}.${min + 1}.0`;
83+
return `${maj}.${min}.${pat + 1}`;
84+
}
85+
86+
function today() {
87+
return new Date().toISOString().slice(0, 10);
88+
}
89+
90+
// ─── main ─────────────────────────────────────────────────────────────────────
91+
92+
log('\n' + c.bold(c.cyan(' git-devkit release automation')) + '\n');
93+
log(c.dim(' This will build, tag, push, and deploy the project.\n'));
94+
95+
// ── 1. safety checks ──────────────────────────────────────────────────────────
96+
step(1, 'Safety checks');
97+
98+
// Must be on main
99+
let currentBranch;
100+
try {
101+
currentBranch = runSilent('git rev-parse --abbrev-ref HEAD');
102+
} catch {
103+
fail('Not inside a git repository.');
104+
}
105+
if (currentBranch !== 'main') {
106+
fail(`You are on branch "${currentBranch}". Please switch to main before releasing.\n Run: git checkout main`);
107+
}
108+
ok(`On branch: ${c.green('main')}`);
109+
110+
// Working tree must be clean
111+
const status = runSilent('git status --porcelain');
112+
if (status.length > 0) {
113+
fail('Working tree is not clean. Commit or stash all changes before releasing.');
114+
}
115+
ok('Working tree is clean');
116+
117+
// Remote must exist
118+
try {
119+
runSilent('git remote get-url origin');
120+
} catch {
121+
fail('No remote named "origin" found. Add one before releasing.');
122+
}
123+
ok('Remote origin exists');
124+
125+
// ── 2. version bump ───────────────────────────────────────────────────────────
126+
step(2, 'Version bump');
127+
128+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
129+
const currentVersion = pkg.version;
130+
log(`\n Current version: ${c.yellow(currentVersion)}\n`);
131+
132+
const choice = await promptChoice('Choose the type of release:', [
133+
{ label: `patch ${c.dim(`→ ${bumpVersion(currentVersion, 'patch')} (bug fixes, small corrections)`)}`, type: 'patch' },
134+
{ label: `minor ${c.dim(`→ ${bumpVersion(currentVersion, 'minor')} (new features, no breaking changes)`)}`, type: 'minor' },
135+
{ label: `major ${c.dim(`→ ${bumpVersion(currentVersion, 'major')} (breaking changes)`)}`, type: 'major' },
136+
{ label: `custom ${c.dim('(enter manually)')}`, type: 'custom' },
137+
]);
138+
139+
let newVersion;
140+
if (choice.type === 'custom') {
141+
newVersion = await prompt(' Enter version (e.g. 1.2.3): ');
142+
if (!/^\d+\.\d+\.\d+$/.test(newVersion)) fail(`"${newVersion}" is not a valid semver string.`);
143+
} else {
144+
newVersion = bumpVersion(currentVersion, choice.type);
145+
}
146+
log(`\n ${c.green('✔')} New version: ${c.bold(c.green(newVersion))}`);
147+
148+
// ── 3. release summary ────────────────────────────────────────────────────────
149+
step(3, 'Release summary');
150+
log(c.dim(' A one-line description used in the CHANGELOG and git tag message.\n'));
151+
const summary = await prompt(' Summary: ');
152+
if (!summary) fail('Release summary cannot be empty.');
153+
154+
// ── 4. build CLI ──────────────────────────────────────────────────────────────
155+
step(4, 'Build CLI');
156+
log(c.dim(' Running: npm run build (esbuild bundles bin/ into dist/)\n'));
157+
run('npm run build', { cwd: ROOT });
158+
ok('CLI built successfully');
159+
160+
// ── 5. build docs-app ─────────────────────────────────────────────────────────
161+
step(5, 'Build docs-app');
162+
log(c.dim(' Running: pnpm run build:ghpages (Angular SSG with --base-href /git-devkit/)\n'));
163+
run('pnpm run build:ghpages', { cwd: DOCS_APP });
164+
ok('Docs-app built successfully');
165+
166+
// ── 6. bump package.json ──────────────────────────────────────────────────────
167+
step(6, 'Update package.json');
168+
pkg.version = newVersion;
169+
fs.writeFileSync(PKG_PATH, JSON.stringify(pkg, null, 2) + '\n');
170+
ok(`package.json → version: ${c.green(newVersion)}`);
171+
172+
// ── 7. prepend CHANGELOG ──────────────────────────────────────────────────────
173+
step(7, 'Update CHANGELOG');
174+
const changelogEntry = `## [${newVersion}] - ${today()}\n\n${summary}\n\n`;
175+
const existingChangelog = fs.existsSync(CHANGELOG_PATH) ? fs.readFileSync(CHANGELOG_PATH, 'utf8') : '';
176+
177+
// Insert after the first heading line if one exists, otherwise prepend
178+
const headingMatch = existingChangelog.match(/^#[^\n]*\n/);
179+
let updatedChangelog;
180+
if (headingMatch) {
181+
const afterHeading = existingChangelog.slice(headingMatch[0].length);
182+
updatedChangelog = `${headingMatch[0]}\n${changelogEntry}${afterHeading}`;
183+
} else {
184+
updatedChangelog = `${changelogEntry}${existingChangelog}`;
185+
}
186+
fs.writeFileSync(CHANGELOG_PATH, updatedChangelog);
187+
ok(`CHANGELOG prepended with [${newVersion}]`);
188+
189+
// ── 8. commit + tag ───────────────────────────────────────────────────────────
190+
step(8, 'Commit and tag');
191+
run(`git add ${PKG_PATH} ${CHANGELOG_PATH}`);
192+
run(`git commit -m "chore(release): v${newVersion}"`);
193+
ok(`Committed: chore(release): v${newVersion}`);
194+
195+
run(`git tag -a v${newVersion} -m "Release v${newVersion}: ${summary}"`);
196+
ok(`Annotated tag created: v${newVersion}`);
197+
198+
// ── 9. push ───────────────────────────────────────────────────────────────────
199+
step(9, 'Push to origin');
200+
run('git push origin main');
201+
run(`git push origin v${newVersion}`);
202+
ok('Pushed main and tag to origin');
203+
204+
// ── 10. deploy docs ───────────────────────────────────────────────────────────
205+
step(10, 'Deploy docs-app to GitHub Pages');
206+
const browserDist = path.join(DOCS_APP, 'dist', 'docs-app', 'browser');
207+
if (!fs.existsSync(browserDist)) {
208+
warn(`Build output not found at ${browserDist} — skipping GitHub Pages deploy.`);
209+
} else {
210+
log(c.dim(' Running: gh-pages -d dist/docs-app/browser\n'));
211+
run(`npx gh-pages -d ${browserDist}`, { cwd: DOCS_APP });
212+
ok('Docs deployed to GitHub Pages');
213+
}
214+
215+
// ── 11. summary ───────────────────────────────────────────────────────────────
216+
log('\n' + '─'.repeat(60));
217+
log(c.bold(c.green('\n ✅ Release complete!\n')));
218+
log(` ${c.bold('Version:')} ${c.cyan(`v${newVersion}`)}`);
219+
log(` ${c.bold('Summary:')} ${summary}`);
220+
log(` ${c.bold('Tag:')} v${newVersion}`);
221+
log(` ${c.bold('Docs:')} https://sahulkola.github.io/git-devkit/`);
222+
log(` ${c.bold('Releases:')} https://github.com/sahulkola/git-devkit/releases`);
223+
log('\n' + c.dim(' Next steps:'));
224+
log(c.dim(' • Go to GitHub → Releases and publish the new tag with release notes'));
225+
log(c.dim(' • Verify the docs site is live in a few minutes'));
226+
log('');

0 commit comments

Comments
 (0)