Skip to content

Commit 993edda

Browse files
committed
chore: add hotfix and release preview scripts to streamline release management
1 parent 494e7d2 commit 993edda

3 files changed

Lines changed: 357 additions & 0 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@
8686
"scripts:copy-monaco-assets:desktop": "node scripts/copy-monaco-assets.mjs --target desktop",
8787
"scripts:copy-monaco-assets:web-extension": "node scripts/copy-monaco-assets.mjs --target web-extension",
8888
"release": "node scripts/release.mjs",
89+
"release:preview": "node scripts/release-preview.mjs",
90+
"hotfix": "node scripts/create-hotfix.mjs",
8991
"docs:index": "docker run -it -e APPLICATION_ID=21D7I5RB7N -e API_KEY=$ALGOLIA_API_KEY -e \"CONFIG=$(cat ./apps/docs/algolia-config.json | jq -r tostring)\" algolia/docsearch-scraper",
9092
"rollbar:upload-sourcemaps": "yarn generate:version && nx ./scripts/upload-source-maps.mjs",
9193
"rollbar:create-deploy": "yarn generate:version && zx ./scripts/generate-rollbar-deploy.mjs",

scripts/create-hotfix.mjs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Create a hotfix branch from the most recently deployed web release.
5+
*
6+
* Anchors on origin/release HEAD — the release workflow force-pushes
7+
* origin/release to whichever ref triggered the current web release, so that
8+
* commit is exactly what is running in production. The matching v* tag is
9+
* surfaced for confirmation.
10+
*
11+
* After running this script:
12+
* 1. Commit the fix on the new hotfix/* branch
13+
* 2. Push and trigger the release workflow from the branch (`yarn release`)
14+
* 3. After release, merge the hotfix branch back to main via PR
15+
*
16+
* Usage: node scripts/create-hotfix.mjs
17+
*/
18+
19+
import { confirm, input } from '@inquirer/prompts';
20+
import { $, chalk } from 'zx';
21+
22+
$.verbose = false;
23+
24+
const REMOTE = 'origin';
25+
26+
function die(message) {
27+
console.error(chalk.red(`\n${message}\n`));
28+
process.exit(1);
29+
}
30+
31+
// ── Header ─────────────────────────────────────────────────────────────────
32+
console.log('\n' + chalk.bold.cyan(' Jetstream Hotfix') + '\n');
33+
34+
// ── Pre-flight: clean working tree ─────────────────────────────────────────
35+
const dirty = (await $`git status --porcelain`).stdout.trim();
36+
if (dirty) {
37+
die('Working tree is dirty. Commit or stash your changes before creating a hotfix branch.');
38+
}
39+
40+
// ── Fetch release branch and tags ──────────────────────────────────────────
41+
process.stdout.write(chalk.dim('Fetching release branch and tags... '));
42+
try {
43+
await $`git fetch ${REMOTE} release --tags --prune --quiet`;
44+
console.log(chalk.green('done'));
45+
} catch (error) {
46+
console.log(chalk.red('failed'));
47+
die(`Could not fetch ${REMOTE}/release. ${error.message ?? ''}`);
48+
}
49+
50+
// ── Resolve the deployed commit ────────────────────────────────────────────
51+
const deployedSha = (await $`git rev-parse ${REMOTE}/release`).stdout.trim();
52+
const deployedShort = deployedSha.slice(0, 9);
53+
const deployedMessage = (await $`git log -1 --format=%s ${deployedSha}`).stdout.trim();
54+
const deployedDate = (await $`git log -1 --format=%cI ${deployedSha}`).stdout.trim();
55+
56+
// Find v* tag at the deployed commit. Web releases tag as v<semver>; we
57+
// explicitly ignore desktop-v* and web-ext-v* tags since this script scopes
58+
// to the web app.
59+
const tagsAtShaRaw = (await $`git tag --points-at ${deployedSha}`).stdout.trim();
60+
const webTags = tagsAtShaRaw
61+
.split('\n')
62+
.map((tag) => tag.trim())
63+
.filter((tag) => /^v\d/.test(tag));
64+
65+
// When multiple v* tags point at the same commit, pick the highest semver
66+
const webTag = webTags.sort((a, b) => compareSemver(b, a))[0];
67+
68+
if (!webTag) {
69+
console.log(chalk.yellow(`\nWarning: No web release tag (v*) found at ${deployedShort}.`));
70+
console.log(chalk.yellow('The release branch may not point at a released commit. Proceed with caution.\n'));
71+
}
72+
73+
console.log('');
74+
console.log(chalk.bold(' Currently deployed (web)'));
75+
console.log(chalk.dim(' ─────────────────────────'));
76+
console.log(` ${chalk.dim('tag')} ${webTag ? chalk.cyan(webTag) : chalk.dim('(none)')}`);
77+
console.log(` ${chalk.dim('commit')} ${chalk.cyan(deployedShort)}`);
78+
console.log(` ${chalk.dim('message')} ${deployedMessage}`);
79+
console.log(` ${chalk.dim('date')} ${chalk.dim(deployedDate)}`);
80+
console.log('');
81+
82+
// ── Prompt: hotfix branch name ─────────────────────────────────────────────
83+
const rawName = await input({
84+
message: 'Hotfix branch name (will be prefixed with hotfix/)',
85+
validate(value) {
86+
const trimmed = value.trim().replace(/^hotfix\//, '');
87+
if (!trimmed) {
88+
return 'Branch name is required';
89+
}
90+
if (!/^[A-Za-z0-9._\-/]+$/.test(trimmed)) {
91+
return 'Branch name contains invalid characters';
92+
}
93+
return true;
94+
},
95+
});
96+
97+
const branchName = `hotfix/${rawName.trim().replace(/^hotfix\//, '')}`;
98+
99+
// ── Guard: existing branch ─────────────────────────────────────────────────
100+
try {
101+
await $`git rev-parse --verify ${branchName}`.quiet();
102+
die(`Branch "${branchName}" already exists locally.`);
103+
} catch {
104+
// Expected: branch does not exist
105+
}
106+
107+
// ── Confirm ────────────────────────────────────────────────────────────────
108+
console.log('');
109+
console.log(chalk.bold(' Create branch'));
110+
console.log(chalk.dim(' ─────────────────────────'));
111+
console.log(` ${chalk.dim('name')} ${chalk.cyan(branchName)}`);
112+
console.log(` ${chalk.dim('from')} ${chalk.cyan(webTag ?? deployedShort)}`);
113+
console.log('');
114+
115+
const ok = await confirm({ message: 'Create hotfix branch?', default: true });
116+
if (!ok) {
117+
console.log(chalk.yellow('\nAborted.'));
118+
process.exit(0);
119+
}
120+
121+
// ── Create branch ──────────────────────────────────────────────────────────
122+
await $`git checkout -b ${branchName} ${deployedSha}`;
123+
console.log(chalk.green(`\n✓ Created ${branchName} at ${deployedShort}`));
124+
125+
console.log('');
126+
console.log(chalk.dim(' Next steps:'));
127+
console.log(chalk.dim(' 1. Implement and commit the fix on this branch'));
128+
console.log(chalk.dim(' 2. Push the branch: ') + chalk.cyan(`git push -u ${REMOTE} ${branchName}`));
129+
console.log(chalk.dim(' 3. Trigger a web release from this branch: ') + chalk.cyan('yarn release'));
130+
console.log(chalk.dim(' 4. After release, merge the hotfix branch back to main via PR'));
131+
console.log('');
132+
133+
// ── Helpers ────────────────────────────────────────────────────────────────
134+
function compareSemver(a, b) {
135+
const parse = (tag) =>
136+
tag
137+
.replace(/^v/, '')
138+
.split(/[.\-+]/)
139+
.map((part) => (Number.isNaN(Number(part)) ? part : Number(part)));
140+
const aParts = parse(a);
141+
const bParts = parse(b);
142+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
143+
const aPart = aParts[i] ?? 0;
144+
const bPart = bParts[i] ?? 0;
145+
if (aPart === bPart) {
146+
continue;
147+
}
148+
if (typeof aPart === 'number' && typeof bPart === 'number') {
149+
return aPart - bPart;
150+
}
151+
return String(aPart).localeCompare(String(bPart));
152+
}
153+
return 0;
154+
}

