SemVer rules and when to break them, annotated and signed tags, GitHub Releases with auto-generated notes, release-please automation, conventional commits and changelogs, GitHub Packages across ecosystems, ghcr.io container registry, and package retention governance
Semantic Versioning: Rules, Pre-Releases & When to Break It
SemVer (semver.org) defines MAJOR.MINOR.PATCH with precise semantics. Consistent SemVer is what lets Dependabot (and your consumers) reason about update risk automatically.
X
MAJOR
Breaking change. Existing consumers must update their code to use this version. Incompatible API changes, removed endpoints, changed behaviour of existing features.
Y
MINOR
New functionality, backwards-compatible. Consumers can update without changing code. New endpoints, new optional parameters, new fields in responses.
Z
PATCH
Bug fixes, backwards-compatible. No new functionality. Security patches always go here (with a security advisory). Performance improvements.
Pre-release identifiers
# Pre-release versions (lower precedence than the release)
1.0.0-alpha ← early internal testing
1.0.0-alpha.1 ← second alpha
1.0.0-beta.3 ← third beta
1.0.0-rc.1 ← release candidate# Build metadata (ignored in precedence comparisons)
1.0.0+20260613 ← build date
1.0.0+sha.a3f8d12 ← commit SHA# Precedence: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0
When to deviate from SemVer
Situation
Common practice
Project in active early development (0.x.y)
Breaking changes allowed in minor bumps while on 0.x. Move to 1.0.0 only when the API is stable enough to commit to.
Internal tools with no external consumers
Date-based versioning (2026.06.13) is fine — SemVer compatibility guarantees only matter when others depend on your API.
Continuously deployed services
Omit version numbers entirely — use commit SHA or build number. SemVer is for artefacts that consumers pin, not services they call via a stable URL.
Marketing-driven version bumps
Avoid. Major version bumps should mean "breaking change", not "this is a big release". Separate product marketing from API versioning.
10.2
Git Tags: Annotated vs Lightweight & Signed Tags
📚 Free Weekly Tutorials
Java, Spring Boot, AWS, DevOps & AI — straight to your inbox.
Annotated vs lightweight
Lightweight tag
Annotated tag
Git object
Just a ref pointing to a commit — no tag object
A full tag object with tagger, date, message, and optional signature
Command
git tag v1.0.0
git tag -a v1.0.0 -m "Release v1.0.0"
Shown in git describe
Yes (unless --tags is omitted)
Yes (preferred — used by default)
Use for releases?
No — no metadata, no signing support
Yes — always use annotated tags for releases
# Create an annotated tag$ git tag -a v2.4.1 -m "Release v2.4.1 — fix null pointer in payment checkout"
$ git push origin v2.4.1
# Tag a specific past commit$ git tag -a v2.4.0 a3f8d12 -m "Release v2.4.0"
# List tags with their annotation messages$ git tag -n # shows first line of message$ git tag -n99 # shows up to 99 lines of message# Delete a tag (local + remote)$ git tag -d v2.4.1
$ git push origin --delete v2.4.1
Signed tags
Signed tags cryptographically prove that the tag was created by a specific person (GPG key) or identity (SSH key, Sigstore). GitHub renders a "Verified" badge on signed tags and commits.
# GPG-signed tag$ git tag -s v2.4.1 -m "Release v2.4.1"
# Requires: GPG key configured in git config user.signingkey# Upload public key to GitHub: Settings → SSH and GPG keys → New GPG key# SSH-signed tag (simpler — uses your existing SSH key)$ git config gpg.format ssh
$ git config user.signingKey "~/.ssh/id_ed25519.pub"
$ git tag -s v2.4.1 -m "Release v2.4.1"
# Verify a signed tag$ git tag -v v2.4.1
AUTOMATED RELEASE TAGGING
For CI-created tags (release-please, semantic-release), use a GitHub App's token rather than a PAT. The tag will be attributed to the app's identity, which is auditable and doesn't depend on any individual's account being active. Combine with SSH signing configured on the runner for verified automated tags.
10.3
GitHub Releases: Drafting, Pre-Release Flag & Auto-Generated Notes
A GitHub Release wraps a tag with a human-readable changelog, downloadable assets, and visibility controls. It's the right place to publish compiled binaries, Docker image references, SBOMs, and release notes for consumers.
Creating a release via CLI
# Create a release from an existing tag$ gh release create v2.4.1 \
--title "v2.4.1 — Payment checkout fix" \
--notes "Fixes a null pointer exception in the payment checkout flow. See #247." \
--verify-tag # ensures the tag exists and is signed# Draft release (not visible to the public yet)$ gh release create v2.5.0 --draft --title "v2.5.0"
# Pre-release flag (visible but marked as unstable)$ gh release create v3.0.0-rc.1 --prerelease --title "v3.0.0 Release Candidate 1"
# Create release and upload build artifacts as assets$ gh release create v2.4.1 \
dist/app-linux-amd64 \
dist/app-darwin-arm64 \
dist/app-windows-amd64.exe \
bom.json \
--title "v2.4.1"
Auto-generated release notes
GitHub can auto-generate release notes from merged PRs between the previous tag and the current one. The output groups PRs by label (features, bug fixes, etc.).
Manual releases are error-prone — wrong version, missing changelog entry, forgotten asset upload. Automating the release pipeline means every merge to main with a conventional commit message is a candidate for a release.
release-please (Google's approach)
release-please maintains a "release PR" that accumulates changelog entries as commits land on main. When you're ready to release, merge the PR — it creates the tag, the GitHub Release, and bumps the version in your manifest files.
semantic-release doesn't maintain a PR — it creates the release automatically on every push to main if conventional commits warrant a release. More hands-off, but requires rigorous commit discipline.
Automatic — every push that warrants a release creates one
Human in the loop?
Yes — you review and merge the release PR
No — fully automated
Best for
Libraries with deliberate release cadence; teams that want release approval gates
Continuously deployed services; high-frequency releases; mature teams with strict commit discipline
Version file updates
Yes — updates package.json, pom.xml, etc.
Yes — via plugins
10.5
Changelogs: Conventional Commits & git-cliff
Both release-please and semantic-release parse conventional commit messages to determine version bumps and generate changelogs. Enforcing the format in CI is what makes this reliable.
Conventional commit format
# Format: type(scope): description# ^^^^^optional^^
feat: add payment retry logic → MINOR bump
fix(checkout): handle null payment method → PATCH bump
docs: update API reference → no release
chore: bump node to 22 → no release
refactor(auth): extract token validator → no release
test: add unit tests for retry logic → no release
perf: cache auth tokens in Redis → PATCH bump# Breaking change — two ways to indicate:
feat!: redesign payment API → MAJOR bump (! suffix)
feat(payment): new checkout flow
BREAKING CHANGE: removed legacy /pay endpoint → MAJOR bump (footer)
Enforcing conventional commits with commitlint
# .github/workflows/commitlint.ymlon:pull_request:jobs:commitlint:runs-on: ubuntu-latest
steps:
- uses:actions/checkout@v4with:fetch-depth:0# need full history to compare commits
- uses:wagoid/commitlint-github-action@v6with:configFile:.commitlintrc.json
git-cliff is a highly configurable changelog generator that parses conventional commits and produces Markdown output. Unlike the built-in generators, it supports full template customisation.
# Generate a changelog for the next release$ git cliff --tag v2.5.0 --output CHANGELOG.md
# Generate only the unreleased section$ git cliff --unreleased --tag v2.5.0
# In Actions — auto-generate and include in the release body$ git cliff --tag ${{ github.ref_name }} --strip header --output body.md
$ gh release edit ${{ github.ref_name }} --notes-file body.md
10.6
GitHub Packages: One Registry Per Ecosystem
GitHub Packages is a multi-ecosystem package registry hosted under your org. Packages are linked to repos, versioned alongside code, and access-controlled via the same RBAC as repos.
Ecosystem
Registry URL
Config file
npm
npm.pkg.github.com
.npmrc
Maven
maven.pkg.github.com
pom.xml distributionManagement
Gradle
maven.pkg.github.com
build.gradle publishing block
Docker
ghcr.io
docker login / buildx
NuGet
nuget.pkg.github.com
nuget.config
RubyGems
rubygems.pkg.github.com
.gemspec + ~/.gem/credentials
Publishing an npm package
# package.json — must be scoped and point to GitHub registry
{
"name": "@acme/auth-utils",
"version": "1.2.3",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
# Actions step — publish with GitHub token as password
- name:Publish Maven packagerun:mvn --batch-mode deploy -DskipTestsenv:GITHUB_TOKEN:${{ secrets.GITHUB_TOKEN }}# settings.xml must have a server entry with id=github# username = ${env.GITHUB_ACTOR}, password = ${env.GITHUB_TOKEN}
10.7
ghcr.io: Container Registry, Image Visibility & Linking to a Repo
GitHub Container Registry (ghcr.io) is GitHub's first-class Docker image registry. Unlike the older GitHub Packages Docker registry (docker.pkg.github.com), ghcr.io supports unauthenticated pulls for public images and is integrated with the repo Security tab for vulnerability scanning.
Build and push workflow
# .github/workflows/docker.ymlon:push:branches: [main]
release:types: [published]
env:REGISTRY: ghcr.io
IMAGE_NAME:${{ github.repository }}# acme/platformjobs:build-push:runs-on: ubuntu-latest
permissions:contents:readpackages:writeid-token:write# for attestationattestations:writesteps:
- uses:actions/checkout@v4
- name:Set up Docker Buildxuses:docker/setup-buildx-action@v3
- name:Log in to GHCRuses:docker/login-action@v3with:registry:${{ env.REGISTRY }}username:${{ github.actor }}password:${{ secrets.GITHUB_TOKEN }}
- name:Extract metadata (tags and labels)id: meta
uses:docker/metadata-action@v5with:images:${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}tags: |
type=ref,event=branchtype=semver,pattern={{version}}type=semver,pattern={{major}}.{{minor}}type=sha,prefix=sha-
- name:Build and pushid: push
uses:docker/build-push-action@v6with:context:.push:truetags:${{ steps.meta.outputs.tags }}labels:${{ steps.meta.outputs.labels }}cache-from:type=gha# GitHub Actions cache for Docker layerscache-to:type=gha,mode=max
- name:Attest provenanceuses:actions/attest-build-provenance@v1with:subject-name:${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}subject-digest:${{ steps.push.outputs.digest }}push-to-registry:true
Linking a container image to a repo
By default, ghcr.io images are linked to the user/org, not a specific repo. Add the org.opencontainers.image.source label to link the image to its source repository — it then appears in the repo's "Packages" sidebar:
Using cache-from: type=gha stores Docker build layers in the GitHub Actions cache. This dramatically speeds up builds with stable base layers — a build that took 8 minutes cold can be 90 seconds with warm layer cache. The mode=max caches all layers including intermediate ones.
10.8
Package Visibility & Access Control
Package visibility and access controls are set independently from the linked repo's visibility. A public repo can have private packages (e.g. internal base images) and a private repo can publish public packages (e.g. an open-source library you build internally).
Visibility options
Visibility
Who can pull
Who can push
Use for
Public
Anyone, unauthenticated
Authenticated users with write permission
Open-source libraries, public base images
Private
Authenticated users with at least Read access to the package
Authenticated users with write permission
Internal services, proprietary packages
Internal (org-only)
Any authenticated org member
Users with write permission
Internal tools shared across all teams
Granting team access to a package
# Manage package access: Package page → Package settings → Manage access# Or via API:$ gh api \
orgs/acme/packages/container/platform/teams/payments-team \
--method PUT --field permission=write
# Inherit access from the linked repo (simplest)# Package settings → "Inherit access from source repository" toggle
INHERIT ACCESS
Enabling "Inherit access from source repository" on a package means anyone who can read the linked repo can also pull the package — no separate access management needed. This is the right default for most packages. Only disable it if you need the package's access to differ from the repo's (e.g. a private internal base image published from a public open-source repo).
10.9
Retaining & Deleting Old Package Versions
Every container image push and every npm publish creates a new package version. Without cleanup, package storage grows unboundedly — and at $0.008/GB/day, a monorepo building 20 Docker images per day accumulates cost fast.
Automated cleanup with delete-package-versions
# .github/workflows/cleanup-packages.ymlon:schedule:
- cron:'0 3 * * 0'# every Sunday 3amjobs:cleanup:runs-on: ubuntu-latest
permissions:packages:writesteps:# Keep the 10 most recent versions; delete anything older
- uses:actions/delete-package-versions@v5with:package-name:platformpackage-type:containermin-versions-to-keep:10delete-only-untagged-versions:true# safer: only delete untagged digests# Delete versions older than 30 days that are untagged
- uses:actions/delete-package-versions@v5with:package-name:platformpackage-type:containermin-versions-to-keep:5delete-only-pre-release-versions:true
Retention strategy by version type
Version type
Retention policy
Tagged release versions (v1.2.3)
Keep indefinitely — these are what consumers pin to
Branch-based images (main-abc1234)
Keep last 10; delete anything older than 14 days
PR preview images (pr-247-sha)
Delete when the PR closes (trigger on pull_request: types: [closed])
Pre-release versions (1.0.0-rc.1)
Delete after the stable release ships
Untagged digests (SHA only)
Delete aggressively — these are intermediate build artifacts
# Delete PR preview images when a PR closes# on: pull_request: types: [closed]$ gh api \
user/packages/container/platform/versions \
--jq '.[] | select(.metadata.container.tags[] | contains("pr-${{ github.event.pull_request.number }}")) | .id' \
| xargs -I{} gh api user/packages/container/platform/versions/{} --method DELETE
10.10
Consuming GitHub Packages from Different Ecosystems
GitHub Packages requires authentication to pull private packages — even in CI. Configure the auth once per ecosystem and all subsequent installs work with GITHUB_TOKEN.
npm
# .npmrc — checked into the repo (no secrets here)
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# In your workflow:
- uses:actions/setup-node@v4with:registry-url:https://npm.pkg.github.comscope:'@acme'
- run:npm cienv:NODE_AUTH_TOKEN:${{ secrets.GITHUB_TOKEN }}
- name:Log in to GHCRuses:docker/login-action@v3with:registry:ghcr.iousername:${{ github.actor }}password:${{ secrets.GITHUB_TOKEN }}# Kubernetes — create an imagePullSecret$ kubectl create secret docker-registry ghcr-pull-secret \
--docker-server=ghcr.io \
--docker-username=$GITHUB_ACTOR \
--docker-password=$GITHUB_PAT \
--namespace=production
CROSS-REPO PACKAGE ACCESS
GITHUB_TOKEN can only pull packages owned by the same org as the current repo's Actions run. If repo A needs to pull a private package from repo B (same org), it works. If repo A needs a package from a different org, you must use a PAT or GitHub App token with the appropriate packages: read permission. Never hardcode PATs — use org-level secrets.
Up Next — Phase 11: GitHub Projects & Engineering Metrics
GitHub Projects v2 board/table/roadmap views, custom fields, automation workflows, milestones vs Projects, YAML issue templates, label taxonomy, DORA metrics via GraphQL, and third-party analytics.