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

# 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

SituationCommon 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 consumersDate-based versioning (2026.06.13) is fine — SemVer compatibility guarantees only matter when others depend on your API.
Continuously deployed servicesOmit 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 bumpsAvoid. 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 tagAnnotated tag
Git objectJust a ref pointing to a commit — no tag objectA full tag object with tagger, date, message, and optional signature
Commandgit tag v1.0.0git tag -a v1.0.0 -m "Release v1.0.0"
Shown in git describeYes (unless --tags is omitted)Yes (preferred — used by default)
Use for releases?No — no metadata, no signing supportYes — 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.).

# .github/release.yml — configure label groupings for auto-generated notes changelog: exclude: labels: - ignore-for-release - dependencies # hide Dependabot PRs from release notes 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]
# Generate notes without creating the release $ 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.

# .github/workflows/release.yml 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 # node | java | python | go | rust | simple 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 }}
# release-please-config.json — multi-package monorepo { "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.

# .releaserc.json { "branches": ["main"], "plugins": [ "@semantic-release/commit-analyzer", // determines version bump from commits "@semantic-release/release-notes-generator", ["@semantic-release/changelog", { "changelogFile": "CHANGELOG.md" }], "@semantic-release/npm", // bumps package.json, publishes to npm ["@semantic-release/git", { "assets": ["CHANGELOG.md", "package.json"], "message": "chore(release): ${nextRelease.version} [skip ci]" }], "@semantic-release/github" // creates GitHub Release ] }

release-please vs semantic-release

release-pleasesemantic-release
Release triggerManual — merge the release PR when readyAutomatic — every push that warrants a release creates one
Human in the loop?Yes — you review and merge the release PRNo — fully automated
Best forLibraries with deliberate release cadence; teams that want release approval gatesContinuously deployed services; high-frequency releases; mature teams with strict commit discipline
Version file updatesYes — 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.yml on: pull_request: jobs: commitlint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # need full history to compare commits - uses: wagoid/commitlint-github-action@v6 with: configFile: .commitlintrc.json
// .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.

# 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.

EcosystemRegistry URLConfig file
npmnpm.pkg.github.com.npmrc
Mavenmaven.pkg.github.compom.xml distributionManagement
Gradlemaven.pkg.github.combuild.gradle publishing block
Dockerghcr.iodocker login / buildx
NuGetnuget.pkg.github.comnuget.config
RubyGemsrubygems.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" } }
# .github/workflows/publish-npm.yml 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

<!-- pom.xml — distributionManagement --> <distributionManagement> <repository> <id>github</id> <name>GitHub Packages</name> <url>https://maven.pkg.github.com/acme/auth-lib</url> </repository> </distributionManagement>
# Actions step — publish with GitHub token as password - name: Publish Maven package run: mvn --batch-mode deploy -DskipTests env: 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.yml on: push: branches: [main] release: types: [published] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} # acme/platform jobs: build-push: runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write # for attestation 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 # GitHub Actions cache for Docker layers 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:

# Dockerfile — add OCI labels 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

VisibilityWho can pullWho can pushUse for
PublicAnyone, unauthenticatedAuthenticated users with write permissionOpen-source libraries, public base images
PrivateAuthenticated users with at least Read access to the packageAuthenticated users with write permissionInternal services, proprietary packages
Internal (org-only)Any authenticated org memberUsers with write permissionInternal 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.yml on: schedule: - cron: '0 3 * * 0' # every Sunday 3am jobs: cleanup: runs-on: ubuntu-latest permissions: packages: write steps: # Keep the 10 most recent versions; delete anything older - uses: actions/delete-package-versions@v5 with: package-name: platform package-type: container min-versions-to-keep: 10 delete-only-untagged-versions: true # safer: only delete untagged digests # Delete versions older than 30 days that are untagged - 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 typeRetention 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@v4 with: registry-url: https://npm.pkg.github.com scope: '@acme' - run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Maven

<!-- ~/.m2/settings.xml (or inject via Actions) --> <servers> <server> <id>github</id> <username>${env.GITHUB_ACTOR}</username> <password>${env.GITHUB_TOKEN}</password> </server> </servers> <!-- pom.xml repositories --> <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 }} # 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.

Continue to Phase 11 → Back to Hub