fix: keep RC release retries monotonic (#3985)
This commit is contained in:
parent
0c751f46a3
commit
7eee8321b0
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue