Tagging and publishing releases from CI
A pushed tag is the trigger that turns a commit into a published release. Gate the workflow on a v*.*.* tag, scope the token least-privilege with a permissions block, and publish with provenance — so a leaked PAT or a publish-on-every-push never ships.
The incident channel lights up at 4pm: acme-sdk@2.4.0-rc.1 is live on npm — a release candidate, never QA’d, now installed by anyone who ran npm update. Nobody clicked publish. The pipeline did. Someone had written on: push at the top of the release workflow, so every commit to every branch ran npm publish, and a version bump on a feature branch shipped straight to the public registry. The fix is one line of YAML — but the team had also stored a long-lived npm automation token as a plain secret, and that token, scoped to publish any of their packages, had just been exercised by code nobody reviewed.
A tag is a named, immutable pointer at one commit
Before reaching for npm publish in your workflow, ask: what decides when a release happens? The answer should be a tag — a deliberate, reviewed act — not “every push”. Here’s why the tag is load-bearing.
A Git tag is a label that pins a human-readable name to one specific commit. There are two kinds, and the difference matters for releases. A lightweight tag is just a pointer — the tag name and the commit hash, nothing else. An annotated tag is a full object in the Git database: it carries the tagger’s name and email, a timestamp, a message, and — critically — it can be GPG/SSH signed. For a release you almost always want annotated: git tag -a v1.2.0 -m "Release 1.2.0". It records who cut the release and when, it survives git describe, and it is the only kind git push --follow-tags propagates. Lightweight tags are for scratch bookmarks you do not intend to publish.
The reason tags anchor releases is that they are meant to be immutable: v1.2.0 should point at exactly the bytes you tested, forever. The moment you let a tag float — re-pointing it at a newer commit, or tagging a branch tip that keeps moving — you have broken the promise that a version is a fixed artifact. A GitHub Release then hangs notes and downloadable assets off that tag, giving consumers a human-facing changelog and a stable place to find binaries. Tag a specific, reviewed, green commit; never a mutable ref.
The tag is the trigger: on: push: tags
Here is the mechanism that ties tagging to publishing. GitHub Actions lets a workflow subscribe to tag pushes. When you push a tag matching the filter, the workflow fires — and only then. This is what separates “every commit builds and tests” (your normal CI) from “a release ships” (this workflow). The glob v*.*.* matches v1.2.0, v2.0.1, and so on; setting branches: [] (or simply omitting branches) keeps ordinary commits from triggering it.
name: publish
on:
push:
tags:
- 'v*.*.*' # fires only on a pushed version tag, e.g. v1.2.0
permissions: # least privilege: this job gets nothing it doesn't use
contents: read # read the repo to build
id-token: write # mint an OIDC token for provenance
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}The contrast with the hook is the whole lesson. on: push (no filter) runs on every branch push — that is how a release-candidate escaped. on: push: tags: ['v*.*.*'] runs only when a maintainer deliberately pushes a version tag, which is a reviewed, intentional act. The tag is the human decision; the workflow is the automation that trusts that decision.
▸lesson.inset.warning
A subtle trap: a tag created by a workflow using the built-in GITHUB_TOKEN will not trigger another workflow’s push event — GitHub suppresses this to prevent infinite CI loops. So if a tool like release-please or semantic-release cuts your tag inside CI, your on: push: tags publish job may silently never run. The standard fixes are to trigger on on: release: [published] instead, or to push the tag with a PAT / GitHub App token rather than the default GITHUB_TOKEN.
Auth: least-privilege token, not a long-lived PAT
The hook’s second failure was the credential. A release workflow needs something to authenticate to the registry, and the choice ranges from dangerous to disciplined.
The built-in GITHUB_TOKEN is the default to reach for: GitHub mints it fresh per run, it expires when the job ends, and you scope it with a permissions: block. The rule is least privilege — grant only what the job uses. For publishing a container image to GitHub Container Registry (GHCR) that is packages: write; for npm provenance it is id-token: write. Crucially, GitHub Actions defaults a token to read-only contents when you declare any permissions: block, so writing the block both grants the one scope you need and revokes everything else. Omit the block and the token may inherit broad write scope across the whole repo — the “forgot permissions:” mistake that turns a build token into a liability.
For pushing to a third-party registry (npm, Docker Hub, a cloud provider), prefer OIDC: the job mints a short-lived signed token, the registry’s trust policy verifies it came from your exact workflow, and you exchange it for ephemeral credentials. Nothing long-lived is stored. The worst option — the hook’s actual bug — is a long-lived PAT or npm automation token pasted as a secret: it does not expire, it must be rotated, and a single leaked token scoped to “publish any package” is a supply-chain breach waiting to happen.
# Publishing a container image to GHCR with the built-in token
permissions:
contents: read
packages: write # only scope GHCR push needs
jobs:
publish-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # ephemeral, per-run
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}Provenance: tie the package to the build that made it
When you see a package installed from npm, you’re trusting that the bytes in the tarball came from the repo it claims. Without provenance, a stolen token is all it takes to publish a poisoned build that looks legitimate. Here’s how you close that gap.
The capstone is provenance. When you run npm publish --provenance from a cloud-hosted runner with id-token: write, npm uses the OIDC token to generate a signed attestation — via Sigstore — that links the published tarball to the exact source repository, the commit, and the build instructions that produced it. The requirements are precise and worth memorising: npm CLI 9.5.0+, the id-token: write permission, and a supported cloud CI runner (e.g. ubuntu-latest on GitHub Actions). The attestation is public and verifiable, so a consumer can confirm where and how a package was built before installing it — closing the gap that lets an attacker with a stolen token publish a poisoned build that looks legitimate.
| Dimension | Long-lived PAT / npm token as a secret | GITHUB_TOKEN + permissions: / OIDC |
|---|---|---|
| Lifetime | Permanent until manually rotated | Minted per run, expires at job end |
| Scope | Often broad — publish any package | Exactly the declared permissions: |
| Blast radius if leaked | Full, until noticed and revoked | One run; OIDC bound to your workflow |
| Provenance | None — anyone with the token can publish | Signed attestation ties tarball to commit |
A release-candidate ended up published to npm from a feature-branch commit nobody reviewed. The workflow starts with `on: push`. What is the root cause?
Your tag-triggered npm job runs `npm publish --provenance` but fails: 'provenance generation requires id-token: write'. The workflow has no `permissions:` block. What is happening and the fix?
Your CI publishes a package to a public registry on release. Pick how the publish job authenticates.
- 01Why does `on: push: tags: ['v*.*.*']` make publishing safe where `on: push` is dangerous, and what one tag-trigger gotcha can silently skip the job?
- 02Compare a long-lived npm token to the built-in GITHUB_TOKEN with a permissions: block plus OIDC provenance, and explain what `--provenance` actually proves.
A Git tag pins a human-readable name to one commit; for releases use an annotated, ideally signed tag (git tag -a v1.2.0 -m ...) because it records who cut the release and when, and treat it as immutable — tag a specific reviewed, green commit, never a moving branch tip, and let a GitHub Release hang notes and assets off it. The tag is the trigger: gating the release workflow on on: push: tags: ['v*.*.*'] means it fires only when a maintainer deliberately pushes a version tag, which is the fix for the classic failure of a bare on: push shipping a release candidate from an unreviewed branch. Watch the gotcha that a tag created inside CI with the default GITHUB_TOKEN will not trigger another workflow’s push event, so reach for on: release: [published] or a PAT/App token when a release tool cuts the tag. For auth, prefer the ephemeral built-in GITHUB_TOKEN narrowed by a least-privilege permissions: block — packages: write for GHCR, id-token: write for provenance — over a long-lived PAT or npm token whose single leak is a supply-chain breach; for third-party registries use OIDC for short-lived credentials. Finally, npm publish --provenance from a cloud runner with id-token: write (npm 9.5.0+) signs a Sigstore attestation that ties the package to the exact commit and build, so consumers can verify where and how it was built. Now when you see a release workflow using on: push with no tag filter, you’ll know exactly why a release candidate can escape to the public registry before anyone reviews it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.