ci(jetbrains): lock release tag before publish

This commit is contained in:
kirillk
2026-05-27 13:01:11 -04:00
parent beb8a406f0
commit 3b58e05e70
8 changed files with 150 additions and 144 deletions
-1
View File
@@ -48,7 +48,6 @@ const active = new Set([
"publish.yml",
"smoke-test.yml",
"source-check-links.yml",
"tag-jetbrains-release.yml",
"test-vscode.yml",
"test.yml",
"triage.yml",
+29 -8
View File
@@ -45,24 +45,28 @@ await $`git fetch origin main --tags`
const tag = `jetbrains/v${ver}`
const branch = `jetbrains/release/v${ver}`
const sha = (await $`git rev-parse origin/main`.text()).trim()
const from = values["from-tag"] ?? (await base(ver, kind))
const notes = await release(from, tag)
const state = await lock(tag, sha, dry)
const notes = await release(from, tag, sha)
const entry = section(ver, notes)
console.log(`JetBrains ${kind} release PR`)
console.log(`version: ${ver}`)
console.log(`base: ${from}`)
console.log(`tag: ${tag}`)
console.log(`commit: ${sha}`)
console.log(`branch: ${branch}`)
console.log(`tag state: ${state}`)
if (dry) {
console.log("\nGenerated changelog entry:\n")
console.log(entry)
console.log("\nDry run complete. No branch, commit, push, or PR was created.")
console.log("\nDry run complete. No tag, branch, commit, push, or PR was created.")
process.exit(0)
}
await $`git checkout -B ${branch} origin/main`
await $`git checkout -B ${branch} ${sha}`
await writepkg(ver)
await writelog(ver, entry)
await $`git add packages/kilo-jetbrains/package.json packages/kilo-jetbrains/CHANGELOG.md`
@@ -72,7 +76,7 @@ if (changed.exitCode !== 0) await $`git commit -m ${`release(jetbrains): v${ver}
await $`git push --force-with-lease origin ${branch}`
const text = body(ver, kind, from, tag, notes)
const text = body(ver, kind, from, tag, sha, notes)
const view = await $`gh pr view ${branch} --repo ${repo} --json number --jq .number`.nothrow()
if (view.exitCode === 0 && view.stdout.toString().trim()) {
const num = view.stdout.toString().trim()
@@ -120,13 +124,29 @@ async function base(ver: string, kind: "rc" | "stable") {
return hit.tag
}
async function release(from: string, tag: string) {
const res = await $`gh api repos/${repo}/releases/generate-notes --method POST -f tag_name=${tag} -f target_commitish=main -f previous_tag_name=${from} --jq .body`
async function lock(tag: string, sha: string, dry: boolean) {
const res = await $`git rev-parse -q --verify ${`refs/tags/${tag}`}`.nothrow()
if (res.exitCode === 0) {
const got = (await $`git rev-list -n 1 ${tag}`.text()).trim()
if (got === sha) return "exists"
throw new Error(`${tag} already exists at ${got}, expected ${sha}`)
}
if (dry) return "would-create"
await $`git tag ${tag} ${sha}`
await $`git push origin ${tag}`
return "created"
}
async function release(from: string, tag: string, sha: string) {
const res = await $`gh api repos/${repo}/releases/generate-notes --method POST -f tag_name=${tag} -f target_commitish=${sha} -f previous_tag_name=${from} --jq .body`
.quiet()
.nothrow()
if (res.exitCode === 0) return res.stdout.toString().trim()
const text = await $`git log --format=%s ${from}..origin/main`.text()
const base = await $`git rev-parse -q --verify ${from}`.nothrow()
if (base.exitCode !== 0) throw new Error(`Previous JetBrains tag not found: ${from}`)
const text = await $`git log --format=%s ${from}..${sha}`.text()
const lines = text
.split(/\r?\n/)
.map((item) => item.trim())
@@ -197,7 +217,7 @@ function regex(ver: string) {
return new RegExp(`\\n?## \\[${safe}\\][\\s\\S]*?(?=\\n## \\[|$)`)
}
function body(ver: string, kind: string, from: string, tag: string, notes: string) {
function body(ver: string, kind: string, from: string, tag: string, sha: string, notes: string) {
return `## Summary
- Prepare JetBrains ${kind} release ${ver}.
- Review and edit \`packages/kilo-jetbrains/CHANGELOG.md\` before merging.
@@ -206,6 +226,7 @@ JetBrains-Version: ${ver}
JetBrains-Kind: ${kind}
JetBrains-From-Tag: ${from}
JetBrains-Tag: ${tag}
JetBrains-Commit: ${sha}
## Generated Notes
${notes || "No notable changes."}
@@ -2,6 +2,7 @@
// kilocode_change - new file
import { $ } from "bun"
import { appendFileSync } from "node:fs"
import semver from "semver"
import { parseArgs } from "util"
@@ -17,7 +18,10 @@ const { values } = parseArgs({
if (values.help) {
console.log(`
Usage: bun script/jetbrains-release-tag.ts --pr <number> [--dry]
Usage: bun script/jetbrains-release-validate.ts --pr <number> [--dry]
Validates a merged JetBrains release PR and the pre-created immutable release tag.
This helper never creates, moves, deletes, or pushes tags.
`)
process.exit(0)
}
@@ -40,11 +44,25 @@ if (!data.headRefName.startsWith("jetbrains/release/")) throw new Error("PR head
if (data.isCrossRepository) throw new Error("JetBrains release PR must come from this repository")
if (!data.mergeCommit?.oid) throw new Error("PR has no merge commit")
const ver = marker(data.body, "JetBrains-Version") ?? data.headRefName.replace(/^jetbrains\/release\/v/, "")
const tag = marker(data.body, "JetBrains-Tag") ?? `jetbrains/v${ver}`
const ver = need(data.body, "JetBrains-Version")
const kind = need(data.body, "JetBrains-Kind")
const tag = need(data.body, "JetBrains-Tag")
const commit = need(data.body, "JetBrains-Commit")
if (!semver.valid(ver)) throw new Error(`Invalid JetBrains version: ${ver}`)
if (kind !== "rc" && kind !== "stable") throw new Error(`Invalid JetBrains kind: ${kind}`)
if (kind === "rc" && !/^\d+\.\d+\.\d+-rc\.\d+$/.test(ver)) throw new Error("RC versions must match x.y.z-rc.n")
if (kind === "stable" && !/^\d+\.\d+\.\d+$/.test(ver)) throw new Error("Stable versions must match x.y.z")
if (tag !== `jetbrains/v${ver}`) throw new Error(`Tag ${tag} does not match version ${ver}`)
if (!/^jetbrains\/v\d+\.\d+\.\d+(-rc\.\d+)?$/.test(tag)) throw new Error(`Invalid JetBrains tag: ${tag}`)
if (!/^[0-9a-f]{40}$/i.test(commit)) throw new Error(`Invalid JetBrains commit: ${commit}`)
await $`git fetch origin --tags`
const existing = await $`git rev-parse -q --verify ${`refs/tags/${tag}`}`.nothrow()
if (existing.exitCode !== 0) throw new Error(`${tag} does not exist`)
const sha = (await $`git rev-list -n 1 ${tag}`.text()).trim()
if (sha !== commit) throw new Error(`${tag} points at ${sha}, expected ${commit}`)
const pkg = await Bun.file("packages/kilo-jetbrains/package.json").json()
if (pkg.version !== ver) throw new Error(`packages/kilo-jetbrains/package.json version is ${pkg.version}, expected ${ver}`)
@@ -52,27 +70,30 @@ if (pkg.version !== ver) throw new Error(`packages/kilo-jetbrains/package.json v
const changelog = await Bun.file("packages/kilo-jetbrains/CHANGELOG.md").text()
if (!changelog.includes(`## [${ver}]`)) throw new Error(`CHANGELOG.md is missing section for ${ver}`)
await $`git fetch origin --tags`
const existing = await $`git rev-parse -q --verify ${`refs/tags/${tag}`}`.nothrow()
if (existing.exitCode === 0) {
const sha = (await $`git rev-list -n 1 ${tag}`.text()).trim()
if (sha === data.mergeCommit.oid) {
console.log(`${tag} already exists at ${sha}`)
process.exit(0)
}
throw new Error(`${tag} already exists at ${sha}, expected ${data.mergeCommit.oid}`)
const marketplace = kind === "rc" ? "eap" : "default"
const cli = kind === "rc" ? "rc" : "latest"
const output = {
version: ver,
kind,
tag,
commit,
merge: data.mergeCommit.oid,
marketplace_channel: marketplace,
cli_channel: cli,
}
console.log(`Creating ${tag} at ${data.mergeCommit.oid}`)
if (values.dry) {
console.log("Dry run complete. No tag was created.")
process.exit(0)
for (const [key, value] of Object.entries(output)) console.log(`${key}=${value}`)
if (process.env.GITHUB_OUTPUT && !values.dry) {
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(output).map(([key, value]) => `${key}=${value}\n`).join(""))
}
await $`git tag ${tag} ${data.mergeCommit.oid}`
await $`git push origin ${tag}`
function marker(body: string, key: string) {
const line = body.split(/\r?\n/).find((item) => item.startsWith(`${key}:`))
return line?.slice(key.length + 1).trim()
}
function need(body: string, key: string) {
const value = marker(body, key)
if (!value) throw new Error(`PR body is missing ${key}`)
return value
}