Skip to content

Commit f461042

Browse files
committed
feat: automate GitHub release pages
1 parent 2effe4c commit f461042

9 files changed

Lines changed: 295 additions & 7 deletions

File tree

.github/release.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
changelog:
2+
exclude:
3+
labels:
4+
- skip-changelog
5+
- duplicate
6+
- invalid
7+
- question
8+
- wontfix
9+
authors:
10+
- github-actions[bot]
11+
categories:
12+
- title: Breaking Changes
13+
labels:
14+
- breaking
15+
- breaking-change
16+
- semver-major
17+
- title: New
18+
labels:
19+
- feat
20+
- feature
21+
- enhancement
22+
- title: Improved
23+
labels:
24+
- improvement
25+
- perf
26+
- performance
27+
- refactor
28+
- title: Fixed
29+
labels:
30+
- fix
31+
- bug
32+
- bugfix
33+
- regression
34+
- title: Docs
35+
labels:
36+
- docs
37+
- documentation
38+
- title: Dependencies
39+
labels:
40+
- dependencies
41+
- dependency
42+
- deps
43+
- build
44+
- ci
45+
- chore
46+
- test

.github/workflows/publish-npm.yml

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
- "*.*.*"
77

88
permissions:
9-
contents: read
9+
contents: write
1010
id-token: write
1111

1212
jobs:
@@ -15,6 +15,8 @@ jobs:
1515
steps:
1616
- name: Checkout repository
1717
uses: actions/checkout@v5
18+
with:
19+
fetch-depth: 0
1820

1921
- name: Setup Node.js
2022
uses: actions/setup-node@v5
@@ -57,3 +59,81 @@ jobs:
5759
- name: Publish package to npm
5860
if: steps.npm_version.outputs.published != 'true'
5961
run: npm publish --access public
62+
63+
- name: Generate release body
64+
id: release_notes
65+
uses: actions/github-script@v8
66+
env:
67+
RELEASE_TAG: ${{ github.ref_name }}
68+
with:
69+
script: |
70+
const tag = process.env.RELEASE_TAG;
71+
const { execFileSync } = require('child_process');
72+
const releaseIntro = execFileSync(
73+
'node',
74+
['scripts/release-notes.js', '--tag', tag],
75+
{
76+
cwd: process.env.GITHUB_WORKSPACE,
77+
encoding: 'utf8'
78+
}
79+
).trim();
80+
81+
const generated = await github.rest.repos.generateReleaseNotes({
82+
owner: context.repo.owner,
83+
repo: context.repo.repo,
84+
tag_name: tag
85+
});
86+
87+
core.setOutput('name', tag);
88+
core.setOutput('body', `${releaseIntro}\n\n${generated.data.body}`);
89+
90+
- name: Create or update GitHub Release
91+
uses: actions/github-script@v8
92+
env:
93+
RELEASE_TAG: ${{ github.ref_name }}
94+
RELEASE_NAME: ${{ steps.release_notes.outputs.name }}
95+
RELEASE_BODY: ${{ steps.release_notes.outputs.body }}
96+
with:
97+
script: |
98+
const tag = process.env.RELEASE_TAG;
99+
const name = process.env.RELEASE_NAME;
100+
const body = process.env.RELEASE_BODY;
101+
const prerelease = tag.includes('-');
102+
const releaseParams = {
103+
owner: context.repo.owner,
104+
repo: context.repo.repo,
105+
tag_name: tag,
106+
name,
107+
body,
108+
draft: false,
109+
prerelease
110+
};
111+
112+
if (!prerelease) {
113+
releaseParams.make_latest = 'legacy';
114+
}
115+
116+
try {
117+
const existing = await github.rest.repos.getReleaseByTag({
118+
owner: context.repo.owner,
119+
repo: context.repo.repo,
120+
tag
121+
});
122+
123+
await github.rest.repos.updateRelease({
124+
owner: context.repo.owner,
125+
repo: context.repo.repo,
126+
release_id: existing.data.id,
127+
name,
128+
body,
129+
draft: false,
130+
prerelease,
131+
...(prerelease ? {} : { make_latest: 'legacy' })
132+
});
133+
} catch (error) {
134+
if (error.status !== 404) {
135+
throw error;
136+
}
137+
138+
await github.rest.repos.createRelease(releaseParams);
139+
}

