diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index baed2514bac..2256fbd8436 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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: diff --git a/config/scripts/release/publication/create-draft-release.mjs b/config/scripts/release/publication/create-draft-release.mjs index a146efa6dc9..8f067b2b8f3 100644 --- a/config/scripts/release/publication/create-draft-release.mjs +++ b/config/scripts/release/publication/create-draft-release.mjs @@ -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 ?? ''} 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) { diff --git a/config/scripts/release/publication/create-draft-release.test.mjs b/config/scripts/release/publication/create-draft-release.test.mjs new file mode 100644 index 00000000000..f58ff25bc79 --- /dev/null +++ b/config/scripts/release/publication/create-draft-release.test.mjs @@ -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 }) + } + }) +})