Skip to content

ci: harden workflows, add npm provenance publishing and supply-chain checks - #31

Merged
ericmmartin merged 3 commits into
mainfrom
chore/ci-hardening-provenance
Jul 30, 2026
Merged

ci: harden workflows, add npm provenance publishing and supply-chain checks#31
ericmmartin merged 3 commits into
mainfrom
chore/ci-hardening-provenance

Conversation

@ericmmartin

Copy link
Copy Markdown
Owner

Why

PR 2 of 2 following the Snyk package health report. Where #30 covered the community files Snyk scores, this one covers the things Snyk doesn't score yet but that actually matter for a package doing ~25k downloads/week.

Independent of #30 — both branch from main.

ci.yml hardening

The existing workflow was functional but permissive:

  • No permissions: block → it inherited the repository default token scope. Now explicitly contents: read.
  • Floating action tags (actions/checkout@v4) → a retargeted or compromised tag silently changes what executes in CI. Now pinned by commit SHA with a version comment. Dependabot keeps them current (see below).
  • persist-credentials: false added, so the checkout token isn't left in the git config for subsequent steps.
  • on: push was unfiltered → every push to every branch ran the full three-node matrix, duplicating the pull_request run. Now scoped to main, with concurrency + cancel-in-progress.
  • fail-fast: false so one Node version failing doesn't hide the others.

release.yml (new) — the main event

Publishing is manual today via prepublishOnly. This replaces it with a workflow triggered by a published GitHub Release (plus a manual dispatch that defaults to dry-run).

The important part is npm publish --provenance under id-token: write. Every published tarball gets a signed attestation binding it to the exact commit and workflow run that built it, verifiable by anyone with npm audit signatures, and surfaced publicly on the npm page. For a package with this download count, it's the single highest-value supply-chain change available.

It also re-runs the full lint/typecheck/test/build gate immediately before publishing rather than trusting that CI passed on the commit — an npm publish is immutable, so it's worth the extra two minutes.

Two guards, both verified locally against the real registry:

Guard Behavior
Tag vs package.json v2.0.2 against 2.0.1 → blocks. v2.0.1 against 2.0.1 → proceeds.
Already published 2.0.1 → detected, blocks. 2.0.2 → not found, proceeds.

scorecard.yml + dependabot.yml (new)

  • OpenSSF Scorecard weekly, uploaded to code scanning, results published to back the README badge. It independently grades most of what's in this PR.
  • Dependabot weekly for npm and github-actions. Dev minor/patch bumps group into one PR; majors stay separate so an ESLint or Vitest upgrade gets reviewed on its own. It also maintains the SHA pins above, so pinning doesn't mean going stale.
  • npm audit --audit-level=high as a CI job. Nothing watched dependencies between your manual bumps before this. Currently clean: 0 vulnerabilities.

Before this can publish — required setup

