From 6d6bd1056f87ab41054573ef0600047bb5d1dbc5 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 1 Nov 2026 13:11:23 -0700 Subject: [PATCH 1/2] fix(ci): verify published artifacts before completing releases --- .github/workflows/release.yml | 35 ++++- package.json | 2 +- scripts/create-github-release.mjs | 24 +++ scripts/release-artifacts.test.mjs | 220 +++++++++++++++++++++++++++ scripts/release-identity.mjs | 7 + scripts/release-identity.test.mjs | 83 ++++++++++ scripts/verify-release-artifacts.mjs | 205 +++++++++++++++++++++++++ 7 files changed, 571 insertions(+), 5 deletions(-) create mode 100644 scripts/release-artifacts.test.mjs create mode 100644 scripts/release-identity.mjs create mode 100644 scripts/release-identity.test.mjs create mode 100644 scripts/verify-release-artifacts.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 074157f1..566d9c8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,11 @@ jobs: contents: write id-token: write pull-requests: write + outputs: + published: ${{ steps.changesets.outputs.published }} + packages: ${{ steps.changesets.outputs.publishedPackages }} + prerelease: ${{ steps.dist-tag.outputs.prerelease }} + latest: ${{ steps.dist-tag.outputs.latest }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -77,11 +82,33 @@ jobs: commit: 'ci: changeset release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + verify-release: + name: Verify Published Release + needs: release + if: needs.release.outputs.published == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + - name: Verify Package Availability and Integrity + run: node scripts/verify-release-artifacts.mjs + env: + PUBLISHED_PACKAGES: ${{ needs.release.outputs.packages }} - name: Create GitHub Release - if: steps.changesets.outputs.published == 'true' - run: node scripts/create-github-release.mjs ${PRERELEASE_FLAG} ${LATEST_FLAG} + run: | + gh auth setup-git + node scripts/create-github-release.mjs ${PRERELEASE_FLAG} ${LATEST_FLAG} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PRERELEASE_FLAG: ${{ steps.dist-tag.outputs.prerelease == 'true' && '--prerelease' }} - LATEST_FLAG: ${{ steps.dist-tag.outputs.latest == 'true' && '--latest' }} + PRERELEASE_FLAG: ${{ needs.release.outputs.prerelease == 'true' && '--prerelease' }} + LATEST_FLAG: ${{ needs.release.outputs.latest == 'true' && '--latest' }} diff --git a/package.json b/package.json index e93d32c6..9be3a488 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "nx run-many --target=build --parallel 5", "dev": "nx run-many --target=dev --parallel 20", "test": "pnpm test:unit && pnpm test:e2e", - "test:unit": "nx run-many --target=test", + "test:unit": "nx run-many --target=test && node --test scripts/release-*.test.mjs", "test:e2e": "nx run @tanstack/cli:test:e2e", "test:e2e:matrix": "nx run @tanstack/cli:test:e2e:matrix", "check-outdated": "node scripts/check-outdated-packages.js", diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs index 7d45e3a2..5286764c 100644 --- a/scripts/create-github-release.mjs +++ b/scripts/create-github-release.mjs @@ -3,6 +3,7 @@ import { execFileSync, execSync } from 'node:child_process' import fs, { globSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' +import { findReleaseForCommit } from './release-identity.mjs' const rootDir = path.join(import.meta.dirname, '..') const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN @@ -64,6 +65,29 @@ const releaseLogs = execSync( const currentRelease = releaseLogs[0] || 'HEAD' const previousRelease = releaseLogs[1] +const releaseHead = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() +const releases = JSON.parse(execFileSync('gh', [ + 'api', '--paginate', '--slurp', 'repos/TanStack/cli/releases?per_page=100', +], { encoding: 'utf8' })).flat() +const existingRelease = await findReleaseForCommit(releases, releaseHead, (tag) => { + try { + return execFileSync('git', ['rev-parse', '--verify', `${tag}^{commit}`], { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], + }).trim() + } catch { + return execFileSync('gh', [ + 'api', `repos/TanStack/cli/commits/${encodeURIComponent(tag)}`, '--jq', '.sha', + ], { encoding: 'utf8' }).trim() + } +}) +if (existingRelease) { + if (existingRelease.draft) { + throw new Error(`A draft release already exists for ${releaseHead}: ${existingRelease.html_url}`) + } + console.info(`Release already exists for ${releaseHead}: ${existingRelease.html_url}`) + process.exit(0) +} + const bumpedPackages = [] for (const workspaceDir of workspaceDirs) { const absWorkspaceDir = path.join(rootDir, workspaceDir) diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs new file mode 100644 index 00000000..28186730 --- /dev/null +++ b/scripts/release-artifacts.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { verifyReleaseArtifacts } from './verify-release-artifacts.mjs' + +const pkg = { name: '@test/package', version: '1.0.0' } +const registryURL = 'https://registry.example' +const tarball = `${registryURL}/package.tgz` + +async function archive(t, packed = pkg) { + const dir = await mkdtemp(join(tmpdir(), 'release-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + await mkdir(join(dir, 'package')) + await writeFile(join(dir, 'package/package.json'), JSON.stringify(packed)) + const path = join(dir, 'package.tgz') + execFileSync('tar', ['-czf', path, '-C', dir, 'package/package.json']) + const bytes = await readFile(path) + return { + bytes, + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + } +} + +function options(fetchImpl) { + let time = 0 + const sleeps = [] + return { + registryURL, + fetchImpl, + timeoutMs: 100, + pollMs: 10, + log: () => {}, + now: () => time, + sleep: async (ms) => { + sleeps.push(ms) + time += ms + }, + sleeps, + } +} + +test('waits for metadata and the actual tarball, then verifies downloaded bytes', async (t) => { + const fixture = await archive(t) + let metadataReads = 0 + let tarballReads = 0 + const opts = options(async (url) => { + if (String(url) === tarball) { + tarballReads++ + return tarballReads === 1 + ? new Response('', { status: 404 }) + : new Response(fixture.bytes) + } + metadataReads++ + return metadataReads === 1 + ? new Response('', { status: 404 }) + : Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }) + }) + const verified = await verifyReleaseArtifacts([pkg], opts) + assert.equal(verified[0].integrity, fixture.integrity) + assert.equal(verified[0].bytes, fixture.bytes.length) + assert.equal(metadataReads, 3) + assert.equal(tarballReads, 2) + assert.deepEqual(opts.sleeps, [10, 10]) +}) + +test('metadata alone cannot complete a release and timeout names the missing package', async (t) => { + const fixture = await archive(t) + const opts = options(async (url) => + String(url) === tarball + ? new Response('', { status: 404 }) + : Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }), + ) + await assert.rejects( + verifyReleaseArtifacts([pkg], opts), + /Release is not ready: @test\/package@1.0.0/, + ) +}) + +test('rejects corrupt tarballs instead of retrying them as unavailable', async (t) => { + const fixture = await archive(t) + const opts = options(async (url) => + String(url) === tarball + ? new Response('corrupt') + : Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }), + ) + await assert.rejects( + verifyReleaseArtifacts([pkg], opts), + /integrity mismatch/, + ) + assert.deepEqual(opts.sleeps, []) +}) + +test('rejects a validly hashed tarball containing the wrong package version', async (t) => { + const fixture = await archive(t, { ...pkg, version: '0.9.0' }) + const opts = options(async (url) => + String(url) === tarball + ? new Response(fixture.bytes) + : Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }), + ) + await assert.rejects( + verifyReleaseArtifacts([pkg], opts), + /Packed identity mismatch/, + ) +}) + +test('honors rate limits without redownloading packages already verified', async (t) => { + const fixture = await archive(t) + const other = { name: '@test/other', version: '2.0.0' } + const otherFixture = await archive(t, other) + let firstDownloads = 0 + let otherReads = 0 + const opts = options(async (url) => { + if (String(url) === tarball) { + firstDownloads++ + return new Response(fixture.bytes) + } + if (String(url) === `${registryURL}/other.tgz`) + return new Response(otherFixture.bytes) + if (String(url).includes('other')) { + otherReads++ + if (otherReads === 1) + return new Response('', { + status: 429, + headers: { 'retry-after': '2' }, + }) + return Response.json({ + ...other, + dist: { + tarball: `${registryURL}/other.tgz`, + integrity: otherFixture.integrity, + }, + }) + } + return Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }) + }) + opts.timeoutMs = 5000 + assert.equal((await verifyReleaseArtifacts([pkg, other], opts)).length, 2) + assert.equal(firstDownloads, 1) + assert.equal(otherReads, 2) + assert.deepEqual(opts.sleeps, [2000]) +}) + +test('retries interrupted downloads but fails authentication errors immediately', async (t) => { + const fixture = await archive(t) + let downloads = 0 + const opts = options(async (url) => { + if (String(url) === tarball) { + downloads++ + if (downloads === 1) + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('connection interrupted')) + }, + }), + ) + return new Response(fixture.bytes) + } + return Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }) + }) + assert.equal((await verifyReleaseArtifacts([pkg], opts)).length, 1) + assert.equal(downloads, 2) + const denied = options(async () => new Response('', { status: 401 })) + await assert.rejects(verifyReleaseArtifacts([pkg], denied), /HTTP 401/) + assert.deepEqual(denied.sleeps, []) +}) + +test('rejects missing package output, unexpected registry identity and external tarball origins', async () => { + await assert.rejects( + verifyReleaseArtifacts( + [], + options(() => {}), + ), + /Expected published/, + ) + await assert.rejects( + verifyReleaseArtifacts( + [pkg], + options(async () => Response.json({ ...pkg, version: 'wrong' })), + ), + /Registry identity/, + ) + await assert.rejects( + verifyReleaseArtifacts( + [pkg], + options(async () => + Response.json({ + ...pkg, + dist: { + tarball: 'https://other.example/file.tgz', + integrity: `sha512-${'A'.repeat(86)}==`, + }, + }), + ), + ), + /Unexpected tarball origin/, + ) +}) diff --git a/scripts/release-identity.mjs b/scripts/release-identity.mjs new file mode 100644 index 00000000..923436fc --- /dev/null +++ b/scripts/release-identity.mjs @@ -0,0 +1,7 @@ +export async function findReleaseForCommit(releases, commit, resolveTag) { + for (const release of releases) { + if (!release.tag_name?.startsWith('release-')) continue + if ((await resolveTag(release.tag_name)) === commit) return release + } + return null +} diff --git a/scripts/release-identity.test.mjs b/scripts/release-identity.test.mjs new file mode 100644 index 00000000..47ac418a --- /dev/null +++ b/scripts/release-identity.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { spawnSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, delimiter } from 'node:path' +import { fileURLToPath } from 'node:url' +import { findReleaseForCommit } from './release-identity.mjs' + +test('finds an existing combined release by its peeled source commit', async () => { + const releases = [ + { tag_name: '@tanstack/cli@1.0.0' }, + { tag_name: 'release-newer', html_url: 'newer' }, + { tag_name: 'release-original', html_url: 'existing' }, + ] + const checked = [] + const found = await findReleaseForCommit(releases, 'target', (tag) => { + checked.push(tag) + return tag === 'release-original' ? 'target' : 'other' + }) + assert.equal(found.html_url, 'existing') + assert.deepEqual(checked, ['release-newer', 'release-original']) +}) + +test('does not mistake a different source commit for the requested release', async () => { + assert.equal( + await findReleaseForCommit( + [{ tag_name: 'release-old' }], + 'new', + () => 'old', + ), + null, + ) +}) + +test('retains matching drafts so the caller can preserve their publication hold', async () => { + const draft = { tag_name: 'release-draft', draft: true } + assert.equal( + await findReleaseForCommit([draft], 'target', () => 'target'), + draft, + ) +}) + +test('does not silently create another release when tag resolution fails', async () => { + await assert.rejects( + findReleaseForCommit([{ tag_name: 'release-old' }], 'target', () => { + throw new Error('tag lookup failed') + }), + /tag lookup failed/, + ) +}) + +test('the release command stops before any write when its source already has a release', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'release-command-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const commit = 'a'.repeat(40) + const git = join(directory, 'git') + const gh = join(directory, 'gh') + await writeFile( + git, + `#!/usr/bin/env node\nconst a=process.argv.slice(2);if(a[0]==='log'||a[0]==='rev-parse')console.log('${commit}');else process.exit(99)\n`, + { mode: 0o755 }, + ) + await writeFile( + gh, + `#!/usr/bin/env node\nconst a=process.argv.slice(2);if(a[0]==='api'&&a.includes('--paginate'))console.log(JSON.stringify([[{tag_name:'release-existing',html_url:'https://example.test/existing'}]]));else process.exit(99)\n`, + { mode: 0o755 }, + ) + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL('./create-github-release.mjs', import.meta.url))], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${directory}${delimiter}${process.env.PATH}`, + }, + }, + ) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /Release already exists/) + assert.match(result.stdout, /https:\/\/example.test\/existing/) +}) diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs new file mode 100644 index 00000000..669c7f20 --- /dev/null +++ b/scripts/verify-release-artifacts.mjs @@ -0,0 +1,205 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { fileURLToPath } from 'node:url' + +class NotAvailable extends Error { + constructor(message, retryAfterMs = 0) { + super(message) + this.retryAfterMs = retryAfterMs + } +} + +async function fetchAvailable(url, fetchImpl, requestTimeoutMs) { + let response + try { + response = await fetchImpl(url, { + signal: AbortSignal.timeout(requestTimeoutMs), + }) + } catch (error) { + throw new NotAvailable(`${url}: ${error.message}`) + } + if ( + response.status === 404 || + response.status === 429 || + response.status >= 500 + ) { + const retryAfter = response.headers.get('retry-after') + const retryAfterMs = + retryAfter && /^\d+$/.test(retryAfter) + ? Number(retryAfter) * 1000 + : retryAfter + ? Math.max(0, Date.parse(retryAfter) - Date.now()) + : 0 + await response.body?.cancel() + throw new NotAvailable(`${url}: HTTP ${response.status}`, retryAfterMs) + } + if (!response.ok) { + await response.body?.cancel() + throw new Error(`${url}: HTTP ${response.status}`) + } + return response +} + +async function verifyPackage(pkg, { registry, fetchImpl, requestTimeoutMs }) { + const metadataURL = `${registry.origin}/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}` + const metadata = await ( + await fetchAvailable(metadataURL, fetchImpl, requestTimeoutMs) + ).json() + if (metadata.name !== pkg.name || metadata.version !== pkg.version) { + throw new Error(`Registry identity mismatch for ${pkg.name}@${pkg.version}`) + } + const integrity = metadata.dist?.integrity + ?.split(/\s+/) + .find((value) => /^sha512-[A-Za-z0-9+/]+={0,2}$/.test(value)) + if (!integrity) + throw new Error(`Missing SHA512 integrity for ${pkg.name}@${pkg.version}`) + const tarballURL = new URL(metadata.dist.tarball) + if (tarballURL.origin !== registry.origin) { + throw new Error(`Unexpected tarball origin for ${pkg.name}@${pkg.version}`) + } + const response = await fetchAvailable(tarballURL, fetchImpl, requestTimeoutMs) + if (!response.body) + throw new Error(`Empty tarball for ${pkg.name}@${pkg.version}`) + const directory = await mkdtemp(join(tmpdir(), 'tanstack-release-')) + const archive = join(directory, 'package.tgz') + const hash = createHash('sha512') + let bytes = 0 + try { + try { + await pipeline( + Readable.fromWeb(response.body), + new Transform({ + transform(chunk, _encoding, callback) { + hash.update(chunk) + bytes += chunk.length + callback(null, chunk) + }, + }), + createWriteStream(archive), + ) + } catch (error) { + if ( + error instanceof TypeError || + ['AbortError', 'TimeoutError'].includes(error.name) + ) { + throw new NotAvailable( + `Tarball download interrupted for ${pkg.name}@${pkg.version}: ${error.message}`, + ) + } + throw error + } + if (`sha512-${hash.digest('base64')}` !== integrity) { + throw new Error( + `Tarball integrity mismatch for ${pkg.name}@${pkg.version}`, + ) + } + const packed = JSON.parse( + execFileSync('tar', ['-xOzf', archive, 'package/package.json'], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }), + ) + if (packed.name !== pkg.name || packed.version !== pkg.version) { + throw new Error(`Packed identity mismatch for ${pkg.name}@${pkg.version}`) + } + return { ...pkg, integrity, bytes } + } finally { + await rm(directory, { recursive: true, force: true }) + } +} + +export async function verifyReleaseArtifacts( + packages, + { + registryURL = 'https://registry.npmjs.org', + fetchImpl = fetch, + now = Date.now, + sleep = (ms) => new Promise((done) => setTimeout(done, ms)), + timeoutMs = 20 * 60 * 1000, + pollMs = 15 * 1000, + log = console.log, + } = {}, +) { + if ( + !Array.isArray(packages) || + !packages.length || + packages.some( + (pkg) => + !pkg || + typeof pkg.name !== 'string' || + !pkg.name || + typeof pkg.version !== 'string' || + !pkg.version, + ) + ) { + throw new Error('Expected published package names and versions') + } + if ( + !Number.isFinite(timeoutMs) || + timeoutMs <= 0 || + !Number.isFinite(pollMs) || + pollMs <= 0 + ) { + throw new Error('Verification timeout and poll interval must be positive') + } + const registry = new URL(registryURL) + if (registry.protocol !== 'https:') + throw new Error('The registry must use HTTPS') + const deadline = now() + timeoutMs + const pending = new Map( + packages.map((pkg) => [`${pkg.name}@${pkg.version}`, pkg]), + ) + const verified = [] + let lastReasons = [] + while (pending.size) { + let retryAfterMs = pollMs + lastReasons = [] + for (const [id, pkg] of pending) { + if (now() >= deadline) break + try { + const receipt = await verifyPackage(pkg, { + registry, + fetchImpl, + requestTimeoutMs: Math.max(1, Math.min(30_000, deadline - now())), + }) + verified.push(receipt) + pending.delete(id) + log( + `Verified ${id}: SHA512 and packed identity (${receipt.bytes} bytes)`, + ) + } catch (error) { + if (!(error instanceof NotAvailable)) throw error + retryAfterMs = Math.max(retryAfterMs, error.retryAfterMs || 0) + lastReasons.push(`${id}: ${error.message}`) + } + } + if (!pending.size) return verified + const remaining = deadline - now() + if (remaining <= 0 || retryAfterMs > remaining) break + log(`Waiting for npm availability: ${lastReasons.join('; ')}`) + await sleep(retryAfterMs) + } + throw new Error( + `Release is not ready: ${[...pending.keys()].join(', ')}. ${lastReasons.join('; ')}. The version may still be scanned or staged. Rerun this verification job after availability changes, without rerunning the publisher.`, + ) +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + await verifyReleaseArtifacts( + JSON.parse(process.env.PUBLISHED_PACKAGES || 'null'), + ) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } +} From d84e632c64158b1cdbb6868e77e0356603b17458 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 1 Nov 2026 15:20:20 -0700 Subject: [PATCH 2/2] fix(ci): make release retries safe across job boundaries --- .github/workflows/release.yml | 2 + scripts/create-github-release.mjs | 89 ++++++++++++++++---- scripts/release-artifacts.test.mjs | 24 ++++++ scripts/release-identity.mjs | 18 ++++ scripts/release-identity.test.mjs | 119 ++++++++++++++++++++++++++- scripts/verify-release-artifacts.mjs | 14 +++- 6 files changed, 246 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 566d9c8d..5093e1fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,6 +106,8 @@ jobs: - name: Create GitHub Release run: | gh auth setup-git + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" node scripts/create-github-release.mjs ${PRERELEASE_FLAG} ${LATEST_FLAG} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs index 5286764c..a170275a 100644 --- a/scripts/create-github-release.mjs +++ b/scripts/create-github-release.mjs @@ -3,7 +3,7 @@ import { execFileSync, execSync } from 'node:child_process' import fs, { globSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { findReleaseForCommit } from './release-identity.mjs' +import { findReleaseForCommit, latestReleaseFlag } from './release-identity.mjs' const rootDir = path.join(import.meta.dirname, '..') const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN @@ -65,26 +65,54 @@ const releaseLogs = execSync( const currentRelease = releaseLogs[0] || 'HEAD' const previousRelease = releaseLogs[1] -const releaseHead = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() -const releases = JSON.parse(execFileSync('gh', [ - 'api', '--paginate', '--slurp', 'repos/TanStack/cli/releases?per_page=100', -], { encoding: 'utf8' })).flat() -const existingRelease = await findReleaseForCommit(releases, releaseHead, (tag) => { +const releaseHead = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim() +const releases = JSON.parse( + execFileSync( + 'gh', + [ + 'api', + '--paginate', + '--slurp', + 'repos/TanStack/cli/releases?per_page=100', + ], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ), +).flat() +const resolveTagCommit = (tag) => { try { return execFileSync('git', ['rev-parse', '--verify', `${tag}^{commit}`], { - encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], }).trim() } catch { - return execFileSync('gh', [ - 'api', `repos/TanStack/cli/commits/${encodeURIComponent(tag)}`, '--jq', '.sha', - ], { encoding: 'utf8' }).trim() + return execFileSync( + 'gh', + [ + 'api', + `repos/TanStack/cli/commits/${encodeURIComponent(tag)}`, + '--jq', + '.sha', + ], + { encoding: 'utf8' }, + ).trim() } -}) +} +const existingRelease = await findReleaseForCommit( + releases, + releaseHead, + resolveTagCommit, +) if (existingRelease) { if (existingRelease.draft) { - throw new Error(`A draft release already exists for ${releaseHead}: ${existingRelease.html_url}`) + throw new Error( + `A draft release already exists for ${releaseHead}: ${existingRelease.html_url}`, + ) } - console.info(`Release already exists for ${releaseHead}: ${existingRelease.html_url}`) + console.info( + `Release already exists for ${releaseHead}: ${existingRelease.html_url}`, + ) process.exit(0) } @@ -248,6 +276,40 @@ ${changelogMd} ${bumpedPackages.map((pkg) => `- ${pkg.name}@${pkg.version}`).join('\n')} ` +let latestHead +if ( + isLatest && + releases.some((release) => !release.draft && !release.prerelease) +) { + const latestRelease = JSON.parse( + execFileSync('gh', ['api', 'repos/TanStack/cli/releases/latest'], { + encoding: 'utf8', + }), + ) + latestHead = resolveTagCommit(latestRelease.tag_name) +} +const latestOption = latestReleaseFlag({ + requested: process.argv.includes('--latest=false') + ? 'false' + : isLatest + ? 'true' + : undefined, + releaseHead, + latestHead, + isAncestor: (older, newer) => { + try { + execFileSync('git', ['merge-base', '--is-ancestor', older, newer], { + stdio: 'ignore', + }) + return true + } catch (error) { + if (error.status === 1) return false + throw error + } + }, +}) +const latestFlag = latestOption ? ` ${latestOption}` : '' + let tagExists = false try { execSync(`git rev-parse ${tagName}`, { stdio: 'ignore' }) @@ -262,7 +324,6 @@ if (!tagExists) { } const prereleaseFlag = isPrerelease ? '--prerelease' : '' -const latestFlag = isLatest ? ' --latest' : '' const tmpFile = path.join(tmpdir(), `release-notes-${tagName}.md`) fs.writeFileSync(tmpFile, body) diff --git a/scripts/release-artifacts.test.mjs b/scripts/release-artifacts.test.mjs index 28186730..321e7803 100644 --- a/scripts/release-artifacts.test.mjs +++ b/scripts/release-artifacts.test.mjs @@ -218,3 +218,27 @@ test('rejects missing package output, unexpected registry identity and external /Unexpected tarball origin/, ) }) + +test('retries a metadata response whose body was interrupted', async (t) => { + const fixture = await archive(t) + let reads = 0 + const opts = options(async (url) => { + if (String(url) === tarball) return new Response(fixture.bytes) + reads++ + if (reads === 1) + return new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('metadata connection interrupted')) + }, + }), + ) + return Response.json({ + ...pkg, + dist: { tarball, integrity: fixture.integrity }, + }) + }) + assert.equal((await verifyReleaseArtifacts([pkg], opts)).length, 1) + assert.equal(reads, 2) + assert.deepEqual(opts.sleeps, [10]) +}) diff --git a/scripts/release-identity.mjs b/scripts/release-identity.mjs index 923436fc..28beb3b6 100644 --- a/scripts/release-identity.mjs +++ b/scripts/release-identity.mjs @@ -5,3 +5,21 @@ export async function findReleaseForCommit(releases, commit, resolveTag) { } return null } + +export function latestReleaseFlag({ + requested, + releaseHead, + latestHead, + isAncestor, +}) { + if (requested === 'false') return '--latest=false' + if (requested !== 'true') return '' + if ( + latestHead && + latestHead !== releaseHead && + isAncestor(releaseHead, latestHead) + ) { + return '--latest=false' + } + return '--latest' +} diff --git a/scripts/release-identity.test.mjs b/scripts/release-identity.test.mjs index 47ac418a..1df4f3cf 100644 --- a/scripts/release-identity.test.mjs +++ b/scripts/release-identity.test.mjs @@ -1,11 +1,11 @@ import assert from 'node:assert/strict' import { test } from 'node:test' import { spawnSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' import { fileURLToPath } from 'node:url' -import { findReleaseForCommit } from './release-identity.mjs' +import { findReleaseForCommit, latestReleaseFlag } from './release-identity.mjs' test('finds an existing combined release by its peeled source commit', async () => { const releases = [ @@ -63,7 +63,7 @@ test('the release command stops before any write when its source already has a r ) await writeFile( gh, - `#!/usr/bin/env node\nconst a=process.argv.slice(2);if(a[0]==='api'&&a.includes('--paginate'))console.log(JSON.stringify([[{tag_name:'release-existing',html_url:'https://example.test/existing'}]]));else process.exit(99)\n`, + `#!/usr/bin/env node\nconst a=process.argv.slice(2);if(a[0]==='api'&&a.includes('--paginate'))console.log(JSON.stringify([[{tag_name:'release-existing',html_url:'https://example.test/existing',body:'x'.repeat(1100000)}]]));else process.exit(99)\n`, { mode: 0o755 }, ) const result = spawnSync( @@ -81,3 +81,116 @@ test('the release command stops before any write when its source already has a r assert.match(result.stdout, /Release already exists/) assert.match(result.stdout, /https:\/\/example.test\/existing/) }) + +test('an older retry cannot replace a newer stable release as latest', () => { + const checked = [] + assert.equal( + latestReleaseFlag({ + requested: 'true', + releaseHead: 'older', + latestHead: 'newer', + isAncestor: (a, b) => { + checked.push([a, b]) + return true + }, + }), + '--latest=false', + ) + assert.deepEqual(checked, [['older', 'newer']]) + assert.equal( + latestReleaseFlag({ + requested: 'true', + releaseHead: 'newer', + latestHead: 'older', + isAncestor: () => false, + }), + '--latest', + ) + assert.equal( + latestReleaseFlag({ + requested: 'false', + releaseHead: 'head', + isAncestor: () => { + throw new Error('unexpected lookup') + }, + }), + '--latest=false', + ) + assert.equal(latestReleaseFlag({ releaseHead: 'head' }), '') +}) + +test('the release command passes an explicit false latest flag for older retries', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'release-latest-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const older = 'a'.repeat(40) + const newer = 'b'.repeat(40) + const recorded = join(directory, 'release-arguments.json') + const tagWrite = join(directory, 'tag-write') + await writeFile( + join(directory, 'git'), + `#!/usr/bin/env node +const a=process.argv.slice(2) +if(a[0]==='log')console.log(a.includes('--format=%H')?'${older}':'') +else if(a[0]==='rev-parse'&&a[1]==='HEAD')console.log('${older}') +else if(a[0]==='rev-parse'&&a[1]==='--verify')console.log('${newer}') +else if(a[0]==='rev-parse')process.exit(1) +else if(a[0]==='merge-base')process.exit(0) +else if(a[0]==='tag'||a[0]==='push')require('node:fs').writeFileSync(${JSON.stringify(tagWrite)},'written') +else process.exit(99) +`, + { mode: 0o755 }, + ) + await writeFile( + join(directory, 'gh'), + `#!/usr/bin/env node +const a=process.argv.slice(2) +if(a[0]==='api'&&a.includes('--paginate'))console.log(JSON.stringify([[{tag_name:'release-newer',draft:false,prerelease:false}]])) +else if(a[0]==='api'&&a[1].endsWith('/releases/latest')){if(process.env.FAIL_LATEST)process.exit(1);console.log(JSON.stringify({tag_name:'release-newer'}))} +else if(a[0]==='release'&&a[1]==='create')require('node:fs').writeFileSync(${JSON.stringify(recorded)},JSON.stringify(a)) +else process.exit(99) +`, + { mode: 0o755 }, + ) + for (const requested of ['--latest', '--latest=false']) { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./create-github-release.mjs', import.meta.url)), + requested, + ], + { + encoding: 'utf8', + env: { + ...process.env, + GH_TOKEN: '', + GITHUB_TOKEN: '', + PATH: `${directory}${delimiter}${process.env.PATH}`, + }, + }, + ) + assert.equal(result.status, 0, result.stderr) + const args = JSON.parse(await readFile(recorded, 'utf8')) + assert.ok(args.includes('--latest=false')) + assert.ok(!args.includes('--latest')) + } + await rm(tagWrite) + const failedLookup = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('./create-github-release.mjs', import.meta.url)), + '--latest', + ], + { + encoding: 'utf8', + env: { + ...process.env, + GH_TOKEN: '', + GITHUB_TOKEN: '', + FAIL_LATEST: '1', + PATH: `${directory}${delimiter}${process.env.PATH}`, + }, + }, + ) + assert.notEqual(failedLookup.status, 0) + await assert.rejects(readFile(tagWrite), { code: 'ENOENT' }) +}) diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs index 669c7f20..333b387e 100644 --- a/scripts/verify-release-artifacts.mjs +++ b/scripts/verify-release-artifacts.mjs @@ -48,9 +48,17 @@ async function fetchAvailable(url, fetchImpl, requestTimeoutMs) { async function verifyPackage(pkg, { registry, fetchImpl, requestTimeoutMs }) { const metadataURL = `${registry.origin}/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}` - const metadata = await ( - await fetchAvailable(metadataURL, fetchImpl, requestTimeoutMs) - ).json() + const metadataResponse = await fetchAvailable( + metadataURL, + fetchImpl, + requestTimeoutMs, + ) + let metadata + try { + metadata = await metadataResponse.json() + } catch (error) { + throw new NotAvailable(`${metadataURL}: ${error.message}`) + } if (metadata.name !== pkg.name || metadata.version !== pkg.version) { throw new Error(`Registry identity mismatch for ${pkg.name}@${pkg.version}`) }