fix(release): restore 258 release-workflow fixes (draft 403, skill suite isolation) (#42343)

* ci: isolate real-process skill release suites

Linux and mac skill-sharing release gates hit child-readiness marker
timeouts in run 43573545467. The nested Vitest children inherit
process.env, and per-file isolation is only on for Windows unless
ORCA_VITEST_ISOLATE_EVERY_FILE is set. Reapply the reviewed 258 fix
(3ad9722af6) to both skill-suite steps. No suite, assertion or
timeout changes.

* fix(release): draft from the verified tag with default-branch metadata

Run 43573545467 got 403 "Resource not accessible by integration" because
the draft POST sent the release commit SHA as target_commitish, which
needs workflow authority GITHUB_TOKEN cannot get. The existing tag owns
source identity; target_commitish is ignored for an existing tag, so
send the default-branch name as API metadata (design B, as in
d28d414bbd), with the D1b fixes:

- An already-published release, or one published mid-preparation, now
  fails instead of returning success to downstream builds.
- Peel the captured tag object ID (git/tags, git/commits) rather than
  re-dereferencing the tag name; bind it to the checkout and SOURCE_SHA.
- Refuse a draft electron-builder would not select first (untagged,
  bare-version shadow, other id, published, invisible), the #21835 shape.
- Re-read the tag after drafting, export the verified tag object and
  commit, and re-check both immediately before publish-release undrafts.

Uses only the official GITHUB_TOKEN.
This commit is contained in:
Brennan Benson
2026-11-14 12:46:23 -08:00
committed by GitHub
parent 50c0ab49c9
commit 042c4516ef
3 changed files with 407 additions and 20 deletions
+34
View File
@@ -668,6 +668,9 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
tag_object: ${{ steps.draft.outputs.tag_object }}
release_sha: ${{ steps.draft.outputs.release_sha }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -685,7 +688,9 @@ jobs:
config/scripts/release/publication/assert-github-release-is-draft.mjs
- name: Create draft release with bounded generated notes
id: draft
env:
SOURCE_SHA: ${{ needs.cut.outputs.source_sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
run: node config/scripts/release/publication/create-draft-release.mjs "$TAG"
@@ -2187,6 +2192,7 @@ jobs:
publish-release:
needs:
- cut
- create-release
- build
- build-mac
- verify-mac-build
@@ -2197,6 +2203,7 @@ jobs:
if: >-
!cancelled() &&
needs.cut.result == 'success' &&
needs.create-release.result == 'success' &&
needs.build.result == 'success' &&
needs.build-mac.result == 'success' &&
needs.verify-mac-build.result == 'success' &&
@@ -2243,6 +2250,33 @@ jobs:
TAG: ${{ needs.cut.outputs.tag }}
run: node config/scripts/release/publication/verify-release-required-assets.mjs "$TAG"
- name: Verify release tag still matches the drafted source
# Why: the draft targets default-branch metadata, so undrafting after a moved or deleted tag would publish
# these assets under the wrong commit. Fail unless the tag is still the object create-release verified.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.cut.outputs.tag }}
TAG_OBJECT: ${{ needs.create-release.outputs.tag_object }}
RELEASE_SHA: ${{ needs.create-release.outputs.release_sha }}
run: |
set -euo pipefail
if [[ ! "$TAG_OBJECT" =~ ^[0-9a-f]{40}$ || ! "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::create-release did not record the verified tag object and release commit for $TAG."
exit 1
fi
ref_json="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG")"
ref="$(jq -r '.ref' <<<"$ref_json")"
object="$(jq -r '.object.sha' <<<"$ref_json")"
type="$(jq -r '.object.type' <<<"$ref_json")"
commit="$object"
if [[ "$type" == "tag" ]]; then
commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object" | jq -r 'select(.object.type == "commit") | .object.sha')"
fi
if [[ "$ref" != "refs/tags/$TAG" || "$object" != "$TAG_OBJECT" || "$commit" != "$RELEASE_SHA" ]]; then
echo "::error::Remote tag $TAG no longer resolves to verified object $TAG_OBJECT (commit $RELEASE_SHA); refusing to publish."
exit 1
fi
- name: Publish release
# Reassert prerelease from tag: on 2026-04-27 builder flipped RC to false/latest and broke release math; builder output is not authoritative.
env:
@@ -1,12 +1,14 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { appendFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
const API_VERSION = '2022-11-28'
export const MAX_RELEASE_BODY_LENGTH = 120_000
const TRUNCATION_NOTICE =
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
const FULL_SHA = /^[a-f0-9]{40}$/
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
export function parseDesktopReleaseTag(tag) {
@@ -111,11 +113,82 @@ export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
return `${body.slice(0, availableLength).trimEnd()}${TRUNCATION_NOTICE}`
}
async function readTagObject({ base, tag, token, fetchImpl }) {
const ref = await githubJson(fetchImpl, `${base}/git/ref/tags/${encodeURIComponent(tag)}`, token)
const object = ref?.object
if (
ref?.ref !== `refs/tags/${tag}` ||
!FULL_SHA.test(object?.sha ?? '') ||
(object.type !== 'tag' && object.type !== 'commit')
) {
throw new Error(`Missing exact remote tag ${tag}`)
}
return object
}
// The existing tag owns source identity. Peel the captured object ID, never the tag name again,
// so every source read describes the same immutable object.
async function verifyReleaseSource(identity) {
const { base, tag, token, targetCommitish, sourceSha, fetchImpl } = identity
const object = await readTagObject(identity)
let commitSha = object.sha
if (object.type === 'tag') {
const annotated = await githubJson(fetchImpl, `${base}/git/tags/${object.sha}`, token)
if (annotated?.sha !== object.sha || annotated.object?.type !== 'commit') {
throw new Error(`Remote tag ${tag} does not point directly at a release commit`)
}
commitSha = annotated.object.sha
}
const commit = await githubJson(fetchImpl, `${base}/git/commits/${commitSha}`, token)
if (
commit?.sha !== commitSha ||
commitSha !== targetCommitish ||
(commitSha !== sourceSha &&
!(commit.parents?.length === 1 && commit.parents[0]?.sha === sourceSha))
) {
throw new Error(`Remote tag ${tag} does not match the approved release source and checkout`)
}
return object.sha
}
async function assertTagUnchanged(identity, tagObject, phase) {
if ((await readTagObject(identity)).sha !== tagObject) {
throw new Error(`Remote tag ${identity.tag} changed ${phase}`)
}
}
// Mirror electron-builder's publisher: default first page, first exact or bare-version match.
// A default-branch target must never leave an untagged or shadowed draft the uploader would miss.
async function verifyUploaderDraft({ base, tag, token, release, fetchImpl, sleep }) {
if (!Number.isInteger(release?.id) || release.draft !== true || release.tag_name !== tag) {
throw new Error(`Expected an exact-tag draft for ${tag}`)
}
for (let attempt = 0; attempt < 3; attempt += 1) {
const page = await githubJson(fetchImpl, `${base}/releases`, token)
if (!Array.isArray(page)) {
throw new Error('GitHub releases response was not an array')
}
const selected = page.find((item) => item?.tag_name === tag || item?.tag_name === tag.slice(1))
if (selected) {
if (selected.id !== release.id || selected.tag_name !== tag || selected.draft !== true) {
throw new Error(`Uploader would not select the prepared draft ${tag}`)
}
return
}
if (attempt < 2) {
await sleep(1000)
}
}
throw new Error(`Prepared draft ${tag} is not visible to the uploader`)
}
export async function createDraftRelease({
repo,
tag,
token,
targetCommitish,
sourceSha,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
fetchImpl = fetch,
log = console.log
}) {
@@ -135,9 +208,23 @@ export async function createDraftRelease({
const releases = await fetchRepoReleases(repo, token, fetchImpl)
const existingRelease = releases.find((release) => release?.tag_name === tag)
if (existingRelease && existingRelease.draft !== true) {
log(`Release ${tag} already exists and is published.`)
return
// Fail rather than succeed: downstream builds must never run without a prepared draft.
throw new Error(`Release ${tag} is already published; refusing to prepare a draft.`)
}
if (!FULL_SHA.test(sourceSha ?? '') || !FULL_SHA.test(targetCommitish)) {
throw new Error('Approved source and checkout must be full commit SHAs')
}
const base = `https://api.github.com/repos/${repo}`
const identity = { base, tag, token, targetCommitish, sourceSha, fetchImpl }
const tagObject = await verifyReleaseSource(identity)
const repository = await githubJson(fetchImpl, base, token)
if (typeof repository?.default_branch !== 'string' || !repository.default_branch) {
throw new Error('Repository default branch is missing')
}
// API metadata only: GitHub ignores target_commitish for an existing tag, and a release-SHA target
// needs workflow authority GITHUB_TOKEN cannot get (403 in run 43573545467).
const targetMetadata = repository.default_branch
let preparedRelease
const previousTag = latestPreviousPublishedDesktopReleaseTag(releases, tag)
const generateNotesBody = {
@@ -174,9 +261,14 @@ export async function createDraftRelease({
token
)
if (currentRelease?.draft !== true) {
log(`Release ${tag} was published while notes were generated; leaving it unchanged.`)
return
throw new Error(
`Release ${tag} was published while notes were generated; leaving it unchanged.`
)
}
if (currentRelease.tag_name !== tag || currentRelease.id !== existingRelease.id) {
throw new Error(`Draft identity changed for ${tag}`)
}
await assertTagUnchanged(identity, tagObject, 'during draft preparation')
// PATCH has no conditional/version guard; if publication wins the GET/PATCH race, restore the published body.
const patchedRelease = await githubJson(
fetchImpl,
@@ -184,14 +276,15 @@ export async function createDraftRelease({
token,
{
method: 'PATCH',
body: JSON.stringify({ body })
body: JSON.stringify({ body, target_commitish: targetMetadata })
}
)
if (patchedRelease?.draft !== true) {
const publishedBody = typeof currentRelease.body === 'string' ? currentRelease.body : ''
if (publishedBody === body) {
log(`Release ${tag} was published while notes were patched; its body is unchanged.`)
return
throw new Error(
`Release ${tag} was published while notes were patched; its body is unchanged.`
)
}
// Rollback only our exact body or it can clobber a newer edit.
const releaseBeforeRollback = await githubJson(
@@ -200,10 +293,9 @@ export async function createDraftRelease({
token
)
if (releaseBeforeRollback?.body !== body) {
log(
throw new Error(
`Release ${tag} was published and its body changed again while notes were patched; leaving the newer body in place.`
)
return
}
await githubJson(
fetchImpl,
@@ -214,14 +306,16 @@ export async function createDraftRelease({
body: JSON.stringify({ body: publishedBody })
}
)
log(
throw new Error(
`Release ${tag} was published while notes were patched; restored its published body and left the generated notes unapplied.`
)
return
}
preparedRelease = patchedRelease
} else {
// Set target_commitish to detached release-bump commit; default branch creates an untagged draft that electron-builder misses, publishing incomplete assets to /latest.
const createdRelease = await githubJson(
await assertTagUnchanged(identity, tagObject, 'during draft preparation')
// #21835: a default-branch target without the tag yields an untagged draft electron-builder misses.
// The tag was verified above and the uploader check below refuses that shape.
preparedRelease = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases`,
token,
@@ -229,7 +323,7 @@ export async function createDraftRelease({
method: 'POST',
body: JSON.stringify({
tag_name: tag,
target_commitish: targetCommitish,
target_commitish: targetMetadata,
name,
body,
draft: true,
@@ -238,13 +332,11 @@ export async function createDraftRelease({
})
}
)
if (createdRelease?.draft !== true || createdRelease?.tag_name !== tag) {
throw new Error(
`GitHub created ${createdRelease?.draft ? 'draft' : 'published'} release ${createdRelease?.tag_name ?? '<missing>'} instead of draft ${tag}`
)
}
}
await verifyUploaderDraft({ base, tag, token, release: preparedRelease, fetchImpl, sleep })
await assertTagUnchanged(identity, tagObject, 'after draft preparation')
if (generatedBody.length !== body.length) {
log(
`${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with truncated generated notes (${body.length} chars).`
@@ -254,6 +346,7 @@ export async function createDraftRelease({
`${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with generated notes (${body.length} chars).`
)
}
return { tagObject, releaseSha: targetCommitish }
}
async function main() {
@@ -261,7 +354,13 @@ async function main() {
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
const targetCommitish = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
await createDraftRelease({ repo, tag, token, targetCommitish })
const sourceSha = process.env.SOURCE_SHA
const prepared = await createDraftRelease({ repo, tag, token, targetCommitish, sourceSha })
if (process.env.GITHUB_OUTPUT) {
// publish-release re-reads the tag against these before undrafting.
const output = `tag_object=${prepared.tagObject}\nrelease_sha=${prepared.releaseSha}\n`
appendFileSync(process.env.GITHUB_OUTPUT, output)
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -0,0 +1,254 @@
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runProcessSync } from '@orca/process-host'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { createDraftRelease } from './create-draft-release.mjs'
// Actual v1.4.259 shape: app source 1d35… is the sole parent of release commit 980c…, tag object d272….
const source = '1d3548fb8bf2794b218affefc103d9b2a956220f'
const checkout = '980c0024741812bd7ae934c0c22144d9926a784e'
const tagObject = 'd2720cd6c86fdf95230bdcb6778abe0f7c061ace'
const tag = 'v1.4.259'
const draft = { id: 259, draft: true, tag_name: tag, body: 'original notes' }
function fixture(options = {}) {
const writes = []
const reads = []
let wrote = false
const response = (body, status = 200) => ({
ok: status === 200,
status,
statusText: status === 200 ? 'OK' : 'Not Found',
json: async () => body,
text: async () => JSON.stringify(body)
})
const fetchImpl = async (url, request = {}) => {
const route = url.replace('https://api.github.com/repos/test/repo', '')
const method = request.method ?? 'GET'
reads.push(route)
if (route === `/git/ref/tags/${tag}`) {
if (options.missing || (wrote && options.deleted)) {
return response({}, 404)
}
const sha = wrote && options.moved ? 'e'.repeat(40) : options.annotated ? tagObject : checkout
return response({
ref: `refs/tags/${tag}`,
object: { type: options.annotated ? 'tag' : 'commit', sha }
})
}
if (route === `/git/tags/${tagObject}`) {
return response({
sha: tagObject,
object: { type: 'commit', sha: options.commit ?? checkout }
})
}
if (route.startsWith('/git/commits/')) {
const sha = route.slice('/git/commits/'.length)
return response({ sha, parents: (options.parents ?? [source]).map((p) => ({ sha: p })) })
}
if (route === '') {
return response({ default_branch: 'main' })
}
if (route.startsWith('/releases?')) {
return response(options.listed ?? (options.existing ? [draft] : []))
}
if (route === '/releases/generate-notes') {
return response({ body: 'new notes', name: 'release' })
}
if (route === '/releases/259' && method === 'GET') {
return response(wrote ? (options.afterPatch ?? draft) : (options.beforePatch ?? draft))
}
if (method === 'POST' || method === 'PATCH') {
writes.push({ method, route, body: JSON.parse(request.body) })
wrote = true
return response(options.writeResult ?? { ...draft, body: JSON.parse(request.body).body })
}
if (route === '/releases') {
return response(options.visible ?? [draft])
}
throw new Error(`Unexpected request ${method} ${route}`)
}
return {
writes,
reads,
run: () =>
createDraftRelease({
repo: 'test/repo',
tag,
token: 'fixture-token',
targetCommitish: checkout,
sourceSha: options.approvedSource ?? source,
fetchImpl,
sleep: async () => {},
log: () => {}
})
}
}
describe('release draft source and uploader contract', () => {
it.each([false, true])(
'drafts the existing tag with default-branch metadata (annotated=%s)',
async (annotated) => {
const f = fixture({ annotated })
await expect(f.run()).resolves.toEqual({
tagObject: annotated ? tagObject : checkout,
releaseSha: checkout
})
expect(f.writes).toEqual([
{
method: 'POST',
route: '/releases',
body: {
tag_name: tag,
target_commitish: 'main',
name: 'release',
body: 'new notes',
draft: true,
prerelease: false,
make_latest: 'false'
}
}
])
}
)
it('peels the captured tag object ID instead of re-dereferencing the tag name', async () => {
const f = fixture({ annotated: true })
await f.run()
expect(f.reads).toContain(`/git/tags/${tagObject}`)
expect(f.reads).toContain(`/git/commits/${checkout}`)
expect(f.reads.some((route) => route.startsWith('/commits/'))).toBe(false)
})
it.each([
{ missing: true },
{ annotated: true, commit: 'e'.repeat(40) },
{ parents: ['e'.repeat(40)] },
{ parents: [source, 'e'.repeat(40)] },
{ approvedSource: 'short' }
])('refuses an absent, mismatched or unauthorized tag before writing: %j', async (options) => {
const f = fixture(options)
await expect(f.run()).rejects.toThrow()
expect(f.writes).toEqual([])
})
it('fails instead of reporting success for an already-published release', async () => {
const f = fixture({ listed: [{ ...draft, draft: false }] })
await expect(f.run()).rejects.toThrow('already published')
expect(f.writes).toEqual([])
})
it.each([
['an untagged draft', { writeResult: { ...draft, tag_name: 'untagged-0123abcd' } }],
[
'a bare-version release first',
{ visible: [{ ...draft, id: 7, tag_name: '1.4.259' }, draft] }
],
['a different draft first', { visible: [{ ...draft, id: 999 }] }],
['a published match', { visible: [{ ...draft, draft: false }] }],
['no visible match', { visible: [] }]
])('refuses a draft the uploader would not select: %s', async (_name, options) => {
await expect(fixture(options).run()).rejects.toThrow(/uploader|exact-tag draft/i)
})
it.each([{ moved: true }, { deleted: true }])(
'refuses a tag changed after drafting: %j',
async (options) => {
await expect(fixture(options).run()).rejects.toThrow()
}
)
it('heals existing-draft metadata and preserves the draft identity', async () => {
const f = fixture({ existing: true })
await f.run()
expect(f.writes).toEqual([
{
method: 'PATCH',
route: '/releases/259',
body: { body: 'new notes', target_commitish: 'main' }
}
])
})
it.each([
{ beforePatch: { ...draft, draft: false } },
{ writeResult: { ...draft, draft: false, body: 'original notes' } },
{
writeResult: { ...draft, draft: false },
afterPatch: { ...draft, draft: false, body: 'newer edit' }
}
])('fails when the existing draft is published mid-preparation: %j', async (options) => {
await expect(fixture({ existing: true, ...options }).run()).rejects.toThrow('published')
})
})
const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
describe('release-cut draft wiring', () => {
it('passes the approved source and only the job token to the helper', () => {
const job = workflow.jobs['create-release']
const step = job.steps.find((s) => s.id === 'draft')
expect(step.env).toMatchObject({
SOURCE_SHA: '${{ needs.cut.outputs.source_sha }}',
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
})
expect(job.outputs.tag_object).toBe('${{ steps.draft.outputs.tag_object }}')
})
it('isolates both real-process skill release suites', () => {
for (const name of ['skill-sharing-release-gate', 'skill-sharing-linux-floor-release-gate']) {
const step = workflow.jobs[name].steps.find((s) => s.name?.startsWith('Run skill package'))
expect(step.env.ORCA_VITEST_ISOLATE_EVERY_FILE).toBe('1')
}
})
const steps = workflow.jobs['publish-release'].steps
const guardIndex = steps.findIndex(
(s) => s.name === 'Verify release tag still matches the drafted source'
)
it('re-checks the tag immediately before undrafting', () => {
expect(steps[guardIndex + 1].name).toBe('Publish release')
expect(workflow.jobs['publish-release'].needs).toContain('create-release')
})
it.skipIf(process.platform === 'win32').each([
['lightweight tag unchanged', { type: 'commit', sha: checkout }, checkout, 0],
['annotated tag unchanged', { type: 'tag', sha: tagObject }, tagObject, 0],
['tag moved', { type: 'commit', sha: 'e'.repeat(40) }, checkout, 1],
['annotated tag recreated', { type: 'tag', sha: 'f'.repeat(40) }, tagObject, 1],
['tag deleted', null, checkout, 1],
['captured object missing', { type: 'commit', sha: checkout }, '', 1]
])('pre-publish guard: %s', (_name, object, captured, code) => {
const root = mkdtempSync(join(tmpdir(), 'release-tag-guard-'))
try {
const gh = join(root, 'gh')
const ref = JSON.stringify({ ref: `refs/tags/${tag}`, object })
const annotated = JSON.stringify({
sha: tagObject,
object: { type: 'commit', sha: checkout }
})
writeFileSync(
gh,
`#!/bin/bash\ncase "$2" in\n*/git/ref/tags/*) ${object ? `echo '${ref}'` : 'exit 1'};;\n*/git/tags/${tagObject}) echo '${annotated}';;\n*) exit 1;;\nesac\n`
)
chmodSync(gh, 0o755)
const result = runProcessSync({
program: 'bash',
args: ['-c', steps[guardIndex].run],
env: {
...process.env,
PATH: `${root}:${process.env.PATH}`,
GITHUB_REPOSITORY: 'test/repo',
TAG: tag,
TAG_OBJECT: captured,
RELEASE_SHA: checkout
}
})
expect(result.code, result.stderr + result.stdout).toBe(code)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})