Skip to content

Commit ff5fa35

Browse files
committed
Improve release process
1 parent 21a034f commit ff5fa35

6 files changed

Lines changed: 389 additions & 77 deletions

File tree

.github/workflows/publish.yml

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,55 +12,51 @@ on:
1212
- patch
1313
- minor
1414
- major
15+
preview:
16+
description: "Preview mode (skip release and publish)"
17+
required: false
18+
type: boolean
19+
default: false
1520

1621
jobs:
17-
publish:
22+
release:
1823
runs-on: ubuntu-latest
1924
permissions:
2025
contents: write
21-
id-token: write
2226
outputs:
23-
version: ${{ steps.publish.outputs.version }}
24-
tag: ${{ steps.publish.outputs.tag }}
25-
commit_hash: ${{ steps.save_hash.outputs.hash }}
27+
version: ${{ steps.release.outputs.version }}
28+
tag: ${{ steps.release.outputs.tag }}
2629
steps:
2730
- uses: actions/checkout@v4
2831
with:
2932
fetch-depth: 0
33+
token: ${{ secrets.GH_PAT }}
3034

3135
- uses: ./.github/actions/setup
3236

33-
- name: Publish to NPM
34-
id: publish
35-
run: bun run scripts/publish.ts
37+
- name: Release
38+
id: release
39+
run: bun run scripts/release.ts
3640
env:
3741
BUMP: ${{ inputs.bump }}
38-
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
39-
40-
- name: Commit version bump
41-
id: commit
42-
uses: stefanzweifel/git-auto-commit-action@v5
43-
with:
44-
commit_message: "release: v${{ needs.publish.outputs.version }}"
45-
file_pattern: "package.json"
46-
token: ${{ secrets.GH_PAT }}
47-
48-
- name: Save Commit Hash
49-
id: save_hash
50-
run: echo "hash=${{ steps.commit.outputs.commit_hash || github.sha }}" >> $GITHUB_OUTPUT
42+
PREVIEW: ${{ inputs.preview }}
43+
GH_TOKEN: ${{ secrets.GH_PAT }}
5144

52-
release:
53-
needs: publish
45+
publish:
46+
needs: release
47+
if: ${{ !inputs.preview }}
5448
runs-on: ubuntu-latest
5549
permissions:
56-
contents: write
50+
contents: read
51+
id-token: write
5752
steps:
5853
- uses: actions/checkout@v4
59-
60-
- name: Create GitHub release
61-
uses: softprops/action-gh-release@v2
6254
with:
63-
tag_name: ${{ needs.publish.outputs.tag }}
64-
name: ${{ needs.publish.outputs.tag }}
65-
generate_release_notes: true
66-
target_commitish: ${{ needs.publish.outputs.commit_hash }}
55+
ref: ${{ needs.release.outputs.tag }}
56+
57+
- uses: ./.github/actions/setup
58+
59+
- name: Publish to NPM
60+
run: bun run scripts/publish.ts
61+
env:
62+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

