|
| 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