Skip to content

Commit a53b146

Browse files
ci: add PR release preview for changelog and version (#741)
Add a standalone workflow that validates the PR title (squash-merge), simulates semver dry-run, and upserts a bot comment with the expected version bump and changelog preview. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f394bc1 commit a53b146

3 files changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: PR Release Preview
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, edited]
6+
7+
jobs:
8+
release-preview:
9+
name: Release preview
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
steps:
15+
- uses: actions/checkout@v6
16+
- uses: ./.github/actions/setup
17+
18+
- name: Validate PR title
19+
id: commitlint
20+
run: |
21+
if echo "${{ github.event.pull_request.title }}" | npx --no-install commitlint > commitlint.err 2>&1; then
22+
echo "ok=true" >> "$GITHUB_OUTPUT"
23+
else
24+
echo "ok=false" >> "$GITHUB_OUTPUT"
25+
fi
26+
27+
- name: Generate release preview comment
28+
id: preview
29+
if: always()
30+
env:
31+
PR_TITLE: ${{ github.event.pull_request.title }}
32+
BASE_REF: ${{ github.base_ref }}
33+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
34+
COMMITLINT_OK: ${{ steps.commitlint.outputs.ok == 'true' && '1' || '0' }}
35+
run: npx ts-node --transpile-only -O '{"module":"commonjs"}' tools/ci/pr-release-preview.ts
36+
37+
- name: Find existing preview comment
38+
if: always() && steps.preview.outcome != 'skipped'
39+
uses: peter-evans/find-comment@v4
40+
id: find-comment
41+
with:
42+
issue-number: ${{ github.event.pull_request.number }}
43+
comment-author: github-actions[bot]
44+
body-includes: ngx-deploy-npm-release-preview
45+
46+
- name: Create or update preview comment
47+
if: always() && steps.preview.outcome != 'skipped'
48+
uses: peter-evans/create-or-update-comment@v5
49+
with:
50+
comment-id: ${{ steps.find-comment.outputs.comment-id }}
51+
issue-number: ${{ github.event.pull_request.number }}
52+
edit-mode: replace
53+
body-path: comment.md
54+
55+
- name: Enforce valid PR title
56+
if: steps.commitlint.outputs.ok != 'true'
57+
run: |
58+
echo "::error::PR title must follow Conventional Commits (see commitlint output in the PR comment)."
59+
exit 1
60+
61+
- name: Enforce release preview succeeded
62+
if: steps.preview.outcome == 'failure'
63+
run: exit 1

