diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8af51c545..f8b369865 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -99,8 +99,9 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable **Safety guarantees:** - Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs. -- Complete RC draft releases created by the release workflow are published before cutting a new tag, so a GitHub queue failure in the final publish job can be resumed safely. -- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag before cutting the next RC. This keeps retries from leaving multiple unpublished draft releases behind. +- Complete RC draft releases created by the release workflow are published before cutting a new tag only when the draft tag was built from the current release ref. Stale drafts are skipped so fixes cut a fresh RC instead of exposing old artifacts. +- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag only when it was built from the current release ref. Otherwise the next RC number is cut. +- RC numbering also considers release commits on `main`, so deleting a stale tag does not let a later cut reuse the same RC number. - Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history. - When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped. diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ad9cd01a5..35672c015 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -252,11 +252,24 @@ jobs: } highest_rc_for_base() { - local base="$1" - git tag --list "v${base}-rc.*" \ - | sed -E "s/^v${base//./\\.}-rc\.//" \ - | sort -n \ - | tail -n 1 || true + node config/scripts/release-rc-history.mjs "$1" + } + + tag_matches_current_ref() { + local tag="$1" + local tag_commit + local head_commit + if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then + return 1 + fi + head_commit="$(git rev-parse HEAD)" + if [[ "$tag_commit" == "$head_commit" ]]; then + return 0 + fi + + local tag_parent + tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1 + [[ "$tag_parent" == "$head_commit" ]] } release_draft_state() { @@ -280,6 +293,10 @@ jobs: release_state="$(release_draft_state "$tag")" case "$release_state" in missing|true) + if ! tag_matches_current_ref "$tag"; then + echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts." + return 1 + fi echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag." echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT" echo "recovered=true" >>"$GITHUB_OUTPUT" @@ -317,10 +334,11 @@ jobs: else existing_rc_tag="v${base}-rc.${highest_rc}" # Why: a failed or GitHub-stuck run can leave the highest RC - # tag attached to a draft/missing release. Resume that tag so - # retries finish the intended release instead of piling up - # vX.Y.Z-rc.N+1 drafts. - recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true + # tag attached to a draft/missing release. Resume only when it + # was cut from this ref; stale attempts advance to rc.N+1. + if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then + recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true + fi new="${base}-rc.$((highest_rc + 1))" fi ;; @@ -363,7 +381,7 @@ jobs: # over a shipped version) and needs human attention. if git rev-parse "v$new" >/dev/null 2>&1; then recover_unpublished_tag "v$new" "tag collision" || { - echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2 + echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2 exit 1 } fi diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index d05f2ccd2..870b61f51 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -90,6 +90,22 @@ describe('Electron runtime package contract', () => { expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) + it('keeps release-cut RC retries monotonic across stale attempts', () => { + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) + const parsedWorkflow = parse(releaseWorkflow) + const versionStep = parsedWorkflow.jobs.cut.steps.find( + (step) => step.name === 'Compute next version' + ) + + expect(versionStep.run).toContain('node config/scripts/release-rc-history.mjs "$1"') + expect(versionStep.run).toContain('tag_matches_current_ref') + expect(versionStep.run).toContain('cutting the next version instead of reusing stale artifacts') + expect(versionStep.run).toContain('git rev-parse "$existing_rc_tag"') + }) + it('bumps separate Homebrew casks for stable and RC desktop tags', () => { const releaseWorkflow = parse( readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') diff --git a/config/scripts/publish-complete-draft-releases.mjs b/config/scripts/publish-complete-draft-releases.mjs index 9797d0673..a02431afe 100644 --- a/config/scripts/publish-complete-draft-releases.mjs +++ b/config/scripts/publish-complete-draft-releases.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { execFileSync } from 'node:child_process' import { appendFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs' @@ -21,6 +22,28 @@ function isRcTag(tag) { return tag.includes('-rc.') } +function gitOutput(args, cwd) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }).trim() +} + +export function isTagBuiltFromCurrentRef(tag, { cwd = process.cwd() } = {}) { + try { + const tagCommit = gitOutput(['rev-parse', `${tag}^{}`], cwd) + const currentCommit = gitOutput(['rev-parse', 'HEAD'], cwd) + if (tagCommit === currentCommit) { + return true + } + + return gitOutput(['rev-parse', `${tagCommit}^`], cwd) === currentCommit + } catch { + return false + } +} + async function githubJson(fetchImpl, url, token, options = {}) { const res = await fetchImpl(url, { ...options, @@ -55,6 +78,7 @@ export async function publishCompleteDraftReleases({ token, fetchImpl = fetch, verifyReleaseAssets = verifyRequiredReleaseAssets, + isDraftBuiltFromCurrentRef = ({ tag }) => isTagBuiltFromCurrentRef(tag), log = console.log }) { if (!repo) { @@ -74,6 +98,13 @@ export async function publishCompleteDraftReleases({ for (const release of candidates) { const tag = release.tag_name + if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) { + const reason = 'tag is not built from the current release ref' + skipped.push({ tag, reason }) + log(`Skipping stale RC draft release ${tag}: ${reason}`) + continue + } + try { await verifyReleaseAssets({ repo, tag, token }) } catch (error) { diff --git a/config/scripts/publish-complete-draft-releases.test.mjs b/config/scripts/publish-complete-draft-releases.test.mjs index 098d63738..bc61c58c7 100644 --- a/config/scripts/publish-complete-draft-releases.test.mjs +++ b/config/scripts/publish-complete-draft-releases.test.mjs @@ -1,13 +1,39 @@ +import { execFileSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + isTagBuiltFromCurrentRef, isReleaseCutDraft, publishCompleteDraftReleases, writeGithubOutputs } from './publish-complete-draft-releases.mjs' +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +function withGitRepo(run) { + const dir = mkdtempSync(join(tmpdir(), 'orca-draft-release-')) + try { + git(dir, ['init', '--initial-branch=main']) + git(dir, ['config', 'user.name', 'Test Bot']) + git(dir, ['config', 'user.email', 'test@example.com']) + run(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +function commit(cwd, message) { + git(cwd, ['commit', '--allow-empty', '-m', message]) +} + function jsonResponse(body, init = {}) { return { ok: init.ok ?? true, @@ -51,6 +77,41 @@ describe('isReleaseCutDraft', () => { }) }) +describe('isTagBuiltFromCurrentRef', () => { + it('accepts a tag on the current release commit', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['tag', 'v1.4.36-rc.6']) + + expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(true) + }) + }) + + it('accepts a tag whose release commit is built from the current ref', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + const source = git(repo, ['rev-parse', 'HEAD']) + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['tag', 'v1.4.36-rc.6']) + git(repo, ['checkout', source]) + + expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(true) + }) + }) + + it('rejects a stale tag when the current ref has moved on', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['tag', 'v1.4.36-rc.6']) + commit(repo, 'fix: release packaging') + + expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(false) + }) + }) +}) + describe('publishCompleteDraftReleases', () => { it('publishes complete release-cut drafts and skips incomplete ones', async () => { const fetchImpl = vi @@ -86,6 +147,7 @@ describe('publishCompleteDraftReleases', () => { token: 'token', fetchImpl, verifyReleaseAssets, + isDraftBuiltFromCurrentRef: vi.fn(async () => true), log }) @@ -106,6 +168,38 @@ describe('publishCompleteDraftReleases', () => { }) ) }) + + it('skips stale complete drafts before publishing', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse([ + { + id: 7, + draft: true, + tag_name: 'v1.4.2-rc.7', + created_at: '2026-05-15T07:31:19Z', + author: { login: 'github-actions[bot]' } + } + ]) + ) + const verifyReleaseAssets = vi.fn() + const log = vi.fn() + + const result = await publishCompleteDraftReleases({ + repo: 'stablyai/orca', + token: 'token', + fetchImpl, + verifyReleaseAssets, + isDraftBuiltFromCurrentRef: vi.fn(async () => false), + log + }) + + expect(result).toEqual({ + published: [], + skipped: [{ tag: 'v1.4.2-rc.7', reason: 'tag is not built from the current release ref' }] + }) + expect(verifyReleaseAssets).not.toHaveBeenCalled() + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) }) describe('writeGithubOutputs', () => { diff --git a/config/scripts/release-rc-history.mjs b/config/scripts/release-rc-history.mjs new file mode 100644 index 000000000..e3cdea2d8 --- /dev/null +++ b/config/scripts/release-rc-history.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +function gitLines(args, cwd) { + try { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +export function rcNumberFromTag(base, tag) { + const prefix = `v${base}-rc.` + if (!tag.startsWith(prefix)) { + return null + } + + const suffix = tag.slice(prefix.length) + return /^\d+$/.test(suffix) ? Number(suffix) : null +} + +export function rcNumberFromReleaseSubject(base, subject) { + const prefix = `release: v${base}-rc.` + if (!subject.startsWith(prefix)) { + return null + } + + const match = /^(\d+)(?:\s|$)/.exec(subject.slice(prefix.length)) + return match ? Number(match[1]) : null +} + +export function highestRcForBase(base, { cwd = process.cwd() } = {}) { + const numbers = [] + + for (const tag of gitLines(['tag', '--list', `v${base}-rc.*`], cwd)) { + const rcNumber = rcNumberFromTag(base, tag) + if (rcNumber !== null) { + numbers.push(rcNumber) + } + } + + const logRefs = ['HEAD', 'origin/main'].filter( + (ref) => gitLines(['rev-parse', '--verify', '--quiet', ref], cwd).length > 0 + ) + if (logRefs.length > 0) { + for (const subject of gitLines(['log', '--format=%s', ...logRefs], cwd)) { + const rcNumber = rcNumberFromReleaseSubject(base, subject) + if (rcNumber !== null) { + numbers.push(rcNumber) + } + } + } + + return numbers.length === 0 ? null : Math.max(...numbers) +} + +function main() { + const base = process.argv[2] + if (!base) { + throw new Error('Usage: node config/scripts/release-rc-history.mjs ') + } + + const highest = highestRcForBase(base) + if (highest !== null) { + process.stdout.write(String(highest)) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + main() + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/release-rc-history.test.mjs b/config/scripts/release-rc-history.test.mjs new file mode 100644 index 000000000..7ceb28f34 --- /dev/null +++ b/config/scripts/release-rc-history.test.mjs @@ -0,0 +1,78 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + highestRcForBase, + rcNumberFromReleaseSubject, + rcNumberFromTag +} from './release-rc-history.mjs' + +function git(cwd, args) { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +function withGitRepo(run) { + const dir = mkdtempSync(join(tmpdir(), 'orca-rc-history-')) + try { + git(dir, ['init', '--initial-branch=main']) + git(dir, ['config', 'user.name', 'Test Bot']) + git(dir, ['config', 'user.email', 'test@example.com']) + run(dir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +function commit(cwd, message, { allowEmpty = true } = {}) { + const args = ['commit', '-m', message] + if (allowEmpty) { + args.splice(1, 0, '--allow-empty') + } + git(cwd, args) +} + +describe('release RC history', () => { + it('parses only exact desktop RC tag suffixes', () => { + expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.7')).toBe(7) + expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.7-extra')).toBeNull() + expect(rcNumberFromTag('1.4.36', 'v1.4.35-rc.7')).toBeNull() + }) + + it('parses release commit subjects with optional slot markers', () => { + expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6')).toBe(6) + expect( + rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6 [rc-slot:2026-05-30-03]') + ).toBe(6) + expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6-extra')).toBeNull() + expect(rcNumberFromReleaseSubject('1.4.36', 'fix: v1.4.36-rc.6')).toBeNull() + }) + + it('keeps RC numbers monotonic after a stale tag is deleted', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + commit(repo, 'release: v1.4.36-rc.5') + git(repo, ['tag', 'v1.4.36-rc.5']) + commit(repo, 'release: v1.4.36-rc.6') + + expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6) + }) + }) + + it('considers origin/main when releasing from an older ref', () => { + withGitRepo((repo) => { + commit(repo, 'initial') + git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + commit(repo, 'release: v1.4.36-rc.6') + git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD']) + git(repo, ['checkout', 'HEAD~1']) + + expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6) + }) + }) +})