diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f4f4e1..897e774 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,5 +22,6 @@ jobs: - run: bun ci - run: bun run lint - run: bun run typecheck + - run: bun run test:scripts - run: bun run compile - run: xvfb-run -a bun run test:coverage diff --git a/.github/workflows/create-draft-release.yml b/.github/workflows/create-draft-release.yml new file mode 100644 index 0000000..db23d1f --- /dev/null +++ b/.github/workflows/create-draft-release.yml @@ -0,0 +1,39 @@ +name: Create Draft Release + +on: + pull_request: + types: + - closed + +permissions: {} + +jobs: + create-draft-release: + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: true + - name: Extract release details + id: release-details + run: | + set -euo pipefail + version="$(node scripts/prepare-release.mjs current-version)" + echo "version=$version" >> "$GITHUB_OUTPUT" + node scripts/prepare-release.mjs release-notes > draft-release-notes.md + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release-details.outputs.version }} + run: | + set -euo pipefail + gh release create "$VERSION" \ + --draft \ + --title "$VERSION" \ + --notes-file draft-release-notes.md \ + --target "$(git rev-parse HEAD)" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 30408da..6399c09 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -31,5 +31,5 @@ jobs: steps: - uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65 with: - one_of: breaking,security,feature,bug,refactor,upgrade,docs,internal + one_of: breaking,security,feature,bug,refactor,upgrade,docs,internal,release repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..bf5f355 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,63 @@ +name: Prepare Release + +on: + workflow_dispatch: + inputs: + bump: + description: Release bump + required: true + type: choice + options: + - patch + - minor + - major + date: + description: Release date in YYYY-MM-DD format. Defaults to today. + required: false + type: string + +permissions: {} + +jobs: + prepare-release: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + token: ${{ secrets.FASTAPI_VSCODE_LATEST_CHANGES }} # zizmor: ignore[secrets-outside-env] + persist-credentials: true + - name: Prepare release + env: + BUMP: ${{ inputs.bump }} + DATE: ${{ inputs.date }} + run: node scripts/prepare-release.mjs prepare "$BUMP" "$DATE" + - name: Get release version + id: release-version + run: | + set -euo pipefail + version="$(node scripts/prepare-release.mjs current-version)" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Create release pull request + env: + GH_TOKEN: ${{ secrets.FASTAPI_VSCODE_LATEST_CHANGES }} + VERSION: ${{ steps.release-version.outputs.version }} + run: | + set -euo pipefail + branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git add package.json CHANGELOG.md + git commit -m "🔖 Release version ${VERSION}" + git push --set-upstream origin "$branch" + gh pr create \ + --base main \ + --head "$branch" \ + --title "🔖 Release version ${VERSION}" \ + --body "Prepare release ${VERSION}." \ + --label release diff --git a/CHANGELOG.md b/CHANGELOG.md index 8795296..151eb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,6 @@ ## Latest Changes -### Internal - -* 🔖 Release version 0.2.2. PR [#169](https://github.com/fastapi/fastapi-vscode/pull/169) by [@savannahostrowski](https://github.com/savannahostrowski). - ## 0.2.2 ### Fixes diff --git a/package.json b/package.json index 458a5fd..256c5c6 100644 --- a/package.json +++ b/package.json @@ -334,8 +334,9 @@ "watch": "bun run esbuild.js --watch", "package": "vsce package", "publish:marketplace": "vsce publish", - "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/", - "test": "bun run compile && vscode-test", + "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/ scripts/", + "test": "bun run test:scripts && bun run compile && vscode-test", + "test:scripts": "node --test scripts/*.test.mjs", "test:coverage": "bash scripts/test-coverage.sh", "test:web": "bun run compile && bunx @vscode/test-web --extensionDevelopmentPath=. --browserType=none", "typecheck": "tsc --noEmit", @@ -366,7 +367,7 @@ "web-tree-sitter": "^0.26.3" }, "lint-staged": { - "**/*.{ts,js,json}": [ + "**/*.{ts,js,mjs,json}": [ "biome check --write" ] } diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..18e70d1 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,184 @@ +/** + * Prepare a release by bumping the version in package.json and rolling CHANGELOG.md. + */ + +import { readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +const VERSION_FILE = + process.env.PREPARE_RELEASE_VERSION_FILE ?? join(ROOT, "package.json") +const CHANGELOG_FILE = + process.env.PREPARE_RELEASE_RELEASE_NOTES_FILE ?? join(ROOT, "CHANGELOG.md") + +const RELEASE_NOTES_HEADER = "# Release Notes\n\n" +const LATEST_CHANGES_HEADER = "## Latest Changes" + +// Matches the single top-level `"version": "X.Y.Z"` in package.json. +const VERSION_PATTERN = + /^(?\s*)"version":\s*"(?\d+\.\d+\.\d+)"/m +// Matches any version section heading, with or without a date suffix, +// e.g. `## 0.2.2` or `## 0.2.2 (2026-06-16)`. +const VERSION_HEADING_PATTERN = /^## \d+\.\d+\.\d+(?: \([^)]+\))?\s*$/m + +function parseVersion(version) { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid version: '${version}'. Expected format: X.Y.Z`) + } + return version.split(".").map(Number) +} + +function bumpVersion(version, bump) { + const [major, minor, patch] = parseVersion(version) + if (bump === "major") return `${major + 1}.0.0` + if (bump === "minor") return `${major}.${minor + 1}.0` + if (bump === "patch") return `${major}.${minor}.${patch + 1}` + throw new Error(`Invalid bump: '${bump}'. Expected major, minor, or patch.`) +} + +function getCurrentVersion(content) { + const matches = [...content.matchAll(new RegExp(VERSION_PATTERN, "gm"))] + if (matches.length !== 1) { + throw new Error( + `Expected exactly one "version" assignment in package.json, found ${matches.length}`, + ) + } + return matches[0].groups.version +} + +function updateVersionFile(content, version) { + const current = getCurrentVersion(content) + if (compareVersions(parseVersion(version), parseVersion(current)) <= 0) { + throw new Error( + `New version ${version} must be greater than current version ${current}`, + ) + } + return content.replace(VERSION_PATTERN, `$"version": "${version}"`) +} + +function compareVersions(a, b) { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i] + } + return 0 +} + +function updateChangelog(content, version, date) { + if (!content.startsWith(RELEASE_NOTES_HEADER)) { + throw new Error( + `CHANGELOG.md must start with '${RELEASE_NOTES_HEADER.trim()}'`, + ) + } + if (versionHeadingRegex(version).test(content)) { + throw new Error(`CHANGELOG.md already contains a section for ${version}`) + } + + const latestHeader = `${RELEASE_NOTES_HEADER}${LATEST_CHANGES_HEADER}\n` + if (!content.startsWith(latestHeader)) { + throw new Error(`CHANGELOG.md must start with '${latestHeader.trim()}'`) + } + + return content.replace( + latestHeader, + `${RELEASE_NOTES_HEADER}${LATEST_CHANGES_HEADER}\n\n## ${version} (${date})\n`, + ) +} + +function getReleaseNotesBody(content, version) { + const match = versionHeadingRegex(version).exec(content) + if (!match) { + throw new Error(`Could not find CHANGELOG section for ${version}`) + } + + const rest = content.slice(match.index + match[0].length) + const next = VERSION_HEADING_PATTERN.exec(rest) + const body = (next ? rest.slice(0, next.index) : rest).trim() + if (!body) { + throw new Error(`CHANGELOG section for ${version} is empty`) + } + return `${body}\n` +} + +function versionHeadingRegex(version) { + return new RegExp(`^## ${escapeRegExp(version)}(?: \\([^)]+\\))?\\s*$`, "m") +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** Validates a YYYY-MM-DD date, or returns today (UTC) when empty. */ +function resolveDate(input) { + if (!input) return new Date().toISOString().slice(0, 10) + const parsed = new Date(`${input}T00:00:00Z`) + if ( + Number.isNaN(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== input + ) { + throw new Error(`Invalid date: '${input}'. Expected format: YYYY-MM-DD`) + } + return input +} + +function commandPrepare(bump, dateArg) { + if (!bump) throw new Error("Usage: prepare [YYYY-MM-DD]") + const date = resolveDate(dateArg) + const pkg = readFileSync(VERSION_FILE, "utf8") + const changelog = readFileSync(CHANGELOG_FILE, "utf8") + const version = bumpVersion(getCurrentVersion(pkg), bump) + + writeFileSync(VERSION_FILE, updateVersionFile(pkg, version)) + writeFileSync(CHANGELOG_FILE, updateChangelog(changelog, version, date)) + process.stdout.write(`Prepared release ${version} (${date})\n`) +} + +function commandCurrentVersion() { + process.stdout.write( + `${getCurrentVersion(readFileSync(VERSION_FILE, "utf8"))}\n`, + ) +} + +function commandReleaseNotes() { + const version = getCurrentVersion(readFileSync(VERSION_FILE, "utf8")) + process.stdout.write( + getReleaseNotesBody(readFileSync(CHANGELOG_FILE, "utf8"), version), + ) +} + +function main(argv) { + const [command, arg, arg2] = argv + try { + if (command === "prepare") commandPrepare(arg, arg2) + else if (command === "current-version") commandCurrentVersion() + else if (command === "release-notes") commandReleaseNotes() + else { + process.stderr.write( + "Usage: prepare-release.mjs |current-version|release-notes>\n", + ) + process.exit(2) + } + } catch (error) { + process.stderr.write(`${error.message}\n`) + process.exit(1) + } +} + +// Run as a CLI only when executed directly, so tests can import the pure +// functions below without triggering file writes. +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main(process.argv.slice(2)) +} + +export { + parseVersion, + bumpVersion, + getCurrentVersion, + updateVersionFile, + updateChangelog, + getReleaseNotesBody, + resolveDate, +} diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs new file mode 100644 index 0000000..2975afc --- /dev/null +++ b/scripts/prepare-release.test.mjs @@ -0,0 +1,263 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { test } from "node:test" +import { fileURLToPath } from "node:url" +import { + bumpVersion, + getCurrentVersion, + getReleaseNotesBody, + parseVersion, + resolveDate, + updateChangelog, + updateVersionFile, +} from "./prepare-release.mjs" + +const SCRIPT = fileURLToPath(new URL("./prepare-release.mjs", import.meta.url)) + +// Runs the CLI in a throwaway dir with the version/changelog files pointed at +// temp copies, so nothing touches the real repo files. Returns { stdout }. +function withCli(versionContent, changelogContent, run) { + const dir = mkdtempSync(join(tmpdir(), "prepare-release-")) + try { + const versionFile = join(dir, "package.json") + const changelogFile = join(dir, "CHANGELOG.md") + if (versionContent !== null) writeFileSync(versionFile, versionContent) + if (changelogContent !== null) + writeFileSync(changelogFile, changelogContent) + const stdout = (args) => + execFileSync("node", [SCRIPT, ...args], { + encoding: "utf8", + env: { + ...process.env, + PREPARE_RELEASE_VERSION_FILE: versionFile, + PREPARE_RELEASE_RELEASE_NOTES_FILE: changelogFile, + }, + }) + return run({ versionFile, changelogFile, stdout }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const PACKAGE_JSON = `{ + "name": "fastapi", + "version": "0.2.2", + "publisher": "FastAPILabs" +} +` + +const CHANGELOG = `# Release Notes + +## Latest Changes + +### Fixes + +* 🐛 Fix a thing. PR [#1](https://example.com/1). + +## 0.2.1 + +### Docs + +* 📝 Document a thing. PR [#2](https://example.com/2). +` + +test("parseVersion accepts X.Y.Z and rejects anything else", () => { + assert.deepEqual(parseVersion("1.2.3"), [1, 2, 3]) + assert.throws(() => parseVersion("1.2"), /Invalid version/) + assert.throws(() => parseVersion("1.2.3-rc1"), /Invalid version/) + assert.throws(() => parseVersion("v1.2.3"), /Invalid version/) +}) + +test("bumpVersion increments the right component and resets lower ones", () => { + assert.equal(bumpVersion("0.2.2", "patch"), "0.2.3") + assert.equal(bumpVersion("0.2.2", "minor"), "0.3.0") + assert.equal(bumpVersion("0.2.2", "major"), "1.0.0") + assert.throws(() => bumpVersion("0.2.2", "huge"), /Invalid bump/) +}) + +test("getCurrentVersion extracts the single version field", () => { + assert.equal(getCurrentVersion(PACKAGE_JSON), "0.2.2") +}) + +test("getCurrentVersion requires exactly one version field", () => { + assert.throws(() => getCurrentVersion(`{ "name": "x" }`), /found 0/) + const twoVersions = `{\n "version": "1.0.0",\n "version": "2.0.0"\n}\n` + assert.throws(() => getCurrentVersion(twoVersions), /found 2/) +}) + +test("updateVersionFile replaces the version and preserves the rest", () => { + const updated = updateVersionFile(PACKAGE_JSON, "0.2.3") + assert.match(updated, /"version": "0.2.3"/) + assert.match(updated, /"name": "fastapi"/) + assert.match(updated, /"publisher": "FastAPILabs"/) + assert.doesNotMatch(updated, /0\.2\.2/) +}) + +test("updateVersionFile rejects a non-newer version", () => { + assert.throws( + () => updateVersionFile(PACKAGE_JSON, "0.2.2"), + /must be greater/, + ) + assert.throws( + () => updateVersionFile(PACKAGE_JSON, "0.2.1"), + /must be greater/, + ) +}) + +test("updateChangelog inserts a dated heading under Latest Changes", () => { + const updated = updateChangelog(CHANGELOG, "0.3.0", "2026-06-16") + assert.match( + updated, + /## Latest Changes\n\n## 0\.3\.0 \(2026-06-16\)\n\n### Fixes/, + ) + // Existing content is preserved below the new section. + assert.match(updated, /## 0\.2\.1\n\n### Docs/) +}) + +test("updateChangelog rejects a version that already has a section", () => { + // 0.2.1 exists as a plain (undated) heading already. + assert.throws( + () => updateChangelog(CHANGELOG, "0.2.1", "2026-06-16"), + /already contains a section/, + ) +}) + +test("updateChangelog requires the expected header", () => { + assert.throws( + () => updateChangelog("## Latest Changes\n", "0.3.0", "2026-06-16"), + /must start with/, + ) +}) + +test("getReleaseNotesBody extracts a dated section", () => { + const content = `# Release Notes + +## Latest Changes + +## 0.3.0 (2026-06-16) + +### Fixes + +* 🐛 Fixed it. PR [#9](https://example.com/9). + +## 0.2.2 + +### Docs + +* old +` + assert.equal( + getReleaseNotesBody(content, "0.3.0"), + "### Fixes\n\n* 🐛 Fixed it. PR [#9](https://example.com/9).\n", + ) +}) + +test("getReleaseNotesBody extracts a plain (undated) section", () => { + assert.equal( + getReleaseNotesBody(CHANGELOG, "0.2.1"), + "### Docs\n\n* 📝 Document a thing. PR [#2](https://example.com/2).\n", + ) +}) + +test("getReleaseNotesBody keeps non-version h2 content inside a section", () => { + const content = `# Release Notes + +## Latest Changes + +## 0.3.0 (2026-06-16) + +### Fixes + +* 🐛 Fixed it. + +## Acknowledgements + +Thanks everyone. + +## 0.2.2 + +* old +` + const body = getReleaseNotesBody(content, "0.3.0") + assert.match(body, /## Acknowledgements/) + assert.match(body, /Thanks everyone\./) + assert.doesNotMatch(body, /old/) +}) + +test("getReleaseNotesBody requires the version section to exist", () => { + assert.throws(() => getReleaseNotesBody(CHANGELOG, "9.9.9"), /Could not find/) +}) + +test("getReleaseNotesBody rejects an empty section", () => { + const content = `# Release Notes + +## Latest Changes + +## 0.3.0 (2026-06-16) + +## 0.2.2 + +* old +` + assert.throws(() => getReleaseNotesBody(content, "0.3.0"), /is empty/) +}) + +test("resolveDate passes through a valid date", () => { + assert.equal(resolveDate("2026-06-16"), "2026-06-16") +}) + +test("resolveDate defaults to a YYYY-MM-DD date when empty", () => { + assert.match(resolveDate(""), /^\d{4}-\d{2}-\d{2}$/) + assert.match(resolveDate(undefined), /^\d{4}-\d{2}-\d{2}$/) +}) + +test("resolveDate rejects malformed or impossible dates", () => { + assert.throws(() => resolveDate("2026-13-40"), /Invalid date/) + assert.throws(() => resolveDate("06-16-2026"), /Invalid date/) + assert.throws(() => resolveDate("not-a-date"), /Invalid date/) +}) + +// --- CLI end-to-end (run against temp files; never touches the repo) --- + +test("CLI prepare updates both files and prints the new version", () => { + const pkg = `{\n "name": "x",\n "version": "0.2.2"\n}\n` + const changelog = + "# Release Notes\n\n## Latest Changes\n\n### Fixes\n\n* Fix something.\n" + withCli(pkg, changelog, ({ versionFile, changelogFile, stdout }) => { + const out = stdout(["prepare", "patch", "2026-06-16"]) + assert.match(out, /Prepared release 0\.2\.3 \(2026-06-16\)/) + assert.match(readFileSync(versionFile, "utf8"), /"version": "0.2.3"/) + assert.match( + readFileSync(changelogFile, "utf8"), + /## 0\.2\.3 \(2026-06-16\)/, + ) + }) +}) + +test("CLI prepare reads file paths from PREPARE_RELEASE_* env vars", () => { + const pkg = `{\n "version": "1.4.9"\n}\n` + const changelog = "# Release Notes\n\n## Latest Changes\n\n* something\n" + withCli(pkg, changelog, ({ versionFile, stdout }) => { + stdout(["prepare", "minor"]) + assert.match(readFileSync(versionFile, "utf8"), /"version": "1.5.0"/) + }) +}) + +test("CLI current-version prints the version", () => { + const pkg = `{\n "version": "0.2.2"\n}\n` + withCli(pkg, null, ({ stdout }) => { + assert.equal(stdout(["current-version"]), "0.2.2\n") + }) +}) + +test("CLI release-notes prints the current version's section body", () => { + const pkg = `{\n "version": "0.2.2"\n}\n` + const changelog = + "# Release Notes\n\n## Latest Changes\n\n## 0.2.2\n\n### Fixes\n\n* Fix it.\n\n## 0.2.1\n\n* old\n" + withCli(pkg, changelog, ({ stdout }) => { + assert.equal(stdout(["release-notes"]), "### Fixes\n\n* Fix it.\n") + }) +})