@scaleway/npm-trust 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @scaleway/npm-trust
2
+
3
+ Configure [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) (GitHub Actions OIDC) for all public packages in a pnpm workspace.
4
+
5
+ Trusted publishing lets GitHub Actions publish to npm without a long-lived `NPM_TOKEN` — npm exchanges a short-lived GitHub OIDC token for a publish token at release time. This CLI automates the npm-side configuration: it registers each public workspace package's GitHub Actions workflow as a trusted publisher.
6
+
7
+ ## Prerequisites
8
+
9
+ - `npm login` (needed for `npm trust` commands)
10
+ - Node >= 22, pnpm >= 12
11
+
12
+ ## Usage
13
+
14
+ ### Configure trust for all public packages
15
+
16
+ ```bash
17
+ pnpm exec npm-trust --repo <owner/name>
18
+ ```
19
+
20
+ This will:
21
+
22
+ 1. List all non-private workspace packages.
23
+ 2. Detect any that are not yet published to npm and offer to publish them first.
24
+ 3. For each package, check if trust is already configured. If not, run `npm trust github` to register the GitHub Actions workflow as a trusted publisher.
25
+
26
+ ### Check mode (CI gate)
27
+
28
+ ```bash
29
+ pnpm exec npm-trust --check --repo <owner/name>
30
+ ```
31
+
32
+ Exits with code `1` if any public package is not yet published on npm. Use this in CI to block merges that add a new publishable package without publishing it first. No npm auth required — uses anonymous `npm view` queries.
33
+
34
+ ### Options
35
+
36
+ ```
37
+ Options:
38
+ -f, --file <workflow> GitHub Actions workflow file (default: deploy-package.yml)
39
+ -r, --repo <owner/name> GitHub repository
40
+ --dry-run Report what would happen, no changes
41
+ --check Only check for unpublished packages; exit 1 if any found
42
+ -y, --yes Skip prompts, answer yes to everything
43
+ -h, --help Show this help
44
+ ```
45
+
46
+ ## CI integration
47
+
48
+ ### Block PRs that add unpublished packages
49
+
50
+ ```yaml
51
+ # .github/workflows/ci.yml
52
+ jobs:
53
+ npm-trust-check:
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v7
57
+ - uses: ./.github/actions/setup-node-pnpm
58
+ - run: pnpm exec npm-trust --check --repo ${{ github.repository }}
59
+ ```
60
+
61
+ ### Gate releases on trust configuration
62
+
63
+ ```yaml
64
+ # .github/workflows/release.yml
65
+ steps:
66
+ - name: Check npm trust
67
+ run: pnpm exec npm-trust --check --repo ${{ github.repository }}
68
+ ```
69
+
70
+ The check runs before the changesets publish step, ensuring no release proceeds with unpublished packages that lack trusted publishing.
71
+
72
+ ## OIDC setup
73
+
74
+ Once trust is configured via this CLI, the release workflow can publish without `NPM_TOKEN`:
75
+
76
+ ```yaml
77
+ permissions:
78
+ id-token: write # required for OIDC
79
+
80
+ env:
81
+ NPM_CONFIG_PROVENANCE: 'true'
82
+ ```
83
+
84
+ See the [npm trusted publishers guide](https://docs.npmjs.com/trusted-publishers) for more details.
package/dist/index.mjs ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import { exec, findWorkspaceRoot, hasExistingTrust, isPackagePublished, listPublicWorkspacePackages } from "./utils.mjs";
3
+ import { realpathSync } from "node:fs";
4
+ import { stdin, stdout } from "node:process";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { parseArgs } from "node:util";
7
+ //#region src/index.ts
8
+ const HELP = `Usage: npm-trust [options]
9
+
10
+ Configure npm trusted publishers (GitHub Actions) for all non-private
11
+ workspace packages. Detects unpublished packages and offers to publish
12
+ them first. Skips packages that already have a trust relationship.
13
+
14
+ Options:
15
+ -f, --file <workflow> GitHub Actions workflow file (default: deploy-package.yml)
16
+ -r, --repo <owner/name> GitHub repository
17
+ --dry-run Report what would happen, no changes
18
+ --check Only check for unpublished packages; exit 1 if any found
19
+ -y, --yes Skip prompts, answer yes to everything
20
+ -h, --help Show this help
21
+
22
+ Note: requires "npm login" first (npm trust list needs auth to check
23
+ existing trusts). If the check fails, the package is tried anyway and
24
+ npm itself will report conflicts (E409).
25
+ `;
26
+ function parseArgs_() {
27
+ const { values } = parseArgs({
28
+ args: process.argv.slice(2),
29
+ options: {
30
+ file: {
31
+ type: "string",
32
+ short: "f",
33
+ default: "deploy-package.yml"
34
+ },
35
+ repo: {
36
+ type: "string",
37
+ short: "r"
38
+ },
39
+ "dry-run": {
40
+ type: "boolean",
41
+ default: false
42
+ },
43
+ check: {
44
+ type: "boolean",
45
+ default: false
46
+ },
47
+ yes: {
48
+ type: "boolean",
49
+ short: "y",
50
+ default: false
51
+ },
52
+ help: {
53
+ type: "boolean",
54
+ short: "h",
55
+ default: false
56
+ }
57
+ }
58
+ });
59
+ if (values.help) {
60
+ console.log(HELP);
61
+ return null;
62
+ }
63
+ return {
64
+ dryRun: values["dry-run"],
65
+ check: values.check,
66
+ workflowFile: values.file,
67
+ repo: values.repo,
68
+ yes: values.yes
69
+ };
70
+ }
71
+ async function promptYesNo(question, defaultValue = false) {
72
+ const rl = createInterface({
73
+ input: stdin,
74
+ output: stdout
75
+ });
76
+ try {
77
+ const answer = (await rl.question(`${question} (y/N) `)).trim().toLowerCase();
78
+ return answer === "y" || answer === "yes";
79
+ } catch {
80
+ return defaultValue;
81
+ } finally {
82
+ rl.close();
83
+ }
84
+ }
85
+ function findUnpublishedPackages(packages) {
86
+ const unpublished = [];
87
+ for (const pkg of packages) if (!isPackagePublished(pkg.name)) unpublished.push(pkg);
88
+ return unpublished;
89
+ }
90
+ async function publishUnpublished(packages, options) {
91
+ if (packages.length === 0) return;
92
+ console.log(`\n[trust] ${packages.length} unpublished package(s) detected:`);
93
+ for (const pkg of packages) console.log(` - ${pkg.name} (v${pkg.version})`);
94
+ if (options.dryRun) {
95
+ console.log("[dry-run] would prompt to publish these packages first");
96
+ return;
97
+ }
98
+ if (!(options.yes || await promptYesNo("\nPublish these packages first?"))) {
99
+ console.log("[trust] skipping publish, proceeding to trust setup");
100
+ return;
101
+ }
102
+ for (const pkg of packages) {
103
+ const cmd = `npm publish --access public --force`;
104
+ console.log(`>>> ${cmd} (in ${pkg.path})`);
105
+ try {
106
+ exec(cmd, {
107
+ cwd: pkg.path,
108
+ stdio: "inherit"
109
+ });
110
+ console.log(`published: ${pkg.name}`);
111
+ } catch {
112
+ console.error(`WARN: publish failed for ${pkg.name}`);
113
+ }
114
+ }
115
+ }
116
+ function configureTrust(packages, options) {
117
+ let applied = 0;
118
+ let skipped = 0;
119
+ let failed = 0;
120
+ for (const pkg of packages) {
121
+ const cmd = `npm trust github ${pkg.name} --file ${options.workflowFile} --repo ${options.repo} -y --allow-publish --force`;
122
+ if (options.dryRun) {
123
+ console.log(`[dry-run] ${cmd}`);
124
+ applied++;
125
+ } else if (hasExistingTrust(pkg.name)) {
126
+ console.log(`SKIP: ${pkg.name} — trust already configured`);
127
+ skipped++;
128
+ } else {
129
+ console.log(`>>> ${cmd}`);
130
+ try {
131
+ exec(cmd, { stdio: "inherit" });
132
+ applied++;
133
+ } catch {
134
+ console.error(`WARN: failed for ${pkg.name}`);
135
+ failed++;
136
+ }
137
+ }
138
+ }
139
+ console.log(`[trust] done: ${applied} applied, ${skipped} skipped, ${failed} failed`);
140
+ }
141
+ async function main() {
142
+ const options = parseArgs_();
143
+ if (!options) return;
144
+ if (!options.repo) {
145
+ console.error("missing --repo");
146
+ process.exit(1);
147
+ }
148
+ const root = findWorkspaceRoot(process.cwd());
149
+ const packages = listPublicWorkspacePackages(root);
150
+ console.log(`[trust] ${packages.length} non-private packages`);
151
+ if (options.check) {
152
+ const unpublished = findUnpublishedPackages(packages);
153
+ if (unpublished.length === 0) {
154
+ console.log("[check] all packages are published on npm");
155
+ process.exit(0);
156
+ }
157
+ console.log(`[check] ${unpublished.length} unpublished package(s) found:`);
158
+ for (const pkg of unpublished) console.log(` - ${pkg.name} (v${pkg.version})`);
159
+ console.log("[check] publish and configure trust before merging");
160
+ process.exit(1);
161
+ }
162
+ if (!options.dryRun) await publishUnpublished(findUnpublishedPackages(packages), options);
163
+ configureTrust(packages, options);
164
+ }
165
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(import.meta.filename)) try {
166
+ await main();
167
+ } catch (error) {
168
+ console.error(error);
169
+ process.exit(1);
170
+ }
171
+ //#endregion
172
+ export {};
package/dist/utils.mjs ADDED
@@ -0,0 +1,69 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { execSync } from "node:child_process";
3
+ import path from "node:path";
4
+ //#region src/utils.ts
5
+ const hasPnpm = (() => {
6
+ try {
7
+ execSync("pnpm --version", { stdio: "ignore" });
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ })();
13
+ const findWorkspaceRoot = (start) => {
14
+ let dir = start;
15
+ while (dir !== "/") try {
16
+ readFileSync(path.join(dir, "pnpm-workspace.yaml"), "utf8");
17
+ return dir;
18
+ } catch {
19
+ dir = path.join(dir, "..");
20
+ }
21
+ return start;
22
+ };
23
+ const exec = (cmd, opts = {}) => {
24
+ return (execSync(cmd, {
25
+ cwd: opts.cwd,
26
+ encoding: "utf8",
27
+ stdio: opts.stdio === "inherit" ? "inherit" : [
28
+ "ignore",
29
+ "pipe",
30
+ "pipe"
31
+ ],
32
+ maxBuffer: 52428800
33
+ }) ?? "").trim();
34
+ };
35
+ const isPnpmListEntry = (e) => typeof e === "object" && e !== null && "name" in e && "path" in e;
36
+ const listPublicWorkspacePackages = (root) => {
37
+ const raw = exec("pnpm ls -r --depth -1 --json", { cwd: root });
38
+ const parsed = JSON.parse(raw);
39
+ return (Array.isArray(parsed) ? parsed : []).filter(isPnpmListEntry).filter((e) => e.version && e.private !== true);
40
+ };
41
+ const isRecord = (value) => typeof value === "object" && value !== null;
42
+ const countTrustEntries = (json) => {
43
+ try {
44
+ const parsed = JSON.parse(json);
45
+ if (!isRecord(parsed)) return 0;
46
+ return Object.values(parsed).flat().filter(Boolean).length;
47
+ } catch {
48
+ return 0;
49
+ }
50
+ };
51
+ const hasExistingTrust = (pkgName) => {
52
+ try {
53
+ const listJson = exec(`npm trust list ${pkgName} --json --force`);
54
+ return countTrustEntries(listJson) > 0;
55
+ } catch {
56
+ return false;
57
+ }
58
+ };
59
+ const isPackagePublished = (pkgName) => {
60
+ const cmd = hasPnpm ? `pnpm view ${pkgName} version` : `npm view ${pkgName} version --force`;
61
+ try {
62
+ exec(cmd);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ };
68
+ //#endregion
69
+ export { exec, findWorkspaceRoot, hasExistingTrust, isPackagePublished, listPublicWorkspacePackages };
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@scaleway/npm-trust",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "description": "Configure npm trusted publishers (GitHub Actions) for workspace packages",
6
+ "homepage": "https://github.com/scaleway/scaleway-lib/tree/main/packages/npm-trust",
7
+ "bugs": {
8
+ "url": "https://github.com/scaleway/scaleway-lib/issues"
9
+ },
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/scaleway/scaleway-lib.git",
14
+ "directory": "packages/npm-trust"
15
+ },
16
+ "bin": {
17
+ "npm-trust": "dist/index.mjs"
18
+ },
19
+ "files": [
20
+ "dist/"
21
+ ],
22
+ "type": "module",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit",
28
+ "build": "tsdown"
29
+ },
30
+ "engines": {
31
+ "node": "24.x || 25.x || 26.x"
32
+ }
33
+ }