DEV Community

Cover image for I just hardened my OSS release pipeline to 11 layers of security — here's the playbook
אחיה כהן
אחיה כהן

Posted on

I just hardened my OSS release pipeline to 11 layers of security — here's the playbook

I just hardened my OSS release pipeline to 11 layers of security — here's the playbook

This weekend I shipped safari-mcp v2.7.9, a minor release on the surface but a complete overhaul of how the project gets published. Along the way I went from "NPM_TOKEN in a workflow secret" to 11 layers of supply-chain defense, all driven by one realistic question: if my GitHub account gets phished tomorrow, how much damage can an attacker do before somebody notices?

If you're a solo maintainer of a JavaScript package, the playbook below is for you. Every step is something I actually did today, with links to the commits, and most of them took under 10 minutes each.

The starting point

  • Package: safari-mcp on npm (~2000 monthly downloads, 27 stars, MIT)
  • Release flow: GitHub Actions workflow triggered by release, authenticating with a long-lived NPM_TOKEN stored as a repo secret
  • Problem: That token could publish anything on my npm account until it expired. If my GitHub got compromised, step one for an attacker would be grabbing the token and uploading a malicious safari-mcp@2.7.10 within minutes.

The goal: make that attack path as hard as possible without turning my release flow into a 20-minute bureaucracy.

Layer 1: npm OIDC Trusted Publisher (no more long-lived token)

npm now supports Trusted Publishers via OIDC. Instead of storing a token, you configure npm to accept short-lived OIDC tokens issued by GitHub Actions — tokens that are cryptographically bound to a specific repository + workflow + environment and expire within minutes.

Setting this up on npm takes three fields (org, repo, workflow filename) and one click. After that, the workflow no longer needs NPM_TOKEN; it just needs:

permissions:
  contents: read
  id-token: write  # required for OIDC

steps:
  - run: npm publish --provenance --access public
Enter fullscreen mode Exit fullscreen mode

That --provenance flag adds a SLSA build attestation to the published package, so anyone downloading it can cryptographically verify that safari-mcp@2.7.9 was built by the specific GitHub Actions run I claim it was.

Upgrade path: After configuring Trusted Publisher, delete the old NPM_TOKEN secret. Otherwise it's still a liability.

Layer 2: manual-approval environment gate

OIDC stops token theft. It doesn't stop a compromised workflow file from publishing malware. So I added a GitHub deployment environment called npm-publish with required reviewers (just me) and can_admins_bypass: false.

Now every release pauses in GitHub Actions until I explicitly click "Approve" in the UI. I see the exact SHA, the commit message, and the tag before I let the publish proceed. Even if an attacker has the ability to push to main and has my repo admin privileges, they still can't skip the approval.

Important detail: by default, deployment environments allow admins to bypass. I set it to false because the entire point was to defend against my own compromised credentials. If I get locked out I can always re-enable it from the Settings page, which itself requires passkey reauth.

Layer 3: custom branch policy (tags + main only)

First time I tried to release, the publish workflow failed with:

Tag "v2.7.9" is not allowed to deploy to npm-publish due to environment protection rules.

The default deployment_branch_policy is "protected branches only" — tags don't count as branches. The fix was switching to custom_branch_policies: true and adding two rules:

branch: main
tag: v*
Enter fullscreen mode Exit fullscreen mode

Now both push-to-main deployments and version tag deployments work, but only those. Feature branches can't deploy.

Layer 4: SHA pinning required for all actions

The single biggest OSS supply-chain incident of 2025 was tj-actions/changed-files — thousands of secrets leaked because workflows used @v1 tags that the attacker retagged. The fix is simple but painful: pin every action to a full commit SHA instead of a version tag.

GitHub recently added a repo-level setting to require this:

gh api --method PUT /repos/OWNER/REPO/actions/permissions \
  --input - <<'EOF'
{"enabled": true, "allowed_actions": "all", "sha_pinning_required": true}
EOF
Enter fullscreen mode Exit fullscreen mode

After I enabled this, every CI run failed because my workflows used actions/checkout@v4. I updated them to the equivalent SHA:

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.0
Enter fullscreen mode Exit fullscreen mode

The comment is important — it's how Dependabot auto-updates the SHA while keeping the version readable.

Layer 5: branch protection on main (signatures required, no force push)

Branch protection for solo maintainers is usually reductive (it blocks your own pushes), but three rules are pure upside:

gh api --method PUT /repos/OWNER/REPO/branches/main/protection --input - <<'EOF'
{
  "required_status_checks": null,
  "enforce_admins": false,
  "required_pull_request_reviews": null,
  "restrictions": null,
  "allow_force_pushes": false,
  "allow_deletions": false,
  "required_conversation_resolution": true
}
EOF
Enter fullscreen mode Exit fullscreen mode
  • allow_force_pushes: false — stops history rewriting, essential with signed commits
  • allow_deletions: false — stops accidental git push --delete origin main
  • required_conversation_resolution: true — every PR comment must be resolved before merge

