@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 +84 -0
- package/dist/index.mjs +172 -0
- package/dist/utils.mjs +69 -0
- package/package.json +33 -0
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
|
+
}
|