Skip to content

Commit d5d4051

Browse files
committed
skills: refine npm-trusted-publishing and add validation script
1 parent 24c80f8 commit d5d4051

2 files changed

Lines changed: 275 additions & 10 deletions

File tree

agents/skills/npm-trusted-publishing/SKILL.md

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,17 @@ permissions:
2424
Trusted Publishing with provenance requires a modern Node.js environment (v20+) and npm v9.5.0+. Using standard runner defaults or `.nvmrc` configurations works reliably.
2525

2626
```yaml
27-
- uses: actions/setup-node@v4
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v4
2829
with:
29-
node-version-file: '.nvmrc' # Or explicit version like 22.x
30+
node-version: lts/* # npm v11.5.1+ (Node v24+) is required for Trusted Publishing
3031
registry-url: 'https://registry.npmjs.org'
3132
```
3233

33-
### 3. package.json Metadata (CRITICAL)
34-
NPM validates provenance against the `repository` field. If this is missing or lacks the strict Sigstore protocol scheme, the publish will fail with a **422 Unprocessable Entity** or preliminary token rejection. The URL string MUST start explicitly with **`git+https://`** and end with **`.git`**.
34+
### 3. package.json Metadata
35+
NPM validates provenance against the `repository` field.
36+
37+
* **`.git` Suffix & `git+` Prefix are OPTIONAL**: You can use the raw HTTPS URL (e.g. `https://github.com/USER/REPO`) or the git-specific URL (e.g. `git+https://github.com/USER/REPO.git`). Most modern npm/node setups handle these automatically. However, OIDC matching can sometimes be sensitive to normalization; if OIDC fails, try toggling these formats.
3538

3639
```json
3740
{
@@ -42,6 +45,7 @@ NPM validates provenance against the `repository` field. If this is missing or l
4245
}
4346
```
4447

48+
4549
## Workflow Implementation
4650

4751
A standard reference template is provided inside this skill folder at `references/publish.yml` for drop-in integration.
@@ -63,8 +67,24 @@ Use standard publish flags to enable provenance.
6367
* **Fix 2**: To guarantee unblocked authorization passes on npm servers, trigger the publication workflow strictly by pushing an authentic annotated Git release tag (e.g., `git tag v1.0.0 && git push origin v1.0.0`).
6468

6569
### 404 Not Found
66-
* **Cause**: Usually a registry mismatch.
67-
* **Fix**: Explicitly set `--registry https://registry.npmjs.org/` in the publish command. Ensure no `.npmrc` is overriding the registry to a mirror or private proxy.
70+
* **Cause 1 (First Publish of a New Package)**: npm does not support "Pending Publishers" (unlike PyPI). You cannot configure Trusted Publishing for a package that does not exist yet. Attempting to publish a new package via OIDC first will fail with a 404 (e.g., `'packagename@1.4.12' is not in this registry`) because npm cannot authorize the publish without a pre-configured relationship.
71+
* **Fix 1**:
72+
1. Perform the **initial publish manually** from your local machine using standard token/session authentication (`npm login` then `npm publish --access public`).
73+
2. Once the package is created on npmjs.com, go to **Package Settings** -> **Trusted Publishing** and configure the GitHub Actions mapping.
74+
3. Future publishes can then run automatically via the OIDC workflow.
75+
* **Cause 2 (Registry Mismatch)**: The publish command is attempting to hit a private registry or mirror where the package does not exist or cannot be written.
76+
* **Fix 2**: Explicitly set `--registry https://registry.npmjs.org/` in the publish command. Ensure no `.npmrc` is overriding the registry to a mirror or private proxy.
77+
* **Cause 3 (Misconfigured or Missing Trusted Publisher on Existing Package)**: npm may return `404 Not Found` (instead of `403 Forbidden`) when the OIDC token claims do not match any configured Trusted Publisher for the package, or if Trusted Publishing has not been configured yet for this package.
78+
* **Fix 3**:
79+
1. Log in to npmjs.com, go to the package settings, and select **Publishing** (or **Trusted Publishers**).
80+
2. Verify that the configured **GitHub Organization/User** matches the repository owner (e.g., `so-fancy` for `so-fancy/diff-so-fancy`) and is case-sensitive.
81+
3. Verify that the **Repository** and **Workflow filename** (`publish.yml`) match exactly.
82+
4. Ensure the **Environment** field on npm is empty if it is not explicitly defined in the GitHub Actions workflow YAML.
83+
* **Cause 4 (repository.url format sensitivity)**: The npm registry's OIDC validation is sensitive to the `repository.url` format in `package.json`. In some cases, the `git+` prefix or missing `.git` extension can cause validation mismatches, even if the npm CLI claims to auto-correct/normalize it.
84+
* **Fix 4**:
85+
1. Ensure the URL matches your GitHub repo.
86+
2. Try explicitly setting it to `git+https://github.com/owner/repo.git` (official format) to avoid normalization warnings.
87+
3. If that fails, try using `https://github.com/owner/repo.git` (without `git+` but with `.git`) as some users have reported this resolves OIDC claim mismatches.
6888