RELEASING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Normal pushes to `main` do not publish.
1010

1111
Only a pushed version tag such as `0.3.1` triggers a release.
1212

13+
GitHub Releases are created from the same bare version tag. Do not use a `v` prefix.
14+
1315
## Standard Release Flow
1416

1517
1. Bump the package version.
@@ -18,6 +20,7 @@ Only a pushed version tag such as `0.3.1` triggers a release.
1820
4. Push the commit to `main`.
1921
5. Create and push the matching version tag.
2022
6. Let GitHub Actions publish to npm.
23+
7. Let GitHub Actions create or update the matching GitHub Release page.
2124

2225
## Bump Version
2326

@@ -60,6 +63,7 @@ Recommended verification:
6063

6164
```bash
6265
npm run release:smoke
66+
npm run release:notes
6367
```
6468

6569
## Publish
@@ -80,6 +84,10 @@ git push origin 0.3.1
8084

8185
The workflow rejects the release if the Git tag does not exactly match `package.json`.
8286

87+
The workflow also creates the GitHub Release page for that tag, prepends upgrade instructions, and appends generated release notes using [`.github/release.yml`](.github/release.yml).
88+
89+
`npm run release:notes` previews the release intro and Git history locally before you commit or push.
90+
8391
## Required Repository Setup
8492

8593
Configure npm Trusted Publishing for this package and repository before using the workflow.

dist/cli.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/usage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ Queue mode stays explicit:
120120
For a project that is already initialized:
121121

122122
```bash
123-
npm install -g @clawplays/ospec-cli@0.3.6
123+
npm install -g @clawplays/ospec-cli@0.3.7
124124
ospec update [path]
125125
```
126126

docs/usage.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ ospec run step [path]
114114
如果是已经初始化过的项目:
115115

116116
```bash
117-
npm install -g @clawplays/ospec-cli@0.3.6
117+
npm install -g @clawplays/ospec-cli@0.3.7
118118
ospec update [path]
119119
```
120120

package-lock.json

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

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@clawplays/ospec-cli",
3-
"version": "0.3.6",
3+
"version": "0.3.7",
44
"description": "CLI tool for enforcing ospec workflow",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -21,6 +21,7 @@
2121
"index:rebuild": "node dist/tools/build-index.js",
2222
"validate": "node dist/cli/commands/validate.js",
2323
"release:smoke": "node scripts/release-smoke.js",
24+
"release:notes": "node scripts/release-notes.js",
2425
"release:sync-version": "node scripts/sync-version.js",
2526
"release:bump:patch": "npm version patch --no-git-tag-version",
2627
"release:bump:minor": "npm version minor --no-git-tag-version",

