Skip to content

Commit 89cffdc

Browse files
authored
Merge pull request #1672 from jetstreamapp/chore/hotfix-and-release-scripts
chore: add hotfix and release preview scripts to streamline release management
2 parents 494e7d2 + 78a54e5 commit 89cffdc

3 files changed

Lines changed: 336 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: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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+
// to the web app. Use Git's version-aware tag sorting so prereleases sort
57+
// lower than the corresponding stable release.
58+
const tagsAtShaRaw = (
59+
await $`git tag --points-at ${deployedSha} --list 'v[0-9]*' --sort=-v:refname`
60+
).stdout.trim();
61+
const webTags = tagsAtShaRaw
62+
.split('\n')
63+
.map((tag) => tag.trim())
64+
.filter(Boolean);
65+
66+
// When multiple v* tags point at the same commit, Git returns the highest
67+
// version tag first.
68+
const webTag = webTags[0];
69+
70+
if (!webTag) {
71+
console.log(chalk.yellow(`\nWarning: No web release tag (v*) found at ${deployedShort}.`));
72+
console.log(chalk.yellow('The release branch may not point at a released commit. Proceed with caution.\n'));
73+
}
74+
75+
console.log('');
76+
console.log(chalk.bold(' Currently deployed (web)'));
77+
console.log(chalk.dim(' ─────────────────────────'));
78+
console.log(` ${chalk.dim('tag')} ${webTag ? chalk.cyan(webTag) : chalk.dim('(none)')}`);
79+
console.log(` ${chalk.dim('commit')} ${chalk.cyan(deployedShort)}`);
80+
console.log(` ${chalk.dim('message')} ${deployedMessage}`);
81+
console.log(` ${chalk.dim('date')} ${chalk.dim(deployedDate)}`);
82+
console.log('');
83+
84+
// ── Prompt: hotfix branch name ─────────────────────────────────────────────
85+
const rawName = await input({
86+
message: 'Hotfix branch name (will be prefixed with hotfix/)',
87+
validate(value) {
88+
const trimmed = value.trim().replace(/^hotfix\//, '');
89+
if (!trimmed) {
90+
return 'Branch name is required';
91+
}
92+
if (!/^[A-Za-z0-9._\-/]+$/.test(trimmed)) {
93+
return 'Branch name contains invalid characters';
94+
}
95+
return true;
96+
},
97+
});
98+
99+
const branchName = `hotfix/${rawName.trim().replace(/^hotfix\//, '')}`;
100+
101+
// ── Guard: existing branch ─────────────────────────────────────────────────
102+
try {
103+
await $`git rev-parse --verify ${branchName}`.quiet();
104+
die(`Branch "${branchName}" already exists locally.`);
105+
} catch {
106+
// Expected: branch does not exist
107+
}
108+
109+
// ── Confirm ────────────────────────────────────────────────────────────────
110+
console.log('');
111+
console.log(chalk.bold(' Create branch'));
112+
console.log(chalk.dim(' ─────────────────────────'));
113+
console.log(` ${chalk.dim('name')} ${chalk.cyan(branchName)}`);
114+
console.log(` ${chalk.dim('from')} ${chalk.cyan(webTag ?? deployedShort)}`);
115+
console.log('');
116+
117+
const ok = await confirm({ message: 'Create hotfix branch?', default: true });
118+
if (!ok) {
119+
console.log(chalk.yellow('\nAborted.'));
120+
process.exit(0);
121+
}
122+
123+
// ── Create branch ──────────────────────────────────────────────────────────
124+
await $`git checkout -b ${branchName} ${deployedSha}`;
125+
console.log(chalk.green(`\n✓ Created ${branchName} at ${deployedShort}`));
126+
127+
console.log('');
128+
console.log(chalk.dim(' Next steps:'));
129+
console.log(chalk.dim(' 1. Implement and commit the fix on this branch'));
130+
console.log(chalk.dim(' 2. Push the branch: ') + chalk.cyan(`git push -u ${REMOTE} ${branchName}`));
131+
console.log(chalk.dim(' 3. Trigger a web release from this branch: ') + chalk.cyan('yarn release'));
132+
console.log(chalk.dim(' 4. After release, merge the hotfix branch back to main via PR'));
133+
console.log('');

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)