From f14ff83b5a34984ecad7ce0ca911e83b6b80e3b0 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 21 Aug 2026 21:57:39 +0100 Subject: [PATCH] test: improve test coverage --- .gitignore | 1 + package.json | 3 +- pnpm-lock.yaml | 163 ++++++- scripts/_cli.ts | 23 + scripts/_dependency-graph.ts | 4 +- scripts/pack.ts | 15 +- scripts/pin-readme.ts | 13 +- scripts/publish.ts | 11 +- scripts/tag-and-release.ts | 11 +- scripts/update-changelog.ts | 65 +-- test/_cli.test.ts | 51 ++ test/_independent.test.ts | 13 + test/_pack-json.test.ts | 9 + test/_workspaces.test.ts | 34 +- test/pack.test.ts | 160 +++++++ test/pin-readme.test.ts | 143 ++++++ test/publish.test.ts | 122 +++++ test/tag-and-release.test.ts | 216 +++++++++ test/update-changelog-main.test.ts | 719 +++++++++++++++++++++++++++++ test/update-changelog.test.ts | 127 ++++- vitest.config.ts | 19 + 21 files changed, 1838 insertions(+), 84 deletions(-) create mode 100644 scripts/_cli.ts create mode 100644 test/_cli.test.ts create mode 100644 test/pack.test.ts create mode 100644 test/pin-readme.test.ts create mode 100644 test/publish.test.ts create mode 100644 test/tag-and-release.test.ts create mode 100644 test/update-changelog-main.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index 58c4935..4380929 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .pnpm-store +coverage diff --git a/package.json b/package.json index 43bc839..c5b4233 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "license": "MIT", "type": "module", "scripts": { - "test": "vitest run", + "test": "vitest run --coverage", "test:types": "tsc --noEmit" }, "devDependencies": { "@types/node": "24.13.3", + "@vitest/coverage-v8": "^4.1.10", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca57eb1..b7f4f8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,18 +11,49 @@ importers: '@types/node': specifier: 24.13.3 version: 24.13.3 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)) packages: + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} @@ -254,6 +285,15 @@ packages: cpu: [x64] os: [win32] + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -287,6 +327,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -322,6 +365,28 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -399,6 +464,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -427,6 +499,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -440,6 +517,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -554,8 +635,30 @@ packages: snapshots: + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@oxc-project/types@0.144.0': {} '@rolldown/binding-android-arm64@1.2.4': @@ -677,6 +780,20 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -720,6 +837,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + chai@6.2.2: {} convert-source-map@2.0.0: {} @@ -741,6 +864,25 @@ snapshots: fsevents@2.3.3: optional: true + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -794,6 +936,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + nanoid@3.3.18: {} obug@2.1.4: {} @@ -830,6 +982,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.4 '@rolldown/binding-win32-x64-msvc': 1.2.4 + semver@7.8.5: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -838,6 +992,10 @@ snapshots: std-env@4.2.0: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tinybench@2.9.0: {} tinyexec@1.3.0: {} @@ -885,7 +1043,7 @@ snapshots: '@types/node': 24.13.3 fsevents: 2.3.3 - vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.2.1(@types/node@24.13.3)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)) @@ -909,6 +1067,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/scripts/_cli.ts b/scripts/_cli.ts new file mode 100644 index 0000000..dd5cca7 --- /dev/null +++ b/scripts/_cli.ts @@ -0,0 +1,23 @@ +import process from 'node:process' + +/** + * Run `main` only when the module is the process entrypoint, so tests can + * import a script for its exports without triggering a release. + * + * @param url the calling module's `import.meta.url` + * @param main entrypoint; sync throws and rejections both exit non-zero + */ +export function runMain (url: string, main: () => unknown): void { + if (url !== `file://${process.argv[1]}`) return + const fail = (err: unknown) => { + console.error(err) + process.exit(1) + } + try { + const result = main() + if (result instanceof Promise) result.catch(fail) + } + catch (err) { + fail(err) + } +} diff --git a/scripts/_dependency-graph.ts b/scripts/_dependency-graph.ts index 3279a94..e5a4869 100644 --- a/scripts/_dependency-graph.ts +++ b/scripts/_dependency-graph.ts @@ -133,9 +133,7 @@ export function propagateReleases ( const order = topologicalOrder(graph) for (const name of order) { if (releases.has(name)) continue - const deps = graph.dependencies.get(name) - if (!deps) continue - for (const dep of deps) { + for (const dep of graph.dependencies.get(name)!) { if (releases.has(dep)) { releases.set(name, { name, bump: 'patch', ownCommits: false }) break diff --git a/scripts/pack.ts b/scripts/pack.ts index 7bf0aa7..29b7c28 100644 --- a/scripts/pack.ts +++ b/scripts/pack.ts @@ -30,12 +30,13 @@ import { execFileSync } from 'node:child_process' import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { resolve } from 'node:path' +import { runMain } from './_cli.ts' import { parseFilenames } from './_pack-json.ts' import { resolveWorkspaces } from './_workspaces.ts' import { COORDINATION_TAG_RE, releasesFromEnv, type ReleaseEntry } from './_independent.ts' -function runCapture (cmd: string, args: string[], cwd?: string): string { - console.log('$', cmd, ...args, cwd ? `(cwd: ${cwd})` : '') +function runCapture (cmd: string, args: string[], cwd: string): string { + console.log('$', cmd, ...args, `(cwd: ${cwd})`) return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'inherit'], encoding: 'utf8', @@ -62,7 +63,7 @@ function releaseTargets (rootDir: string, releases: ReleaseEntry[]): Array<{ nam }) } -function main () { +export function main () { const ref = process.env.GITHUB_REF ?? '' const releases = releasesFromEnv(process.env.RELEASES) @@ -117,10 +118,4 @@ function main () { } } -try { - main() -} -catch (err) { - console.error(err) - process.exit(1) -} +runMain(import.meta.url, main) diff --git a/scripts/pin-readme.ts b/scripts/pin-readme.ts index d456b9f..2d18562 100644 --- a/scripts/pin-readme.ts +++ b/scripts/pin-readme.ts @@ -11,6 +11,8 @@ import process from 'node:process' import { Buffer } from 'node:buffer' +import { runMain } from './_cli.ts' + interface GhRefResponse { object: { sha: string } } interface GhContentResponse { sha: string, content: string, encoding: 'base64' } interface GhPutResponse { commit: { sha: string, html_url: string } } @@ -43,7 +45,7 @@ function rewrite (readme: string, sha: string, tag: string) { return readme.replace(pinRe, (_match, prefix: string) => `${prefix}@${sha} # ${tag}`) } -async function main () { +export async function main () { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN is required') @@ -97,10 +99,6 @@ async function main () { console.log(`attempt ${attempt}: ${branch} advanced under us, retrying`) continue } - if (put.status < 200 || put.status >= 300) { - throw new Error(`PUT contents returned ${put.status}`) - } - console.log(`pinned README.md to ${tag} in ${put.data.commit.html_url}`) return } @@ -108,7 +106,4 @@ async function main () { throw new Error('Exhausted retries trying to update README.md') } -main().catch((err) => { - console.error(err) - process.exit(1) -}) +runMain(import.meta.url, main) diff --git a/scripts/publish.ts b/scripts/publish.ts index df7e424..a6908fa 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -27,6 +27,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, readdirSync } from 'node:fs' import { resolve } from 'node:path' +import { runMain } from './_cli.ts' import { expectedTarballName, releasesFromEnv } from './_independent.ts' function run (cmd: string, args: string[]) { @@ -53,7 +54,7 @@ function parseTarballFiles (raw: string): string[] { return parsed as string[] } -function main () { +export function main () { const access = process.env.NPM_ACCESS === 'restricted' ? 'restricted' : 'public' const dir = process.env.TARBALL_DIR @@ -89,10 +90,4 @@ function main () { } } -try { - main() -} -catch (err) { - console.error(err) - process.exit(1) -} +runMain(import.meta.url, main) diff --git a/scripts/tag-and-release.ts b/scripts/tag-and-release.ts index 2097332..16764af 100644 --- a/scripts/tag-and-release.ts +++ b/scripts/tag-and-release.ts @@ -22,6 +22,7 @@ import process from 'node:process' import { execFileSync } from 'node:child_process' +import { runMain } from './_cli.ts' import { isSemver, resolveCurrentVersion, resolveWorkspaces } from './_workspaces.ts' import { coordinationTag, deriveReleaseSet, packageTag, releaseTitle, serialiseReleases } from './_independent.ts' import { getAllTags } from './update-changelog.ts' @@ -106,7 +107,7 @@ function mainIndependent (repo: string, ghEnv: NodeJS.ProcessEnv) { console.log(`Tagged ${releases.length} package${releases.length === 1 ? '' : 's'} (${releases.map(packageTag).join(', ')}) plus ${coordTag}, created release, dispatched ${workflow}.`) } -function main () { +export function main () { const token = process.env.GITHUB_TOKEN if (!token) throw new Error('GITHUB_TOKEN is required') const repo = process.env.GITHUB_REPOSITORY @@ -146,10 +147,4 @@ function main () { console.log(`Tagged ${tag}, created release, dispatched ${workflow}.`) } -try { - main() -} -catch (err) { - console.error(err) - process.exit(1) -} +runMain(import.meta.url, main) diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index f1e3075..20c95db 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process' import { Buffer } from 'node:buffer' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { runMain } from './_cli.ts' import { makePkgFormatter } from './pkg-format.ts' import { buildScopeMap, parseScopesInput, resolveCurrentVersion, resolveWorkspaces, type Workspace } from './_workspaces.ts' @@ -399,7 +400,7 @@ export function computeIndependentPlan (opts: { bump: release.bump, ownCommits: release.ownCommits, commits: release.ownCommits ? routed.get(release.name)! : [], - scopes: scopesByName.get(release.name) ?? [release.name], + scopes: scopesByName.get(release.name)!, } }) @@ -459,10 +460,8 @@ export function formatChangelog ( return lines.join('\n').trim() } -async function gh (path: string, init: RequestInit & { requireAuth?: boolean } = {}): Promise { - const { requireAuth, ...rest } = init +async function gh (path: string, rest: RequestInit = {}): Promise { const token = process.env.GITHUB_TOKEN - if (requireAuth && !token) throw new Error('GITHUB_TOKEN is required for this call') const res = await fetch(`https://api.github.com${path}`, { ...rest, headers: { @@ -475,7 +474,7 @@ async function gh (path: string, init: RequestInit & { requireAuth?: boolean }, }) if (!res.ok) { - throw new Error(`GitHub ${init.method || 'GET'} ${path} -> ${res.status} ${res.statusText}: ${await res.text()}`) + throw new Error(`GitHub ${rest.method || 'GET'} ${path} -> ${res.status} ${res.statusText}: ${await res.text()}`) } return res.json() as Promise } @@ -539,7 +538,6 @@ async function closeSupersededPRs ( let seedPreamble: string | null = null const openReleasePRs = await gh>( `/repos/${repo.owner}/${repo.repo}/pulls?state=open&per_page=100&base=${encodeURIComponent(baseBranch)}&head=${repo.owner}:`, - { requireAuth: true }, ) const sameRepo = `${repo.owner}/${repo.repo}` const stale = openReleasePRs @@ -556,12 +554,10 @@ async function closeSupersededPRs ( await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${pr.number}`, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }), - requireAuth: true, }) try { await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${pr.head.ref}`, { method: 'DELETE', - requireAuth: true, }) } catch (err) { @@ -590,7 +586,6 @@ async function upsertReleasePR ( await gh(`/repos/${repo.owner}/${repo.repo}/pulls/${opts.currentPR.number}`, { method: 'PATCH', body: JSON.stringify({ title: opts.title, body: opts.body }), - requireAuth: true, }) console.log(`Updated PR #${opts.currentPR.number}`) } else { @@ -598,7 +593,6 @@ async function upsertReleasePR ( `/repos/${repo.owner}/${repo.repo}/pulls`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ title: opts.title, head: opts.head, @@ -646,7 +640,6 @@ async function createTree ( `/repos/${repo.owner}/${repo.repo}/git/blobs`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ content: Buffer.from(file.content, 'utf8').toString('base64'), encoding: 'base64', @@ -660,7 +653,6 @@ async function createTree ( `/repos/${repo.owner}/${repo.repo}/git/trees`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ base_tree: baseTree, tree: blobs.map(b => ({ path: b.path, mode: '100644', type: 'blob', sha: b.sha })), @@ -688,6 +680,9 @@ async function commitFilesToBranch ( repo: { owner: string, repo: string }, opts: { base: string, branch: string, message: string, files: FileToCommit[] }, ): Promise { + /* v8 ignore next 3 -- unreachable: every caller derives `files` from a + non-empty release plan. Kept because the ref update below moves a branch, + and an empty file set would move it to a commit with no changes. */ if (!opts.files.length) { throw new Error('commitFilesToBranch: refusing to commit with no files') } @@ -696,18 +691,15 @@ async function commitFilesToBranch ( try { const branchInfo = await gh<{ commit: { sha: string } }>( `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.branch)}`, - { requireAuth: true }, ) parentSha = branchInfo.commit.sha } catch (err) { if (!(err instanceof Error) || !/-> 404\b/.test(err.message)) throw err const baseInfo = await gh<{ commit: { sha: string } }>( `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, - { requireAuth: true }, ) await gh(`/repos/${repo.owner}/${repo.repo}/git/refs`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ ref: `refs/heads/${opts.branch}`, sha: baseInfo.commit.sha, @@ -718,7 +710,6 @@ async function commitFilesToBranch ( const parentCommit = await gh<{ tree: { sha: string } }>( `/repos/${repo.owner}/${repo.repo}/git/commits/${parentSha}`, - { requireAuth: true }, ) const tree = await createTree(repo, parentCommit.tree.sha, opts.files) @@ -727,7 +718,6 @@ async function commitFilesToBranch ( `/repos/${repo.owner}/${repo.repo}/git/commits`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ message: opts.message, tree, @@ -738,7 +728,6 @@ async function commitFilesToBranch ( await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${opts.branch}`, { method: 'PATCH', - requireAuth: true, body: JSON.stringify({ sha: commit.sha }), }) } @@ -798,6 +787,9 @@ async function syncReleaseBranch ( repo: { owner: string, repo: string }, opts: { base: string, branch: string, message: string, files: FileToCommit[] }, ): Promise { + /* v8 ignore next 3 -- unreachable: `runIndependent` returns early on an + empty plan. Kept because the divergent path below force-updates the + release branch, so an empty file set would discard it. */ if (!opts.files.length) { throw new Error('syncReleaseBranch: refusing to commit with no files') } @@ -809,7 +801,6 @@ async function syncReleaseBranch ( try { const cmp = await gh<{ files?: Array<{ filename: string }>, merge_base_commit: { sha: string }, behind_by: number }>( `/repos/${repo.owner}/${repo.repo}/compare/${encodeURIComponent(opts.base)}...${encodeURIComponent(opts.branch)}`, - { requireAuth: true }, ) divergence = { changed: new Set((cmp.files ?? []).map(file => file.filename)), @@ -829,7 +820,6 @@ async function syncReleaseBranch ( if (divergence.behindBy > 0) { const baseCmp = await gh<{ files?: Array<{ filename: string }> }>( `/repos/${repo.owner}/${repo.repo}/compare/${divergence.mergeBase}...${encodeURIComponent(opts.base)}`, - { requireAuth: true }, ) for (const file of baseCmp.files ?? []) { if (desired.has(file.filename)) baseTouched.push(file.filename) @@ -846,12 +836,10 @@ async function syncReleaseBranch ( const baseInfo = await gh<{ commit: { sha: string } }>( `/repos/${repo.owner}/${repo.repo}/branches/${encodeURIComponent(opts.base)}`, - { requireAuth: true }, ) const baseSha = baseInfo.commit.sha const baseCommit = await gh<{ tree: { sha: string } }>( `/repos/${repo.owner}/${repo.repo}/git/commits/${baseSha}`, - { requireAuth: true }, ) const tree = await createTree(repo, baseCommit.tree.sha, opts.files) @@ -859,7 +847,6 @@ async function syncReleaseBranch ( `/repos/${repo.owner}/${repo.repo}/git/commits`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ message: opts.message, tree, parents: [baseSha] }), }, ) @@ -867,13 +854,11 @@ async function syncReleaseBranch ( if (divergence) { await gh(`/repos/${repo.owner}/${repo.repo}/git/refs/heads/${opts.branch}`, { method: 'PATCH', - requireAuth: true, body: JSON.stringify({ sha: commit.sha, force: true }), }) } else { await gh(`/repos/${repo.owner}/${repo.repo}/git/refs`, { method: 'POST', - requireAuth: true, body: JSON.stringify({ ref: `refs/heads/${opts.branch}`, sha: commit.sha }), }) } @@ -887,7 +872,6 @@ async function getFileContent ( try { const data = await gh<{ content?: string, encoding?: string }>( `/repos/${repo.owner}/${repo.repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(ref)}`, - { requireAuth: true }, ) if (data.encoding !== 'base64' || typeof data.content !== 'string') return null return Buffer.from(data.content, 'base64').toString('utf8') @@ -1027,10 +1011,18 @@ export function buildIndependentBody ( }), '') } else { const causes = propagationCauses(release, releasedNames) - const note = causes.length - ? `_Released because ${causes.map(c => `\`${c}\``).join(' and ')} was bumped; no direct changes._` - : '_Released because a `workspace:` dependency was bumped; no direct changes._' - lines.push(note, '') + /* v8 ignore start -- unreachable for a plan built by + `computeIndependentPlan`: a release with `ownCommits: false` exists + only because `propagateReleases` found a released `workspace:` + dependency, and `propagationCauses` re-derives that same edge with + the same predicate. Kept because `buildIndependentBody` is exported + and could be handed a plan assembled by other means. */ + if (!causes.length) { + lines.push('_Released because a `workspace:` dependency was bumped; no direct changes._', '') + continue + } + /* v8 ignore stop */ + lines.push(`_Released because ${causes.map(c => `\`${c}\``).join(' and ')} was bumped; no direct changes._`, '') } } @@ -1060,7 +1052,7 @@ export function buildIndependentBody ( return lines.join('\n').trimEnd() } -async function main () { +export async function main () { const dryRun = Boolean(process.env.DRY_RUN) const repo = getRepo() const baseBranch = getCurrentBranch() @@ -1315,9 +1307,6 @@ async function runIndependent (packagesInput: string): Promise { }) } - const hasToken = Boolean(process.env.GITHUB_TOKEN) - if (!hasToken && !dryRun) throw new Error('GITHUB_TOKEN is required to create or update the PR') - const ownCommits = plan.releases.flatMap(r => r.commits) const seen = new Set() const uniqueCommits = ownCommits.filter(c => !seen.has(c.hash) && Boolean(seen.add(c.hash))) @@ -1346,10 +1335,4 @@ async function runIndependent (packagesInput: string): Promise { await upsertReleasePR(repo, { currentPR, title, head: releaseBranch, base: baseBranch, body }) } -// Run as a script, not when imported by tests. -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((err) => { - console.error(err) - process.exit(1) - }) -} +runMain(import.meta.url, main) diff --git a/test/_cli.test.ts b/test/_cli.test.ts new file mode 100644 index 0000000..772a2d8 --- /dev/null +++ b/test/_cli.test.ts @@ -0,0 +1,51 @@ +import process from 'node:process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runMain } from '../scripts/_cli.ts' + +const entry = `file://${process.argv[1]}` + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('runMain', () => { + it('does nothing when the module is not the entrypoint', () => { + const main = vi.fn() + runMain('file:///somewhere/else.ts', main) + expect(main).not.toHaveBeenCalled() + }) + + it('runs main when the module is the entrypoint', () => { + const main = vi.fn() + runMain(entry, main) + expect(main).toHaveBeenCalled() + }) + + it('exits non-zero when main throws', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + runMain(entry, () => { + throw new Error('boom') + }) + expect(error).toHaveBeenCalled() + expect(exit).toHaveBeenCalledWith(1) + }) + + it('exits non-zero when an async main rejects', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + runMain(entry, async () => { + throw new Error('boom') + }) + await new Promise(r => setTimeout(r, 0)) + expect(error).toHaveBeenCalled() + expect(exit).toHaveBeenCalledWith(1) + }) + + it('leaves a resolving async main alone', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + runMain(entry, async () => 'ok') + await new Promise(r => setTimeout(r, 0)) + expect(exit).not.toHaveBeenCalled() + }) +}) diff --git a/test/_independent.test.ts b/test/_independent.test.ts index c474f55..2a4c76a 100644 --- a/test/_independent.test.ts +++ b/test/_independent.test.ts @@ -34,6 +34,13 @@ describe('deriveReleaseSet', () => { expect(set).toEqual([{ name: 'a', version: '1.1.0', dir: 'packages/a' }]) }) + it('skips workspaces with no version field', () => { + const versionless = { ...ws('a', '1.0.0'), version: null } + expect(deriveReleaseSet([versionless, ws('b', '2.1.0')], ['b@2.0.0'])).toEqual([ + { name: 'b', version: '2.1.0', dir: 'packages/b' }, + ]) + }) + it('includes packages with no prior tag of any kind', () => { const set = deriveReleaseSet([ws('a', '0.1.0')], []) expect(set).toEqual([{ name: 'a', version: '0.1.0', dir: 'packages/a' }]) @@ -131,6 +138,11 @@ describe('releases payload', () => { expect(releasesFromEnv(serialiseReleases(entries))).toEqual(entries) }) + it('accepts the repo root as a dir', () => { + expect(releasesFromEnv('[{"name":"a","version":"1.0.0","dir":"."}]')) + .toEqual([{ name: 'a', version: '1.0.0', dir: '.' }]) + }) + it('returns null when absent or blank', () => { expect(releasesFromEnv(undefined)).toBeNull() expect(releasesFromEnv('')).toBeNull() @@ -146,6 +158,7 @@ describe('releases payload', () => { expect(() => releasesFromEnv('[{"name":"a","version":"latest","dir":"a"}]')).toThrow(/non-semver version/) expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0","dir":"../a"}]')).toThrow(/unsafe dir/) expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0","dir":"/a"}]')).toThrow(/unsafe dir/) + expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0","dir":""}]')).toThrow(/unsafe dir/) expect(() => releasesFromEnv('[{"name":"a","version":"1.0.0"}]')).toThrow(/unsafe dir/) expect(() => releasesFromEnv(JSON.stringify([entries[0], entries[0]]))) .toThrow(/more than once/) diff --git a/test/_pack-json.test.ts b/test/_pack-json.test.ts index 2780c0b..7c5aff7 100644 --- a/test/_pack-json.test.ts +++ b/test/_pack-json.test.ts @@ -66,6 +66,15 @@ describe('extractTrailingJson', () => { it('throws when stdout does not end with a JSON value', () => { expect(() => extractTrailingJson('[INFO] only logs here\n')).toThrow(/did not end with a JSON value/) }) + + it('falls back to an earlier candidate when the last one is not parseable on its own', () => { + const stdout = '{"name":"x","pack":\n{"filename":"x-1.0.0.tgz"}}\n' + expect(extractTrailingJson(stdout)).toEqual({ name: 'x', pack: { filename: 'x-1.0.0.tgz' } }) + }) + + it('throws when nothing in stdout parses as JSON', () => { + expect(() => extractTrailingJson('[INFO] built}\n')).toThrow(/Could not find a JSON value/) + }) }) describe('parseFilenames', () => { diff --git a/test/_workspaces.test.ts b/test/_workspaces.test.ts index 6575f62..7841db5 100644 --- a/test/_workspaces.test.ts +++ b/test/_workspaces.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -103,7 +103,39 @@ describe('expandPackagePatterns', () => { }) }) +describe('expandPackagePatterns edge cases', () => { + it('returns nothing when every pattern is a negation', () => { + writePackage('packages/a', { name: 'a' }) + expect(expandPackagePatterns(tmp, ['!packages/a'])).toEqual([]) + }) + + it('ignores matches that are files rather than directories', () => { + writeFileSync(resolve(tmp, 'packages.txt'), 'not a workspace') + expect(expandPackagePatterns(tmp, ['packages*'])).toEqual([]) + }) + + it('ignores matches that cannot be stat-ed, such as dangling symlinks', () => { + symlinkSync(resolve(tmp, 'nowhere'), resolve(tmp, 'packages')) + expect(expandPackagePatterns(tmp, ['packages*'])).toEqual([]) + }) + + it('ignores patterns that match nothing on disk', () => { + writePackage('packages/a', { name: 'a' }) + expect(expandPackagePatterns(tmp, ['packages/*', 'other/*'])).toEqual([resolve(tmp, 'packages/a')]) + }) +}) + describe('resolveWorkspaces', () => { + it('reports the repo root as "." when it has no name', () => { + writePackage('.', { version: '1.0.0' }) + expect(() => resolveWorkspaces(tmp, '**')).toThrowError(/Workspace at \. has no "name" field/) + }) + + it('gives the repo root a relDir of "."', () => { + writePackage('.', { name: 'pkg', version: '1.0.0' }) + expect(resolveWorkspaces(tmp, '**').map(ws => ws.relDir)).toEqual(['.']) + }) + it('returns name/version/dir for each match', () => { writePackage('packages/a', { name: 'a', version: '1.0.0' }) writePackage('packages/b', { name: 'b', version: '1.0.0' }) diff --git a/test/pack.test.ts b/test/pack.test.ts new file mode 100644 index 0000000..3091a1e --- /dev/null +++ b/test/pack.test.ts @@ -0,0 +1,160 @@ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileSync = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFileSync })) + +const { main } = await import('../scripts/pack.ts') + +const PACK_ENV = ['GITHUB_REF', 'RELEASES', 'PACK_OUT_DIR', 'PACKAGES', 'GITHUB_OUTPUT'] as const + +let env: NodeJS.ProcessEnv +let cwd: string +let root: string +let outDir: string + +function writePkg (dir: string, pkg: Record) { + mkdirSync(dir, { recursive: true }) + writeFileSync(resolve(dir, 'package.json'), JSON.stringify(pkg)) +} + +/** Make the mocked pack tool emit `filename` and drop a matching tarball in `outDir`. */ +function packsAs (...filenames: string[]) { + let i = 0 + execFileSync.mockImplementation(() => { + const filename = filenames[Math.min(i++, filenames.length - 1)]! + writeFileSync(resolve(outDir, filename), 'tarball') + return `{"name":"pkg","filename":"${filename}"}\n` + }) +} + +beforeEach(() => { + env = { ...process.env } + cwd = process.cwd() + for (const key of PACK_ENV) delete process.env[key] + root = mkdtempSync(resolve(tmpdir(), 'uppt-pack-')) + outDir = resolve(root, 'out') + process.env.PACK_OUT_DIR = outDir + process.env.GITHUB_REF = 'refs/tags/v1.2.3' + writePkg(root, { name: 'root-pkg', version: '1.2.3' }) + process.chdir(root) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + process.chdir(cwd) + process.env = env + execFileSync.mockReset() + vi.restoreAllMocks() +}) + +describe('pack', () => { + it('rejects a ref that is not a semver tag', () => { + process.env.GITHUB_REF = 'refs/heads/main' + expect(() => main()).toThrow(/must be a strict-semver/) + }) + + it('reports an unset ref', () => { + delete process.env.GITHUB_REF + expect(() => main()).toThrow(/got ''/) + }) + + it('requires PACK_OUT_DIR', () => { + delete process.env.PACK_OUT_DIR + expect(() => main()).toThrow('PACK_OUT_DIR is required') + }) + + it('packs the repo root with npm when there is no pnpm lockfile', () => { + packsAs('root-pkg-1.2.3.tgz') + main() + expect(execFileSync).toHaveBeenCalledTimes(1) + const [cmd, args] = execFileSync.mock.calls[0]! + expect(cmd).toBe('npm') + expect(args).toEqual(['pack', '--pack-destination', outDir, '--json', '--silent']) + }) + + it('packs with pnpm when a pnpm lockfile is present', () => { + writeFileSync(resolve(root, 'pnpm-lock.yaml'), '') + packsAs('root-pkg-1.2.3.tgz') + main() + const [cmd, args] = execFileSync.mock.calls[0]! + expect(cmd).toBe('pnpm') + expect(args).toEqual(['pack', '--pack-destination', outDir, '--json']) + }) + + it('packs each declared workspace', () => { + writePkg(resolve(root, 'packages/a'), { name: 'a', version: '1.2.3' }) + writePkg(resolve(root, 'packages/b'), { name: 'b', version: '1.2.3' }) + process.env.PACKAGES = 'packages/*' + packsAs('a-1.2.3.tgz', 'b-1.2.3.tgz') + main() + expect(execFileSync.mock.calls.map(call => call[2].cwd)).toEqual([ + resolve(root, 'packages/a'), + resolve(root, 'packages/b'), + ]) + }) + + it('writes the packed filenames to GITHUB_OUTPUT', () => { + const outputFile = resolve(root, 'gh-output') + writeFileSync(outputFile, '') + process.env.GITHUB_OUTPUT = outputFile + packsAs('root-pkg-1.2.3.tgz') + main() + expect(readFileSync(outputFile, 'utf8')).toBe('files=["root-pkg-1.2.3.tgz"]\n') + }) + + it('throws when the pack tool reports a tarball it did not write', () => { + execFileSync.mockReturnValue('{"name":"pkg","filename":"ghost-1.2.3.tgz"}\n') + expect(() => main()).toThrow(/is not present in/) + }) + + it('throws when two workspaces produce the same tarball name', () => { + writePkg(resolve(root, 'packages/a'), { name: 'a', version: '1.2.3' }) + writePkg(resolve(root, 'packages/b'), { name: 'b', version: '1.2.3' }) + process.env.PACKAGES = 'packages/*' + packsAs('a-1.2.3.tgz') + expect(() => main()).toThrow(/duplicate tarball/) + }) + + describe('independent mode', () => { + beforeEach(() => { + process.env.GITHUB_REF = 'refs/tags/release-2024-05-01' + writePkg(resolve(root, 'packages/kit'), { name: '@nuxt/kit', version: '5.0.0' }) + process.env.RELEASES = JSON.stringify([{ name: '@nuxt/kit', version: '5.0.0', dir: 'packages/kit' }]) + }) + + it('packs exactly the dispatched workspaces', () => { + packsAs('nuxt-kit-5.0.0.tgz') + main() + expect(execFileSync.mock.calls[0]![2].cwd).toBe(resolve(root, 'packages/kit')) + }) + + it('requires a coordination tag', () => { + process.env.GITHUB_REF = 'refs/tags/v1.2.3' + expect(() => main()).toThrow(/coordination tag/) + }) + + it('reports an unset ref', () => { + delete process.env.GITHUB_REF + expect(() => main()).toThrow(/got ''/) + }) + + it('throws when an entry points at a directory with no package.json', () => { + process.env.RELEASES = JSON.stringify([{ name: '@nuxt/kit', version: '5.0.0', dir: 'packages/gone' }]) + expect(() => main()).toThrow(/has no package.json/) + }) + + it('throws when an entry points at a private package', () => { + writePkg(resolve(root, 'packages/kit'), { name: '@nuxt/kit', version: '5.0.0', private: true }) + expect(() => main()).toThrow(/private package/) + }) + + it('throws when an entry does not match the package.json on disk', () => { + writePkg(resolve(root, 'packages/kit'), { name: '@nuxt/kit', version: '4.0.0' }) + expect(() => main()).toThrow(/does not match/) + }) + }) +}) diff --git a/test/pin-readme.test.ts b/test/pin-readme.test.ts new file mode 100644 index 0000000..ba4367d --- /dev/null +++ b/test/pin-readme.test.ts @@ -0,0 +1,143 @@ +import { Buffer } from 'node:buffer' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { main } from '../scripts/pin-readme.ts' + +const SHA = 'a'.repeat(40) + +interface StubResponse { status?: number, body?: unknown } + +let env: NodeJS.ProcessEnv +let fetchMock: ReturnType + +function respond (queue: StubResponse[]) { + fetchMock.mockImplementation(() => { + const next = queue.shift() ?? { status: 200, body: {} } + const text = next.body === undefined ? '' : JSON.stringify(next.body) + const status = next.status ?? 200 + return Promise.resolve({ ok: status >= 200 && status < 300, status, text: () => Promise.resolve(text) }) + }) +} + +function contents (readme: string) { + return { sha: 'filesha', encoding: 'base64', content: Buffer.from(readme, 'utf8').toString('base64') } +} + +const README = 'uses: danielroe/uppt/pr@old # v0.0.1\nuses: danielroe/uppt/publish@old\n' + +beforeEach(() => { + env = { ...process.env } + process.env.GITHUB_TOKEN = 'tok' + process.env.GITHUB_REPOSITORY = 'owner/repo' + process.env.GITHUB_REF_NAME = 'v1.2.3' + process.env.GITHUB_SHA = SHA + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + process.env = env + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('pin-readme', () => { + it('requires a token', async () => { + delete process.env.GITHUB_TOKEN + await expect(main()).rejects.toThrow('GITHUB_TOKEN is required') + }) + + it('rejects an invalid repository', async () => { + process.env.GITHUB_REPOSITORY = 'owner/repo/extra' + await expect(main()).rejects.toThrow(/GITHUB_REPOSITORY invalid/) + }) + + it('rejects a non-semver tag', async () => { + process.env.GITHUB_REF_NAME = 'latest' + await expect(main()).rejects.toThrow(/not a semver tag/) + }) + + it('rejects a missing tag', async () => { + delete process.env.GITHUB_REF_NAME + await expect(main()).rejects.toThrow(/not a semver tag: $/) + }) + + it('rejects an invalid sha', async () => { + process.env.GITHUB_SHA = 'abc' + await expect(main()).rejects.toThrow(/GITHUB_SHA invalid/) + }) + + it('rejects a missing sha', async () => { + delete process.env.GITHUB_SHA + await expect(main()).rejects.toThrow(/GITHUB_SHA invalid: $/) + }) + + it('throws when the default branch cannot be resolved', async () => { + respond([{ status: 200 }]) + await expect(main()).rejects.toThrow('Could not resolve default branch') + }) + + it('surfaces an API failure', async () => { + respond([{ status: 500, body: { message: 'boom' } }]) + await expect(main()).rejects.toThrow(/GET \/repos\/owner\/repo failed: 500/) + }) + + it('rejects an unexpected README encoding', async () => { + respond([ + { body: { default_branch: 'main' } }, + { body: { sha: 'filesha', encoding: 'utf-8', content: README } }, + ]) + await expect(main()).rejects.toThrow(/Unexpected README encoding: utf-8/) + }) + + it('pins every uses line to the release sha', async () => { + respond([ + { body: { default_branch: 'main' } }, + { body: contents(README) }, + { status: 200, body: { commit: { sha: 'c', html_url: 'https://example.com/c' } } }, + ]) + await main() + const [, init] = fetchMock.mock.calls.at(-1)! + const sent = JSON.parse(init.body as string) as { content: string, message: string, sha: string, branch: string } + expect(Buffer.from(sent.content, 'base64').toString('utf8')).toBe( + `uses: danielroe/uppt/pr@${SHA} # v1.2.3\nuses: danielroe/uppt/publish@${SHA} # v1.2.3\n`, + ) + expect(sent).toMatchObject({ message: 'chore: pin README example to v1.2.3', sha: 'filesha', branch: 'main' }) + }) + + it('does nothing when the README is already pinned', async () => { + respond([ + { body: { default_branch: 'main' } }, + { body: contents(`uses: danielroe/uppt/pr@${SHA} # v1.2.3\n`) }, + ]) + await main() + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('retries when the branch advances under it', async () => { + respond([ + { body: { default_branch: 'main' } }, + { body: contents(README) }, + { status: 409, body: { message: 'conflict' } }, + { body: contents(README) }, + { status: 200, body: { commit: { sha: 'c', html_url: 'https://example.com/c' } } }, + ]) + await main() + expect(fetchMock).toHaveBeenCalledTimes(5) + }) + + it('gives up after five failed attempts', async () => { + fetchMock.mockImplementation((url: string, init: { method: string }) => { + const reply = (status: number, body: unknown) => Promise.resolve({ + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(JSON.stringify(body)), + }) + if (init.method === 'PUT') return reply(422, { message: 'does not match' }) + if (url.includes('/contents/')) return reply(200, contents(README)) + return reply(200, { default_branch: 'main' }) + }) + await expect(main()).rejects.toThrow('Exhausted retries trying to update README.md') + }) +}) diff --git a/test/publish.test.ts b/test/publish.test.ts new file mode 100644 index 0000000..7382857 --- /dev/null +++ b/test/publish.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileSync = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFileSync })) + +const { main } = await import('../scripts/publish.ts') + +function fixture (files: string[]): string { + const dir = mkdtempSync(resolve(tmpdir(), 'uppt-publish-')) + for (const file of files) writeFileSync(resolve(dir, file), 'x') + return dir +} + +function npmArgs () { + return execFileSync.mock.calls.map(([, args]) => args as string[]) +} + +let env: NodeJS.ProcessEnv + +beforeEach(() => { + env = { ...process.env } + for (const key of ['NPM_ACCESS', 'TARBALL_DIR', 'TARBALL_FILES', 'RELEASES']) delete process.env[key] + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + process.env = env + execFileSync.mockReset() + vi.restoreAllMocks() +}) + +describe('publish', () => { + it('requires TARBALL_DIR', () => { + expect(() => main()).toThrow('TARBALL_DIR is required') + }) + + it('rejects a missing TARBALL_DIR', () => { + process.env.TARBALL_DIR = resolve(tmpdir(), 'uppt-does-not-exist') + expect(() => main()).toThrow(/TARBALL_DIR does not exist/) + }) + + it('stages every tarball found in the directory, sorted', () => { + process.env.TARBALL_DIR = fixture(['b-1.0.0.tgz', 'a-1.0.0.tgz', 'notes.txt']) + main() + expect(npmArgs()).toEqual([ + ['stage', 'publish', resolve(process.env.TARBALL_DIR!, 'a-1.0.0.tgz'), '--provenance', '--ignore-scripts', '--access=public'], + ['stage', 'publish', resolve(process.env.TARBALL_DIR!, 'b-1.0.0.tgz'), '--provenance', '--ignore-scripts', '--access=public'], + ]) + }) + + it('honours NPM_ACCESS=restricted', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz']) + process.env.NPM_ACCESS = 'restricted' + main() + expect(npmArgs()[0]).toContain('--access=restricted') + }) + + it('falls back to public for an unknown NPM_ACCESS', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz']) + process.env.NPM_ACCESS = 'nonsense' + main() + expect(npmArgs()[0]).toContain('--access=public') + }) + + it('throws when the directory holds no tarballs', () => { + process.env.TARBALL_DIR = fixture(['notes.txt']) + expect(() => main()).toThrow(/No \*\.tgz found in/) + }) + + it('publishes TARBALL_FILES in the given order', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz', 'b-1.0.0.tgz']) + process.env.TARBALL_FILES = '["b-1.0.0.tgz","a-1.0.0.tgz"]' + main() + expect(npmArgs().map(args => args[2])).toEqual([ + resolve(process.env.TARBALL_DIR!, 'b-1.0.0.tgz'), + resolve(process.env.TARBALL_DIR!, 'a-1.0.0.tgz'), + ]) + }) + + it('rejects malformed TARBALL_FILES', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz']) + process.env.TARBALL_FILES = '{' + expect(() => main()).toThrow(/TARBALL_FILES is not valid JSON/) + }) + + it('rejects a non-array TARBALL_FILES', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz']) + process.env.TARBALL_FILES = '{"a":1}' + expect(() => main()).toThrow('TARBALL_FILES must be a JSON array of filenames') + }) + + it('rejects a TARBALL_FILES entry that is not a tarball', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz']) + process.env.TARBALL_FILES = '["a-1.0.0.zip"]' + expect(() => main()).toThrow(/non-tarball entry/) + }) + + it('rejects an empty TARBALL_FILES array', () => { + process.env.TARBALL_DIR = fixture(['a-1.0.0.tgz']) + process.env.TARBALL_FILES = '[]' + expect(() => main()).toThrow('TARBALL_FILES was provided but is empty') + }) + + it('derives tarball names from RELEASES when TARBALL_FILES is absent', () => { + process.env.TARBALL_DIR = fixture(['nuxt-kit-5.0.0.tgz', 'stray-9.0.0.tgz']) + process.env.RELEASES = JSON.stringify([{ name: '@nuxt/kit', version: '5.0.0', dir: 'packages/kit' }]) + main() + expect(npmArgs().map(args => args[2])).toEqual([ + resolve(process.env.TARBALL_DIR!, 'nuxt-kit-5.0.0.tgz'), + ]) + }) + + it('throws when a named tarball is missing', () => { + process.env.TARBALL_DIR = fixture([]) + process.env.TARBALL_FILES = '["a-1.0.0.tgz"]' + expect(() => main()).toThrow(/Tarball 'a-1\.0\.0\.tgz' is not present/) + }) +}) diff --git a/test/tag-and-release.test.ts b/test/tag-and-release.test.ts new file mode 100644 index 0000000..53dec1c --- /dev/null +++ b/test/tag-and-release.test.ts @@ -0,0 +1,216 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileSync = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFileSync })) + +const { main } = await import('../scripts/tag-and-release.ts') + +const TAG_ENV = ['GITHUB_TOKEN', 'GITHUB_REPOSITORY', 'MODE', 'PACKAGES', 'PR_BODY', 'PUBLISH_WORKFLOW'] as const + +let env: NodeJS.ProcessEnv +let cwd: string +let root: string +let existingTags: string[] + +/** + * Route the mocked `execFileSync` to plausible git/gh responses. Tags in + * `existingTags` are reported by both `git for-each-ref` and `gh api`. + */ +function stubGit () { + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args[0] === 'for-each-ref') return existingTags.join('\n') + '\n' + if (cmd === 'git' && args[0] === 'rev-parse') return 'deadbeef\n' + if (cmd === 'gh' && args[0] === 'api' && args[1] !== '-X') { + const tag = args[args.length - 1]!.replace(/^.*\/git\/ref\/tags\//, '') + if (!existingTags.includes(tag)) throw new Error('gh: 404') + return '{"ref":"refs/tags/x"}' + } + return '' + }) +} + +function ghCalls () { + return execFileSync.mock.calls.filter(([cmd]) => cmd === 'gh').map(([, args]) => args as string[]) +} + +function writePkg (dir: string, pkg: Record) { + mkdirSync(dir, { recursive: true }) + writeFileSync(resolve(dir, 'package.json'), JSON.stringify(pkg)) +} + +beforeEach(() => { + env = { ...process.env } + cwd = process.cwd() + for (const key of TAG_ENV) delete process.env[key] + process.env.GITHUB_TOKEN = 'tok' + process.env.GITHUB_REPOSITORY = 'owner/repo' + root = mkdtempSync(resolve(tmpdir(), 'uppt-release-')) + writePkg(root, { name: 'root-pkg', version: '1.2.3' }) + process.chdir(root) + existingTags = [] + stubGit() + vi.spyOn(console, 'log').mockImplementation(() => {}) +}) + +afterEach(() => { + process.chdir(cwd) + process.env = env + execFileSync.mockReset() + vi.restoreAllMocks() +}) + +describe('tag-and-release', () => { + it('requires a token', () => { + delete process.env.GITHUB_TOKEN + expect(() => main()).toThrow('GITHUB_TOKEN is required') + }) + + it('rejects a malformed repository', () => { + process.env.GITHUB_REPOSITORY = 'norepo' + expect(() => main()).toThrow('GITHUB_REPOSITORY is required') + }) + + it('rejects an unknown mode', () => { + process.env.MODE = 'whatever' + expect(() => main()).toThrow(/expected "lockstep" or "independent"/) + }) + + it('rejects a non-semver version', () => { + writePkg(root, { name: 'root-pkg', version: 'nightly' }) + expect(() => main()).toThrow(/is not strict semver/) + }) + + it('tags, releases and dispatches in lockstep mode', () => { + process.env.PR_BODY = 'notes' + main() + expect(ghCalls()).toEqual([ + ['api', '-H', 'Accept: application/vnd.github+json', '/repos/owner/repo/git/ref/tags/v1.2.3'], + ['api', '-X', 'POST', '-H', 'Accept: application/vnd.github+json', '/repos/owner/repo/git/refs', '-f', 'ref=refs/tags/v1.2.3', '-f', 'sha=deadbeef'], + ['release', 'create', 'v1.2.3', '--title', 'v1.2.3', '--notes', 'notes'], + ['workflow', 'run', 'release.yml', '--ref', 'v1.2.3'], + ]) + }) + + it('dispatches a custom publish workflow', () => { + process.env.PUBLISH_WORKFLOW = 'publish.yml' + main() + expect(ghCalls().at(-1)).toEqual(['workflow', 'run', 'publish.yml', '--ref', 'v1.2.3']) + }) + + it('refuses to retag an existing version', () => { + existingTags = ['v1.2.3'] + expect(() => main()).toThrow(/already exists on owner\/repo/) + }) + + describe('independent mode', () => { + beforeEach(() => { + process.env.MODE = 'independent' + process.env.PACKAGES = 'packages/*' + writePkg(resolve(root, 'packages/kit'), { name: '@nuxt/kit', version: '5.0.0' }) + writePkg(resolve(root, 'packages/ui'), { name: '@nuxt/ui', version: '2.0.0' }) + existingTags = ['@nuxt/kit@4.0.0', '@nuxt/ui@2.0.0'] + vi.setSystemTime(new Date('2024-05-01T00:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('requires the packages input', () => { + delete process.env.PACKAGES + expect(() => main()).toThrow(/requires the `packages` input/) + }) + + it('tags only the packages whose version changed', () => { + main() + const created = ghCalls().filter(args => args[1] === '-X').map(args => args[7]) + expect(created).toEqual(['ref=refs/tags/@nuxt/kit@5.0.0', 'ref=refs/tags/release-2024-05-01']) + }) + + it('cuts the release on the coordination tag and dispatches the release set', () => { + process.env.PR_BODY = 'body' + main() + expect(ghCalls().at(-2)).toEqual(['release', 'create', 'release-2024-05-01', '--title', '@nuxt/kit@5.0.0', '--notes', 'body']) + expect(ghCalls().at(-1)).toEqual([ + 'workflow', 'run', 'release.yml', '--ref', 'release-2024-05-01', + '-f', 'releases=[{"name":"@nuxt/kit","version":"5.0.0","dir":"packages/kit"}]', + ]) + }) + + it('suffixes the coordination tag when the date is taken', () => { + existingTags = [...existingTags, 'release-2024-05-01'] + main() + const created = ghCalls().filter(args => args[1] === '-X').map(args => args[7]) + expect(created).toContain('ref=refs/tags/release-2024-05-01.2') + }) + + it('throws when nothing changed', () => { + existingTags = ['@nuxt/kit@5.0.0', '@nuxt/ui@2.0.0'] + expect(() => main()).toThrow(/nothing to release/) + }) + + it('refuses to run when a package tag already exists remotely', () => { + existingTags = ['@nuxt/ui@2.0.0'] + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args[0] === 'for-each-ref') return existingTags.join('\n') + '\n' + if (cmd === 'git') return 'deadbeef\n' + if (cmd === 'gh' && args[0] === 'api' && args[1] !== '-X') { + if (args[args.length - 1]!.endsWith('@nuxt/kit@5.0.0')) return '{"ref":"x"}' + throw new Error('gh: 404') + } + return '' + }) + expect(() => main()).toThrow(/Refusing to tag: @nuxt\/kit@5\.0\.0 already exists/) + }) + + it('refuses to run when several package tags already exist remotely', () => { + existingTags = [] + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args[0] === 'for-each-ref') return '' + if (cmd === 'git') return 'deadbeef\n' + if (cmd === 'gh' && args[0] === 'api' && args[1] !== '-X') { + if (args[args.length - 1]!.includes('release-')) throw new Error('gh: 404') + return '{"ref":"x"}' + } + return '' + }) + expect(() => main()).toThrow(/already exist on owner\/repo/) + }) + + it('reports which tags were created when tag creation fails midway', () => { + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args[0] === 'for-each-ref') return existingTags.join('\n') + '\n' + if (cmd === 'git') return 'deadbeef\n' + if (cmd === 'gh' && args[0] === 'api' && args[1] === '-X') { + if (args[7]!.includes('release-')) throw new Error('gh: 422') + return '' + } + if (cmd === 'gh' && args[0] === 'api') throw new Error('gh: 404') + return '' + }) + expect(() => main()).toThrow(/created so far: @nuxt\/kit@5\.0\.0/) + }) + + it('reports an empty created list when the first tag fails', () => { + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'git' && args[0] === 'for-each-ref') return existingTags.join('\n') + '\n' + if (cmd === 'git') return 'deadbeef\n' + if (cmd === 'gh' && args[0] === 'api' && args[1] === '-X') throw new Error('gh: 422') + if (cmd === 'gh' && args[0] === 'api') throw new Error('gh: 404') + return '' + }) + expect(() => main()).toThrow(/created so far: /) + }) + + it('pluralises the summary for multiple packages', () => { + writePkg(resolve(root, 'packages/ui'), { name: '@nuxt/ui', version: '3.0.0' }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + main() + expect(log.mock.calls.at(-1)?.[0]).toMatch(/Tagged 2 packages/) + }) + }) +}) diff --git a/test/update-changelog-main.test.ts b/test/update-changelog-main.test.ts new file mode 100644 index 0000000..fa1a919 --- /dev/null +++ b/test/update-changelog-main.test.ts @@ -0,0 +1,719 @@ +import { Buffer } from 'node:buffer' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import process from 'node:process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileSync = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execFileSync })) + +const { main } = await import('../scripts/update-changelog.ts') + +const ENV_KEYS = [ + 'GITHUB_TOKEN', + 'GITHUB_REPOSITORY', + 'DRY_RUN', + 'RELEASE_BASE', + 'PACKAGES', + 'PRERELEASE', + 'MODE', + 'SCOPES', +] as const + +interface FakeCommit { + hash: string + short: string + name: string + email: string + subject: string + body?: string +} + +interface FakePR { + number: number + body: string | null + head: { ref: string, repo: { full_name: string } | null } + base: { ref: string } + updated_at: string +} + +interface ApiCall { method: string, path: string, body?: unknown } + +const HEAD_SHA = '0'.repeat(40) + +let tmp: string +let cwd: string +let env: NodeJS.ProcessEnv +let calls: ApiCall[] + +const git = { + tags: [] as string[], + commits: [] as FakeCommit[], + revList: [] as string[], + remote: 'git@github.com:owner/repo.git', + branch: 'main', +} + +const api = { + branches: new Map(), + openPRs: [] as FakePR[], + prsForHead: [] as Array<{ head: { ref: string }, merged_at: string | null }>, + logins: new Map(), + priorCommits: new Map(), + compares: new Map, merge_base_commit: { sha: string }, behind_by: number }>(), + contents: new Map(), + rawContents: new Map(), + fail: new Map(), + /** Fail the Nth (1-based) call to a `METHOD /path` pair, letting earlier ones through. */ + failNth: new Map(), + seen: new Map(), +} + +function record (...commits: FakeCommit[]): string { + return commits + .map(c => [c.hash, c.short, c.name, c.email, c.subject, c.body ?? ''].join('\x1f') + '\x1e') + .join('') +} + +function stubGit () { + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd !== 'git') throw new Error(`unexpected command ${cmd}`) + const [sub] = args + if (sub === 'for-each-ref') return git.tags.join('\n') + '\n' + if (sub === 'remote') return git.remote + if (sub === 'rev-parse') return args.includes('--abbrev-ref') ? git.branch : HEAD_SHA + if (sub === 'rev-list') return git.revList.join('\n') + if (sub === 'log' && args[1] === '-1') return '2024-01-01T00:00:00Z' + if (sub === 'log') return record(...git.commits) + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) +} + +/** Minimal in-memory GitHub REST API over the endpoints the script uses. */ +function route (method: string, path: string, body: unknown): { status: number, body: unknown } { + const key = `${method} ${path.split('?')[0]}` + const forced = api.fail.get(key) + if (forced) return { status: forced, body: { message: 'forced failure' } } + + const count = (api.seen.get(key) ?? 0) + 1 + api.seen.set(key, count) + const transient = api.failNth.get(key) + if (transient && transient.nth === count) return { status: transient.status, body: { message: 'transient failure' } } + + const [pathname, query = ''] = path.split('?') + const rest = pathname!.replace('/repos/owner/repo', '') + const params = new URLSearchParams(query) + + if (method === 'GET' && /^\/commits\/[^/]+\/pulls$/.test(rest)) return { status: 200, body: api.prsForHead } + if (method === 'GET' && rest === '/commits') { + return { status: 200, body: api.priorCommits.get(params.get('author')!) ?? [] } + } + if (method === 'GET' && /^\/commits\/[^/]+$/.test(rest)) { + const login = api.logins.get(decodeURIComponent(rest.slice('/commits/'.length))) + if (login === undefined) return { status: 404, body: {} } + return { status: 200, body: { author: login === null ? null : { login } } } + } + if (method === 'GET' && rest === '/pulls') { + const head = params.get('head') + if (head && head !== 'owner:') { + const branch = head.slice('owner:'.length) + return { status: 200, body: api.openPRs.filter(pr => pr.head.ref === branch) } + } + return { status: 200, body: api.openPRs } + } + if (method === 'POST' && rest === '/pulls') { + return { status: 201, body: { number: 42, html_url: 'https://github.com/owner/repo/pull/42' } } + } + if (method === 'PATCH' && /^\/pulls\/\d+$/.test(rest)) return { status: 200, body: {} } + if (method === 'GET' && rest.startsWith('/branches/')) { + const branch = decodeURIComponent(rest.slice('/branches/'.length)) + const sha = api.branches.get(branch) + return sha ? { status: 200, body: { commit: { sha } } } : { status: 404, body: { message: 'Branch not found' } } + } + if (method === 'GET' && rest.startsWith('/git/commits/')) { + return { status: 200, body: { tree: { sha: `tree-of-${rest.slice('/git/commits/'.length)}` } } } + } + if (method === 'POST' && rest === '/git/blobs') return { status: 201, body: { sha: 'blob-sha' } } + if (method === 'POST' && rest === '/git/trees') return { status: 201, body: { sha: 'tree-sha' } } + if (method === 'POST' && rest === '/git/commits') return { status: 201, body: { sha: 'commit-sha' } } + if (method === 'POST' && rest === '/git/refs') return { status: 201, body: {} } + if (rest.startsWith('/git/refs/heads/')) { + if (method === 'PATCH') { + api.branches.set(rest.slice('/git/refs/heads/'.length), (body as { sha: string }).sha) + return { status: 200, body: {} } + } + if (method === 'DELETE') return { status: 204, body: {} } + } + if (method === 'GET' && rest.startsWith('/compare/')) { + const cmp = api.compares.get(decodeURIComponent(rest.slice('/compare/'.length))) + return cmp ? { status: 200, body: cmp } : { status: 404, body: { message: 'Not Found' } } + } + if (method === 'GET' && rest.startsWith('/contents/')) { + const contentKey = `${params.get('ref')}:${decodeURIComponent(rest.slice('/contents/'.length))}` + if (api.rawContents.has(contentKey)) return { status: 200, body: api.rawContents.get(contentKey) } + const content = api.contents.get(contentKey) + if (content === undefined) return { status: 404, body: { message: 'Not Found' } } + return { status: 200, body: { encoding: 'base64', content: Buffer.from(content, 'utf8').toString('base64') } } + } + throw new Error(`unrouted API call: ${method} ${path}`) +} + +function stubFetch () { + vi.stubGlobal('fetch', vi.fn((url: string, init: RequestInit = {}) => { + const method = init.method ?? 'GET' + const path = url.replace('https://api.github.com', '') + const body = init.body ? JSON.parse(init.body as string) : undefined + calls.push({ method, path, body }) + const { status, body: payload } = route(method, path, body) + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + statusText: '', + text: () => Promise.resolve(JSON.stringify(payload)), + json: () => Promise.resolve(payload), + }) + })) +} + +function writePackage (relDir: string, contents: Record) { + const dir = resolve(tmp, relDir) + mkdirSync(dir, { recursive: true }) + writeFileSync(resolve(dir, 'package.json'), JSON.stringify(contents, null, 2) + '\n') +} + +function committedFiles (): Array<{ path: string, sha: string }> { + const tree = calls.find(call => call.method === 'POST' && call.path.endsWith('/git/trees')) + return (tree?.body as { tree: Array<{ path: string, sha: string }> } | undefined)?.tree ?? [] +} + +function blobContents (): string[] { + return calls + .filter(call => call.method === 'POST' && call.path.endsWith('/git/blobs')) + .map(call => Buffer.from((call.body as { content: string }).content, 'base64').toString('utf8')) +} + +function prBody (): string { + const call = calls.findLast(c => (c.method === 'POST' && c.path.endsWith('/pulls')) || (c.method === 'PATCH' && /\/pulls\/\d+$/.test(c.path))) + return (call?.body as { body: string }).body +} + +const FEAT: FakeCommit = { hash: 'a'.repeat(40), short: 'aaaaaaa', name: 'Ada', email: 'ada@example.com', subject: 'feat: add a thing (#7)' } + +beforeEach(() => { + env = { ...process.env } + cwd = process.cwd() + for (const key of ENV_KEYS) delete process.env[key] + process.env.GITHUB_REPOSITORY = 'owner/repo' + process.env.GITHUB_TOKEN = 'tok' + tmp = mkdtempSync(resolve(tmpdir(), 'uppt-main-')) + process.chdir(tmp) + writePackage('.', { name: 'pkg', version: '1.2.3' }) + + git.tags = ['v1.2.3'] + git.commits = [FEAT] + git.revList = [FEAT.hash] + git.remote = 'git@github.com:owner/repo.git' + git.branch = 'main' + + api.branches = new Map([['main', 'base-sha']]) + api.openPRs = [] + api.prsForHead = [] + api.logins = new Map([['aaaaaaa', 'ada']]) + api.priorCommits = new Map() + api.compares = new Map() + api.contents = new Map() + api.rawContents = new Map() + api.fail = new Map() + api.failNth = new Map() + api.seen = new Map() + + calls = [] + stubGit() + stubFetch() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + process.chdir(cwd) + process.env = env + execFileSync.mockReset() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('lockstep main', () => { + it('creates a release branch and a draft PR', async () => { + await main() + expect(committedFiles()).toEqual([{ path: 'package.json', mode: '100644', type: 'blob', sha: 'blob-sha' }]) + expect(JSON.parse(blobContents()[0]!)).toMatchObject({ version: '1.3.0' }) + const created = calls.find(c => c.method === 'POST' && c.path.endsWith('/pulls'))! + expect(created.body).toMatchObject({ title: 'v1.3.0', head: 'release/v1.3.0', base: 'main', draft: true }) + expect(prBody()).toContain('### 🚀 Enhancements') + expect(prBody()).toContain('- add a thing (#7)') + expect(prBody()).toContain('- Ada (@ada)') + }) + + it('skips when HEAD is the merge of a release PR', async () => { + api.prsForHead = [{ head: { ref: 'release/v1.2.3' }, merged_at: '2024-01-01T00:00:00Z' }] + await main() + expect(calls.filter(c => c.method !== 'GET')).toEqual([]) + }) + + it('skips when HEAD is the merge of a pending release PR', async () => { + api.prsForHead = [{ head: { ref: 'release/main-pending' }, merged_at: '2024-01-01T00:00:00Z' }] + await main() + expect(calls.filter(c => c.method !== 'GET')).toEqual([]) + }) + + it('carries on when the merged-PR lookup fails', async () => { + api.fail.set(`GET /repos/owner/repo/commits/${HEAD_SHA}/pulls`, 500) + await main() + expect(calls.some(c => c.method === 'POST' && c.path.endsWith('/pulls'))).toBe(true) + }) + + it('rejects an unknown mode', async () => { + process.env.MODE = 'sideways' + await expect(main()).rejects.toThrow(/expected "lockstep" or "independent"/) + }) + + it('requires packages for independent mode', async () => { + process.env.MODE = 'independent' + await expect(main()).rejects.toThrow(/requires the `packages` input/) + }) + + it('does nothing when there are no release-worthy commits', async () => { + git.commits = [{ ...FEAT, subject: 'chore(deps): bump something' }] + await main() + expect(calls.filter(c => c.method !== 'GET')).toEqual([]) + }) + + it('reports the repo root when there is no tag at all', async () => { + git.tags = [] + git.commits = [] + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await main() + expect(log).toHaveBeenCalledWith('No release-worthy commits since', 'repo root') + }) + + it('reads the repo from the origin remote when the env var is absent', async () => { + delete process.env.GITHUB_REPOSITORY + await main() + expect(calls[0]!.path).toMatch(/^\/repos\/owner\/repo\//) + }) + + it('throws when the origin remote is unparseable', async () => { + delete process.env.GITHUB_REPOSITORY + git.remote = 'not-a-remote-url' + await expect(main()).rejects.toThrow(/Cannot parse repo from remote url/) + }) + + it('honours RELEASE_BASE over the checked-out branch', async () => { + process.env.RELEASE_BASE = '4.x' + api.branches.set('4.x', 'four-sha') + await main() + const created = calls.find(c => c.method === 'POST' && c.path.endsWith('/pulls'))! + expect(created.body).toMatchObject({ base: '4.x' }) + }) + + it('closes a superseded release PR and lifts its preamble', async () => { + api.openPRs = [{ + number: 3, + body: '> old intro\n\n## 👉 Changelog\n\nstuff', + head: { ref: 'release/v1.2.4', repo: { full_name: 'owner/repo' } }, + base: { ref: 'main' }, + updated_at: '2024-01-02T00:00:00Z', + }] + await main() + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ method: 'PATCH', path: '/repos/owner/repo/pulls/3', body: { state: 'closed' } }), + expect.objectContaining({ method: 'DELETE', path: '/repos/owner/repo/git/refs/heads/release/v1.2.4' }), + ])) + expect(prBody()).toContain('> old intro') + }) + + it('warns but continues when the superseded branch cannot be deleted', async () => { + api.openPRs = [{ + number: 3, + body: null, + head: { ref: 'release/v1.2.4', repo: { full_name: 'owner/repo' } }, + base: { ref: 'main' }, + updated_at: '2024-01-02T00:00:00Z', + }] + api.fail.set('DELETE /repos/owner/repo/git/refs/heads/release/v1.2.4', 422) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await main() + expect(warn).toHaveBeenCalled() + }) + + it('ignores open PRs from forks, other bases, and non-release branches', async () => { + api.openPRs = [ + { number: 1, body: null, head: { ref: 'release/v1.2.4', repo: { full_name: 'fork/repo' } }, base: { ref: 'main' }, updated_at: '2024-01-02T00:00:00Z' }, + { number: 2, body: null, head: { ref: 'release/v1.2.5', repo: { full_name: 'owner/repo' } }, base: { ref: '4.x' }, updated_at: '2024-01-02T00:00:00Z' }, + { number: 3, body: null, head: { ref: 'feat/thing', repo: { full_name: 'owner/repo' } }, base: { ref: 'main' }, updated_at: '2024-01-02T00:00:00Z' }, + { number: 4, body: null, head: { ref: 'release/v1.3.0', repo: { full_name: 'owner/repo' } }, base: { ref: 'main' }, updated_at: '2024-01-02T00:00:00Z' }, + ] + await main() + expect(calls.filter(c => c.method === 'PATCH' && /\/pulls\/[123]$/.test(c.path))).toEqual([]) + }) + + it('updates the existing PR for the target version', async () => { + api.openPRs = [{ + number: 9, + body: '> keep me\n\n## 👉 Changelog\n\nold', + head: { ref: 'release/v1.3.0', repo: { full_name: 'owner/repo' } }, + base: { ref: 'main' }, + updated_at: '2024-01-02T00:00:00Z', + }] + api.branches.set('release/v1.3.0', 'bumped-sha') + await main() + const patch = calls.findLast(c => c.method === 'PATCH' && c.path === '/repos/owner/repo/pulls/9')! + expect(patch.body).toMatchObject({ title: 'v1.3.0' }) + expect(prBody()).toContain('> keep me') + expect(calls.some(c => c.path.endsWith('/git/trees'))).toBe(false) + }) + + it('recovers a release branch that sits at base with no bump', async () => { + api.branches.set('release/v1.3.0', 'base-sha') + await main() + expect(calls.some(c => c.method === 'POST' && c.path.endsWith('/git/trees'))).toBe(true) + }) + + it('requires a token to create the release branch', async () => { + delete process.env.GITHUB_TOKEN + await expect(main()).rejects.toThrow('GITHUB_TOKEN is required to create the release branch') + }) + + it('requires a token to create or update the PR', async () => { + delete process.env.GITHUB_TOKEN + api.branches.set('release/v1.3.0', 'bumped-sha') + await expect(main()).rejects.toThrow('GITHUB_TOKEN is required to create or update the PR') + }) + + it('makes no writes in dry-run mode', async () => { + process.env.DRY_RUN = '1' + await main() + expect(calls.filter(c => c.method !== 'GET')).toEqual([]) + }) + + it('bumps every workspace in a monorepo, plus a matching root', async () => { + writePackage('.', { name: 'monorepo', version: '1.2.3' }) + writePackage('packages/a', { name: 'a', version: '1.2.3' }) + writePackage('packages/b', { name: 'b', version: '1.2.3' }) + process.env.PACKAGES = 'packages/*' + await main() + expect(committedFiles().map(f => f.path)).toEqual(['packages/a/package.json', 'packages/b/package.json', 'package.json']) + expect(blobContents().map(c => JSON.parse(c).version)).toEqual(['1.3.0', '1.3.0', '1.3.0']) + }) + + it('cuts a prerelease when PRERELEASE is set', async () => { + process.env.PRERELEASE = 'beta' + await main() + const created = calls.find(c => c.method === 'POST' && c.path.endsWith('/pulls'))! + expect(created.body).toMatchObject({ title: 'v1.3.0-beta.0' }) + }) + + it('propagates an unexpected branch-lookup failure', async () => { + api.fail.set('GET /repos/owner/repo/branches/release%2Fv1.3.0', 500) + await expect(main()).rejects.toThrow(/-> 500/) + }) + + it('treats a failing tag listing as no tags at all', async () => { + execFileSync.mockImplementation((cmd: string, args: string[]) => { + if (args[0] === 'for-each-ref') throw new Error('not a git repository') + if (args[0] === 'rev-parse') return args.includes('--abbrev-ref') ? git.branch : HEAD_SHA + if (args[0] === 'log') return record(FEAT) + throw new Error(`unexpected git call: ${args.join(' ')}`) + }) + await main() + expect(prBody()).not.toContain('compare changes') + }) + + it('ignores unparseable log records and keeps non-conventional subjects out of the changelog', async () => { + git.commits = [ + FEAT, + { hash: '', short: '', name: '', email: '', subject: '' }, + { ...FEAT, hash: 'e'.repeat(40), short: 'eeeeeee', name: '', email: '', subject: 'not a conventional commit' }, + { ...FEAT, hash: 'f'.repeat(40), short: 'fffffff', name: '', email: '', subject: 'fix: from an unnamed author' }, + ] + await main() + expect(prBody()).toContain('- add a thing (#7)') + expect(prBody()).not.toContain('not a conventional commit') + }) + + it('collects issue references from the commit body', async () => { + git.commits = [{ ...FEAT, subject: 'fix: repair the thing', body: 'Fixes #12\nBREAKING CHANGE: it moved' }] + await main() + expect(prBody()).toContain('- ⚠️ repair the thing (#12)') + const created = calls.find(c => c.method === 'POST' && c.path.endsWith('/pulls'))! + expect(created.body).toMatchObject({ title: 'v2.0.0' }) + }) + + it('carries the preamble of the most recently updated superseded PR', async () => { + api.openPRs = [ + { number: 3, body: '> older intro\n\n## 👉 Changelog', head: { ref: 'release/v1.2.4', repo: { full_name: 'owner/repo' } }, base: { ref: 'main' }, updated_at: '2024-01-02T00:00:00Z' }, + { number: 4, body: '> newer intro\n\n## 👉 Changelog', head: { ref: 'release/v1.2.5', repo: { full_name: 'owner/repo' } }, base: { ref: 'main' }, updated_at: '2024-03-02T00:00:00Z' }, + ] + await main() + expect(prBody()).toContain('> newer intro') + }) + + it('propagates a transient failure while resolving the release branch parent', async () => { + api.branches.set('release/v1.3.0', 'base-sha') + api.failNth.set('GET /repos/owner/repo/branches/release%2Fv1.3.0', { nth: 2, status: 500 }) + await expect(main()).rejects.toThrow(/-> 500/) + }) + + it('creates the release branch when it does not exist yet', async () => { + await main() + const createRef = calls.find(c => c.method === 'POST' && c.path.endsWith('/git/refs'))! + expect(createRef.body).toMatchObject({ ref: 'refs/heads/release/v1.3.0', sha: 'base-sha' }) + }) +}) + +describe('contributors', () => { + const second: FakeCommit = { hash: 'b'.repeat(40), short: 'bbbbbbb', name: 'Bo', email: 'bo@example.com', subject: 'fix: another' } + + it('skips renovate, deduplicates authors, and marks returning contributors', async () => { + git.commits = [ + FEAT, + { ...FEAT, hash: 'c'.repeat(40), short: 'ccccccc', subject: 'fix: same author again' }, + { ...second, name: 'renovate[bot]', email: 'bot@example.com' }, + second, + ] + api.logins = new Map([['aaaaaaa', 'ada'], ['bbbbbbb', 'bo']]) + api.priorCommits = new Map([['ada', [{}]]]) + await main() + expect(prBody()).toContain('### 🎉 New Contributors') + expect(prBody()).toMatch(/### 🎉 New Contributors\n\n- Bo \(@bo\)/) + expect(prBody()).toContain('- Ada (@ada)') + }) + + it('skips commits whose author lookup fails or has no linked account', async () => { + git.commits = [FEAT, second] + api.logins = new Map([['aaaaaaa', null]]) + await main() + expect(prBody()).toContain('_no contributors yet_') + }) + + it('deduplicates two emails mapping to the same account', async () => { + git.commits = [FEAT, { ...second, short: 'bbbbbbb' }] + api.logins = new Map([['aaaaaaa', 'ada'], ['bbbbbbb', 'ada']]) + api.priorCommits = new Map([['ada', [{}]]]) + await main() + expect(prBody()!.match(/- Ada \(@ada\)/g)).toHaveLength(1) + }) + + it('treats a failed prior-commit lookup as a returning contributor', async () => { + api.fail.set('GET /repos/owner/repo/commits', 500) + await main() + expect(prBody()).not.toContain('New Contributors') + }) + + it('treats everyone as first-time when there is no previous tag', async () => { + git.tags = [] + await main() + expect(prBody()).toContain('### 🎉 New Contributors') + }) +}) + +describe('independent main', () => { + beforeEach(() => { + process.env.MODE = 'independent' + process.env.PACKAGES = 'packages/*' + writePackage('.', { name: 'monorepo', version: '0.0.0', private: true }) + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/b', { name: 'b', version: '2.0.0', dependencies: { a: 'workspace:*' } }) + git.tags = ['a@1.0.0', 'b@2.0.0'] + git.commits = [{ ...FEAT, subject: 'feat(a): add a thing (#7)' }] + }) + + it('syncs the pending branch and opens a PR', async () => { + await main() + expect(committedFiles().map(f => f.path)).toEqual(['packages/a/package.json', 'packages/b/package.json']) + expect(blobContents().map(c => JSON.parse(c).version)).toEqual(['1.1.0', '2.0.1']) + const created = calls.find(c => c.method === 'POST' && c.path.endsWith('/pulls'))! + expect(created.body).toMatchObject({ title: 'chore: release 2 packages', head: 'release/main-pending' }) + expect(prBody()).toContain('### a (1.0.0 → 1.1.0)') + expect(prBody()).toContain('_Released because `a` was bumped; no direct changes._') + }) + + it('does nothing when no package has unreleased commits', async () => { + git.commits = [{ ...FEAT, subject: 'feat(unknown): elsewhere' }] + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await main() + expect(log).toHaveBeenCalledWith('Independent release plan: no packages to release.') + expect(calls.filter(c => c.method !== 'GET')).toEqual([]) + }) + + it('lists unrouted commits in the log and the body', async () => { + git.commits = [{ ...FEAT, subject: 'feat(a): routed' }, { ...FEAT, hash: 'd'.repeat(40), short: 'ddddddd', subject: 'fix: unscoped' }] + git.revList = [FEAT.hash, 'd'.repeat(40)] + await main() + expect(prBody()).toContain('### 📝 Other commits') + expect(prBody()).toContain('fix: unscoped') + }) + + it('routes scope overrides from the SCOPES input', async () => { + process.env.SCOPES = 'a: alias' + git.commits = [{ ...FEAT, subject: 'feat(alias): via override' }] + await main() + expect(prBody()).toContain('### a (1.0.0 → 1.1.0)') + }) + + it('uses the full history when a package has no boundary tag', async () => { + git.tags = [] + await main() + expect(prBody()).toContain('### a (1.0.0 → 1.1.0)') + expect(execFileSync.mock.calls.some(([, args]) => args[0] === 'log' && args[1] === 'HEAD')).toBe(true) + }) + + it('leaves the branch alone when it already carries the plan', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.compares.set('main...release/main-pending', { + files: [{ filename: 'packages/a/package.json' }, { filename: 'packages/b/package.json' }], + merge_base_commit: { sha: 'base-sha' }, + behind_by: 0, + }) + api.contents.set('release/main-pending:packages/a/package.json', JSON.stringify({ name: 'a', version: '1.1.0' }, null, 2) + '\n') + api.contents.set('release/main-pending:packages/b/package.json', JSON.stringify({ name: 'b', version: '2.0.1', dependencies: { a: 'workspace:*' } }, null, 2) + '\n') + await main() + expect(calls.some(c => c.path.endsWith('/git/trees'))).toBe(false) + }) + + it('force-updates an existing branch that drifted from the plan', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.compares.set('main...release/main-pending', { + files: [{ filename: 'packages/a/package.json' }], + merge_base_commit: { sha: 'merge-base' }, + behind_by: 2, + }) + api.compares.set('merge-base...main', { files: [{ filename: 'packages/b/package.json' }], merge_base_commit: { sha: 'merge-base' }, behind_by: 0 }) + await main() + const patch = calls.findLast(c => c.method === 'PATCH' && c.path.endsWith('/git/refs/heads/release/main-pending'))! + expect(patch.body).toMatchObject({ sha: 'commit-sha', force: true }) + }) + + it('propagates an unexpected compare failure', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.fail.set('GET /repos/owner/repo/compare/main...release%2Fmain-pending', 500) + await expect(main()).rejects.toThrow(/-> 500/) + }) + + it('treats a non-base64 contents response as a missing file', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.compares.set('main...release/main-pending', { + files: [{ filename: 'packages/a/package.json' }, { filename: 'packages/b/package.json' }], + merge_base_commit: { sha: 'base-sha' }, + behind_by: 0, + }) + api.rawContents.set('release/main-pending:packages/a/package.json', { encoding: 'none' }) + api.rawContents.set('release/main-pending:packages/b/package.json', { encoding: 'base64' }) + await main() + expect(calls.some(c => c.method === 'POST' && c.path.endsWith('/git/trees'))).toBe(true) + }) + + it('rebuilds when the compare response carries no file list', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.compares.set('main...release/main-pending', { merge_base_commit: { sha: 'merge-base' }, behind_by: 1 }) + api.compares.set('merge-base...main', { merge_base_commit: { sha: 'merge-base' }, behind_by: 0 }) + await main() + expect(calls.some(c => c.method === 'POST' && c.path.endsWith('/git/trees'))).toBe(true) + }) + + it('ignores base changes to files outside the plan', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.compares.set('main...release/main-pending', { + files: [{ filename: 'packages/a/package.json' }, { filename: 'packages/b/package.json' }], + merge_base_commit: { sha: 'merge-base' }, + behind_by: 4, + }) + api.compares.set('merge-base...main', { files: [{ filename: 'README.md' }], merge_base_commit: { sha: 'merge-base' }, behind_by: 0 }) + api.contents.set('release/main-pending:packages/a/package.json', JSON.stringify({ name: 'a', version: '1.1.0' }, null, 2) + '\n') + api.contents.set('release/main-pending:packages/b/package.json', JSON.stringify({ name: 'b', version: '2.0.1', dependencies: { a: 'workspace:*' } }, null, 2) + '\n') + await main() + expect(calls.some(c => c.path.endsWith('/git/trees'))).toBe(false) + }) + + it('takes the oldest per-package tag as the union range start', async () => { + git.tags = ['b@2.0.0', 'a@1.0.0'] + await main() + expect(execFileSync.mock.calls.some(([, args]) => args[0] === 'log' && args[1] === 'refs/tags/a@1.0.0..HEAD')).toBe(true) + }) + + it('reuses the cached commit list per boundary tag', async () => { + git.commits = [ + { ...FEAT, subject: 'feat(a): one' }, + { ...FEAT, hash: 'f'.repeat(40), short: 'fffffff', subject: 'fix(a): two' }, + ] + git.revList = [FEAT.hash, 'f'.repeat(40)] + await main() + const revLists = execFileSync.mock.calls.filter(([, args]) => args[0] === 'rev-list' && args[1] === 'refs/tags/a@1.0.0..HEAD') + expect(revLists).toHaveLength(1) + expect(prBody()).toContain('### a (1.0.0 → 1.1.0)') + }) + + it('propagates an unexpected file-contents failure', async () => { + api.branches.set('release/main-pending', 'pending-sha') + api.compares.set('main...release/main-pending', { + files: [{ filename: 'packages/a/package.json' }, { filename: 'packages/b/package.json' }], + merge_base_commit: { sha: 'base-sha' }, + behind_by: 0, + }) + api.fail.set('GET /repos/owner/repo/contents/packages/a/package.json', 500) + await expect(main()).rejects.toThrow(/-> 500/) + }) + + it('closes a stale lockstep PR before syncing', async () => { + api.openPRs = [{ + number: 5, + body: '> lockstep intro', + head: { ref: 'release/v1.2.4', repo: { full_name: 'owner/repo' } }, + base: { ref: 'main' }, + updated_at: '2024-01-02T00:00:00Z', + }] + await main() + expect(calls).toEqual(expect.arrayContaining([ + expect.objectContaining({ method: 'PATCH', path: '/repos/owner/repo/pulls/5', body: { state: 'closed' } }), + ])) + expect(prBody()).toContain('> lockstep intro') + }) + + it('updates an existing pending PR and keeps its preamble', async () => { + api.openPRs = [{ + number: 8, + body: '> pending intro\n\n## 👉 Changelog\n\nold', + head: { ref: 'release/main-pending', repo: { full_name: 'owner/repo' } }, + base: { ref: 'main' }, + updated_at: '2024-01-02T00:00:00Z', + }] + await main() + const patch = calls.findLast(c => c.method === 'PATCH' && c.path === '/repos/owner/repo/pulls/8')! + expect((patch.body as { body: string }).body).toContain('> pending intro') + }) + + it('names a single-package release in the title', async () => { + writePackage('packages/b', { name: 'b', version: '2.0.0' }) + await main() + const created = calls.find(c => c.method === 'POST' && c.path.endsWith('/pulls'))! + expect(created.body).toMatchObject({ title: 'chore: release 1 package' }) + }) + + it('makes no writes in dry-run mode', async () => { + process.env.DRY_RUN = '1' + await main() + expect(calls.filter(c => c.method !== 'GET')).toEqual([]) + }) + + it('requires a token to create the release branch', async () => { + delete process.env.GITHUB_TOKEN + await expect(main()).rejects.toThrow('GITHUB_TOKEN is required to create the release branch') + }) + + it('cuts prereleases for every planned package', async () => { + process.env.PRERELEASE = 'rc' + await main() + expect(blobContents().map(c => JSON.parse(c).version)).toEqual(['1.1.0-rc.0', '2.0.1-rc.0']) + }) +}) diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index cc9c9d5..445e7a7 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildBumpFileSet, buildIndependentBody, formatChangelog, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, incVersion, latestLockstepTag, latestTagForPackage, releaseBranchDrift, type Commit } from '../scripts/update-changelog.ts' +import { buildBumpFileSet, buildIndependentBody, determineBump, formatChangelog, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, incVersion, latestLockstepTag, latestTagForPackage, releaseBranchDrift, type Commit } from '../scripts/update-changelog.ts' import { resolveWorkspaces } from '../scripts/_workspaces.ts' let tmp: string @@ -228,6 +228,24 @@ describe('buildBumpFileSet', () => { expect(files.map(f => f.path)).toEqual(['packages/a/package.json']) }) + it('writes the root only once when it is itself a listed workspace', () => { + writePackage('.', { name: 'root-pkg', version: '1.2.3' }) + const workspaces = resolveWorkspaces(tmp, '**') + const rootPkg = { name: 'root-pkg', version: '1.2.3' } + + const files = buildBumpFileSet({ + monorepo: true, + workspaces, + rootPkg, + rootPkgSource: sourceOf(rootPkg), + currentVersion: '1.2.3', + newVersion: '1.2.4', + }) + + expect(files.map(f => f.path)).toEqual(['package.json']) + expect(JSON.parse(files[0]!.content)).toMatchObject({ version: '1.2.4' }) + }) + it('preserves unrelated workspace fields', () => { writePackage('packages/a', { name: 'a', version: '1.2.3', dependencies: { foo: 'workspace:^' } }) const workspaces = resolveWorkspaces(tmp, 'packages/a') @@ -665,6 +683,65 @@ describe('independent release PR', () => { expect(byPath['packages/fontless/package.json']!.endsWith('\n')).toBe(true) }) + it('throws when a planned package has no version field', () => { + writePackage('packages/a', { name: 'a' }) + expect(() => computeIndependentPlan({ + workspaces: resolveWorkspaces(tmp, 'packages/a'), + scopeOverrides: new Map([['a', ['a']]]), + tags: [], + commits: [commit('fix(a): thing')], + })).toThrow(/has no `version` field/) + }) + + it('ignores a self-referential workspace dependency when naming propagation causes', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/b', { name: 'b', version: '2.0.0', dependencies: { a: 'workspace:*', b: 'workspace:*' } }) + const body = buildIndependentBody(computeIndependentPlan({ + workspaces: resolveWorkspaces(tmp, 'packages/*'), + scopeOverrides: new Map(), + tags: ['a@1.0.0', 'b@2.0.0'], + commits: [commit('fix(a): thing')], + }), bodyOpts) + expect(body).toContain('_Released because `a` was bumped; no direct changes._') + }) + + it('skips dependency fields that are absent or not objects', () => { + writePackage('packages/a', { name: 'a', version: '1.0.0' }) + writePackage('packages/b', { + name: 'b', + version: '2.0.0', + dependencies: { a: 'workspace:*', external: '^1.0.0' }, + optionalDependencies: 'not-an-object', + }) + const body = buildIndependentBody(computeIndependentPlan({ + workspaces: resolveWorkspaces(tmp, 'packages/*'), + scopeOverrides: new Map(), + tags: ['a@1.0.0', 'b@2.0.0'], + commits: [commit('fix(a): thing')], + }), bodyOpts) + expect(body).toContain('_Released because `a` was bumped; no direct changes._') + }) + + it('renders a placeholder when there are no contributors at all', () => { + const body = buildIndependentBody(fontainePlan([commit('feat(fontaine): thing')]), { + ...bodyOpts, + contributors: [], + }) + expect(body).toContain('### ❤️ Contributors\n\n_no contributors yet_') + expect(body).not.toContain('New Contributors') + }) + + it('bumps the root manifest when the root is itself a planned release', () => { + writePackage('.', { name: 'root-pkg', version: '1.0.0' }) + const files = buildIndependentBumpFileSet(computeIndependentPlan({ + workspaces: resolveWorkspaces(tmp, '**'), + scopeOverrides: new Map(), + tags: ['root-pkg@1.0.0'], + commits: [commit('fix(root-pkg): thing')], + })) + expect(files.map(f => f.path)).toEqual(['package.json']) + }) + it('bumps only the released package when nothing propagates', () => { writePackage('packages/a', { name: 'a', version: '1.0.0' }) writePackage('packages/b', { name: 'b', version: '2.0.0' }) @@ -679,6 +756,47 @@ describe('independent release PR', () => { }) }) +describe('determineBump', () => { + function commit (overrides: Partial): Commit { + return { + hash: 'a'.repeat(40), + shortHash: 'aaaaaaa', + message: 'chore: thing', + type: 'chore', + scope: '', + description: 'thing', + isBreaking: false, + author: { name: 'Test', email: 'test@example.com' }, + references: [], + ...overrides, + } + } + + it('bumps major for a breaking change', () => { + expect(determineBump([commit({}), commit({ isBreaking: true })])).toBe('major') + }) + + it('bumps minor for a feature', () => { + expect(determineBump([commit({ type: 'feat' })])).toBe('minor') + }) + + it('bumps patch for anything else', () => { + expect(determineBump([commit({ type: 'fix' })])).toBe('patch') + }) + + it('renders breaking changes with a warning marker and skips unknown types', () => { + const changelog = formatChangelog( + [ + commit({ type: 'feat', description: 'new thing', isBreaking: true }), + commit({ type: 'wip', description: 'not a known type' }), + ], + { owner: 'owner', repo: 'repo', fromRef: null, toRef: 'main' }, + ) + expect(changelog).toContain('- ⚠️ new thing') + expect(changelog).not.toContain('not a known type') + }) +}) + describe('releaseBranchDrift', () => { const desired = new Map([['packages/a/package.json', '{"version":"1.1.0"}\n']]) const inSync = { @@ -720,6 +838,13 @@ describe('releaseBranchDrift', () => { })).toBe('packages/a/package.json differs from the plan') }) + it('rebuilds when the branch carries no diff at all', () => { + expect(releaseBranchDrift({ + ...inSync, + divergence: { ...inSync.divergence, changed: new Set() }, + })).toBe('branch changes nothing, plan changes packages/a/package.json') + }) + it('rebuilds when base has since touched the same manifest', () => { expect(releaseBranchDrift({ ...inSync, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..45e3b70 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['scripts/**/*.ts'], + // Thresholds are absolute so that untested code cannot land. Guards that + // assert an invariant the callers already enforce are genuinely + // unreachable; mark those with `/* v8 ignore */` and a comment saying why + // rather than deleting the guard to satisfy the threshold. + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +}) -- 2.51.2