The release workflow will fail without these:

  1. NPM_TOKEN secret — an npm automation token (granular or classic automation; a regular publish token won't work with 2FA). Settings → Secrets and variables → Actions.
  2. npm trusted publishing / provenance — the package must allow provenance. Nothing to do if you publish under your own account, but worth confirming on the first run.
  3. The workflow references an npm deployment environment; GitHub creates it on first use. Add a required reviewer to it if you want a manual approval gate before publish.

Suggested first use: Actions → Release → Run workflow with dry-run left checked. It runs everything including npm pack --dry-run and stops short of publishing.

Testing

npm run lint       # pass
npm run typecheck  # pass
npm run build      # pass
npm test           # 122 passed | 1 skipped
npm audit --audit-level=high   # 0 vulnerabilities

All four YAML files validated with js-yaml; Prettier clean. Release guard logic exercised locally as tabulated above. Note the shell steps use the runner-provided $GITHUB_REF_NAME rather than ${{ }} interpolation, so there's no workflow-injection surface.

…checks

Hardens the CI pipeline and adds automated release publishing with
provenance attestation.

ci.yml:
- Add an explicit 'permissions: contents: read' block. The workflow
  previously inherited the repository default token scope.
- Pin third-party actions by commit SHA with a version comment, so a
  retargeted or compromised tag cannot change what executes.
- Set persist-credentials: false, so the checkout token is not left in
  the git config for later steps to pick up.
- Scope the push trigger to main. It was previously unfiltered and ran
  the full three-node matrix on every branch push, duplicating the
  pull_request run.
- Add concurrency cancel-in-progress and fail-fast: false.
- Add an audit job running 'npm audit --audit-level=high'. Nothing
  watched dependencies between manual bumps before this. Currently
  reports 0 vulnerabilities.

release.yml (new):
- Publishes on GitHub Release, with a manual dispatch that defaults to
  dry-run.
- Publishes with --provenance under 'id-token: write', linking each
  tarball to the commit and workflow run that produced it. Consumers can
  verify with 'npm audit signatures'.
- Re-runs lint, typecheck, test and build before publishing rather than
  trusting the commit's earlier CI result.
- Guards against a tag that disagrees with package.json, and against
  republishing an existing version. Both guards were verified locally.

scorecard.yml (new):
- Weekly OpenSSF Scorecard analysis uploaded to code scanning, with
  published results backing the README badge.

dependabot.yml (new):
- Weekly npm and github-actions updates. Dev minor/patch bumps are
  grouped into one PR; majors stay separate for individual review.

README: add CI and Scorecard badges, and a Supply Chain section
documenting provenance verification.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

CI hardening: pinned actions, npm provenance releases, Scorecard checks

✨ Enhancement ⚙️ Configuration changes 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Harden CI with least-privilege token permissions, SHA-pinned actions, and concurrency controls.
• Add release workflow to publish to npm with provenance and pre-publish safety guards.
• Introduce Dependabot and OpenSSF Scorecard automation; document supply-chain verification steps.
Diagram

graph TD
  A["GitHub repo"] --> B["CI workflow"] --> C["Dependency audit"]
  A --> D["Release workflow"] --> E[("npm registry")]
  A --> F["Scorecard workflow"] --> G["OpenSSF API"]
  F --> H["Code scanning (SARIF)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use a dedicated publish action (e.g., npm/action or a community publish action)
  • ➕ Less custom bash logic for version checks and dry-run handling
  • ➕ Potentially clearer intent and fewer moving parts
  • ➖ More third-party surface area in the release path
  • ➖ May still require custom guards (tag/version, already-published) depending on action features
2. Adopt semantic-release (fully automated versioning + publishing)
  • ➕ Eliminates manual GitHub Release step; consistent versioning from commit history
  • ➕ Can automatically generate changelogs and tags
  • ➖ Bigger process change; higher learning/maintenance cost
  • ➖ Harder to keep “manual but safe” release control that this PR preserves
3. Split CI hardening and release publishing into separate workflows/reusable workflows
  • ➕ Clear separation between build/test and security checks; easier reuse across repos
  • ➕ Can reduce duplication of setup steps across jobs
  • ➖ More files/indirection for a single-package repo
  • ➖ Current duplication is small and may be acceptable for simplicity

Recommendation: The PR’s approach is strong for supply-chain hardening while keeping releases intentionally controlled: SHA-pinned actions, least-privilege tokens, and npm publish --provenance with explicit guards are high-value and straightforward to audit. If this grows further, consider reusable workflows to reduce duplication, but keep the explicit in-workflow guards since they directly protect against immutable bad publishes.

Files changed (5) +243 / -2

Documentation (1) +13 / -0
README.mdDocument supply-chain provenance and add CI/Scorecard badges +13/-0

Document supply-chain provenance and add CI/Scorecard badges

• Adds CI and OpenSSF Scorecard badges to the README. Documents npm provenance-based publishing and how consumers can verify dependency signatures via 'npm audit signatures'.

README.md

Other (4) +230 / -2
dependabot.ymlAdd weekly Dependabot updates for npm and GitHub Actions +42/-0

Add weekly Dependabot updates for npm and GitHub Actions

• Introduces Dependabot configuration for weekly npm and GitHub Actions updates. Groups devDependency minor/patch bumps while leaving majors ungrouped for focused review, and labels dependency PRs consistently.

.github/dependabot.yml

ci.ymlHarden CI with least-privilege permissions, pinned actions, and npm audit job +45/-2

Harden CI with least-privilege permissions, pinned actions, and npm audit job

• Scopes push triggers to main, adds explicit 'permissions: contents: read', and enables concurrency cancellation with non-fail-fast matrix runs. Pins checkout/setup-node by SHA, disables persisted credentials, and adds a separate 'npm audit --audit-level=high' job.

.github/workflows/ci.yml

release.ymlAdd npm publish workflow with provenance, dry-run, and version guards +88/-0

Add npm publish workflow with provenance, dry-run, and version guards

• Creates a release workflow triggered by GitHub Release publish or manual dispatch (defaulting to dry-run). Re-runs quality gates before publishing, verifies tag-to-package.json version match, blocks already-published versions, and publishes with '--provenance' using 'id-token: write'.

.github/workflows/release.yml

scorecard.ymlAdd OpenSSF Scorecard workflow with SARIF upload and published results +55/-0

Add OpenSSF Scorecard workflow with SARIF upload and published results

• Adds a scheduled/on-push Scorecard workflow with pinned actions and scoped permissions. Publishes results to the OpenSSF API for the public badge and uploads SARIF to GitHub code scanning.

.github/workflows/scorecard.yml

@qodo-code-review

qodo-code-review Bot commented Jul 30, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Broken dry-run condition ✓ Resolved 🐞 Bug ≡ Correctness
Description
release.yml defines a workflow_dispatch input named "dry-run" but later references it as
"inputs.dry-run", which is not valid property access for hyphenated names in GitHub Actions
expressions. This can cause the Publish/Dry run conditions to mis-evaluate or fail parsing,
potentially publishing when a manual run was intended to be a dry run (or blocking release
publishing).
Code

.github/workflows/release.yml[R80-88]

+      - name: Publish
+        if: github.event_name == 'release' || inputs.dry-run == false
+        run: npm publish --provenance --access public
+        env:
+          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+      - name: Dry run (no publish)
+        if: github.event_name == 'workflow_dispatch' && inputs.dry-run
+        run: echo "Dry run complete. Re-run with dry-run unchecked to publish."
Evidence
The workflow defines the input key as dry-run but later uses inputs.dry-run in if:
expressions, which does not correctly reference that input name.

.github/workflows/release.yml[8-13]
.github/workflows/release.yml[80-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The workflow_dispatch input is named `dry-run` but is referenced using dot notation (`inputs.dry-run`). Hyphenated input names must be accessed with bracket syntax (or renamed).

### Issue Context
This affects the publish guard and the dry-run step guard in `.github/workflows/release.yml`.

### Fix Focus Areas
- .github/workflows/release.yml[8-13]
- .github/workflows/release.yml[80-88]

### Suggested change
Use bracket notation:
- `if: github.event_name == 'release' || inputs['dry-run'] == false`
- `if: github.event_name == 'workflow_dispatch' && inputs['dry-run']`

OR rename the input to `dry_run` and reference `inputs.dry_run`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Cache permission missing 🐞 Bug ☼ Reliability
Description
ci.yml restricts GITHUB_TOKEN to only contents: read while actions/setup-node is configured with
cache: 'npm', which uses the GitHub Actions cache service. With actions permission implicitly
set to none under an explicit permissions block, caching will typically be unable to restore/save
(slower CI and possible cache warnings); the same pattern exists in release.yml.
Code

.github/workflows/ci.yml[R9-12]

+# The default GITHUB_TOKEN is granted no more than read access to the repo.
+permissions:
+  contents: read
+
Evidence
CI and Release explicitly set limited token permissions, while setup-node enables npm caching in
both workflows.

.github/workflows/ci.yml[9-12]
.github/workflows/ci.yml[36-41]
.github/workflows/release.yml[26-29]
.github/workflows/release.yml[40-45]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Workflows explicitly set token permissions to `contents: read` but still enable Node caching (`cache: 'npm'`). When permissions are explicitly set, unspecified scopes default to `none`, which can prevent cache restore/save.

### Issue Context
Affects CI and Release workflows.

### Fix Focus Areas
- .github/workflows/ci.yml[9-12]
- .github/workflows/ci.yml[36-41]
- .github/workflows/release.yml[26-29]
- .github/workflows/release.yml[40-45]

### Suggested change (choose one)
**Option A (keep caching):** add the minimal required permission where caching is used, e.g.
```yml
permissions:
 contents: read
 actions: write
```
(Apply to CI workflow and to the Release job’s permissions block.)

**Option B (keep token minimal):** remove `cache: 'npm'` from setup-node steps so the workflow doesn’t attempt cache operations.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/ci.yml
Actions expressions parse `inputs.dry-run` as `inputs.dry - run`, so the
hyphenated input name could never be referenced. The publish guard would
mis-evaluate (or fail to parse), risking a publish on a run the operator
intended as a dry run. Rename the input to `dry_run` and make the publish
condition explicit about the workflow_dispatch case.
@ericmmartin ericmmartin added the qodo-triaged Qodo review feedback triaged label Jul 30, 2026
npm revoked classic automation tokens in December 2025, and granular tokens
now cap at a 90-day lifetime. Switch to OIDC trusted publishing instead: the
npm CLI detects the Actions OIDC environment and exchanges it for short-lived
publish credentials, so there is no NPM_TOKEN secret and nothing to rotate.

Drops the NODE_AUTH_TOKEN env block and --access public (already covered by
publishConfig.access). Keeps --provenance so the attestation stays explicit.
@ericmmartin
ericmmartin merged commit 40cfe3e into main Jul 30, 2026
4 checks passed
@ericmmartin
ericmmartin deleted the chore/ci-hardening-provenance branch July 30, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

qodo-triaged Qodo review feedback triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant