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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -315,20 +323,43 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
pattern: bindings-*
path: bridge/node/artifacts
- name: Move artifacts
run: bun run artifacts
working-directory: bridge/node
- 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
Expand Down
6 changes: 4 additions & 2 deletions bridge/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
64 changes: 64 additions & 0 deletions bridge/node/scripts/pack.js
Original file line number Diff line number Diff line change
@@ -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 <new output directory>");

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));
51 changes: 38 additions & 13 deletions bridge/node/test/packaging.test.js
Original file line number Diff line number Diff line change
@@ -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}`];
Expand All @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions docs/npm-publishing.md
Original file line number Diff line number Diff line change
@@ -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 <tarball> --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/).
Loading