fix: keep RC release retries monotonic (#3985)

This commit is contained in:
Neil 2026-05-30 21:46:13 -07:00 committed by GitHub
parent 0c751f46a3
commit 7eee8321b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 335 additions and 12 deletions

View File

@ -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.

View File

@ -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

View File

@ -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')

View File

@ -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) {

View File

@ -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', () => {

View File

@ -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 <base-version>')
}
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)
}
}

View File

@ -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)
})
})
})