Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 14 additions & 11 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
44 changes: 44 additions & 0 deletions docs/prompts/2026-09-25-release-tidy.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
85 changes: 82 additions & 3 deletions scripts/publish-release.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,96 @@
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']],
['corepack', ['yarn', 'changeset', 'tag']],
])
})

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) })
Expand Down Expand Up @@ -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')
})
39 changes: 32 additions & 7 deletions scripts/publish-release.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,57 @@
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<void> {
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<boolean> {
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<void> {
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<void> {
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(
'npm',
['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.
Expand Down
82 changes: 82 additions & 0 deletions scripts/release-run.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading
Loading