diff --git a/.github/actions/publish-verified-package/action.yml b/.github/actions/publish-verified-package/action.yml new file mode 100644 index 00000000..60b3c7b7 --- /dev/null +++ b/.github/actions/publish-verified-package/action.yml @@ -0,0 +1,39 @@ +name: Publish and verify one stable package +description: Publish one package once and wait for its verified public archive. +inputs: + directory: + description: Release package workspace directory. + required: true + plan: + description: Version and dependency manifest from the checked-out release commit. + required: true +runs: + using: composite + steps: + - name: Check registry and prior upload attempt + id: inspect + shell: bash + run: node scripts/release-publish-plan.mjs inspect + env: + GH_TOKEN: ${{ github.token }} + STABLE_RELEASE_PLAN: ${{ inputs.plan }} + RELEASE_PACKAGE_DIR: ${{ inputs.directory }} + - name: Persist upload attempt before publishing + if: steps.inspect.outputs.publish == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.inspect.outputs.marker }} + path: ${{ steps.inspect.outputs.receipt }} + if-no-files-found: error + - name: Publish exact package + if: steps.inspect.outputs.publish == 'true' + shell: bash + run: pnpm --dir "$RELEASE_PACKAGE_DIR" publish --json --no-git-checks --access public --tag latest + env: + RELEASE_PACKAGE_DIR: ${{ inputs.directory }} + npm_config_registry: https://registry.npmjs.org + - name: Verify exact public archive + shell: bash + run: node scripts/verify-release-artifacts.mjs + env: + PUBLISHED_PACKAGES: ${{ steps.inspect.outputs.package }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 074157f1..8b1beb09 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false + queue: max env: NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} @@ -22,7 +22,13 @@ jobs: permissions: contents: write id-token: write + actions: read pull-requests: write + outputs: + published: ${{ steps.changesets.outputs.published == 'true' || steps.stable-tags.outputs.published == 'true' }} + 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 @@ -72,16 +78,93 @@ jobs: uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 with: version: pnpm run changeset:version - publish: pnpm run changeset:publish ${{ steps.dist-tag.outputs.tag && format('--tag {0}', steps.dist-tag.outputs.tag) }} + publish: ${{ github.ref_name != 'main' && format('pnpm run changeset:publish --tag {0}', steps.dist-tag.outputs.tag) || '' }} title: 'ci: Version Packages' commit: 'ci: changeset release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Plan stable publication + id: stable-plan + if: github.ref_name == 'main' && steps.changesets.outputs.hasChangesets == 'false' + run: node scripts/release-publish-plan.mjs plan + - name: Publish and verify create + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: packages/create + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Publish and verify cli + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: packages/cli + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Publish and verify create-start-app + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: cli-aliases/create-start-app + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Publish and verify create-tanstack + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: cli-aliases/create-tanstack + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Publish and verify create-tanstack-app + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: cli-aliases/create-tanstack-app + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Publish and verify create-tsrouter-app + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: cli-aliases/create-tsrouter-app + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Publish and verify ts-create-start + if: steps.stable-plan.outputs.active == 'true' + uses: ./.github/actions/publish-verified-package + with: + directory: cli-aliases/ts-create-start + plan: ${{ steps.stable-plan.outputs.packages }} + - name: Tag verified stable packages + id: stable-tags + if: steps.stable-plan.outputs.active == 'true' + run: node scripts/release-publish-plan.mjs tags + env: + STABLE_RELEASE_PLAN: ${{ steps.stable-plan.outputs.packages }} + 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 + if: needs.release.outputs.latest != 'true' + 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 + 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 }} 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..a170275a 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, latestReleaseFlag } from './release-identity.mjs' const rootDir = path.join(import.meta.dirname, '..') const ghToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN @@ -64,6 +65,57 @@ 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', maxBuffer: 16 * 1024 * 1024 }, + ), +).flat() +const resolveTagCommit = (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() + } +} +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}`, + ) + } + 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) @@ -224,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' }) @@ -238,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 new file mode 100644 index 00000000..a411627c --- /dev/null +++ b/scripts/release-artifacts.test.mjs @@ -0,0 +1,258 @@ +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/, + ) +}) + +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]) +}) + +test('rejects a public archive with the wrong exact internal dependency', async (t) => { + const fixture = await archive(t, { ...pkg, dependencies: { '@tanstack/create': '0.71.14' } }) + const { sleeps, ...config } = options(async (url) => + String(url) === tarball + ? new Response(fixture.bytes) + : Response.json({ ...pkg, dist: { tarball, integrity: fixture.integrity } }), + ) + await assert.rejects( + verifyReleaseArtifacts([{ ...pkg, dependencies: { '@tanstack/create': '0.71.15' } }], config), + /Packed dependency mismatch/, + ) + assert.deepEqual(sleeps, []) +}) diff --git a/scripts/release-identity.mjs b/scripts/release-identity.mjs new file mode 100644 index 00000000..28beb3b6 --- /dev/null +++ b/scripts/release-identity.mjs @@ -0,0 +1,25 @@ +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 +} + +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 new file mode 100644 index 00000000..1df4f3cf --- /dev/null +++ b/scripts/release-identity.test.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { spawnSync } from 'node:child_process' +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, latestReleaseFlag } 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',body:'x'.repeat(1100000)}]]));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/) +}) + +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/release-publish-plan.mjs b/scripts/release-publish-plan.mjs new file mode 100644 index 00000000..3db105d2 --- /dev/null +++ b/scripts/release-publish-plan.mjs @@ -0,0 +1,177 @@ +import { execFileSync } from 'node:child_process' +import { appendFileSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const packageDirectories = [ + 'packages/create', + 'packages/cli', + 'cli-aliases/create-start-app', + 'cli-aliases/create-tanstack', + 'cli-aliases/create-tanstack-app', + 'cli-aliases/create-tsrouter-app', + 'cli-aliases/ts-create-start', +] + +export function planStableRelease(manifests, previous, changedPaths) { + const changed = manifests.filter((pkg) => previous.get(pkg.dir)?.version !== pkg.version) + if (!changed.length) return [] + + const allowed = new Set(changed.flatMap(({ dir }) => [ + `${dir}/package.json`, + `${dir}/CHANGELOG.md`, + ])) + for (const file of changedPaths) { + if (!allowed.has(file) && !/^\.changeset\/[\w-]+\.md$/.test(file)) { + throw new Error(`Stable version commit includes an unexpected file: ${file}`) + } + } + for (const pkg of changed) { + const { version: oldVersion, ...oldFields } = previous.get(pkg.dir) ?? {} + const { version, dir, ...fields } = pkg + if (!oldVersion || JSON.stringify(oldFields) !== JSON.stringify(fields)) { + throw new Error(`Expected a version-only manifest change in ${dir}`) + } + } + + const byName = new Map(manifests.map((pkg, index) => [pkg.name, { pkg, index }])) + if (byName.size !== manifests.length) throw new Error('Duplicate release package names') + return manifests.map((pkg, index) => { + const { name, version, dir } = pkg + if (!/^(?:@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(name) || !/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid stable package identity in ${dir}`) + } + const dependencies = {} + for (const [dependency, range] of Object.entries(pkg.dependencies ?? {})) { + const target = byName.get(dependency) + if (!target) { + if (range.startsWith('workspace:')) throw new Error(`Unknown release dependency: ${dependency}`) + continue + } + if (target.index >= index || range !== 'workspace:*') { + throw new Error(`Unexpected release dependency ${name} -> ${dependency}`) + } + dependencies[dependency] = target.pkg.version + } + return { name, version, dir, dependencies, changed: changed.includes(pkg) } + }) +} + +export function shouldPublish(pkg, { attempted, registryStatus }) { + if (registryStatus !== 200 && registryStatus !== 404) { + throw new Error(`Cannot establish publication state for ${pkg.name}: HTTP ${registryStatus}`) + } + return pkg.changed && registryStatus === 404 && !attempted +} + +export function priorPhaseWasReached(jobs, directory) { + const release = jobs.find((job) => job.name === 'Release') + if (!release) return false + if (release.status !== 'completed') throw new Error('Prior release job state is unavailable') + const name = `Publish and verify ${directory.split('/').at(-1)}` + return release.steps.some((step) => step.name === name && step.conclusion !== 'skipped' && step.started_at !== null) +} + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim() +const readManifest = (dir) => JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')) +const output = (name, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`) + +function assertCurrentVersionPlan(plan) { + git('fetch', 'origin', 'refs/heads/main') + for (const { name, version, dir } of plan) { + const live = JSON.parse(git('show', `FETCH_HEAD:${dir}/package.json`)) + if (live.name !== name || live.version !== version) { + throw new Error(`Stable release superseded for ${name}@${version}`) + } + } +} + +async function run(command) { + const { GITHUB_REPOSITORY: repo, GITHUB_REF: ref, GITHUB_SHA: sha, GITHUB_RUN_ID: runId } = process.env + if (repo !== 'TanStack/cli' || ref !== 'refs/heads/main' || sha !== git('rev-parse', 'HEAD') || !/^\d+$/.test(runId ?? '')) { + throw new Error('Stable publishing requires the checked-out TanStack/cli main workflow') + } + if (command === 'plan') { + const publicDirectories = ['packages', 'cli-aliases'].flatMap((parent) => + readdirSync(parent, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => { + const dir = `${parent}/${entry.name}` + try { return readManifest(dir).private ? [] : [dir] } + catch (error) { if (error.code === 'ENOENT') return []; throw error } + }), + ) + if (publicDirectories.sort().join('\n') !== [...packageDirectories].sort().join('\n')) { + throw new Error('Update the stable release phase list for the current public workspaces') + } + const manifests = packageDirectories.map((dir) => ({ ...readManifest(dir), dir })) + const previous = new Map(packageDirectories.map((dir) => [dir, JSON.parse(git('show', `HEAD^:${dir}/package.json`))])) + const paths = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n') + const plan = planStableRelease(manifests, previous, paths) + if (plan.length) assertCurrentVersionPlan(plan) + output('active', String(plan.length > 0)) + output('packages', JSON.stringify(plan)) + return + } + + const plan = JSON.parse(process.env.STABLE_RELEASE_PLAN) + if (command === 'inspect') { + const pkg = plan.find(({ dir }) => dir === process.env.RELEASE_PACKAGE_DIR) + if (!pkg) throw new Error('Package is missing from the stable release plan') + const manifest = readManifest(pkg.dir) + if (manifest.name !== pkg.name || manifest.version !== pkg.version) throw new Error('Manifest identity changed') + const marker = `npm-upload-${pkg.dir.replaceAll('/', '-')}-${pkg.version}-${sha}` + const artifacts = JSON.parse(execFileSync('gh', ['api', `repos/${repo}/actions/runs/${runId}/artifacts?name=${marker}`], { encoding: 'utf8' })) + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${pkg.version}`, { + headers: { 'cache-control': 'no-cache' }, + signal: AbortSignal.timeout(30_000), + }) + if (response.ok) { + const identity = await response.json() + if (identity.name !== pkg.name || identity.version !== pkg.version) throw new Error('Registry identity mismatch') + } + if (!Number.isSafeInteger(artifacts.total_count) || artifacts.total_count < 0) { + throw new Error('Upload attempt evidence is unavailable') + } + let attempted = artifacts.total_count > 0 + if (!attempted && response.status === 404 && pkg.changed) { + const runAttempt = Number(process.env.GITHUB_RUN_ATTEMPT) + if (!Number.isSafeInteger(runAttempt) || runAttempt < 1) throw new Error('Unknown workflow attempt') + for (let attempt = 1; attempt < runAttempt && !attempted; attempt++) { + const pages = JSON.parse(execFileSync('gh', [ + 'api', '--paginate', '--slurp', + `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100`, + ], { encoding: 'utf8' })) + attempted = priorPhaseWasReached(pages.flatMap((page) => page.jobs), pkg.dir) + } + } + const publish = shouldPublish(pkg, { attempted, registryStatus: response.status }) + if (publish) assertCurrentVersionPlan(plan) + const receipt = resolve(process.env.RUNNER_TEMP, `${marker}.json`) + writeFileSync(receipt, JSON.stringify({ name: pkg.name, version: pkg.version, sha, runId })) + output('publish', String(publish)) + output('marker', marker) + output('receipt', receipt) + output('package', JSON.stringify([pkg])) + return + } + if (command === 'tags') { + assertCurrentVersionPlan(plan) + const refs = [] + for (const { name, version, changed } of plan) { + if (!changed) continue + const tag = `${name}@${version}` + const ref = `refs/tags/${tag}` + const existing = git('tag', '--list', tag) + if (existing && git('rev-parse', `${ref}^{commit}`) !== sha) throw new Error(`Tag already points elsewhere: ${tag}`) + if (!existing) git('tag', tag, sha) + refs.push(`${ref}:${ref}`) + } + if (refs.length) execFileSync('git', ['push', '--atomic', 'origin', ...refs], { stdio: 'inherit' }) + output('published', String(refs.length > 0)) + return + } + throw new Error(`Unknown stable release command: ${command}`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await run(process.argv[2]) +} diff --git a/scripts/release-publish-plan.test.mjs b/scripts/release-publish-plan.test.mjs new file mode 100644 index 00000000..e95fdd1b --- /dev/null +++ b/scripts/release-publish-plan.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { packageDirectories, planStableRelease, priorPhaseWasReached, shouldPublish } from './release-publish-plan.mjs' + +const manifests = packageDirectories.map((dir) => ({ ...JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')), dir })) +const previous = new Map(manifests.map(({ dir, ...pkg }) => [dir, { ...pkg, version: '0.0.0' }])) +const changed = packageDirectories.map((dir) => `${dir}/package.json`) + +test('plans the real stable workspace graph in dependency order', () => { + const plan = planStableRelease(manifests, previous, changed) + const [core, cli, ...aliases] = plan + assert.equal(core.name, '@tanstack/create') + assert.deepEqual(core.dependencies, {}) + assert.deepEqual(cli.dependencies, { [core.name]: core.version }) + assert.equal(aliases.length, 5) + for (const alias of aliases) assert.deepEqual(alias.dependencies, { [cli.name]: cli.version, [core.name]: core.version }) + const workflow = readFileSync('.github/workflows/release.yml', 'utf8') + const directories = [...workflow.matchAll(/^ directory: (.+)$/gm)].map((match) => match[1]) + assert.deepEqual(directories, plan.map((pkg) => pkg.dir)) +}) + +test('ordinary main changes cannot start publication', () => { + const sameVersions = new Map(manifests.map(({ dir, ...pkg }) => [dir, pkg])) + assert.deepEqual(planStableRelease(manifests, sameVersions, ['packages/cli/src/cli.ts']), []) + assert.throws(() => planStableRelease(manifests, previous, [...changed, 'packages/cli/src/cli.ts']), /unexpected file/) + const changedDependencies = new Map(previous) + changedDependencies.set('packages/cli', { ...previous.get('packages/cli'), dependencies: {} }) + assert.throws(() => planStableRelease(manifests, changedDependencies, changed), /version-only/) +}) + +test('only an unattempted missing changed version may publish', () => { + const [pkg] = planStableRelease(manifests, previous, changed) + assert.equal(shouldPublish(pkg, { attempted: false, registryStatus: 404 }), true) + assert.equal(shouldPublish(pkg, { attempted: true, registryStatus: 404 }), false) + assert.equal(shouldPublish(pkg, { attempted: false, registryStatus: 200 }), false) + assert.equal(shouldPublish({ ...pkg, changed: false }, { attempted: false, registryStatus: 404 }), false) + assert.throws(() => shouldPublish(pkg, { attempted: false, registryStatus: 503 }), /publication state/) +}) + +test('reruns preserve the upload boundary when a prior artifact is missing', () => { + const jobs = [{ name: 'Release', status: 'completed', steps: [ + { name: 'Publish and verify create', conclusion: 'failure' }, + { name: 'Publish and verify cli', conclusion: 'skipped' }, + ] }] + assert.equal(priorPhaseWasReached(jobs, 'packages/create'), true) + assert.equal(priorPhaseWasReached(jobs, 'packages/cli'), false) + assert.equal(priorPhaseWasReached([], 'packages/create'), false) + assert.throws(() => priorPhaseWasReached([{ name: 'Release', status: 'queued' }], 'packages/create'), /unavailable/) +}) diff --git a/scripts/verify-release-artifacts.mjs b/scripts/verify-release-artifacts.mjs new file mode 100644 index 00000000..c6d05b87 --- /dev/null +++ b/scripts/verify-release-artifacts.mjs @@ -0,0 +1,218 @@ +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 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}`) + } + 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}`) + } + for (const [name, version] of Object.entries(pkg.dependencies ?? {})) { + if (packed.dependencies?.[name] !== version) { + throw new Error(`Packed dependency mismatch for ${pkg.name}: ${name}@${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 + } +}