Skip to content

Commit c69bc2e

Browse files
👷 Automate release process (#170)
1 parent 46c908d commit c69bc2e

8 files changed

Lines changed: 555 additions & 8 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ jobs:
2222
- run: bun ci
2323
- run: bun run lint
2424
- run: bun run typecheck
25+
- run: bun run test:scripts
2526
- run: bun run compile
2627
- run: xvfb-run -a bun run test:coverage
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Create Draft Release
2+
3+
on:
4+
pull_request:
5+
types:
6+
- closed
7+
8+
permissions: {}
9+
10+
jobs:
11+
create-draft-release:
12+
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 5
15+
permissions:
16+
contents: write
17+
steps:
18+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
19+
with:
20+
ref: ${{ github.event.repository.default_branch }}
21+
persist-credentials: true
22+
- name: Extract release details
23+
id: release-details
24+
run: |
25+
set -euo pipefail
26+
version="$(node scripts/prepare-release.mjs current-version)"
27+
echo "version=$version" >> "$GITHUB_OUTPUT"
28+
node scripts/prepare-release.mjs release-notes > draft-release-notes.md
29+
- name: Create draft release
30+
env:
31+
GH_TOKEN: ${{ github.token }}
32+
VERSION: ${{ steps.release-details.outputs.version }}
33+
run: |
34+
set -euo pipefail
35+
gh release create "$VERSION" \
36+
--draft \
37+
--title "$VERSION" \
38+
--notes-file draft-release-notes.md \
39+
--target "$(git rev-parse HEAD)"

‎.github/workflows/labeler.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,5 @@ jobs:
3131
steps:
3232
- uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
3333
with:
34-
one_of: breaking,security,feature,bug,refactor,upgrade,docs,internal
34+
one_of: breaking,security,feature,bug,refactor,upgrade,docs,internal,release
3535
repo_token: ${{ secrets.GITHUB_TOKEN }}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Prepare Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump:
7+
description: Release bump
8+
required: true
9+
type: choice
10+
options:
11+
- patch
12+
- minor
13+
- major
14+
date:
15+
description: Release date in YYYY-MM-DD format. Defaults to today.
16+
required: false
17+
type: string
18+
19+
permissions: {}
20+
21+
jobs:
22+
prepare-release:
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 5
25+
permissions:
26+
contents: write
27+
issues: write
28+
pull-requests: write
29+
steps:
30+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
31+
with:
32+
token: ${{ secrets.FASTAPI_VSCODE_LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env]
33+
persist-credentials: true
34+
- name: Prepare release
35+
env:
36+
BUMP: ${{ inputs.bump }}
37+
DATE: ${{ inputs.date }}
38+
run: node scripts/prepare-release.mjs prepare "$BUMP" "$DATE"
39+
- name: Get release version
40+
id: release-version
41+
run: |
42+
set -euo pipefail
43+
version="$(node scripts/prepare-release.mjs current-version)"
44+
echo "version=$version" >> "$GITHUB_OUTPUT"
45+
- name: Create release pull request
46+
env:
47+
GH_TOKEN: ${{ secrets.FASTAPI_VSCODE_LATEST_CHANGES }}
48+
VERSION: ${{ steps.release-version.outputs.version }}
49+
run: |
50+
set -euo pipefail
51+
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
52+
git config user.name "github-actions[bot]"
53+
git config user.email "github-actions[bot]@users.noreply.github.com"
54+
git switch -c "$branch"
55+
git add package.json CHANGELOG.md
56+
git commit -m "🔖 Release version ${VERSION}"
57+
git push --set-upstream origin "$branch"
58+
gh pr create \
59+
--base main \
60+
--head "$branch" \
61+
--title "🔖 Release version ${VERSION}" \
62+
--body "Prepare release ${VERSION}." \
63+
--label release

‎CHANGELOG.md‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@
22

33
## Latest Changes
44

5-
### Internal
6-
7-
* 🔖 Release version 0.2.2. PR [#169](https://github.com/fastapi/fastapi-vscode/pull/169) by [@savannahostrowski](https://github.com/savannahostrowski).
8-
95
## 0.2.2
106

117
### Fixes

‎package.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,9 @@
334334
"watch": "bun run esbuild.js --watch",
335335
"package": "vsce package",
336336
"publish:marketplace": "vsce publish",
337-
"lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/",
338-
"test": "bun run compile && vscode-test",
337+
"lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/ scripts/",
338+
"test": "bun run test:scripts && bun run compile && vscode-test",
339+
"test:scripts": "node --test scripts/*.test.mjs",
339340
"test:coverage": "bash scripts/test-coverage.sh",
340341
"test:web": "bun run compile && bunx @vscode/test-web --extensionDevelopmentPath=. --browserType=none",
341342
"typecheck": "tsc --noEmit",
@@ -366,7 +367,7 @@
366367
"web-tree-sitter": "^0.26.3"
367368
},
368369
"lint-staged": {
369-
"**/*.{ts,js,json}": [
370+
"**/*.{ts,js,mjs,json}": [
370371
"biome check --write"
371372
]
372373
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* Prepare a release by bumping the version in package.json and rolling CHANGELOG.md.
3+
*/
4+
5+
import { readFileSync, writeFileSync } from "node:fs"
6+
import { dirname, join } from "node:path"
7+
import { fileURLToPath, pathToFileURL } from "node:url"
8+
9+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
10+
const VERSION_FILE =
11+
process.env.PREPARE_RELEASE_VERSION_FILE ?? join(ROOT, "package.json")
12+
const CHANGELOG_FILE =
13+
process.env.PREPARE_RELEASE_RELEASE_NOTES_FILE ?? join(ROOT, "CHANGELOG.md")
14+
15+
const RELEASE_NOTES_HEADER = "# Release Notes\n\n"
16+
const LATEST_CHANGES_HEADER = "## Latest Changes"
17+
18+
// Matches the single top-level `"version": "X.Y.Z"` in package.json.
19+
const VERSION_PATTERN =
20+
/^(?<indent>\s*)"version":\s*"(?<version>\d+\.\d+\.\d+)"/m
21+
// Matches any version section heading, with or without a date suffix,
22+
// e.g. `## 0.2.2` or `## 0.2.2 (2026-06-16)`.
23+
const VERSION_HEADING_PATTERN = /^## \d+\.\d+\.\d+(?: \([^)]+\))?\s*$/m
24+
25+
function parseVersion(version) {
26+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
27+
throw new Error(`Invalid version: '${version}'. Expected format: X.Y.Z`)
28+
}
29+
return version.split(".").map(Number)
30+
}
31+
32+
function bumpVersion(version, bump) {
33+
const [major, minor, patch] = parseVersion(version)
34+
if (bump === "major") return `${major + 1}.0.0`
35+
if (bump === "minor") return `${major}.${minor + 1}.0`
36+
if (bump === "patch") return `${major}.${minor}.${patch + 1}`
37+
throw new Error(`Invalid bump: '${bump}'. Expected major, minor, or patch.`)
38+
}
39+
40+
function getCurrentVersion(content) {
41+
const matches = [...content.matchAll(new RegExp(VERSION_PATTERN, "gm"))]
42+
if (matches.length !== 1) {
43+
throw new Error(
44+
`Expected exactly one "version" assignment in package.json, found ${matches.length}`,
45+
)
46+
}
47+
return matches[0].groups.version
48+
}
49+
50+
function updateVersionFile(content, version) {
51+
const current = getCurrentVersion(content)
52+
if (compareVersions(parseVersion(version), parseVersion(current)) <= 0) {
53+
throw new Error(
54+
`New version ${version} must be greater than current version ${current}`,
55+
)
56+
}
57+
return content.replace(VERSION_PATTERN, `$<indent>"version": "${version}"`)
58+
}
59+
60+
function compareVersions(a, b) {
61+
for (let i = 0; i < 3; i++) {
62+
if (a[i] !== b[i]) return a[i] - b[i]
63+
}
64+
return 0
65+
}
66+
67+
function updateChangelog(content, version, date) {
68+
if (!content.startsWith(RELEASE_NOTES_HEADER)) {
69+
throw new Error(
70+
`CHANGELOG.md must start with '${RELEASE_NOTES_HEADER.trim()}'`,
71+
)
72+
}
73+
if (versionHeadingRegex(version).test(content)) {
74+
throw new Error(`CHANGELOG.md already contains a section for ${version}`)
75+
}
76+
77+
const latestHeader = `${RELEASE_NOTES_HEADER}${LATEST_CHANGES_HEADER}\n`
78+
if (!content.startsWith(latestHeader)) {
79+
throw new Error(`CHANGELOG.md must start with '${latestHeader.trim()}'`)
80+
}
81+
82+
return content.replace(
83+
latestHeader,
84+
`${RELEASE_NOTES_HEADER}${LATEST_CHANGES_HEADER}\n\n## ${version} (${date})\n`,
85+
)
86+
}
87+
88+
function getReleaseNotesBody(content, version) {
89+
const match = versionHeadingRegex(version).exec(content)
90+
if (!match) {
91+
throw new Error(`Could not find CHANGELOG section for ${version}`)
92+
}
93+
94+
const rest = content.slice(match.index + match[0].length)
95+
const next = VERSION_HEADING_PATTERN.exec(rest)
96+
const body = (next ? rest.slice(0, next.index) : rest).trim()
97+
if (!body) {
98+
throw new Error(`CHANGELOG section for ${version} is empty`)
99+
}
100+
return `${body}\n`
101+
}
102+
103+
function versionHeadingRegex(version) {
104+
return new RegExp(`^## ${escapeRegExp(version)}(?: \\([^)]+\\))?\\s*$`, "m")
105+
}
106+
107+
function escapeRegExp(value) {
108+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
109+
}
110+
111+
/** Validates a YYYY-MM-DD date, or returns today (UTC) when empty. */
112+
function resolveDate(input) {
113+
if (!input) return new Date().toISOString().slice(0, 10)
114+
const parsed = new Date(`${input}T00:00:00Z`)
115+
if (
116+
Number.isNaN(parsed.getTime()) ||
117+
parsed.toISOString().slice(0, 10) !== input
118+
) {
119+
throw new Error(`Invalid date: '${input}'. Expected format: YYYY-MM-DD`)
120+
}
121+
return input
122+
}
123+
124+
function commandPrepare(bump, dateArg) {
125+
if (!bump) throw new Error("Usage: prepare <patch|minor|major> [YYYY-MM-DD]")
126+
const date = resolveDate(dateArg)
127+
const pkg = readFileSync(VERSION_FILE, "utf8")
128+
const changelog = readFileSync(CHANGELOG_FILE, "utf8")
129+
const version = bumpVersion(getCurrentVersion(pkg), bump)
130+
131+
writeFileSync(VERSION_FILE, updateVersionFile(pkg, version))
132+
writeFileSync(CHANGELOG_FILE, updateChangelog(changelog, version, date))
133+
process.stdout.write(`Prepared release ${version} (${date})\n`)
134+
}
135+
136+
function commandCurrentVersion() {
137+
process.stdout.write(
138+
`${getCurrentVersion(readFileSync(VERSION_FILE, "utf8"))}\n`,
139+
)
140+
}
141+
142+
function commandReleaseNotes() {
143+
const version = getCurrentVersion(readFileSync(VERSION_FILE, "utf8"))
144+
process.stdout.write(
145+
getReleaseNotesBody(readFileSync(CHANGELOG_FILE, "utf8"), version),
146+
)
147+
}
148+
149+
function main(argv) {
150+
const [command, arg, arg2] = argv
151+
try {
152+
if (command === "prepare") commandPrepare(arg, arg2)
153+
else if (command === "current-version") commandCurrentVersion()
154+
else if (command === "release-notes") commandReleaseNotes()
155+
else {
156+
process.stderr.write(
157+
"Usage: prepare-release.mjs <prepare <bump>|current-version|release-notes>\n",
158+
)
159+
process.exit(2)
160+
}
161+
} catch (error) {
162+
process.stderr.write(`${error.message}\n`)
163+
process.exit(1)
164+
}
165+
}
166+
167+
// Run as a CLI only when executed directly, so tests can import the pure
168+
// functions below without triggering file writes.
169+
if (
170+
process.argv[1] &&
171+
import.meta.url === pathToFileURL(process.argv[1]).href
172+
) {
173+
main(process.argv.slice(2))
174+
}
175+
176+
export {
177+
parseVersion,
178+
bumpVersion,
179+
getCurrentVersion,
180+
updateVersionFile,
181+
updateChangelog,
182+
getReleaseNotesBody,
183+
resolveDate,
184+
}

0 commit comments

Comments
 (0)