mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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 ind28d414bbd), 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:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user