diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ebda383..f15d456b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,13 +5,16 @@ on: tags: ['v*'] permissions: - contents: write - id-token: write + contents: read jobs: - release: - name: Create Release + verify: + name: Verify Release runs-on: ubuntu-latest + outputs: + already_published: ${{ steps.npm_publish_state.outputs.already_published }} + dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }} + package_file: ${{ steps.pack.outputs.package_file }} steps: - name: Checkout @@ -97,6 +100,42 @@ jobs: - For migration tips and compatibility notes, see README and CHANGELOG. EOF + - name: Pack npm artifact + id: pack + run: | + npm pack --json > npm-pack.json + PACKAGE_FILE=$(node -e "const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); console.log(data[0].filename)") + echo "package_file=${PACKAGE_FILE}" >> "$GITHUB_OUTPUT" + + - name: Upload release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ecc-release-artifacts + path: | + release_body.md + ${{ steps.pack.outputs.package_file }} + if-no-files-found: error + + publish: + name: Publish Release + runs-on: ubuntu-latest + needs: verify + permissions: + contents: write + id-token: write + + steps: + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ecc-release-artifacts + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - name: Create GitHub Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: @@ -106,7 +145,7 @@ jobs: make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} - name: Publish npm package - if: steps.npm_publish_state.outputs.already_published != 'true' + if: needs.verify.outputs.already_published != 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public --provenance --tag "${{ steps.npm_publish_state.outputs.dist_tag }}" + run: npm publish "${{ needs.verify.outputs.package_file }}" --access public --provenance --tag "${{ needs.verify.outputs.dist_tag }}" diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 62839e74..365a1260 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -28,13 +28,16 @@ on: default: true permissions: - contents: write - id-token: write + contents: read jobs: - release: - name: Create Release + verify: + name: Verify Release runs-on: ubuntu-latest + outputs: + already_published: ${{ steps.npm_publish_state.outputs.already_published }} + dist_tag: ${{ steps.npm_publish_state.outputs.dist_tag }} + package_file: ${{ steps.pack.outputs.package_file }} steps: - name: Checkout @@ -114,6 +117,42 @@ jobs: - Claude marketplace/plugin identifier: \`everything-claude-code@everything-claude-code\` EOF + - name: Pack npm artifact + id: pack + run: | + npm pack --json > npm-pack.json + PACKAGE_FILE=$(node -e "const fs = require('fs'); const data = JSON.parse(fs.readFileSync('npm-pack.json', 'utf8')); console.log(data[0].filename)") + echo "package_file=${PACKAGE_FILE}" >> "$GITHUB_OUTPUT" + + - name: Upload release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ecc-release-artifacts + path: | + release_body.md + ${{ steps.pack.outputs.package_file }} + if-no-files-found: error + + publish: + name: Publish Release + runs-on: ubuntu-latest + needs: verify + permissions: + contents: write + id-token: write + + steps: + - name: Download release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ecc-release-artifacts + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + - name: Create GitHub Release uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: @@ -124,7 +163,7 @@ jobs: make_latest: ${{ contains(inputs.tag, '-') && 'false' || 'true' }} - name: Publish npm package - if: steps.npm_publish_state.outputs.already_published != 'true' + if: needs.verify.outputs.already_published != 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public --provenance --tag "${{ steps.npm_publish_state.outputs.dist_tag }}" + run: npm publish "${{ needs.verify.outputs.package_file }}" --access public --provenance --tag "${{ needs.verify.outputs.dist_tag }}" diff --git a/scripts/ci/validate-workflow-security.js b/scripts/ci/validate-workflow-security.js index ec7f82ef..5afec1ba 100644 --- a/scripts/ci/validate-workflow-security.js +++ b/scripts/ci/validate-workflow-security.js @@ -45,6 +45,7 @@ const NPM_AUDIT_PATTERN = /\bnpm\s+audit\b(?!\s+signatures\b)/; const NPM_AUDIT_SIGNATURES_PATTERN = /\bnpm\s+audit\s+signatures\b/; const ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m; const ID_TOKEN_WRITE_PATTERN = /^\s*id-token:\s*write\b/m; +const TOP_LEVEL_JOBS_PATTERN = /^jobs:\s*$/m; const UNSAFE_INSTALL_PATTERNS = [ { pattern: /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g, @@ -121,6 +122,8 @@ function extractCheckoutSteps(source) { function findViolations(filePath, source) { const violations = []; const checkoutSteps = extractCheckoutSteps(source); + const jobsIndex = source.search(TOP_LEVEL_JOBS_PATTERN); + const workflowHeader = jobsIndex >= 0 ? source.slice(0, jobsIndex) : source; for (const rule of RULES) { if (!rule.eventPattern.test(source)) { @@ -175,6 +178,16 @@ function findViolations(filePath, source) { } + if (ID_TOKEN_WRITE_PATTERN.test(workflowHeader)) { + violations.push({ + filePath, + event: 'workflow-scoped id-token', + description: 'id-token: write must be scoped to a publish-only job, not the entire workflow', + expression: 'top-level id-token: write', + line: getLineNumber(source, source.search(ID_TOKEN_WRITE_PATTERN)), + }); + } + for (const installRule of UNSAFE_INSTALL_PATTERNS) { for (const match of source.matchAll(installRule.pattern)) { violations.push({ diff --git a/tests/ci/validate-workflow-security.test.js b/tests/ci/validate-workflow-security.test.js index 89cf88a9..d7991ed4 100644 --- a/tests/ci/validate-workflow-security.test.js +++ b/tests/ci/validate-workflow-security.test.js @@ -244,12 +244,27 @@ function run() { if (test('rejects actions/cache in workflows with id-token write', () => { const result = runValidator({ - 'unsafe-oidc-cache.yml': `name: Unsafe\non:\n push:\npermissions:\n contents: read\n id-token: write\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/cache@v5\n with:\n path: ~/.npm\n key: cache\n`, + 'unsafe-oidc-cache.yml': `name: Unsafe\non:\n push:\npermissions:\n contents: read\njobs:\n release:\n runs-on: ubuntu-latest\n permissions:\n contents: read\n id-token: write\n steps:\n - uses: actions/cache@v5\n with:\n path: ~/.npm\n key: cache\n`, }); assert.notStrictEqual(result.status, 0, 'Expected validator to fail on id-token workflow cache use'); assert.match(result.stderr, /id-token: write must not restore or save shared dependency caches/); })) passed++; else failed++; + if (test('rejects workflow-scoped id-token write', () => { + const result = runValidator({ + 'unsafe-workflow-oidc.yml': `name: Unsafe\non:\n push:\npermissions:\n contents: read\n id-token: write\njobs:\n verify:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci --ignore-scripts\n`, + }); + assert.notStrictEqual(result.status, 0, 'Expected validator to fail on workflow-level id-token write'); + assert.match(result.stderr, /id-token: write must be scoped to a publish-only job/); + })) passed++; else failed++; + + if (test('allows job-scoped id-token for publish-only jobs', () => { + const result = runValidator({ + 'safe-publish-oidc.yml': `name: Safe\non:\n push:\npermissions:\n contents: read\njobs:\n publish:\n runs-on: ubuntu-latest\n permissions:\n contents: write\n id-token: write\n steps:\n - run: npm publish package.tgz --access public --provenance\n`, + }); + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + })) passed++; else failed++; + if (test('rejects npm audit without registry signature verification', () => { const result = runValidator({ 'unsafe-audit.yml': `name: Unsafe\non:\n push:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm audit --audit-level=high\n`, diff --git a/tests/scripts/release-publish.test.js b/tests/scripts/release-publish.test.js index 6365161d..f3318fb7 100644 --- a/tests/scripts/release-publish.test.js +++ b/tests/scripts/release-publish.test.js @@ -32,9 +32,11 @@ for (const workflow of [ '.github/workflows/reusable-release.yml', ]) { const content = load(workflow); + const workflowHeader = content.slice(0, content.indexOf('\njobs:\n')); - test(`${workflow} grants id-token for npm provenance`, () => { - assert.match(content, /permissions:\s*[\s\S]*id-token:\s*write/m); + test(`${workflow} scopes id-token to the publish job for npm provenance`, () => { + assert.doesNotMatch(workflowHeader, /id-token:\s*write/); + assert.match(content, /\n\s+permissions:\n\s+contents:\s*write\n\s+id-token:\s*write/m); }); test(`${workflow} configures the npm registry`, () => { @@ -51,7 +53,7 @@ for (const workflow of [ }); test(`${workflow} publishes new tag versions to npm`, () => { - assert.match(content, /npm publish --access public --provenance/); + assert.match(content, /npm publish "\$\{\{ needs\.verify\.outputs\.package_file \}\}" --access public --provenance/); assert.match(content, /NODE_AUTH_TOKEN:\s*\$\{\{\s*secrets\.NPM_TOKEN\s*\}\}/); });