diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cae66db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + # Serialize versions as well as retries; never cancel a partial npm release. + group: npm-release + cancel-in-progress: false + +env: + RELEASE_TAG: ${{ github.ref_name }} + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: pnpm + - name: Validate tag, release set and main ancestry + run: | + git merge-base --is-ancestor HEAD origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + node scripts/npm-release.mjs check "$RELEASE_TAG" + - run: pnpm install --frozen-lockfile + - run: pnpm run check + - run: pnpm run test:release + # Preserve the project's prepublishOnly work: format, tsc, adapters, + # library tests against dist, and the three-framework example suite. + - run: pnpm run prepublishOnly + - run: pnpm run test:cov + - name: Pack workspace protocols and validate all public entries + run: node scripts/npm-release.mjs pack "$RELEASE_TAG" "$RUNNER_TEMP/npm-release" + - name: Install tarballs in a clean consumer and smoke test + run: node scripts/npm-release-smoke.mjs "$RELEASE_TAG" "$RUNNER_TEMP/npm-release" + - name: Verify lifecycle did not modify tagged sources + run: git diff --exit-code + - uses: actions/upload-artifact@v4 + with: + name: npm-release + path: ${{ runner.temp }}/npm-release/ + overwrite: true + if-no-files-found: error + retention-days: 30 + + publish: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + # Omit registry-url: OIDC needs no token placeholder or NPM_TOKEN. + node-version: '24' + - name: Check npm OIDC support and release commit + run: | + git merge-base --is-ancestor HEAD origin/main + test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")" + node scripts/npm-release.mjs runtime + node scripts/npm-release.mjs check "$RELEASE_TAG" + - uses: actions/download-artifact@v4 + with: + name: npm-release + path: ${{ runner.temp }}/npm-release/ + # Each of the five npm packages must trust yourtion/node-erest/release.yml. + # No GitHub environment is used; leave that npm field blank. + - name: Publish validated tarballs, then complete GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/npm-release.mjs publish "$RELEASE_TAG" "$RUNNER_TEMP/npm-release" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f929c21..6e50eb5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,9 @@ jobs: - name: Run code format check run: pnpm run check + - name: Test release validation and retry behavior + run: pnpm run test:release + - name: Build project run: pnpm run build @@ -49,6 +52,13 @@ jobs: - name: Run examples tests run: pnpm --filter erest-example test + - name: Validate publishable tarballs and consumer imports + if: matrix.node-version == '24.x' + run: | + RELEASE_TAG="v$(node --print "require('./package.json').version")" + node scripts/npm-release.mjs pack "$RELEASE_TAG" "$RUNNER_TEMP/npm-release" + node scripts/npm-release-smoke.mjs "$RELEASE_TAG" "$RUNNER_TEMP/npm-release" + - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 if: matrix.node-version == '20.x' diff --git a/AGENTS.md b/AGENTS.md index 98175c1..3d6b0ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,9 @@ packages/ # v3.0:框架适配器实现独立子包(npm scope @er - 根包 `erest` 与受影响的子包(`packages/erest-{express,koa,leizmweb,gen}/`)的 `version` 字段一起改,保持一致(`pnpm publish -r` 整体发布,版本不齐会乱)。 - 语义化版本:bug 修复/文档/非 breaking 新增 → patch(x.y.Z);新增向后兼容能力 → minor(x.Y.0);breaking → major(X.0.0)。 - 同步在 `MIGRATION.md` 加对应版本章节(即使非 breaking 也要登记新增/修复项)。 - - 发版用 `pnpm run publish:all`(`pnpm publish -r --no-git-checks`,`prepublishOnly` 会先跑 format + test)。 + - 默认通过 `.github/workflows/release.yml` 自动发版:版本 PR 合入 `main` 且 CI 通过后,维护者推送匹配的 `vX.Y.Z` tag。每个 npm 包须先绑定 Trusted Publisher,详见 `RELEASING.md`。 + - `publish:all` 仅保留为有发布凭据的明确手动操作;不要通过 `postpublish` 自动推 Git 分支或 tag。自动流程显式执行 `prepublishOnly`,再发布验证过的同一批 workspace-aware tarball。 + - 接入或修改 CI 不代表获准实际发版;只有用户明确授权后才推送发布 tag 或调用真实 publish。 - 反例:只改了 `Reply.markSent()` 实现 + MIGRATION 登记,却忘了把 `3.2.0` 改成 `3.2.1` —— 这种「代码动了版本没动」会让下游无法通过版本号判断是否包含修复,必须避免。 > API 用法(registerTyped/bind/hooks/schema.register 等)与测试/构建命令见 README.md,此处不再重复。 diff --git a/MIGRATION.md b/MIGRATION.md index 26f4d41..a31ddb3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -376,6 +376,15 @@ instead」)。`@koa/router` 是官方维护的继任包,API 与 koa-router * - 新增 `stryker.config.json` 与 `pnpm run mutation` / `mutation:incremental` 脚本(vitest runner + perTest 覆盖分析)。 - 变异分数从 44.93% 提升至 89.7%+(新增约 270 个内容级断言用例,全部融入现有测试文件)。 +### 发布与验证 + +- 根包 `erest` 与 `@erest/express`、`@erest/koa`、`@erest/leizmweb`、`@erest/gen` 统一为 `3.2.3`,按仓库约定整体发布;adapter 的 `workspace:^` peer 在 pnpm 打包时转换为 `erest: ^3.2.3`。 +- 新增增量变异测试 CI:PR 读取兼容主分支基线,主分支成功后保存;`pnpm run mutation:full` 可强制全量重建。 +- 源码测试在 `ISLIB` 模式将 adapter 的 `erest` 导入解析到源码,支持无 `dist` 的洁净环境。 +- 已合入代码通过 604 个库测试、13 个示例测试和覆盖率门槛;完整 3,070 个变异的分数为 90.10%,无超时或运行错误。 +- 新增 tag 触发的 npm OIDC 自动发布:校验统一版本及 `main` 归属,验证五个 tarball 和消费端导入,按依赖顺序发布,全部成功后完成 GitHub Release;重跑跳过已发布的精确版本。 +- 四个子包补齐仓库元数据以支持 provenance。移除 `postpublish` 自动推送分支/tag 的副作用,维护者按 `RELEASING.md` 配置每个包的 Trusted Publisher 后显式推送发版 tag。 + ## v3.2.2 — 文档展示返回结果 schema(Swagger / Postman) ### 新增(非 breaking) diff --git a/README.md b/README.md index ae5e948..53d0761 100644 --- a/README.md +++ b/README.md @@ -723,6 +723,14 @@ PR 不写共享基线,主分支运行不会被后续提交主动取消。 静态变异或未跟踪的环境变化可能需要手动全量重跑。当前未设置最低变异分数门禁,作业成功仍可能 存在存活变异;此工作流也不会自动配置分支保护或 required status check。 +## 自动发布 + +推送与五个包版本一致、且指向 `main` 提交的 `vX.Y.Z` tag 后,GitHub Actions 会检查、构建、 +打包并通过 npm Trusted Publishing(OIDC)发布,全部 npm 包成功后创建 GitHub Release。 +正式版走 `latest`,预发布走 `next`;普通 PR 不发布,不需要 `NPM_TOKEN`。 +维护者需先为每个 npm 包绑定 `yourtion/node-erest` 的 `release.yml`,environment 留空。 +一次性配置、发版命令、产物校验和失败重跑见 [RELEASING.md](RELEASING.md)。 + ## API 文档 线上文档站(typedoc 产物)由 CI 自动构建部署: diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..4328379 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,83 @@ +# npm 自动发布 + +仓库通过 GitHub 托管 runner 的 `.github/workflows/release.yml` 使用 npm Trusted Publishing(OIDC)。 +不需要 `NPM_TOKEN`,也不在 GitHub 保存 npm 长期凭据。workflow 使用 Node 24,并检查实际 npm +版本至少为 11.5.1(OIDC 要求 Node 至少 22.14.0)。公开仓库的公开包通过 OIDC 发布时,npm 自动生成 provenance。 + +## 一次性 npm 配置 + +维护者须登录 npm,分别进入以下五个包的 Settings → Trusted Publisher,选择 GitHub Actions。 +它们已经在 npm 上存在,无需新包引导流程。仅合入 workflow 不会替代 npm 后台绑定;本仓库无法从代码确认绑定状态。 + +| npm 包 | Organization or user | Repository | Workflow filename | Environment name | +| --- | --- | --- | --- | --- | +| `erest` | `yourtion` | `node-erest` | `release.yml` | 留空 | +| `@erest/express` | `yourtion` | `node-erest` | `release.yml` | 留空 | +| `@erest/koa` | `yourtion` | `node-erest` | `release.yml` | 留空 | +| `@erest/leizmweb` | `yourtion` | `node-erest` | `release.yml` | 留空 | +| `@erest/gen` | `yourtion` | `node-erest` | `release.yml` | 留空 | + +workflow 名称填写文件名 `release.yml`,不要填写显示名称 `Release` 或完整路径。如果 npm 界面 +提供 Allowed actions,须允许直接 `npm publish`;本流程不使用 staged publishing。 +本 workflow 没有 GitHub environment,所以 npm 的 Environment name 留空。将来添加 environment +或重命名 workflow 时,要同步修改每个包的绑定。 + +四个子包的 `repository.url` 与根包一致,且包含各自的 `repository.directory`。provenance +要求仓库 URL 与实际发布仓库匹配。不要用 token 掩盖 OIDC 配置错误,也不要将凭据写入仓库或日志。 +官方说明:[Trusted publishers](https://docs.npmjs.com/trusted-publishers/)。 + +## 正式发版 + +1. 将根包和四个公开子包的版本统一更新,更新 `MIGRATION.md`,通过 PR 合入 `main`。 +2. 确认该提交的 CI 通过,且上一次发版已经完成。拉取 `main` 后创建并推送精确 tag: + + ```bash + git switch main + git pull --ff-only + pnpm run tag + git push origin v3.2.3 + ``` + + 示例用于 `3.2.3`;以后将推送的 tag 替换为五个 manifest 中的实际版本。`pnpm run tag` + 只创建本地 tag,不执行 npm 发布。也可用 `git tag v` 创建 tag。 +3. 在 Actions → Release 查看结果。只有推送 tag 触发发布,普通 PR/main push 不发布。 + +tag 必须是严格的 `vX.Y.Z`,支持例如 `v3.3.0-rc.1` 的预发布,拒绝 npm 无法原样保留的 +`+build` 元数据。tag 必须指向 `main` 中已有的提交,annotated 和轻量 tag 都会检查实际提交。 +所有待发包的版本必须与 tag 一致;private 的 `examples` 永不发布。 + +正式版显式使用 `latest`;预发布显式使用 `next`,GitHub Release 标记为 prerelease。 +发布作业全局串行且不主动取消。GitHub concurrency 不保证 FIFO 且只保留一个 pending, +因此不要同时积压多个版本;逐个确认完成后再发下一个版本。 + +## 校验、产物与重跑 + +只读验证 job 在安装项目依赖前校验 tag/版本/main 归属,再运行: + +- frozen-lockfile 安装、`check`、`test:release`; +- 显式执行 `prepublishOnly`(format、根包与子包构建、发布产物测试及示例测试)和覆盖率门槛; +- pnpm workspace-aware pack,检查每个真实 tarball 的 manifest、exports/main/types、CLI shebang/执行权限及内部依赖; +- 在临时消费项目安装五个 tarball,验证核心与三框架 adapter 的 ESM 导入以及 codegen CLI 入口; +- 确认 lifecycle 未改动 tag 的受跟踪源码。 + +产物以 `npm-release` Actions artifact 保留 30 天,包含五个 `.tgz` 和记录 SHA-256 的 +`release.json`。发布 job 重新校验全部产物的身份、内容哈希和内部依赖,使用 npm CLI 发布同一批 +tarball;不重新 pack 或构建。pnpm 负责 workspace 协议转换,不能直接用 npm 在 workspace +目录打包。声明与实际文件必须一致;通配符 exports 需要先扩展验证器。 + +先发布依赖,再发布消费者;当前顺序为 `erest`、三个 adapter、`@erest/gen`。每个包先查询 +精确版本,已存在则跳过,只有结构化 npm `E404` 才视为缺失。认证、网络、限流、publish 错误均 +终止流程。全部必需包完成后才创建或完成 GitHub Release,notes 由 GitHub 生成;tarball 保留在 +Actions artifact,不额外附加 GitHub Release assets。 + +失败后修正外部配置并在原 tag 的 Actions run 中选择 **Re-run all jobs**。已发布的包会跳过, +继续剩余包;npm 全部成功但 GitHub 失败时,重跑只补 GitHub Release,不重复上传 npm 版本。 +重新构建的文件仍须经过完整校验。npm 版本不可覆盖:若需要修改发布代码,必须使用新版本和新 tag, +不要移动既有 tag。没有自动回滚已发布包的操作。 + +PR CI 会运行同一套发布工具测试,并在 Node 24 下真实 pack 和安装消费验证,但不会访问发布 API。 +unit test 对 npm/GitHub 发布、404/权限/网络错误及部分失败使用 mock;本地 pack/smoke 成功不代表 +真实 OIDC 认证已配置或真实发布已发生。真实端到端认证只能由后台绑定后的首次 tag 发布验证。 + +原 `publish:all` 保留用于有 npm 发布凭据的明确手动场景;它不等于 OIDC 自动流程。 +`postpublish` 不再推送 Git 分支或 tag,避免本地发布生命周期意外触发自动发布。 diff --git a/package.json b/package.json index 6c74cd4..79906cd 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "npm run build && npm run build:packages && vitest run && pnpm --filter erest-example test", "test:lib": "export ISLIB=1 && vitest run", "test:cov": "export ISLIB=1 && vitest run --coverage", + "test:release": "node --test scripts/npm-release.test.mjs", "mutation": "export ISLIB=1 && stryker run", "mutation:full": "export ISLIB=1 && stryker run --force", "mutation:incremental": "export ISLIB=1 && stryker run --incremental", @@ -34,7 +35,6 @@ "build:packages": "pnpm --filter @erest/express --filter @erest/koa --filter @erest/leizmweb --filter @erest/gen build", "publish:all": "pnpm publish -r --no-git-checks --access public", "prepublishOnly": "npm run format && npm run test", - "postpublish": "npm run tag && git push && git push --tags", "prepare": "husky" }, "repository": { diff --git a/packages/erest-express/package.json b/packages/erest-express/package.json index 0a48c3f..3c85454 100644 --- a/packages/erest-express/package.json +++ b/packages/erest-express/package.json @@ -1,6 +1,11 @@ { "name": "@erest/express", - "version": "3.2.2", + "version": "3.2.3", + "repository": { + "type": "git", + "url": "git+https://github.com/yourtion/node-erest.git", + "directory": "packages/erest-express" + }, "description": "Express adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-gen/package.json b/packages/erest-gen/package.json index a4593cf..aa13bf1 100644 --- a/packages/erest-gen/package.json +++ b/packages/erest-gen/package.json @@ -1,6 +1,11 @@ { "name": "@erest/gen", - "version": "3.2.2", + "version": "3.2.3", + "repository": { + "type": "git", + "url": "git+https://github.com/yourtion/node-erest.git", + "directory": "packages/erest-gen" + }, "description": "erest codegen CLI:从 Zod schema 生成 handler 骨架等(实验性,独立于 erest 主版本演进)", "type": "module", "bin": { diff --git a/packages/erest-koa/package.json b/packages/erest-koa/package.json index d633e95..ddcd9b4 100644 --- a/packages/erest-koa/package.json +++ b/packages/erest-koa/package.json @@ -1,6 +1,11 @@ { "name": "@erest/koa", - "version": "3.2.2", + "version": "3.2.3", + "repository": { + "type": "git", + "url": "git+https://github.com/yourtion/node-erest.git", + "directory": "packages/erest-koa" + }, "description": "Koa adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/packages/erest-leizmweb/package.json b/packages/erest-leizmweb/package.json index ec8256e..971e218 100644 --- a/packages/erest-leizmweb/package.json +++ b/packages/erest-leizmweb/package.json @@ -1,6 +1,11 @@ { "name": "@erest/leizmweb", - "version": "3.2.2", + "version": "3.2.3", + "repository": { + "type": "git", + "url": "git+https://github.com/yourtion/node-erest.git", + "directory": "packages/erest-leizmweb" + }, "description": "@leizm/web adapter for erest", "type": "module", "main": "./dist/index.js", diff --git a/scripts/npm-release-smoke.mjs b/scripts/npm-release-smoke.mjs new file mode 100644 index 0000000..0abeba3 --- /dev/null +++ b/scripts/npm-release-smoke.mjs @@ -0,0 +1,34 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { execute, output, readBundle } from './npm-release.mjs'; + +const [tag, destination] = process.argv.slice(2); +if (!destination) throw new Error('Usage: npm-release-smoke.mjs v '); +const packages = readBundle(tag, destination); +const consumer = mkdtempSync(resolve(tmpdir(), 'erest-release-consumer-')); +try { + writeFileSync(resolve(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + output(execute('npm', [ + 'install', '--ignore-scripts', '--no-audit', '--no-fund', + ...packages.map((pkg) => pkg.tarball), + ], { cwd: consumer })); + writeFileSync(resolve(consumer, 'smoke.mjs'), ` + import assert from 'node:assert/strict'; + import { spawnSync } from 'node:child_process'; + const core = await import('erest'); + assert.equal(typeof core.ERest, 'function'); + assert.equal(core.z.string().parse('published package'), 'published package'); + for (const name of ['@erest/express', '@erest/koa', '@erest/leizmweb']) { + const adapter = await import(name); + assert.equal(typeof adapter.createERest, 'function', name); + } + const cli = spawnSync('node_modules/.bin/erest-gen', [], { encoding: 'utf8' }); + assert.equal(cli.status, 1); + assert.match(cli.stdout + cli.stderr, /erest-gen handler --from/); + console.log('All five packed packages: consumer smoke passed'); + `); + console.log(output(execute(process.execPath, ['smoke.mjs'], { cwd: consumer }))); +} finally { + rmSync(consumer, { recursive: true, force: true }); +} diff --git a/scripts/npm-release.mjs b/scripts/npm-release.mjs new file mode 100644 index 0000000..6c252fb --- /dev/null +++ b/scripts/npm-release.mjs @@ -0,0 +1,367 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// npm drops SemVer build metadata; reject tags that cannot round-trip. +const number = '(?:0|[1-9][0-9]*)'; +const identifier = `(?:${number}|[0-9]*[A-Za-z-][0-9A-Za-z-]*)`; +const versionPattern = new RegExp( + `^${number}\\.${number}\\.${number}(?:-${identifier}(?:\\.${identifier})*)?$`, +); + +export function releaseInfo(tag, manifest) { + const version = tag?.startsWith('v') ? tag.slice(1) : ''; + if (!versionPattern.test(version) || manifest.version !== version) { + throw new Error( + `Tag ${tag} must be v and match package.json version`, + ); + } + if (version.length > 256 || version.split(/[-.]/).some((part) => /^\d+$/.test(part) && !Number.isSafeInteger(Number(part)))) { + throw new Error('Version contains an unsupported numeric component'); + } + if ( + manifest.private || + typeof manifest.name !== 'string' || + !/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(manifest.name) + ) { + throw new Error('Expected a named, public npm package'); + } + return { + tag, + name: manifest.name, + version, + prerelease: version.includes('-'), + distTag: version.includes('-') ? 'next' : 'latest', + }; +} + +export function validatePack(pack, manifest) { + if ( + pack.name !== manifest.name || + pack.version !== manifest.version || + !pack.filename?.endsWith('.tgz') || + basename(pack.filename) !== pack.filename + ) { + throw new Error('Packed name/version/filename does not match the release'); + } + const files = new Set(pack.files.map((file) => file.path)); + const targets = []; + function collect(value) { + if (typeof value === 'string') targets.push(value); + else if (value && typeof value === 'object') { + for (const nested of Object.values(value)) collect(nested); + } + } + collect(manifest.main); + collect(manifest.types); + collect(manifest.bin); + collect(manifest.exports); + for (const target of ['package.json', ...targets]) { + const path = target.replace(/^\.\//, ''); + if (path.includes('*')) { + throw new Error( + `Add package-specific validation for wildcard export ${target}`, + ); + } + if (!files.has(path)) + throw new Error(`Tarball is missing entry point ${target}`); + } +} + +export function execute(command, args, options = {}) { + return spawnSync(command, args, { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + ...options, + env: { + ...process.env, + ...options.env, + NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', + npm_config_registry: 'https://registry.npmjs.org/', + }, + }); +} + +export function output(result) { + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || 'Release command failed'); + } + return result.stdout; +} + +export function isPublished(info, run = execute) { + const result = run('npm', [ + 'view', + `${info.name}@${info.version}`, + 'version', + '--json', + ]); + if (result.error) throw result.error; + let data; + try { + data = JSON.parse(result.stdout); + } catch { + throw new Error(result.stderr || 'Invalid npm registry response'); + } + // Only a structured npm E404 means absent. Auth/network errors must propagate. + if (result.status !== 0 && data?.error?.code === 'E404') return false; + output(result); + if (data !== info.version) throw new Error('Unexpected npm registry version'); + return true; +} + +export function publish(info, tarball, run = execute) { + if (isPublished(info, run)) { + console.log(`${info.name}@${info.version} already published; skipping npm`); + return; + } + console.log( + output( + run('npm', [ + 'publish', + tarball, + '--access', + 'public', + '--tag', + info.distTag, + '--ignore-scripts', + ]), + ), + ); +} + +export function completeRelease(info, repository, run = execute) { + if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '')) { + throw new Error('GITHUB_REPOSITORY must be owner/repo'); + } + const tags = output( + run('gh', [ + 'api', + '--paginate', + `repos/${repository}/releases`, + '--jq', + '.[].tag_name', + ]), + ).split('\n'); + const args = tags.includes(info.tag) + ? [ + 'release', + 'edit', + info.tag, + '--draft=false', + `--prerelease=${info.prerelease}`, + ] + : [ + 'release', + 'create', + info.tag, + '--verify-tag', + '--title', + info.tag, + '--generate-notes', + ]; + if (!tags.includes(info.tag) && info.prerelease) args.push('--prerelease'); + console.log(output(run('gh', args))); +} + +// Explicit public release set: examples is private and is never published. +export const packageDirectories = [ + '.', 'packages/erest-express', 'packages/erest-koa', + 'packages/erest-leizmweb', 'packages/erest-gen', +]; +const repositoryUrl = 'git+https://github.com/yourtion/node-erest.git'; +const dependencyFields = ['dependencies', 'optionalDependencies', 'peerDependencies', 'devDependencies']; +const hashFile = (file) => createHash('sha256').update(readFileSync(file)).digest('hex'); + +export function orderPackages(packages) { + const byName = new Map(packages.map((pkg) => [pkg.manifest.name, pkg])); + if (byName.size !== packages.length) throw new Error('Duplicate release package name'); + const active = new Set(); + const complete = new Set(); + const ordered = []; + function visit(pkg) { + const { name } = pkg.manifest; + if (active.has(name)) throw new Error(`Cyclic release dependency: ${name}`); + if (complete.has(name)) return; + active.add(name); + for (const field of dependencyFields.filter((field) => field !== 'devDependencies')) { + for (const dependency of Object.keys(pkg.manifest[field] ?? {})) { + if (byName.has(dependency)) visit(byName.get(dependency)); + } + } + active.delete(name); + complete.add(name); + ordered.push(pkg); + } + packages.forEach(visit); + return ordered; +} + +function expectedInternalRange(range, version) { + if (range === 'workspace:*') return version; + if (range === 'workspace:^') return `^${version}`; + if (range === 'workspace:~') return `~${version}`; + if ([version, `^${version}`, `~${version}`, '*'].includes(range)) return range; + throw new Error(`Unsupported internal dependency range: ${range}; adapt the release validator first`); +} + +export function workspaceRelease(tag, root = process.cwd()) { + const packages = packageDirectories.map((directory) => { + const manifest = JSON.parse(readFileSync(resolve(root, directory, 'package.json'), 'utf8')); + const info = releaseInfo(tag, manifest); + if (manifest.repository?.url !== repositoryUrl || manifest.repository?.type !== 'git') { + throw new Error(`Repository metadata must match yourtion/node-erest: ${info.name}`); + } + if (manifest.publishConfig?.registry && !/^https:\/\/registry\.npmjs\.org\/?$/.test(manifest.publishConfig.registry)) { + throw new Error(`Unexpected registry for ${info.name}`); + } + return { ...info, directory, manifest }; + }); + const names = new Set(packages.map((pkg) => pkg.name)); + for (const pkg of packages) { + for (const field of dependencyFields) { + for (const [name, range] of Object.entries(pkg.manifest[field] ?? {})) { + if (range.startsWith('workspace:') && !names.has(name)) throw new Error(`Unreleased workspace dependency: ${name}`); + if (names.has(name)) expectedInternalRange(range, pkg.version); + } + } + } + return orderPackages(packages); +} + +export function inspectTarball(tarball, run = execute) { + const manifest = JSON.parse(output(run('tar', ['-xOf', tarball, 'package/package.json']))); + const entries = output(run('tar', ['-tzf', tarball])).trim().split('\n'); + for (const entry of entries) { + if (!entry.startsWith('package/') || entry.split('/').includes('..')) throw new Error('Unsafe tarball entry'); + } + const pack = { + name: manifest.name, version: manifest.version, filename: basename(tarball), + files: entries.map((path) => ({ path: path.slice('package/'.length) })), + }; + validatePack(pack, manifest); + const bin = manifest.bin; + for (const target of typeof bin === 'string' ? [bin] : Object.values(bin ?? {})) { + const path = `package/${target.replace(/^\.\//, '')}`; + if (!output(run('tar', ['-xOf', tarball, path])).startsWith('#!')) throw new Error('CLI is missing its shebang'); + const mode = output(run('tar', ['-tvzf', tarball, path])).split(/\s+/)[0]; + if (!mode.includes('x')) throw new Error('CLI is not executable'); + } + return { manifest, pack }; +} + +export function validatePackedPackage(pkg, packed, packages) { + releaseInfo(pkg.tag, packed.manifest); + validatePack(packed.pack, pkg.manifest); + if (packed.manifest.name !== pkg.name || packed.manifest.repository?.url !== repositoryUrl) { + throw new Error('Packed identity or repository does not match source'); + } + for (const field of ['main', 'types', 'exports', 'bin', 'publishConfig']) { + if (JSON.stringify(packed.manifest[field]) !== JSON.stringify(pkg.manifest[field])) { + throw new Error(`Packed ${field} differs from source`); + } + } + const names = new Set(packages.map((pkg) => pkg.name)); + for (const field of dependencyFields) { + const source = pkg.manifest[field] ?? {}; + const actual = packed.manifest[field] ?? {}; + if (Object.keys(source).sort().join('\0') !== Object.keys(actual).sort().join('\0')) throw new Error('Packed dependencies changed'); + for (const [name, range] of Object.entries(actual)) { + if (range.startsWith('workspace:')) throw new Error(`Unresolved workspace protocol in ${pkg.name}`); + const expected = names.has(name) ? expectedInternalRange(source[name], pkg.version) : source[name]; + if (range !== expected) throw new Error(`Unexpected packed dependency ${name}: ${range}`); + } + } +} + +export function packWorkspace(tag, destination, root = process.cwd(), run = execute) { + const packages = workspaceRelease(tag, root); + const directory = resolve(destination); + mkdirSync(directory, { recursive: true }); + const records = packages.map((pkg) => { + // pnpm rewrites workspace protocols. Required lifecycle work ran explicitly + // in CI; ignore pack/publish hooks so they cannot rebuild or push Git tags. + output(run('pnpm', ['pack', '--pack-destination', directory], { + cwd: resolve(root, pkg.directory), env: { npm_config_ignore_scripts: 'true' }, + })); + const filename = `${pkg.name.replace(/^@/, '').replace('/', '-')}-${pkg.version}.tgz`; + const tarball = resolve(directory, filename); + validatePackedPackage(pkg, inspectTarball(tarball, run), packages); + return { name: pkg.name, version: pkg.version, filename, sha256: hashFile(tarball) }; + }); + const bundle = { tag, packages: records }; + writeFileSync(resolve(directory, 'release.json'), `${JSON.stringify(bundle, null, 2)}\n`); + return bundle; +} + +export function readBundle(tag, destination, root = process.cwd(), run = execute) { + const packages = workspaceRelease(tag, root); + const bundle = JSON.parse(readFileSync(resolve(destination, 'release.json'), 'utf8')); + if (bundle.tag !== tag || !Array.isArray(bundle.packages) || bundle.packages.length !== packages.length) { + throw new Error('Release bundle does not match the release set'); + } + // Validate every file before the first registry query or irreversible publish. + return packages.map((pkg, index) => { + const record = bundle.packages[index]; + if (record.name !== pkg.name || record.version !== pkg.version || typeof record.filename !== 'string' + || basename(record.filename) !== record.filename || !record.filename.endsWith('.tgz')) throw new Error('Unexpected release bundle entry'); + const tarball = resolve(destination, record.filename); + if (hashFile(tarball) !== record.sha256) throw new Error(`Tarball integrity mismatch: ${pkg.name}`); + validatePackedPackage(pkg, inspectTarball(tarball, run), packages); + return { ...pkg, tarball }; + }); +} + +export function publishReleaseSet(packages, repository, run = execute) { + if (repository !== 'yourtion/node-erest') throw new Error('Unexpected GitHub publishing repository'); + if (!packages.length || new Set(packages.map((pkg) => pkg.version)).size !== 1) throw new Error('Expected one coordinated release'); + for (const pkg of packages) publish(pkg, pkg.tarball, run); + completeRelease(packages[0], repository, run); +} + +export function publishingRuntime(nodeVersion, npmVersion) { + const atLeast = (version, minimum) => { + if (!/^\d+\.\d+\.\d+$/.test(version)) return false; + const parts = version.split('.').map(Number); + for (let index = 0; index < 3; index++) { + if (parts[index] !== minimum[index]) return parts[index] > minimum[index]; + } + return true; + }; + if (!atLeast(nodeVersion, [22, 14, 0]) || !atLeast(npmVersion, [11, 5, 1])) { + throw new Error('npm Trusted Publishing requires Node >=22.14.0 and npm >=11.5.1'); + } +} + +function main() { + const [command, tag, destination, ...extra] = process.argv.slice(2); + if (extra.length) throw new Error('Unexpected release arguments'); + if (command === 'runtime') { + const npmVersion = output(execute('npm', ['--version'])).trim(); + publishingRuntime(process.versions.node, npmVersion); + console.log(`Node ${process.versions.node}, npm ${npmVersion}: OIDC capable`); + return; + } + const packages = workspaceRelease(tag); + if (command === 'check') console.log(JSON.stringify(packages.map(({ manifest, ...pkg }) => pkg), null, 2)); + else if (command === 'pack' && destination) console.log(JSON.stringify(packWorkspace(tag, destination), null, 2)); + else if (command === 'publish' && destination) { + publishingRuntime(process.versions.node, output(execute('npm', ['--version'])).trim()); + publishReleaseSet(readBundle(tag, destination), process.env.GITHUB_REPOSITORY); + } else throw new Error('Usage: npm-release.mjs check|pack|publish v [directory], or runtime'); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/npm-release.test.mjs b/scripts/npm-release.test.mjs new file mode 100644 index 0000000..c479d64 --- /dev/null +++ b/scripts/npm-release.test.mjs @@ -0,0 +1,419 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { + completeRelease, + isPublished, + publish, + releaseInfo, + validatePack, + orderPackages, + packageDirectories, + workspaceRelease, + packWorkspace, + readBundle, + validatePackedPackage, + inspectTarball, + publishReleaseSet, + publishingRuntime, +} from './npm-release.mjs'; + +const manifest = { + name: '@example/library', + version: '1.2.3', + main: './src/index.tsx', + types: './src/index.tsx', + exports: { + '.': { import: './src/index.tsx' }, + './styles': './dist/styles.css', + }, +}; +const info = releaseInfo('v1.2.3', manifest); +const ok = (value) => ({ status: 0, stdout: value, stderr: '' }); +const failure = (code) => ({ + status: 1, + stdout: JSON.stringify({ error: { code } }), + stderr: code, +}); + +test('stable and prerelease tags choose distinct npm channels', () => { + assert.equal(info.distTag, 'latest'); + for (const version of ['1.2.3-beta.1', '1.2.3-0', '1.2.3-1alpha']) { + const pre = releaseInfo(`v${version}`, { ...manifest, version }); + assert.equal(pre.distTag, 'next'); + assert.equal(pre.prerelease, true); + } +}); + +test('reject malformed, mismatched and non-publishable releases', () => { + for (const tag of [ + undefined, + '1.2.3', + 'v1.2', + 'v01.2.3', + 'v1.2.4', + 'v1.2.3+build', + 'v1.2.3-01', + ]) { + assert.throws(() => releaseInfo(tag, manifest)); + } + assert.throws(() => releaseInfo(info.tag, { ...manifest, private: true })); + assert.throws(() => releaseInfo(info.tag, { ...manifest, name: '--bad' })); +}); + +test('pack checks nested exports, types and generated CSS without changing source exports', () => { + const pack = { + name: manifest.name, + version: manifest.version, + filename: 'example-library-1.2.3.tgz', + files: ['package.json', 'src/index.tsx', 'dist/styles.css'].map((path) => ({ + path, + })), + }; + validatePack(pack, manifest); + assert.throws( + () => validatePack({ ...pack, files: pack.files.slice(0, 2) }, manifest), + /styles.css/, + ); + assert.throws( + () => validatePack({ ...pack, version: '9.0.0' }, manifest), + /name\/version/, + ); + assert.throws(() => + validatePack({ ...pack, filename: '../other.tgz' }, manifest), + ); + assert.throws( + () => validatePack(pack, { ...manifest, types: './missing.d.ts' }), + /missing.d.ts/, + ); + assert.throws( + () => validatePack(pack, { ...manifest, bin: { cli: './missing.js' } }), + /missing.js/, + ); + assert.throws( + () => validatePack(pack, { ...manifest, exports: './dist/*.js' }), + /wildcard/, + ); +}); + +test('only exact version or structured E404 establish registry state', () => { + assert.equal( + isPublished(info, () => ok(JSON.stringify(info.version))), + true, + ); + assert.equal( + isPublished(info, () => failure('E404')), + false, + ); + for (const code of [ + 'E401', + 'E403', + 'E429', + 'E500', + 'ENOTFOUND', + 'ECONNRESET', + ]) { + assert.throws( + () => isPublished(info, () => failure(code)), + new RegExp(code), + ); + } + assert.throws(() => isPublished(info, () => ok('not json')), /Invalid/); + assert.throws(() => isPublished(info, () => ok('"1.2.4"')), /Unexpected/); + assert.throws( + () => isPublished(info, () => ({ error: new Error('spawn failed') })), + /spawn failed/, + ); +}); + +test('reruns skip published npm versions', () => { + const calls = []; + publish(info, '/tmp/validated.tgz', (command, args) => { + calls.push([command, args]); + return ok(JSON.stringify(info.version)); + }); + assert.equal(calls.length, 1); + assert.equal(calls[0][1][0], 'view'); +}); + +test('publish the exact validated tarball with explicit channel and no lifecycle rebuild', () => { + for (const version of ['1.2.3', '1.2.3-rc.0']) { + const release = releaseInfo(`v${version}`, { ...manifest, version }); + const calls = []; + publish(release, '/tmp/validated.tgz', (command, args) => { + calls.push([command, args]); + return args[0] === 'view' ? failure('E404') : ok('published'); + }); + assert.deepEqual(calls[1], [ + 'npm', + [ + 'publish', + '/tmp/validated.tgz', + '--access', + 'public', + '--tag', + release.distTag, + '--ignore-scripts', + ], + ]); + } +}); + +test('registry and publish failures stop completion', () => { + const calls = []; + assert.throws( + () => + publish(info, '/tmp/validated.tgz', (command, args) => { + calls.push([command, args]); + return failure('E401'); + }), + /E401/, + ); + assert.equal(calls.length, 1); + assert.throws( + () => + publish(info, '/tmp/validated.tgz', (_, args) => + failure(args[0] === 'view' ? 'E404' : 'E403'), + ), + /E403/, + ); +}); + +test('GitHub list errors abort instead of being treated as a missing release', () => { + const calls = []; + assert.throws( + () => + completeRelease(info, 'owner/repo', (command, args) => { + calls.push([command, args]); + return { status: 1, stderr: 'network failure' }; + }), + /network failure/, + ); + assert.equal(calls.length, 1); + assert.throws(() => completeRelease(info, undefined), /GITHUB_REPOSITORY/); +}); + +test('create or complete GitHub release with the matching prerelease state', () => { + for (const version of ['1.2.3', '1.2.3-rc.0']) { + const release = releaseInfo(`v${version}`, { ...manifest, version }); + for (const existing of [false, true]) { + const calls = []; + completeRelease(release, 'owner/repo', (command, args) => { + calls.push([command, args]); + return ok( + args[0] === 'api' + ? existing + ? `${release.tag}\n` + : 'v0.1.0\n' + : 'done', + ); + }); + assert.ok(calls[0][1].includes('--paginate')); + assert.equal(calls[1][1][1], existing ? 'edit' : 'create'); + assert.ok( + calls[1][1].includes( + existing ? `--prerelease=${release.prerelease}` : '--verify-tag', + ), + ); + if (!existing) + assert.equal(calls[1][1].includes('--prerelease'), release.prerelease); + } + } +}); + +const repository = { type: 'git', url: 'git+https://github.com/yourtion/node-erest.git' }; +const names = ['erest', '@erest/express', '@erest/koa', '@erest/leizmweb', '@erest/gen']; + +function fixture(t, version = '3.2.3') { + const root = mkdtempSync(join(tmpdir(), 'erest-release-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const [index, directory] of packageDirectories.entries()) { + const path = resolve(root, directory); + mkdirSync(path, { recursive: true }); + const manifest = { + name: names[index], version, repository, type: 'module', + main: './dist/index.js', types: './dist/index.d.ts', + }; + if (index > 0 && index < 4) manifest.peerDependencies = { erest: 'workspace:^' }; + writeFileSync(join(path, 'package.json'), JSON.stringify(manifest)); + } + mkdirSync(join(root, 'examples')); + writeFileSync(join(root, 'examples/package.json'), JSON.stringify({ name: 'erest-example', version: '1.0.0', private: true })); + return root; +} + +// A local pack stand-in writes real .tgz files; no test contacts a registry. +function localPacker(root, changeManifest = (value) => value) { + return (command, args, options) => { + if (command !== 'pnpm') return spawnSync(command, args, { encoding: 'utf8' }); + assert.equal(options.env.npm_config_ignore_scripts, 'true'); + const manifest = JSON.parse(readFileSync(join(options.cwd, 'package.json'))); + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + for (const [name, range] of Object.entries(manifest[field] ?? {})) { + if (range === 'workspace:^') manifest[field][name] = `^${manifest.version}`; + else if (range === 'workspace:*') manifest[field][name] = manifest.version; + } + } + const staging = join(root, '.pack-staging'); + rmSync(staging, { recursive: true, force: true }); + mkdirSync(join(staging, 'package/dist'), { recursive: true }); + writeFileSync(join(staging, 'package/package.json'), JSON.stringify(changeManifest(manifest))); + writeFileSync(join(staging, 'package/dist/index.js'), 'export default {};'); + writeFileSync(join(staging, 'package/dist/index.d.ts'), 'export {};'); + const filename = `${manifest.name.replace(/^@/, '').replace('/', '-')}-${manifest.version}.tgz`; + const target = join(args[args.indexOf('--pack-destination') + 1], filename); + return spawnSync('tar', ['-czf', target, '-C', staging, 'package'], { encoding: 'utf8' }); + }; +} + +test('release set includes exactly five public packages, matching versions and repository metadata', (t) => { + const root = fixture(t); + const packages = workspaceRelease('v3.2.3', root); + assert.deepEqual(packages.map((pkg) => pkg.name), names); + assert.ok(!packages.some((pkg) => pkg.name === 'erest-example')); + const target = join(root, 'packages/erest-koa/package.json'); + const original = readFileSync(target, 'utf8'); + for (const edit of [ + { version: '3.2.2' }, { private: true }, { repository: undefined }, + { publishConfig: { registry: 'https://other.example/' } }, + { peerDependencies: { unknown: 'workspace:*' } }, + { peerDependencies: { erest: 'workspace:../unexpected' } }, + ]) { + writeFileSync(target, JSON.stringify({ ...JSON.parse(original), ...edit })); + assert.throws(() => workspaceRelease('v3.2.3', root)); + } +}); + +test('dependency graph orders dependencies first, handles optional/peer edges and rejects cycles/duplicates', () => { + const pkg = (name, fields = {}) => ({ manifest: { name, ...fields } }); + const a = pkg('a'); + const b = pkg('b', { peerDependencies: { a: '*' } }); + const c = pkg('c', { optionalDependencies: { b: '*' } }); + assert.deepEqual(orderPackages([c, b, a]).map((p) => p.manifest.name), ['a', 'b', 'c']); + assert.throws(() => orderPackages([a, a]), /Duplicate/); + assert.throws(() => orderPackages([pkg('a', { dependencies: { b: '*' } }), pkg('b', { peerDependencies: { a: '*' } })]), /Cyclic/); +}); + +test('workspace-aware pack checks real tarball contents and records integrity for all packages', (t) => { + const root = fixture(t); + const destination = join(root, '.bundle'); + const bundle = packWorkspace('v3.2.3', destination, root, localPacker(root)); + assert.equal(bundle.packages.length, 5); + const restored = readBundle('v3.2.3', destination, root); + assert.deepEqual(restored.map((pkg) => pkg.name), names); + const adapter = inspectTarball(restored[1].tarball); + assert.equal(adapter.manifest.peerDependencies.erest, '^3.2.3'); + const tarball = restored[3].tarball; + writeFileSync(tarball, 'tampered after verification'); + assert.throws(() => readBundle('v3.2.3', destination, root), /integrity mismatch/); +}); + +test('bundle rejects missing packages, path traversal, wrong identity and tag before any publication', (t) => { + const root = fixture(t); + const destination = join(root, '.bundle'); + const bundle = packWorkspace('v3.2.3', destination, root, localPacker(root)); + for (const edit of [ + { ...bundle, tag: 'v3.2.4' }, + { ...bundle, packages: bundle.packages.slice(1) }, + { ...bundle, packages: bundle.packages.map((p, i) => i ? p : { ...p, filename: '../outside.tgz' }) }, + { ...bundle, packages: bundle.packages.map((p, i) => i ? p : { ...p, name: 'wrong-package' }) }, + ]) { + writeFileSync(join(destination, 'release.json'), JSON.stringify(edit)); + assert.throws(() => readBundle('v3.2.3', destination, root), /bundle/); + } +}); + +test('packed workspace dependencies must be rewritten to the coordinated version', (t) => { + const root = fixture(t); + const packages = workspaceRelease('v3.2.3', root); + const pkg = packages[1]; + const packedManifest = { ...pkg.manifest, peerDependencies: { erest: '^3.2.3' } }; + const pack = { name: pkg.name, version: pkg.version, filename: 'package.tgz', files: ['package.json', 'dist/index.js', 'dist/index.d.ts'].map((path) => ({ path })) }; + validatePackedPackage(pkg, { manifest: packedManifest, pack }, packages); + for (const range of ['workspace:^', '^3.2.2', '*']) { + assert.throws(() => validatePackedPackage(pkg, { manifest: { ...packedManifest, peerDependencies: { erest: range } }, pack }, packages), /workspace|dependency/); + } + assert.throws(() => validatePackedPackage(pkg, { manifest: { ...packedManifest, main: './other.js' }, pack }, packages), /main/); +}); + +test('release publication resumes after a partial npm failure and completes GitHub only after every package', () => { + const packages = names.map((name) => ({ ...releaseInfo('v3.2.3', { name, version: '3.2.3' }), tarball: `/${name.replace('/', '-')}.tgz` })); + const published = new Set(); + const npmCalls = []; + let failPackage = '@erest/koa'; + let githubCalls = 0; + const run = (command, args) => { + if (command === 'gh') { + assert.equal(published.size, names.length); + githubCalls++; + return ok(''); + } + const pkg = args[0] === 'view' + ? packages.find((pkg) => `${pkg.name}@3.2.3` === args[1]) + : packages.find((pkg) => pkg.tarball === args[1]); + if (args[0] === 'view') return published.has(pkg.name) ? ok('"3.2.3"') : failure('E404'); + npmCalls.push(pkg.name); + if (pkg.name === failPackage) return failure('E403'); + published.add(pkg.name); + return ok('published'); + }; + assert.throws(() => publishReleaseSet(packages, 'yourtion/node-erest', run), /E403/); + assert.deepEqual([...published], ['erest', '@erest/express']); + assert.equal(githubCalls, 0); + failPackage = undefined; + publishReleaseSet(packages, 'yourtion/node-erest', run); + assert.equal(published.size, 5); + assert.equal(npmCalls.filter((name) => name === 'erest').length, 1); + assert.equal(githubCalls, 2); +}); + +test('GitHub failure after successful npm publishing can be retried without republishing npm versions', () => { + const pkg = { ...releaseInfo('v3.2.3', { name: 'erest', version: '3.2.3' }), tarball: '/erest.tgz' }; + let published = false; + let publishCount = 0; + const run = (command, args) => { + if (command === 'gh') return { status: 1, stderr: 'GitHub unavailable' }; + if (args[0] === 'view') return published ? ok('"3.2.3"') : failure('E404'); + published = true; + publishCount++; + return ok('published'); + }; + assert.throws(() => publishReleaseSet([pkg], 'yourtion/node-erest', run), /GitHub unavailable/); + publishReleaseSet([pkg], 'yourtion/node-erest', (command, args) => command === 'gh' ? ok('v3.2.3\n') : run(command, args)); + assert.equal(publishCount, 1); + assert.throws(() => publishReleaseSet([pkg], 'wrong/repo', run), /repository/); +}); + +test('OIDC runtime and strict SemVer reject unsupported or lossy versions', () => { + publishingRuntime('24.0.0', '11.5.1'); + publishingRuntime('22.14.0', '12.0.0'); + for (const [node, npm] of [['22.13.0', '11.5.1'], ['24.0.0', '11.5.0'], ['24.0.0', '11.5.1-beta']]) { + assert.throws(() => publishingRuntime(node, npm), /requires/); + } + assert.throws(() => releaseInfo('v9007199254740992.0.0', { ...manifest, version: '9007199254740992.0.0' }), /numeric/); +}); + +test('CLI tarball validation rejects missing shebang or executable permission', () => { + const manifest = { name: '@erest/gen', version: '3.2.3', bin: { 'erest-gen': './dist/index.js' } }; + const inspect = (source, mode) => inspectTarball('/gen.tgz', (_command, args) => { + if (args[0] === '-tzf') return ok('package/package.json\npackage/dist/index.js\n'); + if (args[0] === '-tvzf') return ok(`${mode} user/group 10 date package/dist/index.js`); + return ok(args.at(-1) === 'package/package.json' ? JSON.stringify(manifest) : source); + }); + inspect('#!/usr/bin/env node\n', '-rwxr-xr-x'); + assert.throws(() => inspect('console.log("missing shebang");', '-rwxr-xr-x'), /shebang/); + assert.throws(() => inspect('#!/usr/bin/env node\n', '-rw-r--r--'), /executable/); +}); + +test('CLI validation fails before attempting dependencies or publishing for bad tags', () => { + const script = new URL('./npm-release.mjs', import.meta.url); + const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version; + for (const tag of ['v3.2', 'v3.2.3+build', `v${version}-mismatched-test`]) { + const result = spawnSync(process.execPath, [script.pathname, 'check', tag], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Tag/); + } +});