Skip to content

Commit bfdbd22

Browse files
authored
Merge pull request #10 from cardmagic/ci/github-release
ci: create the GitHub release from the changelog
2 parents dfc8578 + d7ae048 commit bfdbd22

4 files changed

Lines changed: 193 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ jobs:
130130
needs: [quality, postgresql, mysql, browser, redis]
131131
runs-on: ubuntu-latest
132132
permissions:
133-
contents: read
133+
contents: write
134134
id-token: write
135135
steps:
136136
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
@@ -164,3 +164,10 @@ jobs:
164164
fi
165165
- if: steps.registry.outputs.exists != 'true'
166166
run: npm publish --access public
167+
- name: Write release notes
168+
run: node scripts/release-notes.mjs "${GITHUB_REF_NAME#v}" > "${RUNNER_TEMP}/release-notes.md"
169+
- name: Create GitHub release
170+
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
171+
with:
172+
name: ${{ github.ref_name }}
173+
body_path: ${{ runner.temp }}/release-notes.md

‎docs/releasing.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ npm trust github solid-objects \
2828

2929
1. Update the version in `package.json` and `src/version.ts`, refresh the
3030
lockfile when needed, and move the release notes out of the Unreleased
31-
section in `CHANGELOG.md`.
31+
section in `CHANGELOG.md` into a dated section for the new version. The
32+
publish job reads that section, so a version without one fails the release.
3233
2. Run `pnpm run format:check`, `pnpm run check`, `pnpm run test:coverage`,
3334
`pnpm run build`, `pnpm run pack:check`, `pnpm run test:package`,
3435
`pnpm run test:recovery`, `pnpm run test:browser`, and
@@ -46,3 +47,8 @@ The tag runs the complete CI matrix. The publish job starts only after every
4647
quality, database, Redis, and browser job succeeds. It rejects tags that do not
4748
match `package.json`, safely skips versions already present in npm, and
4849
publishes new versions with npm provenance.
50+
51+
The job then builds the release notes with `scripts/release-notes.mjs`, which
52+
prints the `CHANGELOG.md` section for the tagged version, and creates the GitHub
53+
release for the tag. Re-running the job on a tag that npm already holds still
54+
creates a missing release.

