diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 39cf2f1..4706068 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -298,14 +298,22 @@ jobs: needs: - changepacks - node-build - if: ${{ contains(needs.changepacks.outputs.changepacks, 'bridge/node/package.json') }} + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'bridge/node/package.json') }} steps: - uses: actions/checkout@v7 + - name: Setup Node.js for npm trusted publishing + uses: actions/setup-node@v7 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Ensure npm supports OIDC + run: npm install -g 'npm@^11' - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Build run: bun run build working-directory: bridge/node @@ -315,6 +323,7 @@ jobs: - name: Download all artifacts uses: actions/download-artifact@v8 with: + pattern: bindings-* path: bridge/node/artifacts - name: Move artifacts run: bun run artifacts @@ -322,13 +331,35 @@ jobs: - name: List packages run: ls -R ./npm working-directory: bridge/node - - name: Publish + - name: Pack and verify every npm package + run: bun run pack:publish "$RUNNER_TEMP/npm-publish" + working-directory: bridge/node + # Register all five @dependency-check-updates/cli* packages on npm with + # GitHub owner dev-five-git, repository dependency-check-updates, workflow + # filename CI.yml (case-sensitive), no environment, and npm publish allowed. + # Publish the already verified tarballs: no token or lifecycle publisher. + - name: Publish with npm trusted publishing + shell: bash run: | - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc - npm publish --access public - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + set -euo pipefail + publish_package() { + local tarball="$1" + local package_id + package_id=$(tar -xOf "$tarball" package/package.json | node -e ' + const pkg = JSON.parse(require("node:fs").readFileSync(0, "utf8")); + process.stdout.write(`${pkg.name}@${pkg.version}`); + ') + # Resume partial publication or retry a failed release asset upload. + if npm view "$package_id" version >/dev/null 2>&1; then + echo "::notice::$package_id is already published; skipping." + return + fi + npm publish "$tarball" --access public --ignore-scripts + } + for tarball in "$RUNNER_TEMP/npm-publish/native/"*.tgz; do + publish_package "$tarball" + done + publish_package "$RUNNER_TEMP/npm-publish/cli/"*.tgz working-directory: bridge/node - name: Upload Asset uses: owjs3901/upload-github-release-asset@main diff --git a/bridge/node/package.json b/bridge/node/package.json index c0f9cff..785848f 100644 --- a/bridge/node/package.json +++ b/bridge/node/package.json @@ -57,10 +57,12 @@ "build": "napi build --platform --release", "build:debug": "napi build --platform", "lint": "echo 'lint'", - "prepublishOnly": "napi prepublish -t npm", + "prepublishOnly": "napi prepublish -t npm --skip-optional-publish --no-gh-release", + "pack:publish": "bun scripts/pack.js", "test": "bun run build:debug && bun test" }, "devDependencies": { - "@napi-rs/cli": "^3.8" + "@napi-rs/cli": "^3.8", + "npm-package-arg": "^13" } } diff --git a/bridge/node/scripts/pack.js b/bridge/node/scripts/pack.js new file mode 100644 index 0000000..2d98a67 --- /dev/null +++ b/bridge/node/scripts/pack.js @@ -0,0 +1,64 @@ +// Prepare every package without publishing, then verify the exact tarballs +// handed to npm. Bun materializes workspace ranges; npm supplies OIDC. +const { execFileSync } = require("node:child_process"); +const { mkdirSync, readFileSync, readdirSync, realpathSync } = require("node:fs"); +const { basename, join, resolve } = require("node:path"); +const packageArg = require("npm-package-arg"); + +const command = (args) => execFileSync(args[0], args.slice(1), { encoding: "utf8" }); +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); +const destination = process.argv[2]; +if (!destination) throw new Error("Usage: bun scripts/pack.js "); + +command(["bun", "run", "prepublishOnly"]); +const root = readJson("package.json"); +const nativeDirs = readdirSync("npm", { withFileTypes: true }) + .filter((entry) => entry.isDirectory()).map((entry) => join("npm", entry.name)).sort(); +if (nativeDirs.length !== root.napi.targets.length) { + throw new Error("Expected one native package for every configured N-API target"); +} + +const output = resolve(destination); +// Refuse stale output so a retry cannot publish an older tarball by accident. +mkdirSync(output); +const packed = []; +for (const dir of [...nativeDirs, "."]) { + const manifest = readJson(join(dir, "package.json")); + const native = dir !== "."; + if (native && (manifest.name !== `${root.name}-${basename(dir)}` + || manifest.version !== root.version + || root.optionalDependencies?.[manifest.name] !== root.version)) { + throw new Error(`Native package name/version mismatch: ${dir}`); + } + const packDir = join(output, native ? "native" : "cli"); + mkdirSync(packDir, { recursive: true }); + const filename = resolve(packDir, command([ + "bun", "pm", "pack", "--quiet", "--ignore-scripts", "--destination", packDir, + // Native realpath expands Windows 8.3 aliases (e.g. RUNNER~1). + // Bun cannot locate the workspace lockfile when --cwd contains an alias. + "--cwd", realpathSync.native(resolve(dir)), + ]).trim()); + const contents = command(["tar", "-xOf", filename, "package/package.json"]); + const published = JSON.parse(contents); + for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) { + for (const [name, range] of Object.entries(published[section] ?? {})) { + // Use npm's parser so bare paths/archives cannot escape a prefix check. + let spec; + try { spec = packageArg.resolve(name, range); } catch { + throw new Error(`Unresolved local dependency or invalid npm spec in ${manifest.name}: ${name}=${range}`); + } + if (spec.type === "file" || spec.type === "directory" || /^git\+file:/i.test(range)) { + throw new Error(`Unresolved local dependency in ${manifest.name}: ${name}=${range}`); + } + } + } + const files = command(["tar", "-tf", filename]).split(/\r?\n/); + const required = native + ? [`${root.napi.binaryName}.${basename(dir)}.node`] + : ["main.js", "index.js", "index.d.ts"]; + for (const file of required) { + if (!files.includes(`package/${file}`)) throw new Error(`Missing ${file} in ${filename}`); + } + packed.push({ name: published.name, version: published.version, filename }); +} +console.log(JSON.stringify(packed)); diff --git a/bridge/node/test/packaging.test.js b/bridge/node/test/packaging.test.js index ea692d3..3f31331 100644 --- a/bridge/node/test/packaging.test.js +++ b/bridge/node/test/packaging.test.js @@ -1,18 +1,26 @@ const { expect, test } = require("bun:test"); -const { mkdtempSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync } = require("node:fs"); -const { join } = require("node:path"); +const { mkdtempSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, rmSync, realpathSync } = require("node:fs"); +const { delimiter, join } = require("node:path"); const { tmpdir } = require("node:os"); -async function command(args, cwd) { - const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env, NAPI_RS_ENFORCE_VERSION_CHECK: "1" } }); +async function command(args, cwd, expectedStatus = 0) { + const env = { ...process.env, NAPI_RS_ENFORCE_VERSION_CHECK: "1" }; + // Windows may name this variable Path. Duplicate Path/PATH keys cause Node's + // subprocesses to lose the local N-API binary directory. + const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"; + env[pathKey] = join(__dirname, "..", "node_modules", ".bin") + delimiter + (env[pathKey] ?? ""); + const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe", env }); const [status, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); - expect(status, `${args.join(" ")}\n${stderr}\n${stdout}`).toBe(0); - return stdout; + expect(status, `${args.join(" ")}\n${stderr}\n${stdout}`).toBe(expectedStatus); + return expectedStatus === 0 ? stdout : stderr; } test("packed npm CLI loads its separately installed native platform package", async () => { const repo = join(__dirname, ".."); - const root = mkdtempSync(join(tmpdir(), "dcu-npm-pack-")); + // Windows runners expose an 8.3 TEMP path (RUNNER~1). Bun records canonical + // workspace paths, so use that spelling before creating or installing them. + const tempRoot = mkdtempSync(join(tmpdir(), "dcu-npm-pack-")); + const root = realpathSync.native(tempRoot); const copy = join(root, "package"); mkdirSync(copy); try { const suffix = { "win32-x64": "win32-x64-msvc", "darwin-x64": "darwin-x64", "darwin-arm64": "darwin-arm64", "linux-x64": "linux-x64-gnu" }[`${process.platform}-${process.arch}`]; @@ -23,17 +31,34 @@ test("packed npm CLI loads its separately installed native platform package", as // targets. Release CI separately collects every configured target. const config = JSON.parse(readFileSync(join(copy, "package.json"), "utf8")); config.napi.targets = [{ "win32-x64-msvc": "x86_64-pc-windows-msvc", "darwin-x64": "x86_64-apple-darwin", "darwin-arm64": "aarch64-apple-darwin", "linux-x64-gnu": "x86_64-unknown-linux-gnu" }[suffix]]; + // A real workspace range must be materialized in the artifact npm publishes. + writeFileSync(join(root, "package.json"), JSON.stringify({ private: true, workspaces: ["package", "fixture"] })); + mkdirSync(join(root, "fixture")); + writeFileSync(join(root, "fixture", "package.json"), JSON.stringify({ name: "@dcu-test/fixture", version: "1.2.3" })); + config.devDependencies = { "@dcu-test/fixture": "workspace:^" }; writeFileSync(join(copy, "package.json"), JSON.stringify(config)); + await command(["bun", "install", "--lockfile-only", "--ignore-scripts"], root); await command(["bun", "x", "--no-install", "napi", "create-npm-dirs", "--cwd", copy], repo); await command(["bun", "x", "--no-install", "napi", "artifacts", "--cwd", copy, "--output-dir", "."], repo); const npm = process.platform === "win32" ? ["cmd.exe", "/d", "/c", "npm"] : ["npm"]; - const pack = async dir => JSON.parse(await command([...npm, "pack", "--ignore-scripts", "--json", "--pack-destination", root], dir))[0]; - const cli = await pack(copy); - const native = await pack(join(copy, "npm", suffix)); - expect(cli.files.some(f => f.path === "main.js")).toBe(true); - expect(native.files.some(f => f.path === binary)).toBe(true); + // Keep the caller's original spelling: the packing script must also handle + // an 8.3 cwd, even though the fixture was installed through its real path. + const packCwd = join(tempRoot, "package"); + const packed = JSON.parse(await command(["bun", join(repo, "scripts", "pack.js"), join(root, "packed")], packCwd)); + const cli = packed.find(p => p.name === "@dependency-check-updates/cli"); + const native = packed.find(p => p.name === `@dependency-check-updates/cli-${suffix}`); + expect(packed.length).toBe(2); + const manifest = JSON.parse(await command(["tar", "-xOf", cli.filename, "package/package.json"], root)); + expect(manifest.devDependencies["@dcu-test/fixture"]).toBe("^1.2.3"); + expect(manifest.optionalDependencies[native.name]).toBe(config.version); + const local = JSON.parse(readFileSync(join(copy, "package.json"), "utf8")); + for (const [index, range] of ["file:../fixture", "../fixture"].entries()) { + local.devDependencies["@dcu-test/fixture"] = range; + writeFileSync(join(copy, "package.json"), JSON.stringify(local)); + expect(await command(["bun", join(repo, "scripts", "pack.js"), join(root, `rejected-${index}`)], packCwd, 1)).toContain("Unresolved local dependency"); + } const installed = join(root, "installed"); mkdirSync(installed); - await command([...npm, "install", "--offline", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev", "--prefix", installed, join(root, cli.filename), join(root, native.filename)], installed); + await command([...npm, "install", "--offline", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev", "--prefix", installed, cli.filename, native.filename], installed); const installedMain = join(installed, "node_modules", "@dependency-check-updates", "cli", "main.js"); expect(readdirSync(join(installed, "node_modules", "@dependency-check-updates", "cli")).some(f => f.endsWith(".node"))).toBe(false); const help = await command(["node", installedMain, "--help"], root); diff --git a/bun.lock b/bun.lock index d1eef3a..d542ce1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,13 +10,14 @@ }, "bridge/node": { "name": "@dependency-check-updates/cli", - "version": "0.1.15", + "version": "0.3.0", "bin": { "dcu": "./main.js", "dependency-check-updates": "./main.js", }, "devDependencies": { "@napi-rs/cli": "^3.8", + "npm-package-arg": "^13", }, }, }, @@ -215,6 +216,8 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], @@ -223,12 +226,18 @@ "json-with-bigint": ["json-with-bigint@3.5.12", "", {}, "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -243,6 +252,8 @@ "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + "@napi-rs/lzma-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], "@napi-rs/lzma-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], diff --git a/docs/npm-publishing.md b/docs/npm-publishing.md new file mode 100644 index 0000000..bfbc0a0 --- /dev/null +++ b/docs/npm-publishing.md @@ -0,0 +1,67 @@ +# npm trusted publishing + +The `node-publish` job in `.github/workflows/CI.yml` publishes through GitHub +Actions OIDC. It runs only on pushes to `main`, uses Node.js 24 and npm 11, and +does not use `NPM_TOKEN`. PyPI and crates.io publication are separate. + +## Registry setup + +Configure a GitHub Actions trusted publisher in the npm settings of **each** +package: + +- `@dependency-check-updates/cli` +- `@dependency-check-updates/cli-win32-x64-msvc` +- `@dependency-check-updates/cli-darwin-x64` +- `@dependency-check-updates/cli-linux-x64-gnu` +- `@dependency-check-updates/cli-darwin-arm64` + +Use these exact values: + +| Field | Value | +| --- | --- | +| Organization or user | `dev-five-git` | +| Repository | `dependency-check-updates` | +| Workflow filename | `CI.yml` | +| Environment name | Leave empty | +| Allowed actions | Enable direct `npm publish` | + +The filename is case-sensitive and must not include `.github/workflows/`. +Changing the workflow filename requires updating the registry configuration. +The job already has `id-token: write`. Configure every platform package before +enabling this workflow; configuring only the CLI package is insufficient. +After a successful OIDC release, remove the obsolete repository `NPM_TOKEN` +secret and revoke the old token if no other project uses it. + +## Packaging and verification + +After building the CLI and collecting all platform artifacts: + +```sh +cd bridge/node +bun run pack:publish /absolute/path/to/new-output-directory +``` + +The script runs N-API preparation with optional publication and GitHub release +creation disabled. It checks that every target has a platform package with the +CLI version, packs the native packages and CLI with `bun pm pack`, and inspects +the actual tarballs for unresolved local dependency ranges and missing entry +points or native binaries. Bun converts `workspace:` ranges before npm sees +the tarballs. The packing script expands Windows 8.3 path aliases before +passing package directories to Bun, preserving workspace resolution on Windows +CI runners. An existing output directory is rejected to prevent stale files +from entering a release. + +CI completes all checks before publishing anything. It publishes the tarballs +under `native/` first, then the tarball under `cli/`, using +`npm publish --access public --ignore-scripts`. Do not replace this +with publication from source directories: npm does not materialize Bun +workspace protocols. `prepublishOnly` now prepares packages without publishing +the platform packages automatically. +Already published versions are skipped for each package individually, so a +partial publication or failed GitHub release asset upload can be retried. + +The packaging regression test uses a real native binary, a `workspace:^` +fixture, and the same packing script. It checks the materialized range, rejects +a `file:` dependency, and installs both tarballs offline before running the CLI. + +See [npm's trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/).