Then, separately:

gh api --method POST /repos/OWNER/REPO/branches/main/protection/required_signatures
Enter fullscreen mode Exit fullscreen mode

Every new commit on main must carry a verified signature. Commits from unsigned laptops get rejected at push time.

Layer 6: SSH commit signing without losing your mind

Setting up commit signing always feels like yak-shaving. Here's the short version for macOS:

# Generate a dedicated signing key (no passphrase, used ONLY for git sign)
ssh-keygen -t ed25519 -f ~/.ssh/git_signing_ed25519 -N "" -C "git-signing"

# Point git at it
git config --local gpg.format ssh
git config --local user.signingkey ~/.ssh/git_signing_ed25519.pub
git config --local commit.gpgsign true

# Upload to GitHub as a *signing* key (not auth!)
gh ssh-key add ~/.ssh/git_signing_ed25519.pub --type signing --title "git signing"
Enter fullscreen mode Exit fullscreen mode

Gotcha: if your primary ~/.ssh/id_ed25519 has a passphrase, git commit -S will hang forever trying to unlock it without prompting (in a non-interactive shell). The dedicated no-passphrase key avoids that, and since it's only usable for signing — not auth — the security trade-off is small.

Second gotcha: commits aren't shown as "Verified" on GitHub unless the committer email matches a verified email on your GitHub account. If yours doesn't, switch to the <user-id>+<username>@users.noreply.github.com format, which GitHub recognizes automatically.

Layer 7: Approval for outside-collaborator workflows

The default GitHub setting is "Require approval for first-time contributors". That means a malicious user who's had one merged PR 5 years ago can push any workflow to your repo without approval. Ramp it up:

In Settings → Actions → General → Fork pull request workflows from outside collaborators, pick "Require approval for all external contributors". Every outside PR now waits for me to click "Approve and run" before its workflow touches any of my secrets.

Layers 8–11: the boring-but-essential ones

  • CODEOWNERS — auto-request my review on any PR that touches .github/**, package.json, or native code
  • Dependabot for GitHub Actions — by default Dependabot only monitors npm / pip / etc. Add github-actions to .github/dependabot.yml so action pins get security updates too
  • npm WebAuthn 2FA — not new, but confirm your npm account uses a hardware-backed second factor, not just TOTP
  • package.json overrides — when a transitive dep has a security advisory that the parent hasn't fixed, force the patched version with overrides instead of waiting

What the attack surface looks like now

For an attacker to publish malicious safari-mcp@X.Y.Z to npm, they need to simultaneously:

  1. Compromise my GitHub account (phishing-resistant passkey)
  2. Bypass signed commits on main (signing key on a different laptop)
  3. Either compromise the code review process or commit directly (blocked by required_signatures)
  4. Bypass the npm-publish environment approval (can_admins_bypass: false — not even I can skip it)
  5. Either compromise my local keychain (for the approval click) or the npm OIDC signing key at GitHub Actions runtime

Pre-playbook, the attacker needed: (1) my GitHub token, or (2) the NPM_TOKEN secret. One step.

Post-playbook: five independent, cryptographically-bounded steps — four of which require a different device or a human decision.

The 30-minute version

If you're reading this and thinking "I'll do this later", here's the minimum viable version you can copy-paste right now (replace OWNER/REPO):

# 1. SHA pinning (10s)
gh api --method PUT /repos/OWNER/REPO/actions/permissions \
  --input - <<'EOF'
{"enabled": true, "allowed_actions": "all", "sha_pinning_required": true}
EOF

# 2. Branch protection (10s)
gh api --method PUT /repos/OWNER/REPO/branches/main/protection --input - <<'EOF'
{"required_status_checks":null,"enforce_admins":false,"required_pull_request_reviews":null,"restrictions":null,"allow_force_pushes":false,"allow_deletions":false,"required_conversation_resolution":true}
EOF

# 3. Add github-actions to Dependabot (edit .github/dependabot.yml)
# 4. Enable npm Trusted Publisher on npmjs.com for your package
# 5. Delete NPM_TOKEN secret
Enter fullscreen mode Exit fullscreen mode

Five steps. Zero downtime. You're 70% of the way there.


Plug

I do this on a project called Safari MCP — a macOS-only MCP server that lets AI agents drive your real Safari (with all your existing logins) instead of spawning Chrome. It's MIT, runs on npx safari-mcp, and has 80 tools for navigation, form fill, screenshots, and everything in between.

If you're tired of Chrome DevTools MCP eating your M-series battery, give it a spin. And if you have opinions about any of the security layers above — or know one I missed — hit me on GitHub Issues.


Sources and links:

Top comments (0)