Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions .github/workflows/create-draft-release.yml
Original file line number Diff line number Diff line change
@@ -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)"
2 changes: 1 addition & 1 deletion .github/workflows/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
63 changes: 63 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 0 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -366,7 +367,7 @@
"web-tree-sitter": "^0.26.3"
},
"lint-staged": {
"**/*.{ts,js,json}": [
"**/*.{ts,js,mjs,json}": [
"biome check --write"
]
}
Expand Down
184 changes: 184 additions & 0 deletions scripts/prepare-release.mjs
Original file line number Diff line number Diff line change
@@ -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 =
/^(?<indent>\s*)"version":\s*"(?<version>\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, `$<indent>"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 <patch|minor|major> [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 <prepare <bump>|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,
}
Loading
Loading