open atlas
↑ Back to track
CI/CD pipelines CICD · 04 · 02

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.

CICD Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior

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.

DimensionLong-lived PAT / npm token as a secretGITHUB_TOKEN + permissions: / OIDC
LifetimePermanent until manually rotatedMinted per run, expires at job end
ScopeOften broad — publish any packageExactly the declared permissions:
Blast radius if leakedFull, until noticed and revokedOne run; OIDC bound to your workflow
ProvenanceNone — anyone with the token can publishSigned attestation ties tarball to commit
Quiz

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?

Quiz

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?

Pick the best fit

Your CI publishes a package to a public registry on release. Pick how the publish job authenticates.

Recall before you leave
  1. 01
    Why does `on: push: tags: ['v*.*.*']` make publishing safe where `on: push` is dangerous, and what one tag-trigger gotcha can silently skip the job?
  2. 02
    Compare a long-lived npm token to the built-in GITHUB_TOKEN with a permissions: block plus OIDC provenance, and explain what `--provenance` actually proves.
Recap

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.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.