PHASE 10 OF 14
Releases, Tags & GitHub Packages
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
SemVer
Git Tags
GitHub Releases
release-please
GitHub Packages
ghcr.io
Changelog
10.1
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
1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta.3
1.0.0-rc.1
1.0.0+20260613
1.0.0+sha.a3f8d12
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
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 |
$ git tag -a v2.4.1 -m "Release v2.4.1 — fix null pointer in payment checkout"
$ git push origin v2.4.1
$ git tag -a v2.4.0 a3f8d12 -m "Release v2.4.0"
$ git tag -n
$ git tag -n99
$ 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.
$ git tag -s v2.4.1 -m "Release v2.4.1"
$ git config gpg.format ssh
$ git config user.signingKey "~/.ssh/id_ed25519.pub"
$ git tag -s v2.4.1 -m "Release v2.4.1"
$ 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
$ 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
$ gh release create v2.5.0 --draft --title "v2.5.0"
$ gh release create v3.0.0-rc.1 --prerelease --title "v3.0.0 Release Candidate 1"
$ 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.).
changelog:
exclude:
labels:
- ignore-for-release
- dependencies
categories:
- title: 🚀 New Features
labels: [feature, enhancement]
- title: 🐛 Bug Fixes
labels: [bug, fix]
- title: 🔧 Maintenance
labels: [refactor, docs, chore]
- title: ⚠️ Breaking Changes
labels: [breaking-change]
$ gh api repos/acme/platform/releases/generate-notes \
--method POST \
--field tag_name=v2.5.0 \
--field previous_tag_name=v2.4.1 \
--jq '.body'
10.4
Automating Releases: release-please & semantic-release
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.
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: node
token: ${{ secrets.GITHUB_TOKEN }}
publish:
needs: release-please
if: needs.release-please.outputs.release_created
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
{
"packages": {
"services/payments": {
"release-type": "node",
"package-name": "@acme/payments"
},
"services/auth": {
"release-type": "node",
"package-name": "@acme/auth"
}
}
}
semantic-release (fully automated approach)
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.
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
["@semantic-release/changelog", {
"changelogFile": "CHANGELOG.md"
}],
"@semantic-release/npm",
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}],
"@semantic-release/github"
]
}
release-please vs semantic-release
| release-please | semantic-release |
| Release trigger | Manual — merge the release PR when ready | 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
feat: add payment retry logic
fix(checkout): handle null payment method
docs: update API reference
chore: bump node to 22
refactor(auth): extract token validator
test: add unit tests for retry logic
perf: cache auth tokens in Redis
feat!: redesign payment API
feat(payment): new checkout flow
BREAKING CHANGE: removed legacy /pay endpoint
Enforcing conventional commits with commitlint
on:
pull_request:
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
{
"extends": ["@commitlint/config-conventional"],
"rules": {
"type-enum": [2, "always", [
"feat", "fix", "docs", "style", "refactor",
"perf", "test", "chore", "ci", "revert"
]],
"subject-case": [2, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"]],
"subject-max-length": [2, "always", 72]
}
}
git-cliff — rich changelog generation
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.
$ git cliff --tag v2.5.0 --output CHANGELOG.md
$ git cliff --unreleased --tag v2.5.0
$ 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
{
"name": "@acme/auth-utils",
"version": "1.2.3",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: https://npm.pkg.github.com
scope: '@acme'
- run: npm ci && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Publishing a Maven package
<distributionManagement>
<repository>
<id>github</id>
<name>GitHub Packages</name>
<url>https://maven.pkg.github.com/acme/auth-lib</url>
</repository>
</distributionManagement>
- name: Publish Maven package
run: mvn --batch-mode deploy -DskipTests
env:
GITHUB_TOKEN: ${{ secrets.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
on:
push:
branches: [main]
release:
types: [published]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags and labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Attest provenance
uses: actions/attest-build-provenance@v1
with:
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:
LABEL org.opencontainers.image.source="https://github.com/acme/platform"
LABEL org.opencontainers.image.description="Acme platform service"
LABEL org.opencontainers.image.licenses="MIT"
DOCKER LAYER CACHING
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
$ gh api \
orgs/acme/packages/container/platform/teams/payments-team \
--method PUT --field permission=write
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
on:
schedule:
- cron: '0 3 * * 0'
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/delete-package-versions@v5
with:
package-name: platform
package-type: container
min-versions-to-keep: 10
delete-only-untagged-versions: true
- uses: actions/delete-package-versions@v5
with:
package-name: platform
package-type: container
min-versions-to-keep: 5
delete-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 |
$ 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
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
- uses: actions/setup-node@v4
with:
registry-url: https://npm.pkg.github.com
scope: '@acme'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Maven
<servers>
<server>
<id>github</id>
<username>${env.GITHUB_ACTOR}</username>
<password>${env.GITHUB_TOKEN}</password>
</server>
</servers>
<repositories>
<repository>
<id>github</id>
<url>https://maven.pkg.github.com/acme/auth-lib</url>
</repository>
</repositories>
Docker (pulling from ghcr.io)
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
$ 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.
Continue to Phase 11 →
Back to Hub