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@v4defaults 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.