Tagging / SemVer
Git tags drive most modern release pipelines: push a vX.Y.Z tag, CI builds an artifact, signs it, publishes it, and updates the changelog. The shape is the same in GitHub Actions, GitLab CI, and CircleCI; only the trigger syntax differs. Get this right once and "ship" stops being a manual checklist.
Tag-triggered release pipeline with provenance
EXAMPLE
# .github/workflows/release.yml
name: release
on:
push:
tags: ['v*.*.*']
permissions:
contents: write # for creating a GitHub Release
packages: write # for GHCR or GH Packages
id-token: write # for OIDC to a cloud provider
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
sha: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # needed for changelogs
- id: meta
name: Parse tag
run: |
v="${GITHUB_REF_NAME#v}"
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "VERSION=$v" >> "$GITHUB_ENV"
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npm test
- run: npm run build
# 1) Build a Docker image, tag with both 'vX.Y.Z' and 'latest'
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:v${{ env.VERSION }}
ghcr.io/${{ github.repository }}:latest
publish-github-release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
# 2) Auto-generate release notes from PRs since the previous tag
- id: notes
run: |
prev="$(git tag --list 'v*.*.*' --sort=-version:refname | sed -n '2p')"
if [ -z "$prev" ]; then range="$GITHUB_SHA"; else range="${prev}..HEAD"; fi
{
echo 'notes<<EOF'
git log "$range" --no-merges --pretty='* %s (%h)'
echo 'EOF'
} >> "$GITHUB_OUTPUT"
# 3) Publish a GitHub Release with the notes
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: ${{ steps.notes.outputs.notes }}
draft: false
prerelease: ${{ contains(github.ref_name, '-rc') }}
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v4
with: { role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}, aws-region: ap-southeast-2 }
- name: Deploy to ECS
run: |
aws ecs update-service --cluster shop-prod --service api \
--task-definition shop-app:${{ github.ref_name }} --force-new-deployment
aws ecs wait services-stable --cluster shop-prod --services api
# ===== Conventions to make this work cleanly =====
# 1) Annotated tags only (git tag -a vX.Y.Z -m 'Release vX.Y.Z')
# 2) Semantic versioning — vX.Y.Z; -rc.N for release candidates
# 3) Tag from main only; pre-release branches use vX.Y.Z-rc.N
# 4) NEVER delete or move a published tag — issue a new patch instead
# 5) Combine with a changelog generator (release-please, changesets) if you want
# the PRs to drive the version bump automatically
Why it matters
Tag-triggered pipelines turn "release" from a checklist into a deterministic event: cut the tag, watch CI ship the binary, deploy the image, write the release notes. Keep the tag immutable, build provenance into the artifact (signed image + SBOM), and the audit trail comes for free.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
git tag v1.4.0 && git push --tags # Use SemVer: MAJOR.MINOR.PATCH. # Automate via conventional commits → release-please / semantic-release.Try it Yourself »
Exercise
Push a new git tag to the remote.
git push --
Four letters.
Discussion
Loading…