scripts/release-preview.mjs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Preview commits that would be included in the next web release.
5+
*
6+
* Shows the commit list between a base release tag and the current HEAD,
7+
* grouped into merged PRs and direct commits. Defaults the base to the
8+
* most recent v* (web) tag; pass --base to override, or --path to filter to
9+
* a specific pathspec (e.g. `--path apps/jetstream/`).
10+
*
11+
* Usage:
12+
* node scripts/release-preview.mjs [--base <tag>] [--path <pathspec>]
13+
*/
14+
15+
import { select } from '@inquirer/prompts';
16+
import { $, chalk } from 'zx';
17+
18+
$.verbose = false;
19+
20+
const REPO_SLUG = 'jetstreamapp/jetstream';
21+
const REMOTE = 'origin';
22+
const MERGE_PR_REGEX = /^Merge pull request #(\d+) from (\S+)/;
23+
24+
// ── Args ───────────────────────────────────────────────────────────────────
25+
const options = parseArgs(process.argv.slice(2));
26+
27+
console.log('\n' + chalk.bold.cyan(' Release Preview') + '\n');
28+
29+
// ── Fetch tags ─────────────────────────────────────────────────────────────
30+
process.stdout.write(chalk.dim('Fetching tags... '));
31+
try {
32+
await $`git fetch ${REMOTE} --tags --prune --quiet`;
33+
console.log(chalk.green('done'));
34+
} catch {
35+
console.log(chalk.yellow('skipped (offline?)'));
36+
}
37+
38+
// ── Determine base tag ─────────────────────────────────────────────────────
39+
let baseTag = options.base;
40+
41+
if (!baseTag) {
42+
const webTagsRaw = (await $`git tag --list 'v[0-9]*' --sort=-v:refname`).stdout.trim();
43+
const webTags = webTagsRaw.split('\n').filter(Boolean);
44+
if (webTags.length === 0) {
45+
console.error(chalk.red('\nNo v* tags found in this repo.\n'));
46+
process.exit(1);
47+
}
48+
baseTag = await select({
49+
message: 'Base release tag',
50+
choices: webTags.slice(0, 10).map((tag) => ({ value: tag, name: tag })),
51+
default: webTags[0],
52+
});
53+
}
54+
55+
// ── Validate base tag ──────────────────────────────────────────────────────
56+
try {
57+
await $`git rev-parse --verify ${baseTag}`.quiet();
58+
} catch {
59+
console.error(chalk.red(`\nBase tag "${baseTag}" not found.\n`));
60+
process.exit(1);
61+
}
62+
63+
const baseSha = (await $`git rev-parse ${baseTag}`).stdout.trim();
64+
const currentBranch = (await $`git rev-parse --abbrev-ref HEAD`).stdout.trim();
65+
const headSha = (await $`git rev-parse HEAD`).stdout.trim();
66+
const range = `${baseTag}..HEAD`;
67+
68+
// ── Gather commits ─────────────────────────────────────────────────────────
69+
// --first-parent walks only the main-line history, so we see each merge
70+
// commit once and any commits pushed directly (without a PR). Without this,
71+
// every commit inside every merged PR branch would also show up.
72+
const pathArgs = options.path ? ['--', options.path] : [];
73+
const logOutput = (
74+
await $`git log --first-parent --format=%H%x09%s ${range} ${pathArgs}`
75+
).stdout.trim();
76+
77+
const mainlineCommits = logOutput
78+
? logOutput.split('\n').map((line) => {
79+
const tabIndex = line.indexOf('\t');
80+
const sha = line.slice(0, tabIndex);
81+
const subject = line.slice(tabIndex + 1);
82+
return { sha, short: sha.slice(0, 9), subject };
83+
})
84+
: [];
85+
86+
const prs = [];
87+
const directCommits = [];
88+
for (const commit of mainlineCommits) {
89+
const match = commit.subject.match(MERGE_PR_REGEX);
90+
if (match) {
91+
const number = match[1];
92+
const label = await resolvePrLabel(commit.sha, match[2]);
93+
prs.push({ ...commit, number, label });
94+
} else {
95+
directCommits.push(commit);
96+
}
97+
}
98+
99+
// Total commit count includes every commit reachable from HEAD but not from
100+
// base — useful context separate from the mainline view.
101+
const totalCommitsOutput = (
102+
await $`git log --format=%H ${range} ${pathArgs}`
103+
).stdout.trim();
104+
const totalCommits = totalCommitsOutput ? totalCommitsOutput.split('\n').length : 0;
105+
106+
// ── Summary ────────────────────────────────────────────────────────────────
107+
console.log('');
108+
console.log(chalk.bold(' Summary'));
109+
console.log(chalk.dim(' ─────────────────────────'));
110+
console.log(` ${chalk.dim('base')} ${chalk.cyan(baseTag)} ${chalk.dim(`(${baseSha.slice(0, 9)})`)}`);
111+
console.log(` ${chalk.dim('head')} ${chalk.cyan(currentBranch)} ${chalk.dim(`(${headSha.slice(0, 9)})`)}`);
112+
if (options.path) {
113+
console.log(` ${chalk.dim('path')} ${chalk.cyan(options.path)}`);
114+
}
115+
console.log(` ${chalk.dim('commits')} ${chalk.cyan(String(totalCommits))} ${chalk.dim(`(${mainlineCommits.length} on mainline)`)}`);
116+
console.log(` ${chalk.dim('PRs')} ${chalk.cyan(String(prs.length))}`);
117+
console.log('');
118+
119+
if (totalCommits === 0) {
120+
console.log(chalk.yellow(' No commits between base and HEAD.'));
121+
if (options.path) {
122+
console.log(chalk.dim(' (Try running without --path filter.)'));
123+
}
124+
console.log('');
125+
process.exit(0);
126+
}
127+
128+
// ── PR list ────────────────────────────────────────────────────────────────
129+
if (prs.length > 0) {
130+
console.log(chalk.bold(' Merged PRs'));
131+
console.log(chalk.dim(' ─────────────────────────'));
132+
const numberWidth = Math.max(...prs.map((pr) => pr.number.length));
133+
for (const pr of prs) {
134+
const padded = `#${pr.number}`.padEnd(numberWidth + 1);
135+
console.log(` ${chalk.cyan(padded)} ${pr.label}`);
136+
}
137+
console.log('');
138+
}
139+
140+
// ── Direct commits ─────────────────────────────────────────────────────────
141+
if (directCommits.length > 0) {
142+
console.log(chalk.bold(' Direct commits'));
143+
console.log(chalk.dim(' ─────────────────────────'));
144+
for (const commit of directCommits) {
145+
console.log(` ${chalk.dim(commit.short)} ${commit.subject}`);
146+
}
147+
console.log('');
148+
}
149+
150+
// ── GitHub compare link ────────────────────────────────────────────────────
151+
const compareUrl = `https://github.com/${REPO_SLUG}/compare/${baseTag}...${headSha}`;
152+
console.log(` ${chalk.dim('compare')} ${chalk.underline.blue(compareUrl)}`);
153+
console.log('');
154+
155+
// ── Helpers ────────────────────────────────────────────────────────────────
156+
157+
/**
158+
* Pulls the real commit subject for a merged PR. The merge commit's own
159+
* subject is just "Merge pull request #X from ..." which isn't useful — the
160+
* actual change description lives in the merge commit body, or falls back to
161+
* the source branch name.
162+
*/
163+
async function resolvePrLabel(mergeSha, sourceRef) {
164+
try {
165+
const body = (await $`git log -1 --format=%b ${mergeSha}`).stdout.trim();
166+
const firstLine = body.split('\n').find((line) => line.trim());
167+
if (firstLine) {
168+
return firstLine.trim();
169+
}
170+
} catch {
171+
// fall through
172+
}
173+
// Fallback: the source branch name from the merge subject
174+
return sourceRef.replace(/^[^/]+\//, '');
175+
}
176+
177+
function parseArgs(args) {
178+
const result = { base: null, path: null };
179+
for (let i = 0; i < args.length; i++) {
180+
const arg = args[i];
181+
if (arg === '--base' || arg === '-b') {
182+
result.base = args[++i];
183+
} else if (arg === '--path' || arg === '-p') {
184+
result.path = args[++i];
185+
} else if (arg === '--help' || arg === '-h') {
186+
console.log(`
187+
Usage: node scripts/release-preview.mjs [options]
188+
189+
Options:
190+
--base, -b <tag> Base release tag to compare against (default: prompt with latest v* tags)
191+
--path, -p <spec> Path filter (e.g. "apps/jetstream/" to scope to the web app)
192+
--help, -h Show this help
193+
`);
194+
process.exit(0);
195+
} else {
196+
console.error(chalk.red(`Unknown argument: ${arg}`));
197+
process.exit(1);
198+
}
199+
}
200+
return result;
201+
}

0 commit comments

Comments
 (0)