bun.lock

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
},
5959
"devDependencies": {
6060
"@biomejs/biome": "^2.3.11",
61+
"@opencode-ai/sdk": "^1.1.41",
6162
"@types/bun": "latest",
6263
"@types/node": "^20.0.0",
6364
"tsup": "^8.5.1",

scripts/changelog.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/usr/bin/env bun
2+
3+
import { parseArgs } from "node:util";
4+
import { createOpencode } from "@opencode-ai/sdk";
5+
import { $ } from "bun";
6+
7+
export const team = ["donnes", "actions-user"];
8+
9+
export async function getLatestRelease() {
10+
return fetch("https://api.github.com/repos/donnes/syncode/releases/latest")
11+
.then((res) => {
12+
if (!res.ok) throw new Error(res.statusText);
13+
return res.json();
14+
})
15+
.then((data: { tag_name: string }) => data.tag_name.replace(/^v/, ""));
16+
}
17+
18+
type Commit = {
19+
hash: string;
20+
author: string | null;
21+
message: string;
22+
};
23+
24+
export async function getCommits(from: string, to: string): Promise<Commit[]> {
25+
const fromRef = from.startsWith("v") ? from : `v${from}`;
26+
const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`;
27+
28+
// Get commit data with GitHub usernames from the API
29+
const compare =
30+
await $`gh api "/repos/donnes/syncode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text();
31+
32+
const commitData = new Map<
33+
string,
34+
{ login: string | null; message: string }
35+
>();
36+
for (const line of compare.split("\n").filter(Boolean)) {
37+
const data = JSON.parse(line) as {
38+
sha: string;
39+
login: string | null;
40+
message: string;
41+
};
42+
commitData.set(data.sha, {
43+
login: data.login,
44+
message: data.message.split("\n")[0] ?? "",
45+
});
46+
}
47+
48+
// Get commits from the range
49+
const log =
50+
await $`git log ${fromRef}..${toRef} --oneline --format="%H"`.text();
51+
const hashes = log.split("\n").filter(Boolean);
52+
53+
const commits: Commit[] = [];
54+
for (const hash of hashes) {
55+
const data = commitData.get(hash);
56+
if (!data) continue;
57+
58+
const message = data.message;
59+
if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue;
60+
61+
commits.push({
62+
hash: hash.slice(0, 7),
63+
author: data.login,
64+
message,
65+
});
66+
}
67+
68+
return filterRevertedCommits(commits);
69+
}
70+
71+
function filterRevertedCommits(commits: Commit[]): Commit[] {
72+
const revertPattern = /^Revert "(.+)"$/;
73+
const seen = new Map<string, Commit>();
74+
75+
for (const commit of commits) {
76+
const match = commit.message.match(revertPattern);
77+
if (match) {
78+
const original = match[1]!;
79+
if (seen.has(original)) seen.delete(original);
80+
else seen.set(commit.message, commit);
81+
} else {
82+
const revertMsg = `Revert "${commit.message}"`;
83+
if (seen.has(revertMsg)) seen.delete(revertMsg);
84+
else seen.set(commit.message, commit);
85+
}
86+
}
87+
88+
return [...seen.values()];
89+
}
90+
91+
async function summarizeCommit(
92+
opencode: Awaited<ReturnType<typeof createOpencode>>,
93+
message: string,
94+
): Promise<string> {
95+
console.log("summarizing commit:", message);
96+
const session = await opencode.client.session.create();
97+
const result = await opencode.client.session
98+
.prompt({
99+
path: { id: session.data!.id },
100+
body: {
101+
model: { providerID: "opencode", modelID: "claude-sonnet-4-5" },
102+
tools: {
103+
"*": false,
104+
},
105+
parts: [
106+
{
107+
type: "text",
108+
text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:".
109+
110+
Commit: ${message}`,
111+
},
112+
],
113+
},
114+
signal: AbortSignal.timeout(120_000),
115+
})
116+
.then(
117+
(x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message,
118+
);
119+
return result.trim();
120+
}
121+
122+
export async function generateChangelog(
123+
commits: Commit[],
124+
opencode: Awaited<ReturnType<typeof createOpencode>>,
125+
) {
126+
// Summarize commits in parallel with max 10 concurrent requests
127+
const BATCH_SIZE = 10;
128+
const summaries: string[] = [];
129+
for (let i = 0; i < commits.length; i += BATCH_SIZE) {
130+
const batch = commits.slice(i, i + BATCH_SIZE);
131+
const results = await Promise.all(
132+
batch.map((c) => summarizeCommit(opencode, c.message)),
133+
);
134+
summaries.push(...results);
135+
}
136+
137+
const lines: string[] = [];
138+
lines.push("## Changes");
139+
140+
for (let i = 0; i < commits.length; i++) {
141+
const commit = commits[i]!;
142+
const attribution =
143+
commit.author && !team.includes(commit.author)
144+
? ` (@${commit.author})`
145+
: "";
146+
lines.push(`- ${summaries[i]}${attribution}`);
147+
}
148+
149+
return lines;
150+
}
151+
152+
export async function getContributors(from: string, to: string) {
153+
const fromRef = from.startsWith("v") ? from : `v${from}`;
154+
const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}`;
155+
const compare =
156+
await $`gh api "/repos/donnes/syncode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text();
157+
const contributors = new Map<string, Set<string>>();
158+
159+
for (const line of compare.split("\n").filter(Boolean)) {
160+
const { login, message } = JSON.parse(line) as {
161+
login: string | null;
162+
message: string;
163+
};
164+
const title = message.split("\n")[0] ?? "";
165+
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue;
166+
167+
if (login && !team.includes(login)) {
168+
if (!contributors.has(login)) contributors.set(login, new Set());
169+
contributors.get(login)!.add(title);
170+
}
171+
}
172+
173+
return contributors;
174+
}
175+
176+
export async function buildNotes(from: string, to: string) {
177+
const commits = await getCommits(from, to);
178+
179+
if (commits.length === 0) {
180+
return [];
181+
}
182+
183+
console.log("generating changelog since " + from);
184+
185+
const opencode = await createOpencode({ port: 5044 });
186+
const notes: string[] = [];
187+
188+
try {
189+
const lines = await generateChangelog(commits, opencode);
190+
notes.push(...lines);
191+
console.log("---- Generated Changelog ----");
192+
console.log(notes.join("\n"));
193+
console.log("-----------------------------");
194+
} catch (error) {
195+
if (error instanceof Error && error.name === "TimeoutError") {
196+
console.log("Changelog generation timed out, using raw commits");
197+
for (const commit of commits) {
198+
const attribution =
199+
commit.author && !team.includes(commit.author)
200+
? ` (@${commit.author})`
201+
: "";
202+
notes.push(`- ${commit.message}${attribution}`);
203+
}
204+
} else {
205+
throw error;
206+
}
207+
} finally {
208+
opencode.server.close();
209+
}
210+
211+
const contributors = await getContributors(from, to);
212+
213+
if (contributors.size > 0) {
214+
notes.push("");
215+
notes.push(
216+
`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`,
217+
);
218+
for (const [username, userCommits] of contributors) {
219+
notes.push(`- @${username}:`);
220+
for (const c of userCommits) {
221+
notes.push(` - ${c}`);
222+
}
223+
}
224+
}
225+
226+
return notes;
227+
}
228+
229+
// CLI entrypoint
230+
if (import.meta.main) {
231+
const { values } = parseArgs({
232+
args: Bun.argv.slice(2),
233+
options: {
234+
from: { type: "string", short: "f" },
235+
to: { type: "string", short: "t", default: "HEAD" },
236+
help: { type: "boolean", short: "h", default: false },
237+
},
238+
});
239+
240+
if (values.help) {
241+
console.log(`
242+
Usage: bun scripts/changelog.ts [options]
243+
244+
Options:
245+
-f, --from <version> Starting version (default: latest GitHub release)
246+
-t, --to <ref> Ending ref (default: HEAD)
247+
-h, --help Show this help message
248+
249+
Examples:
250+
bun scripts/changelog.ts # Latest release to HEAD
251+
bun scripts/changelog.ts --from 1.0.0 # v1.0.0 to HEAD
252+
bun scripts/changelog.ts -f 1.0.0 -t 1.1.0
253+
`);
254+
process.exit(0);
255+
}
256+
257+
const to = values.to!;
258+
const from = values.from ?? (await getLatestRelease());
259+
260+
console.log(`Generating changelog: v${from} -> ${to}\n`);
261+
262+
const notes = await buildNotes(from, to);
263+
console.log("\n=== Final Notes ===");
264+
console.log(notes.join("\n"));
265+
}

0 commit comments

Comments
 (0)