By admin , 29 April 2026

What you will achieve

You will build a release pipeline that turns a Git tag into a deployed artefact - signed, tested, versioned, and published. The pattern works for npm packages, Docker images, mobile apps, and binary distributions. By the end, you will tag a commit and watch the release happen.

The contract

The pipeline's contract:

  • Push a tag matching v*.*.*.
  • CI builds, tests, signs, and publishes.
  • A GitHub release is created with auto-generated notes.
  • The deployment is logged and verifiable.

Step 1: tag conventions

Stick to Semantic Versioning. Annotated tags (not lightweight):

git tag -a v1.4.0 -m "Release 1.4.0"
git tag -s v1.4.0 -m "Release 1.4.0"      # signed (recommended)

Step 2: configure the workflow

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ['v*.*.*']

permissions:
  contents: write       # for creating releases
  packages: write       # for publishing
  id-token: write       # for OIDC if used

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          registry-url: 'https://registry.npmjs.org'
      - run: npm ci
      - run: npm run lint
      - run: npm test
      - run: npm run build
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Step 3: auto-generate release notes

      - name: Generate notes
    id: notes
    uses: orhun/git-cliff-action@v2
    with:
      config: cliff.toml
      args: --latest --strip header
  - name: Create release
    uses: softprops/action-gh-release@v2
    with:
      body: ${{ steps.notes.outputs.content }}
      generate_release_notes: true

Step 4: build matrix for multi-platform releases

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - run: cargo build --release
      - uses: actions/upload-artifact@v4
        with:
          name: bin-${{ matrix.os }}
          path: target/release/myapp*

For Rust, Go, or other compiled binaries, build per platform and attach to the release.

Step 5: signed artefacts

Sign artefacts with cosign for supply-chain attestation:

      - uses: sigstore/cosign-installer@v3
  - run: |
      cosign sign-blob --yes \
        --output-certificate cert.pem \
        --output-signature sig.bin \
        dist/myapp-1.4.0.tgz

Or use npm's built-in provenance (--provenance) which records the build environment in transparency log.

Step 6: Docker image release

      - uses: docker/setup-buildx-action@v3
  - uses: docker/login-action@v3
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}
  - uses: docker/build-push-action@v5
    with:
      push: true
      tags: |
        ghcr.io/${{ github.repository }}:${{ github.ref_name }}
        ghcr.io/${{ github.repository }}:latest

Step 7: deploy after publish

      - name: Deploy to production
    run: ./scripts/deploy.sh
    env:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

For SaaS products, this triggers your deployment system. For libraries, no deploy step - publication is the release.

Step 8: notification

      - name: Slack notify
    uses: slackapi/slack-github-action@v1
    with:
      payload: |
        {
          "text": "Released ${{ github.ref_name }} :rocket:"
        }
    env:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Step 9: pre-release channels

jobs:
  release:
    steps:
      - id: classify
        run: |
          if [[ "${{ github.ref_name }}" =~ -(alpha|beta|rc) ]]; then
            echo "tag=next" >> $GITHUB_OUTPUT
          else
            echo "tag=latest" >> $GITHUB_OUTPUT
          fi
      - run: npm publish --tag ${{ steps.classify.outputs.tag }}

Step 10: integrating with release-please

Combine automated version bumping with this pipeline:

# .github/workflows/release-please.yml triggers on push to main
# release-please opens a release PR; merging it creates the tag
# this workflow's tag trigger then handles publish

Step 11: tagging the SHA in artefacts

      - name: Get short SHA
    id: vars
    run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
  - run: docker build -t myapp:${{ github.ref_name }} -t myapp:${{ steps.vars.outputs.sha }} .

Now docker inspect tells you which Git SHA built any container.

Step 12: tag protection

Prevent accidental retags:

gh api -X POST repos/owner/repo/tags/protection \
  -F pattern='v*.*.*'

Now only authorised actors (CI bots, release engineers) can create matching tags.

Common pitfalls

  • Pipeline runs on every push - make sure tag pipeline is separate from PR pipeline.
  • Force-pushing tags after release - downstream consumers pin the SHA; do not retag a published version.
  • Secrets in CI logs - use echo "::add-mask::$SECRET" if you must echo, prefer not to.
  • Building from main on tag push instead of from the tagged SHA - actions/checkout@v4 defaults to the ref correctly.

The result

Tagging a commit triggers an automated, signed, tested release. Manual npm publish from a developer laptop becomes unnecessary - and forbidden by branch protection. The pipeline is auditable, reproducible, and reliable. Releases become routine.