From 57fc589e5b07f9d594612e76a38f80195d754822 Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Fri, 25 Sep 2026 21:11:02 +0200 Subject: [PATCH] Simplify Changesets releases and wait for registry visibility --- .github/workflows/release.yml | 9 +- CONTRIBUTING.md | 25 +- docs/prompts/2026-09-25-release-tidy.md | 44 +++ package.json | 2 +- scripts/publish-release.test.ts | 85 +++++- scripts/publish-release.ts | 39 ++- scripts/release-run.test.ts | 82 ++++++ scripts/release-run.ts | 38 +++ scripts/version-release.test.ts | 363 ------------------------ scripts/version-release.ts | 258 ----------------- 10 files changed, 298 insertions(+), 647 deletions(-) create mode 100644 docs/prompts/2026-09-25-release-tidy.md create mode 100644 scripts/release-run.test.ts create mode 100644 scripts/release-run.ts delete mode 100644 scripts/version-release.test.ts delete mode 100644 scripts/version-release.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d82b5cb..987cea42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,14 +32,15 @@ jobs: - run: corepack yarn tsc:utils - run: corepack yarn tsc:zod - run: corepack yarn tsc:node - - name: Prepare the version PR without force pushes - id: version - run: node scripts/version-release.ts + - name: Skip superseded versioning runs, not publications + id: release_state + run: node scripts/release-run.ts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d - if: steps.version.outputs.has_changesets == 'false' + if: steps.release_state.outputs.proceed == 'true' with: + version: corepack yarn changeset:version:release publish: corepack yarn release:publish commitMode: github-api env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59684240..bf129804 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,14 +123,14 @@ Release flow: 4. Review and merge the version PR. CI publishes automatically via npm trusted publishing (OIDC). 5. Add [release notes](https://github.com/transloadit/node-sdk/releases) once the publish succeeds. -The version PR updater restores old generated files to their merge base, merges `main` into -`changeset-release/main`, then appends freshly generated versions. These are GitHub-signed commits, -not force pushes or branch recreation: the organization requires verified commits and blocks -non-fast-forward updates. Restoring only generated files prevents conflicts with new dependencies, -lockfile entries or changeset edits. Changesets still owns version calculation, changelogs and -publication. Unexpected source edits or concurrent branch changes stop the update; inspect them and -rerun the latest main release job. Do not merge an incomplete update. Approve any CI runs awaiting -approval on the bot-created PR before merging it. Do not bypass the required checks or branch rules. +The standard Changesets action maintains `changeset-release/main`, using GitHub API commits +to preserve verified signatures. The generated release branch permits force updates; `main` +and ordinary feature branches still forbid them. Do not edit the generated branch by hand. + +Changesets owns version calculation, changelogs and publication. If a version update fails, inspect +the error and rerun the latest main release job. Do not merge an incomplete update. Approve any CI +runs awaiting approval on the bot-created PR before merging it. Do not bypass the required checks +or branch rules. Release runs use GitHub's `queue: max` so newer pushes do not replace a pending publication run. The queue supports up to 100 pending runs; monitor a larger backlog rather than assuming unlimited retention. A superseded run with changesets skips versioning; a version-PR merge with no pending @@ -146,9 +146,12 @@ Manual fallback (maintainers only): - On the generated version PR's merged commit: `corepack yarn release:publish`. - This publishes Viewer with its explicit `alpha` tag, then uses `changeset publish --no-git-tag` - for the remaining packages and one `changeset tag` pass for release discovery. Duplicate tag - announcements make the release action try to create the same GitHub release twice. A failed registry lookup stops the - release; retries do not republish an existing version. + for the remaining packages and one `changeset tag` pass for release discovery. After npm accepts + Viewer, the script waits up to ten minutes for registry visibility before handing off to + Changesets. Lookup failures stop the release; a timeout does not trigger another publication. + Check registry visibility before retrying the workflow on the same commit. Duplicate tag + announcements make the release action try to create the same GitHub release twice. Retries do not + republish an existing version. Notes: diff --git a/docs/prompts/2026-09-25-release-tidy.md b/docs/prompts/2026-09-25-release-tidy.md new file mode 100644 index 00000000..47429012 --- /dev/null +++ b/docs/prompts/2026-09-25-release-tidy.md @@ -0,0 +1,44 @@ +# Restore standard Changesets version PRs + +Kevin approved this follow-up on September 25, 2026, after the generated release-branch +force-update exemption was installed and Convex release #34 passed publication/deployment. + +## Why + +The SDK's append-only version writer existed to work around a rule that now has a narrow, +intentional exception. The standard action already supports GitHub-signed commits. Remove the +writer and its implementation-specific tests, while retaining queued main runs and the existing +version/install and alpha-aware publish commands. + +Separately, npm accepted Viewer 0.0.3 before exposing it in package metadata. Handing control to +Changesets immediately caused a second publication attempt. This change waits for that exact +version, with a ten-minute deadline and bounded fresh registry lookups; auth/server failures +stop the run, and a timeout never republishes or changes tags. + +## Progress and review + +- [x] Start from main `5b2b550` in the configured, clean framework1 SDK checkout; preserve the + separate local checkout's contract work. +- [x] Reproduce red first: five failures in the 11-test publisher/workflow suite on unchanged + implementation (missing visibility checks and custom writer still wired into the workflow). +- [x] Remove the 258-line writer and its 363-line tests. Recovery remains in Git history. +- [x] Restore standard `changesets/action` versioning with `commitMode: github-api`. +- [x] Preserve Viewer alpha publication, single tag emission, OIDC and FIFO main release runs. +- [x] Wait for accepted publication to become visible; test delayed visibility, deadline, and + errors without a second publish. Focused suite: 11 tests pass. +- [x] Run required repository checks and immutable installation. `yarn check` passes after + refreshing ignored generated Zod output from the previous checkout. Publisher/workflow: + 11; script suite: 46; Utils: 63; Viewer: 456; Node: 863 plus one existing skip; MCP: 53; + generated Types/Zod tests and notify relay: pass. Existing informational lint notices and + Vitest/coverage peer-version warnings remain outside this release-only change. +- [x] Council review (`/tmp/council-qCNup1`, two reviewers plus arbiter): both findings were valid. + Restore a small stale-main guard, separate from the removed version writer; publication + runs without changesets still proceed. Remove the obsolete append-only instructions. + Nine new/extended assertions failed first; the combined publisher/guard suite now passes + all 19 tests, and a second full `yarn check` passes (script suite: 54). +- [ ] Open PR and monitor exact-head CI to green. +- [ ] After merge, follow the main release workflow; do not manufacture a package bump just to + exercise npm publication. + +No product runtime or published package version changes are intended. Tests mock npm publication; +no credentials, existing npm tags, global rules or `.env` files are modified. diff --git a/package.json b/package.json index 2d952bbe..b8cacc1a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "parity:transloadit": "node scripts/prepare-transloadit.ts && node scripts/fingerprint-pack.ts packages/transloadit --ignore-scripts --quiet --out /tmp/transloadit-after.json && node scripts/verify-fingerprint.ts --current /tmp/transloadit-after.json --diff", "test:img:fixture": "node scripts/test-img-next-fixture.ts", "test:sdk:edge": "node scripts/test-sdk-edge.ts", - "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts ./scripts/knip.test.ts ./scripts/publish-release.test.ts ./scripts/version-release.test.ts ./scripts/sdk-edge.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/viewer test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit", + "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts ./scripts/knip.test.ts ./scripts/publish-release.test.ts ./scripts/release-run.test.ts ./scripts/sdk-edge.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/viewer test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit", "test:types": "yarn workspace @transloadit/zod test:types", "test:e2e": "yarn workspace @transloadit/node test:e2e", "test": "yarn workspace @transloadit/node test", diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts index 4fff9169..ff839464 100644 --- a/scripts/publish-release.test.ts +++ b/scripts/publish-release.test.ts @@ -1,24 +1,36 @@ import { readFile } from 'node:fs/promises' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' import viewer from '../packages/img/package.json' with { type: 'json' } -const { run } = vi.hoisted(() => ({ run: vi.fn() })) +const { run, pause } = vi.hoisted(() => ({ run: vi.fn(), pause: vi.fn() })) vi.mock('execa', () => ({ execa: run })) +vi.mock('node:timers/promises', () => ({ setTimeout: pause })) + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + pause.mockImplementation((milliseconds: number) => { + vi.setSystemTime(Date.now() + milliseconds) + return Promise.resolve() + }) +}) afterEach(() => { + vi.useRealTimers() vi.resetModules() vi.resetAllMocks() }) test('the release publishes Viewer to alpha and leaves stable packages to Changesets', async () => { - run.mockResolvedValue({ exitCode: 0 }) + run.mockResolvedValue({ exitCode: 0, stdout: JSON.stringify(viewer.version) }) run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E404' }) await import('./publish-release.ts') expect(run.mock.calls.map(([command, args]) => [command, args])).toEqual([ ['npm', ['view', `${viewer.name}@${viewer.version}`, 'version', '--json']], ['npm', ['publish', './packages/img', '--access', 'public', '--tag', 'alpha']], + ['npm', ['view', `${viewer.name}@${viewer.version}`, 'version', '--json']], // Only the following tag command may announce tags to changesets/action; duplicate // announcements make it try to create each stable GitHub release twice. ['corepack', ['yarn', 'changeset', 'publish', '--no-git-tag']], @@ -26,6 +38,59 @@ test('the release publishes Viewer to alpha and leaves stable packages to Change ]) }) +test('accepted publication waits for registry visibility before Changesets can publish', async () => { + run.mockResolvedValue({ exitCode: 0, stdout: JSON.stringify(viewer.version) }) + run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E404' }) + run.mockResolvedValueOnce({ exitCode: 0 }) + run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E404' }) + run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E404' }) + + await import('./publish-release.ts') + + expect(run.mock.calls.map(([, args]) => args[0])).toEqual([ + 'view', + 'publish', + 'view', + 'view', + 'view', + 'yarn', + 'yarn', + ]) + expect(pause.mock.calls).toEqual([[5_000], [5_000]]) + expect(run).toHaveBeenNthCalledWith( + 3, + 'npm', + ['view', `${viewer.name}@${viewer.version}`, 'version', '--json'], + expect.objectContaining({ + timeout: 30_000, + env: { NPM_CONFIG_PREFER_ONLINE: 'true' }, + }), + ) +}) + +test('persistent registry invisibility stops after ten minutes without a second publish', async () => { + run.mockResolvedValue({ exitCode: 1, stderr: 'npm error code E404' }) + run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E404' }) + run.mockResolvedValueOnce({ exitCode: 0 }) + + await expect(import('./publish-release.ts')).rejects.toThrow('not visible') + + expect(Date.now()).toBe(10 * 60_000) + expect(run.mock.calls.filter(([, args]) => args[0] === 'publish')).toHaveLength(1) + expect(run.mock.calls.some(([command]) => command === 'corepack')).toBe(false) +}) + +test('a registry failure after publication is not retried as a metadata delay', async () => { + run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E404' }) + run.mockResolvedValueOnce({ exitCode: 0 }) + run.mockResolvedValueOnce({ exitCode: 1, stderr: 'npm error code E503' }) + + await expect(import('./publish-release.ts')).rejects.toThrow('lookup') + + expect(run).toHaveBeenCalledTimes(3) + expect(pause).not.toHaveBeenCalled() +}) + test('an already published Viewer is not republished on a release retry', async () => { run.mockResolvedValue({ exitCode: 0 }) run.mockResolvedValueOnce({ exitCode: 0, stdout: JSON.stringify(viewer.version) }) @@ -69,4 +134,18 @@ test('the release workflow uses the package-aware Changesets publisher', async ( 'utf8', ) expect(workflow).toContain('publish: corepack yarn release:publish') + expect(workflow).toContain('version: corepack yarn changeset:version:release') + expect(workflow).toContain('commitMode: github-api') + expect(workflow).toContain('run: node scripts/release-run.ts') + expect(workflow).toContain("if: steps.release_state.outputs.proceed == 'true'") + expect(workflow).not.toContain('node scripts/version-release.ts') + expect(workflow).not.toContain('steps.version.outputs.has_changesets') + expect(workflow).toContain('queue: max') +}) + +test('release guidance no longer describes the retired append-only updater', async () => { + const guide = await readFile(new URL('../CONTRIBUTING.md', import.meta.url), 'utf8') + expect(guide).not.toContain('The version PR updater restores old generated files') + expect(guide).not.toContain('blocks\nnon-fast-forward updates') + expect(guide).toContain('superseded run with changesets skips versioning') }) diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts index 98e09a17..95e41623 100644 --- a/scripts/publish-release.ts +++ b/scripts/publish-release.ts @@ -1,23 +1,47 @@ import type { Options } from 'execa' +import { setTimeout } from 'node:timers/promises' + import { execa } from 'execa' import viewer from '../packages/img/package.json' with { type: 'json' } -async function main(): Promise { - const cwd = new URL('..', import.meta.url) - const options = { cwd, stdio: 'inherit' } satisfies Options +const cwd = new URL('..', import.meta.url) + +async function viewerIsPublished(timeout: number): Promise { const published = await execa( 'npm', ['view', `${viewer.name}@${viewer.version}`, 'version', '--json'], - { cwd, reject: false }, + { cwd, reject: false, timeout, env: { NPM_CONFIG_PREFER_ONLINE: 'true' } }, ) if (published.exitCode === 0) { if (JSON.parse(published.stdout) !== viewer.version) { throw new Error('Unexpected Viewer registry lookup response; refusing to publish') } - } else if (/^npm (error|ERR!) code E404$/m.test(published.stderr)) { + return true + } + if (/^npm (error|ERR!) code E404$/m.test(published.stderr)) return false + throw new Error('Viewer registry lookup failed; refusing to treat a network/auth error as E404') +} + +async function waitForViewer(): Promise { + const deadline = Date.now() + 10 * 60_000 + for (;;) { + const remaining = deadline - Date.now() + if (remaining <= 0) { + throw new Error( + 'Viewer publication was accepted but is not visible after ten minutes; check npm before retrying', + ) + } + if (await viewerIsPublished(Math.min(30_000, remaining))) return + await setTimeout(Math.min(5_000, Math.max(0, deadline - Date.now()))) + } +} + +async function main(): Promise { + const options = { cwd, stdio: 'inherit' } satisfies Options + if (!(await viewerIsPublished(30_000))) { // Changesets hardcodes --tag latest, overriding publishConfig.tag. Publish this one alpha // first; Changesets then discovers it as already published and handles the stable packages. await execa( @@ -25,8 +49,9 @@ async function main(): Promise { ['publish', './packages/img', '--access', 'public', '--tag', 'alpha'], options, ) - } else { - throw new Error('Viewer registry lookup failed; refusing to treat a network/auth error as E404') + // npm can accept the tarball before its package metadata is updated. Changesets reads that + // same metadata; handing over early would make it republish Viewer with its default tag. + await waitForViewer() } // Emit each tag only once: changesets/action turns every announcement into a GitHub release. diff --git a/scripts/release-run.test.ts b/scripts/release-run.test.ts new file mode 100644 index 00000000..a041d93a --- /dev/null +++ b/scripts/release-run.test.ts @@ -0,0 +1,82 @@ +import { afterEach, expect, test, vi } from 'vitest' + +const { appendFile, readdir, run } = vi.hoisted(() => ({ + appendFile: vi.fn(), + readdir: vi.fn(), + run: vi.fn(), +})) +vi.mock('node:fs/promises', () => ({ appendFile, readdir })) +vi.mock('execa', () => ({ execa: run })) + +const current = 'a'.repeat(40) +const newer = 'b'.repeat(40) + +function workflow(): void { + vi.stubEnv('GITHUB_OUTPUT', '/workflow-output') + vi.stubEnv('GITHUB_SHA', current) + vi.stubEnv('GITHUB_REPOSITORY', 'transloadit/node-sdk') + vi.stubEnv('GITHUB_REF', 'refs/heads/main') + readdir.mockResolvedValue(['README.md', 'config.json', 'new-feature.md']) + run.mockResolvedValueOnce({ stdout: current }) +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + vi.resetAllMocks() +}) + +test('an older versioning run cannot overwrite a newer release branch', async () => { + workflow() + run.mockResolvedValueOnce({ stdout: newer }) + await import('./release-run.ts') + expect(appendFile).toHaveBeenCalledWith('/workflow-output', 'proceed=false\n') +}) + +test('the current main run can update the generated release branch', async () => { + workflow() + run.mockResolvedValueOnce({ stdout: current }) + await import('./release-run.ts') + expect(appendFile).toHaveBeenCalledWith('/workflow-output', 'proceed=true\n') + expect(run).toHaveBeenLastCalledWith( + 'gh', + ['api', 'repos/transloadit/node-sdk/git/ref/heads/main', '--jq', '.object.sha'], + { timeout: 30_000 }, + ) +}) + +test('a publication run proceeds even when main has advanced', async () => { + workflow() + readdir.mockResolvedValue(['README.md', 'config.json']) + await import('./release-run.ts') + expect(appendFile).toHaveBeenCalledWith('/workflow-output', 'proceed=true\n') + expect(run).not.toHaveBeenCalled() +}) + +test('a failed remote-main lookup cannot allow a stale update', async () => { + workflow() + run.mockRejectedValueOnce(new Error('GitHub unavailable')) + await expect(import('./release-run.ts')).rejects.toThrow('GitHub unavailable') + expect(appendFile).not.toHaveBeenCalled() +}) + +test('an invalid remote SHA is not treated as a harmless superseded run', async () => { + workflow() + run.mockResolvedValueOnce({ stdout: '' }) + await expect(import('./release-run.ts')).rejects.toThrow('main SHA') + expect(appendFile).not.toHaveBeenCalled() +}) + +test('a mismatched checkout cannot update the version branch', async () => { + workflow() + vi.stubEnv('GITHUB_SHA', newer) + await expect(import('./release-run.ts')).rejects.toThrow('checkout') + expect(appendFile).not.toHaveBeenCalled() +}) + +test('only the main release workflow may update the version branch', async () => { + workflow() + vi.stubEnv('GITHUB_REF', 'refs/heads/a-feature') + await expect(import('./release-run.ts')).rejects.toThrow('main release workflow') + expect(appendFile).not.toHaveBeenCalled() +}) diff --git a/scripts/release-run.ts b/scripts/release-run.ts new file mode 100644 index 00000000..ed9c4137 --- /dev/null +++ b/scripts/release-run.ts @@ -0,0 +1,38 @@ +import { appendFile, readdir } from 'node:fs/promises' + +import { execa } from 'execa' + +async function main(): Promise { + const output = process.env.GITHUB_OUTPUT + if (!output) throw new Error('Run this script in the release workflow') + const notes = (await readdir('.changeset')).filter( + (name) => name.endsWith('.md') && name !== 'README.md', + ) + // A merged version PR must still publish its versions even if a feature merge is queued next. + if (notes.length === 0) { + await appendFile(output, 'proceed=true\n') + return + } + const sha = process.env.GITHUB_SHA + const shaPattern = /^[a-f0-9]{40}$/ + if ( + process.env.GITHUB_REPOSITORY !== 'transloadit/node-sdk' || + process.env.GITHUB_REF !== 'refs/heads/main' || + !sha || + !shaPattern.test(sha) + ) { + throw new Error('Only the main release workflow may update the version branch') + } + const { stdout: head } = await execa('git', ['rev-parse', 'HEAD']) + if (head !== sha) throw new Error('Expected a checkout of the main run SHA') + const { stdout: latest } = await execa( + 'gh', + ['api', 'repos/transloadit/node-sdk/git/ref/heads/main', '--jq', '.object.sha'], + { timeout: 30_000 }, + ) + if (!shaPattern.test(latest)) throw new Error('Invalid remote main SHA') + // FIFO ordering does not make an explicitly rerun old workflow current again. + await appendFile(output, `proceed=${latest === sha}\n`) +} + +await main() diff --git a/scripts/version-release.test.ts b/scripts/version-release.test.ts deleted file mode 100644 index a7e8f7cf..00000000 --- a/scripts/version-release.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { afterEach, assert, expect, test, vi } from 'vitest' - -const { run, dirs, append } = vi.hoisted(() => ({ - run: vi.fn(), - dirs: vi.fn(), - append: vi.fn(), -})) -vi.mock('execa', () => ({ execa: run })) -vi.mock('node:fs/promises', async (original) => ({ - ...(await original()), - readdir: dirs, - appendFile: append, -})) - -const main = 'a'.repeat(40) -const previous = 'b'.repeat(40) -const tree = 'c'.repeat(40) -const merged = 'e'.repeat(40) -const base = '1'.repeat(40) -const restored = '2'.repeat(40) -const paths = 'packages/node/package.json\0yarn.lock\0.changeset/release.md\0' -const manifest = JSON.stringify({ name: '@transloadit/node', version: '4.14.0' }) - -afterEach(() => { - vi.resetModules() - vi.resetAllMocks() - vi.unstubAllEnvs() -}) - -function prepare(existing: string | null = previous): object[] { - vi.stubEnv('GITHUB_REPOSITORY', 'transloadit/node-sdk') - vi.stubEnv('GITHUB_REF', 'refs/heads/main') - vi.stubEnv('GITHUB_SHA', main) - vi.stubEnv('GITHUB_OUTPUT', '/tmp/release-output') - const payloads: object[] = [] - dirs.mockResolvedValue(['README.md', 'config.json', 'release.md']) - run.mockImplementation( - ( - command: string, - args: string[], - options?: { input?: string; stripFinalNewline?: boolean }, - ) => { - const line = `${command} ${args.join(' ')}` - if (command === 'corepack') return { stdout: '' } - if (line === 'git status --porcelain') return { stdout: '' } - if (line === 'git rev-parse HEAD') return { stdout: main } - if (line === 'git write-tree') return { stdout: tree } - if (line.startsWith('git merge-base')) return { stdout: base } - if (line.startsWith('git diff --name-only --no-renames -z')) return { stdout: paths } - if (line.startsWith('git ls-tree -z')) - return { - stdout: - args.at(-1) === '.changeset/release.md' ? '' : `100644 blob ${main}\t${args.at(-1)}\0`, - } - if (line.startsWith('git cat-file blob')) { - const content = args.at(-1)?.endsWith('package.json') ? manifest : 'new lockfile\n' - return { - stdout: Buffer.from( - options?.stripFinalNewline === false ? content : content.replace(/\n$/, ''), - ), - } - } - if (line.startsWith('git ')) return { stdout: '' } - if (command === 'gh' && args[0] === 'api' && args.includes('graphql')) { - const payload = JSON.parse(options?.input ?? '{}') - payloads.push(payload) - if (payload.query.includes('createCommitOnBranch')) return { stdout: restored } - return { stdout: `${main}:${existing ?? ''}` } - } - if (line.startsWith('gh api repos/transloadit/node-sdk/merges')) - return { stdout: `${merged}:${restored}:${main}:true` } - if (line.startsWith('gh pr list')) return { stdout: existing ? '509' : '' } - if (line.startsWith('gh pr ') || line.startsWith('gh api ')) return { stdout: '' } - throw new Error(`Unexpected command ${line}`) - }, - ) - return payloads -} - -test('release updates append to the existing branch with an exact-head signed API commit', async () => { - const payloads = prepare() - await import('./version-release.ts') - expect(payloads).toContainEqual( - expect.objectContaining({ - variables: { - input: expect.objectContaining({ - expectedHeadOid: merged, - branch: { - repositoryNameWithOwner: 'transloadit/node-sdk', - branchName: 'changeset-release/main', - }, - }), - }, - }), - ) - expect( - run.mock.calls.some(([, args]) => args.includes('--force') || args.includes('DELETE')), - ).toBe(false) - expect( - run.mock.calls.some( - ([command, args]) => - command === 'gh' && args[0] === 'pr' && args[1] === 'edit' && args[2] === '509', - ), - ).toBe(true) - expect(append).toHaveBeenCalledWith('/tmp/release-output', 'has_changesets=true\n') -}) - -test('the generated tree replaces stale version files and removes consumed changesets', async () => { - const payloads = prepare() - await import('./version-release.ts') - expect(payloads).toContainEqual( - expect.objectContaining({ - variables: { - input: expect.objectContaining({ - fileChanges: { - additions: [ - { - path: 'packages/node/package.json', - contents: Buffer.from(manifest).toString('base64'), - }, - { path: 'yarn.lock', contents: Buffer.from('new lockfile\n').toString('base64') }, - ], - deletions: [{ path: '.changeset/release.md' }], - }, - }), - }, - }), - ) -}) - -test('a missing release branch is created from main without deleting another branch', async () => { - const payloads = prepare(null) - await import('./version-release.ts') - expect( - run.mock.calls.some(([, args]) => args.includes('repos/transloadit/node-sdk/git/refs')), - ).toBe(true) - expect(payloads).toContainEqual( - expect.objectContaining({ - variables: { input: expect.objectContaining({ expectedHeadOid: main }) }, - }), - ) - expect(run.mock.calls.some(([, args]) => args[0] === 'pr' && args[1] === 'create')).toBe(true) -}) - -test('no pending changesets leaves publication to the existing publisher', async () => { - prepare() - dirs.mockResolvedValue(['README.md', 'config.json']) - await import('./version-release.ts') - expect(append).toHaveBeenCalledWith('/tmp/release-output', 'has_changesets=false\n') - expect(run).not.toHaveBeenCalled() -}) - -test('an outdated checkout cannot update the version branch', async () => { - prepare() - vi.stubEnv('GITHUB_SHA', 'd'.repeat(40)) - await expect(import('./version-release.ts')).rejects.toThrow('main') - expect(run.mock.calls.some(([command]) => command === 'corepack')).toBe(false) -}) - -test('a refused commit never falls back to a force push or branch recreation', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[2]?.input?.includes('createCommitOnBranch')) throw new Error('head changed') - return original(...args) - }) - await expect(import('./version-release.ts')).rejects.toThrow('head changed') - expect(run.mock.calls.some(([, args]) => args[0] === 'pr')).toBe(false) - expect( - run.mock.calls.some(([, args]) => args.includes('--force') || args.includes('DELETE')), - ).toBe(false) -}) - -test('a rerun preserves an identical release tree and repairs the PR if needed', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[0] === 'git' && args[1][0] === 'merge-base') return { stdout: main } - if (args[0] === 'gh' && args[1].includes('repos/transloadit/node-sdk/merges')) - return { stdout: '' } - if (args[0] === 'git' && args[1][0] === 'diff' && args[1].includes(previous)) - return { stdout: '' } - return original(...args) - }) - await import('./version-release.ts') - expect( - run.mock.calls.some(([, , options]) => options?.input?.includes('createCommitOnBranch')), - ).toBe(false) - expect(run.mock.calls.some(([, args]) => args[0] === 'pr' && args[1] === 'edit')).toBe(true) -}) - -test('main history and its file modes are merged before generated changesets are removed', async () => { - prepare() - await import('./version-release.ts') - const mergeIndex = run.mock.calls.findIndex(([, args]) => - args.includes('repos/transloadit/node-sdk/merges'), - ) - const commitIndex = run.mock.calls.findIndex(([, , options]) => - options?.input?.includes('"headline":"Version Packages"'), - ) - expect(mergeIndex).toBeGreaterThan(-1) - expect(mergeIndex).toBeLessThan(commitIndex) - expect(run.mock.calls).toContainEqual([ - 'git', - ['diff', '--name-only', '--no-renames', '-z', merged, tree], - ]) -}) - -test('unexpected generated source changes stop before any remote write', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[0] === 'git' && args[1][0] === 'diff' && args[1].includes(main)) - return { stdout: `${paths}src/mistake.ts\0` } - return original(...args) - }) - await expect(import('./version-release.ts')).rejects.toThrow('outside package versions') - expect( - run.mock.calls.some(([, , options]) => options?.input?.includes('createCommitOnBranch')), - ).toBe(false) -}) - -test('changing an executable to a regular file cannot silently retain the executable bit', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[0] === 'git' && args[1][0] === 'ls-tree' && args[1].includes(merged)) - return { stdout: `100755 blob ${main}\t${args[1].at(-1)}\0` } - return original(...args) - }) - await expect(import('./version-release.ts')).rejects.toThrow('file mode') - expect( - run.mock.calls.some(([, , options]) => - options?.input?.includes('"headline":"Version Packages"'), - ), - ).toBe(false) -}) - -test('a concurrent release edit included by the merge stops before replacing generated files', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[0] === 'gh' && args[1].includes('repos/transloadit/node-sdk/merges')) - return { stdout: `${merged}:${'f'.repeat(40)}:${main}:true` } - return original(...args) - }) - await expect(import('./version-release.ts')).rejects.toThrow('concurrent') - expect( - run.mock.calls.some(([, , options]) => - options?.input?.includes('"headline":"Version Packages"'), - ), - ).toBe(false) -}) - -test('a superseded main run exits successfully without versioning or publishing', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[2]?.input?.includes('query {')) return { stdout: `${'d'.repeat(40)}:${previous}` } - return original(...args) - }) - await import('./version-release.ts') - expect(append).toHaveBeenCalledWith('/tmp/release-output', 'has_changesets=true\n') - expect(run.mock.calls.some(([command]) => command === 'corepack')).toBe(false) - expect(run.mock.calls.some(([, args]) => args[0] === 'pr')).toBe(false) -}) - -test('generated conflicts are prevented by restoring the old generated files before merging main', async () => { - const payloads = prepare() - const original = run.getMockImplementation() - assert(original) - let restoredOldVersions = false - run.mockImplementation((...args: Parameters) => { - if (args[2]?.input?.includes('Restore generated files before merging main')) - restoredOldVersions = true - if ( - args[0] === 'gh' && - args[1].includes('repos/transloadit/node-sdk/merges') && - !restoredOldVersions - ) - throw new Error('Merge conflict (HTTP 409)') - return original(...args) - }) - await import('./version-release.ts') - expect(payloads).toContainEqual( - expect.objectContaining({ - variables: { - input: expect.objectContaining({ - expectedHeadOid: previous, - message: { headline: 'Restore generated files before merging main' }, - }), - }, - }), - ) - expect(run.mock.calls).toContainEqual([ - 'git', - ['diff', '--name-only', '--no-renames', '-z', previous, base], - ]) -}) - -test('release-only source edits stop before restoring generated files or merging main', async () => { - prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[0] === 'git' && args[1][0] === 'diff' && args[1].includes(base)) - return { stdout: `${paths}src/handwritten.ts\0` } - return original(...args) - }) - await expect(import('./version-release.ts')).rejects.toThrow( - 'release-only edit: src/handwritten.ts', - ) - expect( - run.mock.calls.some(([, , options]) => options?.input?.includes('createCommitOnBranch')), - ).toBe(false) - expect( - run.mock.calls.some(([, args]) => args.includes('repos/transloadit/node-sdk/merges')), - ).toBe(false) -}) - -test('an already merged main needs only the final signed version commit', async () => { - const payloads = prepare() - const original = run.getMockImplementation() - assert(original) - run.mockImplementation((...args: Parameters) => { - if (args[0] === 'git' && args[1][0] === 'merge-base') return { stdout: main } - if (args[0] === 'gh' && args[1].includes('repos/transloadit/node-sdk/merges')) - return { stdout: '' } - return original(...args) - }) - await import('./version-release.ts') - expect(payloads).toContainEqual( - expect.objectContaining({ - variables: { - input: expect.objectContaining({ - expectedHeadOid: previous, - message: { headline: 'Version Packages' }, - }), - }, - }), - ) - expect( - run.mock.calls.some(([, , options]) => options?.input?.includes('Restore generated files')), - ).toBe(false) -}) - -test('the workflow versions safely and only invokes Changesets publication without pending notes', async () => { - const actual = await vi.importActual('node:fs/promises') - const workflow = await actual.readFile( - new URL('../.github/workflows/release.yml', import.meta.url), - 'utf8', - ) - expect(workflow).toContain('node scripts/version-release.ts') - expect(workflow).toContain("if: steps.version.outputs.has_changesets == 'false'") - expect(workflow).toContain('cancel-in-progress: false') - expect(workflow).toContain('queue: max') -}) diff --git a/scripts/version-release.ts b/scripts/version-release.ts deleted file mode 100644 index fbc97ef3..00000000 --- a/scripts/version-release.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { appendFile, mkdtemp, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { execa } from 'execa' - -const repository = 'transloadit/node-sdk' -const branch = 'changeset-release/main' -const shaPattern = /^[a-f0-9]{40}$/ - -function isVersionFile(path: string): boolean { - return path === 'yarn.lock' || /^packages\/[^/]+\/(package\.json|CHANGELOG\.md)$/.test(path) -} - -async function appendTree( - parent: string, - tree: string, - headline: string, - isAllowed: (path: string) => boolean, -): Promise { - const { stdout: changed } = await execa('git', [ - 'diff', - '--name-only', - '--no-renames', - '-z', - parent, - tree, - ]) - const additions: { path: string; contents: string }[] = [] - const deletions: { path: string }[] = [] - for (const path of changed.split('\0').filter(Boolean)) { - if (!isAllowed(path)) throw new Error(`Refusing to overwrite a release-only edit: ${path}`) - const { stdout: entry } = await execa('git', ['ls-tree', '-z', tree, '--', path]) - if (!entry) { - deletions.push({ path }) - continue - } - // GraphQL writes regular files. Refuse executable/symlink changes instead of losing modes. - const { stdout: oldEntry } = await execa('git', ['ls-tree', '-z', parent, '--', path]) - if (!entry.startsWith('100644 blob ') || (oldEntry && !oldEntry.startsWith('100644 blob '))) - throw new Error(`Unsupported release file mode: ${path}`) - const { stdout: content } = await execa('git', ['cat-file', 'blob', `${tree}:${path}`], { - encoding: 'buffer', - stripFinalNewline: false, - }) - additions.push({ path, contents: Buffer.from(content).toString('base64') }) - } - if (additions.length + deletions.length === 0) return parent - const input = JSON.stringify({ - query: `mutation($input: CreateCommitOnBranchInput!) { - createCommitOnBranch(input: $input) { commit { oid } } - }`, - variables: { - input: { - branch: { repositoryNameWithOwner: repository, branchName: branch }, - expectedHeadOid: parent, - message: { headline }, - fileChanges: { additions, deletions }, - }, - }, - }) - if (Buffer.byteLength(input) > 30 * 1024 * 1024) - throw new Error('Release update exceeds the bounded GitHub API payload size') - // GitHub signs this append-only commit. Never recover by force-pushing or weakening rules. - const { stdout: sha } = await execa( - 'gh', - ['api', 'graphql', '--input', '-', '--jq', '.data.createCommitOnBranch.commit.oid'], - { input }, - ) - if (!shaPattern.test(sha)) throw new Error('Invalid signed release commit SHA') - return sha -} - -async function main(): Promise { - const output = process.env.GITHUB_OUTPUT - if (!output) throw new Error('Run this script in the release workflow') - const notes = (await readdir('.changeset')).filter( - (name) => name.endsWith('.md') && name !== 'README.md', - ) - if (notes.length === 0) { - await appendFile(output, 'has_changesets=false\n') - return - } - const mainSha = process.env.GITHUB_SHA - if ( - process.env.GITHUB_REPOSITORY !== repository || - process.env.GITHUB_REF !== 'refs/heads/main' || - !mainSha || - !shaPattern.test(mainSha) - ) { - throw new Error('Only the main release workflow may update the version branch') - } - const { stdout: head } = await execa('git', ['rev-parse', 'HEAD']) - const { stdout: dirty } = await execa('git', ['status', '--porcelain']) - if (head !== mainSha || dirty) throw new Error('Expected a clean checkout of the main run SHA') - - const { stdout: refs } = await execa( - 'gh', - [ - 'api', - 'graphql', - '--input', - '-', - '--jq', - '.data.repository | .main.target.oid + ":" + (.release.target.oid // "")', - ], - { - input: JSON.stringify({ - query: `query { - repository(owner: "transloadit", name: "node-sdk") { - main: ref(qualifiedName: "refs/heads/main") { target { oid } } - release: ref(qualifiedName: "refs/heads/${branch}") { target { oid } } - } - }`, - }), - }, - ) - const [remoteMain, releaseSha] = refs.split(':') - if (remoteMain !== mainSha) { - // The newer queued run owns versioning. Do not let this run fall through to publication. - console.log('Skipping superseded release run; main has advanced') - await appendFile(output, 'has_changesets=true\n') - return - } - if (releaseSha && !shaPattern.test(releaseSha)) throw new Error('Invalid release branch SHA') - if (releaseSha) await execa('git', ['fetch', 'origin', `refs/heads/${branch}`]) - - await execa('corepack', ['yarn', 'changeset:version:release'], { stdio: 'inherit' }) - const scratch = await mkdtemp(join(tmpdir(), 'transloadit-version-index-')) - try { - // Snapshot main + freshly generated versions without changing the checkout's index. - // Compute versions from main, not from the previous generated version bump. - const env = { GIT_INDEX_FILE: join(scratch, 'index') } - await execa('git', ['read-tree', mainSha], { env }) - await execa('git', ['add', '--all'], { env }) - const { stdout: tree } = await execa('git', ['write-tree'], { env }) - const { stdout: generated } = await execa('git', [ - 'diff', - '--name-only', - '--no-renames', - '-z', - mainSha, - tree, - ]) - const generatedPaths = generated.split('\0').filter(Boolean) - const isGeneratedPath = (path: string): boolean => - isVersionFile(path) || notes.some((note) => path === `.changeset/${note}`) - if (generatedPaths.some((path) => !isGeneratedPath(path))) - throw new Error( - 'Versioning changed files outside package versions, changelogs and changesets', - ) - if (generatedPaths.length === 0) - throw new Error('Pending changesets produced no release changes') - - let parent = releaseSha || mainSha - if (releaseSha) { - const { stdout: base } = await execa('git', ['merge-base', mainSha, releaseSha]) - if (!shaPattern.test(base)) throw new Error('Invalid release merge base') - if (base !== mainSha) { - // Only generated deltas may live on this bot branch. Undo them back to their base in - // a signed append-only commit, so dependency/lockfile/note edits on main cannot conflict. - // Restoring deleted notes here is safe: the final version commit consumes them again. - parent = await appendTree( - releaseSha, - base, - 'Restore generated files before merging main', - (path) => - isVersionFile(path) || - (path !== '.changeset/README.md' && /^\.changeset\/[^/]+\.md$/.test(path)), - ) - } - // Main must be an ancestor, not just identical source bytes. Otherwise squash-merging - // keeps newly added changesets that were absent in the old merge base and versions twice. - // GitHub's merge also preserves executable/symlink changes from main without copying them - // through GraphQL's regular-file-only API. Both operations produce verified commits. - const { stdout: merged } = await execa('gh', [ - 'api', - `repos/${repository}/merges`, - '--method', - 'POST', - '-f', - `base=${branch}`, - '-f', - `head=${mainSha}`, - '-f', - 'commit_message=Merge main before regenerating release versions', - '--jq', - '[.sha, .parents[0].sha, .parents[1].sha, .commit.verification.verified] | join(":")', - ]) - // HTTP 204 means main was already an ancestor. An unexpected first parent means a - // concurrent edit won the race; retain that edit and let the next run recompute safely. - if (merged) { - const [sha, first, second, verified] = merged.split(':') - if ( - !sha || - !shaPattern.test(sha) || - verified !== 'true' || - (sha !== mainSha && (first !== parent || second !== mainSha)) - ) { - throw new Error('Refusing an unverified or concurrent release-branch merge') - } - parent = sha - await execa('git', ['fetch', 'origin', parent]) - } - } - if (!releaseSha) { - await execa('gh', [ - 'api', - `repos/${repository}/git/refs`, - '--method', - 'POST', - '-f', - `ref=refs/heads/${branch}`, - '-f', - `sha=${mainSha}`, - ]) - } - await appendTree(parent, tree, 'Version Packages', isGeneratedPath) - const { stdout: number } = await execa('gh', [ - 'pr', - 'list', - '--repo', - repository, - '--head', - branch, - '--base', - 'main', - '--state', - 'open', - '--json', - 'number', - '--jq', - '.[0].number // empty', - ]) - const body = - '## Why\n\nPublish the pending Changesets as reviewed package releases.\n\n' + - `Generated from main \`${mainSha}\` by \`yarn changeset:version:release\`. ` + - 'See the package manifests and changelogs in Files changed for the release contents.\n\n' + - 'Merging this PR publishes through the existing trusted-publishing workflow. ' + - 'Viewer remains an alpha. Approve any waiting GitHub Actions runs before merging.\n' - if (number && !/^\d+$/.test(number)) throw new Error('Unexpected release PR number') - await execa('gh', [ - 'pr', - ...(number ? ['edit', number] : ['create', '--head', branch, '--base', 'main']), - '--repo', - repository, - '--title', - 'Version Packages', - '--body', - body, - ]) - await appendFile(output, 'has_changesets=true\n') - } finally { - await rm(scratch, { recursive: true, force: true }) - } -} - -await main()