6989
### 422 Unprocessable Entity
7090
* **Cause**: Metadata mismatch (most common: `repository.url` in `package.json` is missing or doesn't match the GitHub URL).
@@ -76,19 +96,37 @@ Use standard publish flags to enable provenance.
7696
1. Check **npmjs.com → Package Settings → Trusted Publishing**.
7797
2. Verify **Organization**, **Repository**, and **Workflow filename** (e.g., `publish.yml`) are exact.
7898
3. If an **Environment** is specified on npm (e.g., "release"), it MUST be specified in the workflow: `jobs.publish.environment: release`.
99+
## Validation Tool
100+
101+
The skill includes a validation script to check your local configuration:
102+
103+
```bash
104+
node <path-to-skill>/scripts/validate.mjs <path-to-package.json>
105+
```
106+
107+
It verifies:
108+
* Mandatory `.git` suffix on `repository.url`.
109+
* Presence of `id-token: write` permission.
110+
* Absence of conflicting `registry-url` configurations in `setup-node`.
111+
* Modern Node version (`lts/*` or `24+`) for robust OIDC support.
79112

80113
## User Instructions
81114

82115
When setting up a new project, provide these instructions to the user in strict sequential order:
83116

84117
1. **Commit Workflow First (CRITICAL Order of Operations)**: You MUST commit and push the `.github/workflows/publish.yml` file to your remote GitHub repository *before* initializing the trusted publisher mapping on npmjs.com. Configuring the dashboard mapping while the upstream repository lacks the workflow script can cause preliminary token evaluation handshakes to cache invalid target structures, resulting in unresolvable `400 Bad Request` authorization drops during deployment runs.
85-
2. **Configure npmjs.com**: Go to your package settings → Trusted Publishing and add a new "GitHub Actions" publisher.
86-
3. **Fields**:
87-
* **Organization**: `YOUR_USERNAME`
118+
2. **Initial Publish (For New Packages)**: If this is the first time publishing this package, you **cannot** use Trusted Publishing immediately. You must first publish the package manually from your local terminal to create it on the registry:
119+
```bash
120+
npm login
121+
npm publish --access public
122+
```
123+
3. **Configure npmjs.com**: Once the package exists on npm, go to the package page on npmjs.com -> **Settings** -> **Trusted Publishing** and add a new "GitHub Actions" publisher.
124+
4. **Fields**:
125+
* **Organization**: `YOUR_USERNAME` (or your npm organization name)
88126
* **Repository**: `YOUR_REPO`
89127
* **Workflow filename**: `publish.yml`
90128
* **Environment**: Leave blank (unless explicitly requested).
91-
4. **No Secrets Needed**: Remind the user that `NPM_TOKEN` is NO LONGER REQUIRED in GitHub Secrets.
129+
5. **No Secrets Needed**: Remind the user that `NPM_TOKEN` is NO LONGER REQUIRED in GitHub Secrets.
92130

93131
## Additional Resources
94132

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'fs';
4+
import path from 'path';
5+
import { fileURLToPath } from 'url';
6+
7+
// --- Main Execution ---
8+
function main() {
9+
const args = process.argv.slice(2);
10+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
11+
printUsage();
12+
process.exit(0);
13+
}
14+
15+
const packageJsonPath = path.resolve(args[0]);
16+
const packageDir = path.dirname(packageJsonPath);
17+
const gitRoot = findGitRoot(packageDir) || packageDir;
18+
19+
console.log(`Analyzing package at: ${packageDir}`);
20+
if (gitRoot !== packageDir) {
21+
console.log(`Resolved Git root at: ${gitRoot}`);
22+
}
23+
24+
const errors = [];
25+
const warnings = [];
26+
27+
const packageJson = readPackageJson(packageJsonPath);
28+
validatePackageJson(packageJson, errors, warnings);
29+
30+
const workflow = findWorkflowFile(gitRoot);
31+
if (!workflow) {
32+
warnings.push("Could not find a GitHub Actions workflow file containing 'npm publish' under .github/workflows/.");
33+
} else {
34+
console.log(`Analyzing workflow file: ${path.relative(gitRoot, workflow.path)}`);
35+
validateWorkflow(workflow.content, workflow.path, gitRoot, errors, warnings);
36+
}
37+
38+
printResults(errors, warnings);
39+
40+
process.exit(errors.length > 0 ? 1 : 0);
41+
}
42+
43+
// --- Helper Functions ---
44+
45+
function printUsage() {
46+
console.log('Usage: node validate.mjs <path-to-package.json>');
47+
console.log('Example: node validate.mjs ./package.json');
48+
}
49+
50+
function readPackageJson(filePath) {
51+
try {
52+
const content = fs.readFileSync(filePath, 'utf8');
53+
return JSON.parse(content);
54+
} catch (e) {
55+
if (e.code === 'ENOENT') {
56+
console.error(`Error: package.json not found at ${filePath}`);
57+
} else {
58+
console.error(`Error reading/parsing package.json: ${e.message}`);
59+
}
60+
process.exit(1);
61+
}
62+
}
63+
64+
function validatePackageJson(packageJson, errors, warnings) {
65+
if (packageJson.private === true) {
66+
warnings.push("package.json is marked as private. Provenance and Trusted Publishing are only supported for public packages.");
67+
}
68+
69+
if (!packageJson.repository) {
70+
errors.push("package.json: Missing 'repository' field. NPM validates provenance against this field.");
71+
} else {
72+
const repoUrl = typeof packageJson.repository === 'string' ? packageJson.repository : packageJson.repository.url;
73+
if (!repoUrl) {
74+
errors.push("package.json: Missing 'repository.url' field.");
75+
}
76+
}
77+
}
78+
79+
function findWorkflowFile(gitRoot) {
80+
const workflowDirs = [path.join(gitRoot, '.github', 'workflows')];
81+
82+
for (const dir of workflowDirs) {
83+
let files;
84+
try {
85+
files = fs.readdirSync(dir);
86+
} catch (e) {
87+
if (e.code === 'ENOENT') continue;
88+
throw e;
89+
}
90+
91+
const preferredFiles = files.filter(f => ['publish.yml', 'publish.yaml', 'release.yml', 'release.yaml'].includes(f));
92+
const otherFiles = files.filter(f => !preferredFiles.includes(f) && (f.endsWith('.yml') || f.endsWith('.yaml')));
93+
94+
const candidateFiles = [...preferredFiles, ...otherFiles];
95+
96+
for (const file of candidateFiles) {
97+
const p = path.join(dir, file);
98+
try {
99+
const content = fs.readFileSync(p, 'utf8');
100+
const isPreferred = preferredFiles.includes(file);
101+
const hasPublishCommand = content.includes('npm publish') ||
102+
content.includes('npm-publish') ||
103+
/uses\s*:\s*[^\n]*npm-publish/.test(content) ||
104+
/npm\s+run\s+[^\n]*publish/.test(content);
105+
if (isPreferred || hasPublishCommand) {
106+
return { path: p, content };
107+
}
108+
} catch (e) {
109+
// ignore read errors
110+
}
111+
}
112+
}
113+
return null;
114+
}
115+
116+
function validateWorkflow(workflowContent, workflowPath, gitRoot, errors, warnings) {
117+
const filename = path.basename(workflowPath);
118+
119+
if (!/id-token\s*:\s*write/.test(workflowContent)) {
120+
errors.push(`${filename}: Missing 'id-token: write' permission. This is required for OIDC token exchange.`);
121+
}
122+
if (!/contents\s*:\s*read/.test(workflowContent)) {
123+
warnings.push(`${filename}: Recommended 'contents: read' permission is missing.`);
124+
}
125+
126+
const hasNpmUpgrade = /npm\s+(install|i)\s+(-g|--global)\s+npm/.test(workflowContent);
127+
if (hasNpmUpgrade) {
128+
console.log("Detected npm upgrade command in workflow.");
129+
}
130+
131+
const steps = parseSteps(workflowContent);
132+
const setupNodeStep = steps.find(s => /uses\s*:\s*actions\/setup-node/.test(s));
133+
134+
if (setupNodeStep) {
135+
const versionMatch = /node-version\s*:\s*([^\n]+)/.exec(setupNodeStep);
136+
const versionFileMatch = /node-version-file\s*:\s*([^\n]+)/.exec(setupNodeStep);
137+
138+
if (!versionMatch && !versionFileMatch) {
139+
warnings.push(`${filename}: Neither 'node-version' nor 'node-version-file' is specified in setup-node step. It is recommended to use Node 24+ (or 'lts/*') for npm 11+ to ensure robust OIDC support.`);
140+
} else if (versionFileMatch) {
141+
const file = versionFileMatch[1].split('#')[0].trim().replace(/['"]/g, '');
142+
const filePath = path.join(gitRoot, file);
143+
try {
144+
const fileContent = fs.readFileSync(filePath, 'utf8').trim();
145+
console.log(`Found node-version-file "${file}" specifying Node version: ${fileContent}`);
146+
} catch (e) {
147+
if (e.code === 'ENOENT') {
148+
errors.push(`${filename}: Referenced node-version-file "${file}" does not exist at ${filePath}`);
149+
} else {
150+
warnings.push(`${filename}: Could not read node-version-file "${file}": ${e.message}`);
151+
}
152+
}
153+
} else {
154+
const version = versionMatch[1].split('#')[0].trim().replace(/['"]/g, '');
155+
if (version === '20' || version.startsWith('20.')) {
156+
warnings.push(`${filename}: Node ${version} is deprecated on GitHub Actions. Consider upgrading to 24 or 'lts/*'.`);
157+
} else if (version !== '24' && version !== 'lts/*' && !version.startsWith('24.')) {
158+
if (!hasNpmUpgrade) {
159+
warnings.push(`${filename}: Node version is set to "${version}". Ensure it provides npm v11.5.1+ (Node v24+) if you encounter OIDC issues, or add a step to upgrade npm: "npm install -g npm@latest".`);
160+
}
161+
}
162+
}
163+
} else {
164+
warnings.push(`${filename}: Could not find 'actions/setup-node' step. Ensure you are using a modern Node/npm version.`);
165+
}
166+
}
167+
168+
function printResults(errors, warnings) {
169+
console.log('\n--- Validation Results ---');
170+
if (errors.length === 0 && warnings.length === 0) {
171+
console.log('✅ All checks passed! Configuration looks good for NPM Trusted Publishing.');
172+
} else {
173+
if (errors.length > 0) {
174+
console.log(`\n❌ Errors (${errors.length}):`);
175+
errors.forEach(e => console.log(` - ${e}`));
176+
}
177+
if (warnings.length > 0) {
178+
console.log(`\n⚠️ Warnings (${warnings.length}):`);
179+
warnings.forEach(w => console.log(` - ${w}`));
180+
}
181+
}
182+
}
183+
184+
function findGitRoot(startDir) {
185+
let dir = startDir;
186+
while (dir !== path.parse(dir).root) {
187+
// Checking exists here is not a TOCTOU violation as we don't immediately operate on it.
188+
if (fs.existsSync(path.join(dir, '.git'))) {
189+
return dir;
190+
}
191+
dir = path.dirname(dir);
192+
}
193+
return null;
194+
}
195+
196+
function parseSteps(yamlContent) {
197+
const lines = yamlContent.split('\n');
198+
const steps = [];
199+
let currentStep = null;
200+
201+
for (let line of lines) {
202+
const trimmed = line.trim();
203+
if (trimmed.startsWith('-')) {
204+
if (currentStep) {
205+
steps.push(currentStep);
206+
}
207+
currentStep = [line];
208+
} else if (currentStep && (line.startsWith(' ') || line.startsWith('\t') || trimmed === '')) {
209+
currentStep.push(line);
210+
} else {
211+
if (currentStep) {
212+
steps.push(currentStep);
213+
currentStep = null;
214+
}
215+
}
216+
}
217+
if (currentStep) {
218+
steps.push(currentStep);
219+
}
220+
return steps.map(s => s.join('\n'));
221+
}
222+
223+
// --- Import Guard ---
224+
const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
225+
if (isMain) {
226+
main();
227+
}

0 commit comments

Comments
 (0)