docs/README_contributors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ On VsCode, create a [_JavaScript Debug Terminal_](https://code.visualstudio.com/
104104
- We use the commit history to generate the changelog automagically, do your best describing the changes that you introduce 😄. Creating the commit right is essential.
105105
- We encourage the use of Unit Tests for the fixes and new features. Don't you know how to write Unit Tests? Don't let that stop your contribution; we are here to help 👋.
106106
5. Make a PR against `main`
107+
- We **squash and merge** PRs: the **PR title** becomes the commit message used for versioning and the changelog. Use a [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) title (e.g. `feat: add foo`).
108+
- The **PR Release Preview** workflow comments on your PR with the expected version bump and changelog, and **fails** if the title is not a valid conventional commit.
107109
6. Wait for the review
108110
7. Merge and Party 🎉
109111

tools/ci/pr-release-preview.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/**
2+
* Simulates squash-merge (PR title as commit message), runs semver dry-run,
3+
* and writes comment.md for the PR release preview bot comment.
4+
*/
5+
import { execFileSync, spawnSync } from 'child_process';
6+
import { existsSync, readFileSync, writeFileSync } from 'fs';
7+
import { join } from 'path';
8+
9+
const COMMENT_MARKER = '<!-- ngx-deploy-npm-release-preview -->';
10+
const COMMENT_FILE = process.env.COMMENT_FILE ?? 'comment.md';
11+
const SEMVER_LOG = process.env.SEMVER_LOG ?? '/tmp/semver.log';
12+
const PACKAGE_JSON = join('packages', 'ngx-deploy-npm', 'package.json');
13+
14+
const prTitle = requireEnv('PR_TITLE');
15+
const baseRef = requireEnv('BASE_REF');
16+
const prHeadSha = requireEnv('PR_HEAD_SHA');
17+
const commitlintOk = process.env.COMMITLINT_OK === '1';
18+
const commitlintOutput =
19+
process.env.COMMITLINT_OUTPUT ??
20+
(existsSync('commitlint.err') ? readFileSync('commitlint.err', 'utf-8') : '');
21+
22+
function requireEnv(name: string): string {
23+
const value = process.env[name];
24+
if (!value) {
25+
console.error(`Missing required environment variable: ${name}`);
26+
process.exit(1);
27+
}
28+
return value;
29+
}
30+
31+
function escapeMdInline(text: string): string {
32+
return text.replace(/`/g, '\\`');
33+
}
34+
35+
const ANSI_ESCAPE_PATTERN = new RegExp(
36+
`${String.fromCharCode(27)}\\[[0-9;]*m`,
37+
'g'
38+
);
39+
40+
function stripAnsi(text: string): string {
41+
return text.replace(ANSI_ESCAPE_PATTERN, '');
42+
}
43+
44+
function runGit(args: string[]): void {
45+
execFileSync('git', args, {
46+
stdio: 'inherit',
47+
env: { ...process.env, HUSKY: '0' },
48+
});
49+
}
50+
51+
function getCurrentVersion(): string {
52+
try {
53+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8')) as {
54+
version?: string;
55+
};
56+
return pkg.version ?? 'unknown';
57+
} catch {
58+
return 'unknown';
59+
}
60+
}
61+
62+
function simulateSquashAndDryRun(): { ok: boolean; log: string } {
63+
runGit(['fetch', 'origin', baseRef]);
64+
runGit(['checkout', '-B', 'release-preview', `origin/${baseRef}`]);
65+
runGit(['merge', '--squash', prHeadSha]);
66+
runGit(['commit', '--no-verify', '-m', prTitle]);
67+
68+
const result = spawnSync(
69+
'npx',
70+
['nx', 'run', 'ngx-deploy-npm:version', '--dry-run'],
71+
{
72+
encoding: 'utf-8',
73+
env: { ...process.env, HUSKY: '0' },
74+
}
75+
);
76+
77+
const log = stripAnsi(`${result.stdout ?? ''}${result.stderr ?? ''}`);
78+
writeFileSync(SEMVER_LOG, log);
79+
80+
return { ok: result.status === 0, log };
81+
}
82+
83+
interface ParsedSemver {
84+
versionSection: string;
85+
changelogSection: string;
86+
parseFailed: boolean;
87+
}
88+
89+
function parseSemverLog(logContent: string): ParsedSemver {
90+
if (logContent.includes('Nothing changed since last release')) {
91+
return {
92+
versionSection:
93+
'No npm release for this PR (no changes under `packages/ngx-deploy-npm` since the last tag).',
94+
changelogSection: '',
95+
parseFailed: false,
96+
};
97+
}
98+
99+
const versionMatch = logContent.match(/Calculated new version "([^"]+)"/);
100+
const newVersion = versionMatch?.[1];
101+
102+
if (!newVersion) {
103+
return {
104+
versionSection: '⚠️ Could not determine version from semver output.',
105+
changelogSection: logContent.split('\n').slice(-30).join('\n'),
106+
parseFailed: true,
107+
};
108+
}
109+
110+
const currentVersion = getCurrentVersion();
111+
const changelogSection = extractChangelogBlock(logContent);
112+
113+
return {
114+
versionSection: `\`${currentVersion}\` → \`${newVersion}\``,
115+
changelogSection,
116+
parseFailed: false,
117+
};
118+
}
119+
120+
function extractChangelogBlock(logContent: string): string {
121+
const lines = logContent.split('\n');
122+
const parts: string[] = [];
123+
let delimiterCount = 0;
124+
let capturing = false;
125+
126+
for (const line of lines) {
127+
if (line === '---') {
128+
delimiterCount++;
129+
if (delimiterCount === 1) {
130+
capturing = true;
131+
continue;
132+
}
133+
if (delimiterCount === 2) {
134+
break;
135+
}
136+
}
137+
if (capturing && delimiterCount === 1) {
138+
parts.push(line);
139+
}
140+
}
141+
142+
const block = parts.join('\n').trim();
143+
return block || '_No changelog block in dry-run output._';
144+
}
145+
146+
function buildCommentHeader(): string {
147+
const titleEscaped = escapeMdInline(prTitle);
148+
return `${COMMENT_MARKER}
149+
150+
## Release preview
151+
152+
> Simulates **squash merge** into \`${baseRef}\` using the **PR title** as the commit message.
153+
> Only changes under \`packages/ngx-deploy-npm\` affect the version.
154+
155+
**PR title:** \`${titleEscaped}\`
156+
157+
`;
158+
}
159+
160+
function buildCommitlintSection(): string {
161+
if (commitlintOk) {
162+
return `### Semantic commit (PR title)
163+
164+
✅ Valid conventional commit
165+
`;
166+
}
167+
168+
const output = commitlintOutput.trim() || '(no commitlint output)';
169+
return `### Semantic commit (PR title)
170+
171+
❌ **Invalid** — PR title must follow [Conventional Commits](https://www.conventionalcommits.org/):
172+
173+
\`\`\`
174+
${output}
175+
\`\`\`
176+
`;
177+
}
178+
179+
function buildFooter(): string {
180+
return `
181+
---
182+
*Generated by CI (PR Release Preview). Actual release runs on merge via publishment.yml.*
183+
184+
**Notes:** Preview uses only the PR title (squash merge). Changes outside \`packages/ngx-deploy-npm\` do not trigger a version bump. Breaking changes must appear in the title (e.g. \`feat!:\`), not only in the PR description.
185+
`;
186+
}
187+
188+
function buildVersionSection(
189+
versionSection: string,
190+
changelogSection: string
191+
): string {
192+
let body = `
193+
194+
### Version
195+
196+
${versionSection}
197+
`;
198+
199+
if (changelogSection) {
200+
body += `
201+
### Changelog preview
202+
203+
\`\`\`markdown
204+
${changelogSection}
205+
\`\`\`
206+
`;
207+
}
208+
209+
return body;
210+
}
211+
212+
function writeComment(content: string): void {
213+
writeFileSync(COMMENT_FILE, content);
214+
}
215+
216+
function main(): void {
217+
let comment = buildCommentHeader() + buildCommitlintSection();
218+
219+
if (!commitlintOk) {
220+
comment += `
221+
222+
### Version
223+
224+
_Preview skipped — fix the PR title first._
225+
`;
226+
writeComment(comment + buildFooter());
227+
process.exit(0);
228+
}
229+
230+
try {
231+
const { ok, log } = simulateSquashAndDryRun();
232+
if (!ok) {
233+
comment += `
234+
235+
### Version
236+
237+
❌ **Semver dry-run failed.** See workflow logs for details.
238+
`;
239+
if (log) {
240+
const tail = log.split('\n').slice(-40).join('\n');
241+
comment += `
242+
<details>
243+
<summary>Semver output (last 40 lines)</summary>
244+
245+
\`\`\`
246+
${tail}
247+
\`\`\`
248+
249+
</details>
250+
`;
251+
}
252+
writeComment(comment + buildFooter());
253+
process.exit(1);
254+
}
255+
256+
const parsed = parseSemverLog(log);
257+
comment += buildVersionSection(
258+
parsed.versionSection,
259+
parsed.changelogSection
260+
);
261+
writeComment(comment + buildFooter());
262+
263+
if (parsed.parseFailed) {
264+
process.exit(1);
265+
}
266+
} catch (error) {
267+
const message = error instanceof Error ? error.message : String(error);
268+
comment += `
269+
270+
### Version
271+
272+
❌ **Release preview failed:** ${message}
273+
`;
274+
writeComment(comment + buildFooter());
275+
process.exit(1);
276+
}
277+
}
278+
279+
main();

0 commit comments

Comments
 (0)