‎scripts/release-notes.mjs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import fs from "node:fs"
2+
import process from "node:process"
3+
4+
const version = process.argv[2]
5+
6+
if (!version) {
7+
process.stderr.write("usage: node scripts/release-notes.mjs <version>\n")
8+
process.exit(1)
9+
}
10+
11+
const entry = findEntry(fs.readFileSync("CHANGELOG.md", "utf8"), version)
12+
13+
if (!entry) {
14+
process.stderr.write(`CHANGELOG.md has no entry for ${version}\n`)
15+
process.exit(1)
16+
}
17+
18+
const manifest = JSON.parse(fs.readFileSync("package.json", "utf8"))
19+
const repository = manifest.repository.url.replace(/^git\+/, "").replace(/\.git$/, "")
20+
const tag = `v${version}`
21+
const changelogLink = `${repository}/blob/${tag}/CHANGELOG.md#${anchor(entry.heading)}`
22+
const correctnessLink = `${repository}/blob/${tag}/docs/correctness.md`
23+
24+
process.stdout.write(
25+
[
26+
entry.body,
27+
"",
28+
`Install with \`npm install ${manifest.name}@${version}\`.`,
29+
"",
30+
`See the [full changelog](${changelogLink}) and [correctness boundaries](${correctnessLink}).`,
31+
"",
32+
].join("\n"),
33+
)
34+
35+
function findEntry(changelog, releasedVersion) {
36+
const lines = changelog.split("\n")
37+
const start = lines.findIndex((line) => isHeadingFor(line, releasedVersion))
38+
if (start === -1) return undefined
39+
40+
const remainder = lines.slice(start + 1)
41+
const end = remainder.findIndex((line) => line.startsWith("## "))
42+
const body = end === -1 ? remainder : remainder.slice(0, end)
43+
44+
return { heading: heading(lines[start]), body: body.join("\n").trim() }
45+
}
46+
47+
function isHeadingFor(line, releasedVersion) {
48+
if (!line.startsWith("## ")) return false
49+
50+
const text = heading(line)
51+
52+
return text === releasedVersion || text.startsWith(`${releasedVersion} `)
53+
}
54+
55+
function heading(line) {
56+
return line.replace(/^##\s+/, "").trim()
57+
}
58+
59+
function anchor(text) {
60+
return text
61+
.toLowerCase()
62+
.replace(/[^\w\- ]+/g, "")
63+
.replace(/ /g, "-")
64+
}

‎test/release-notes.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { spawnSync } from "node:child_process"
2+
import fs from "node:fs"
3+
import os from "node:os"
4+
import path from "node:path"
5+
import { afterEach, describe, expect, it } from "vitest"
6+
7+
const scriptPath = path.resolve("scripts/release-notes.mjs")
8+
9+
const changelog = `# Changelog
10+
11+
## 0.14.0 - 2026-09-01
12+
13+
- Add the newest thing.
14+
15+
## 0.13.2 - 2026-08-17
16+
17+
- Accept a \`key\` on \`schedule\`.
18+
- Add a process administration query.
19+
20+
## 0.13.0 - 2026-08-16
21+
22+
- Release the oldest thing.
23+
`
24+
25+
let workspace: string | undefined
26+
27+
afterEach(() => {
28+
if (workspace !== undefined) fs.rmSync(workspace, { recursive: true, force: true })
29+
workspace = undefined
30+
})
31+
32+
describe("release notes", () => {
33+
it("writes the changelog entry for the requested version", () => {
34+
const notes = releaseNotes({ version: "0.13.2" })
35+
36+
expect(notes).toContain("- Accept a `key` on `schedule`.")
37+
expect(notes).toContain("- Add a process administration query.")
38+
})
39+
40+
it("leaves the neighbouring entries out", () => {
41+
const notes = releaseNotes({ version: "0.13.2" })
42+
43+
expect(notes).not.toContain("Add the newest thing")
44+
expect(notes).not.toContain("Release the oldest thing")
45+
expect(notes).not.toContain("## 0.13.0")
46+
})
47+
48+
it("reads the oldest entry to the end of the file", () => {
49+
const notes = releaseNotes({ version: "0.13.0" })
50+
51+
expect(notes).toContain("- Release the oldest thing.")
52+
})
53+
54+
it("names the package and the version to install", () => {
55+
const notes = releaseNotes({ version: "0.13.2" })
56+
57+
expect(notes).toContain("npm install solid-objects@0.13.2")
58+
})
59+
60+
it("links the changelog entry and the correctness boundaries at the tag", () => {
61+
const notes = releaseNotes({ version: "0.13.2" })
62+
63+
expect(notes).toContain(
64+
"https://github.com/cardmagic/solid-objects-js/blob/v0.13.2/CHANGELOG.md#0132---2026-08-17",
65+
)
66+
expect(notes).toContain(
67+
"https://github.com/cardmagic/solid-objects-js/blob/v0.13.2/docs/correctness.md",
68+
)
69+
})
70+
71+
it("fails when the changelog has no entry for the version", () => {
72+
const result = runScript({ version: "9.9.9" })
73+
74+
expect(result.status).not.toBe(0)
75+
expect(result.stderr).toContain("9.9.9")
76+
})
77+
78+
it("fails when no version is given", () => {
79+
const result = runScript({ version: undefined })
80+
81+
expect(result.status).not.toBe(0)
82+
})
83+
})
84+
85+
function releaseNotes({ version }: { version: string }) {
86+
const result = runScript({ version })
87+
if (result.status !== 0) throw new Error(`the script failed: ${result.stderr}`)
88+
89+
return result.stdout
90+
}
91+
92+
function runScript({ version }: { version: string | undefined }) {
93+
const directory = buildWorkspace()
94+
95+
return spawnSync(process.execPath, version === undefined ? [scriptPath] : [scriptPath, version], {
96+
cwd: directory,
97+
encoding: "utf8",
98+
})
99+
}
100+
101+
function buildWorkspace() {
102+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-notes-"))
103+
fs.writeFileSync(path.join(directory, "CHANGELOG.md"), changelog)
104+
fs.writeFileSync(
105+
path.join(directory, "package.json"),
106+
JSON.stringify({
107+
name: "solid-objects",
108+
repository: { url: "git+https://github.com/cardmagic/solid-objects-js.git" },
109+
}),
110+
)
111+
workspace = directory
112+
113+
return directory
114+
}

0 commit comments

Comments
 (0)