scripts/release-notes.js

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const { spawnSync } = require('child_process');
6+
7+
const rootDir = path.resolve(__dirname, '..');
8+
const packageJson = require(path.join(rootDir, 'package.json'));
9+
10+
function parseArgs(argv) {
11+
const args = {};
12+
for (let index = 0; index < argv.length; index += 1) {
13+
const current = argv[index];
14+
if (!current.startsWith('--')) {
15+
continue;
16+
}
17+
18+
const key = current.slice(2);
19+
const next = argv[index + 1];
20+
if (!next || next.startsWith('--')) {
21+
args[key] = true;
22+
continue;
23+
}
24+
25+
args[key] = next;
26+
index += 1;
27+
}
28+
29+
return args;
30+
}
31+
32+
function run(command, args, options = {}) {
33+
const result = spawnSync(command, args, {
34+
cwd: options.cwd || rootDir,
35+
encoding: 'utf8',
36+
shell: false,
37+
});
38+
39+
if (result.status !== 0) {
40+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
41+
throw new Error(`Command failed: ${command} ${args.join(' ')}\n${output}`);
42+
}
43+
44+
return (result.stdout || '').trim();
45+
}
46+
47+
function tryRun(command, args, options = {}) {
48+
const result = spawnSync(command, args, {
49+
cwd: options.cwd || rootDir,
50+
encoding: 'utf8',
51+
shell: false,
52+
});
53+
54+
if (result.status !== 0) {
55+
return null;
56+
}
57+
58+
return (result.stdout || '').trim();
59+
}
60+
61+
function git(args) {
62+
return run('git', args);
63+
}
64+
65+
function tryGit(args) {
66+
return tryRun('git', args);
67+
}
68+
69+
function resolveTag(args) {
70+
return args.tag || process.env.RELEASE_TAG || packageJson.version;
71+
}
72+
73+
function resolvePreviousTag(tag, explicitPreviousTag) {
74+
if (explicitPreviousTag) {
75+
return explicitPreviousTag;
76+
}
77+
78+
const tagRef = tryGit(['rev-parse', '--verify', `refs/tags/${tag}`]);
79+
if (tagRef) {
80+
return tryGit(['describe', '--tags', '--abbrev=0', `${tag}^`]);
81+
}
82+
83+
return tryGit(['describe', '--tags', '--abbrev=0', 'HEAD']);
84+
}
85+
86+
function getCommitLines(previousTag) {
87+
const range = previousTag ? `${previousTag}..HEAD` : 'HEAD';
88+
const raw = git(['log', '--no-merges', '--pretty=format:%h%x09%s', range]);
89+
90+
if (!raw) {
91+
return ['- No non-merge commits found in this release range.'];
92+
}
93+
94+
return raw
95+
.split('\n')
96+
.map(line => line.trim())
97+
.filter(Boolean)
98+
.map(line => {
99+
const [hash, ...subjectParts] = line.split('\t');
100+
const subject = subjectParts.join('\t').trim();
101+
return `- \`${hash}\` ${subject}`;
102+
});
103+
}
104+
105+
function buildReleaseNotes(tag, previousTag, commitLines) {
106+
const body = [
107+
`OSpec CLI release \`${tag}\` is now available on npm as \`${packageJson.name}\`.`,
108+
'',
109+
'## Upgrade',
110+
'',
111+
'```bash',
112+
`npm install -g ${packageJson.name}@${tag}`,
113+
'ospec update',
114+
'```',
115+
'',
116+
'## Notes',
117+
'',
118+
'- Existing OSpec projects should run `ospec update` after upgrading the CLI.',
119+
'- Release tags use bare semantic versions such as `0.3.7`, without a `v` prefix.',
120+
'',
121+
'## Git History',
122+
''
123+
];
124+
125+
if (previousTag) {
126+
body.push(`Range: \`${previousTag}..${tag}\``);
127+
body.push('');
128+
}
129+
130+
body.push(...commitLines);
131+
return `${body.join('\n')}\n`;
132+
}
133+
134+
function writeOutput(outputPath, content) {
135+
fs.writeFileSync(path.resolve(rootDir, outputPath), content, 'utf8');
136+
}
137+
138+
function main() {
139+
const args = parseArgs(process.argv.slice(2));
140+
const tag = resolveTag(args);
141+
const previousTag = resolvePreviousTag(tag, args['previous-tag']);
142+
const commitLines = getCommitLines(previousTag);
143+
const body = buildReleaseNotes(tag, previousTag, commitLines);
144+
145+
if (args.output) {
146+
writeOutput(args.output, body);
147+
return;
148+
}
149+
150+
process.stdout.write(body);
151+
}
152+
153+
main();

0 commit comments

Comments
 (0)