This repository demonstrates how to make modern, safe, and automated versioning easy to understand and adopt. It shows how any team can go from a manual, error-prone "version bump and tag" workflow to a fully automated release pipeline that handles versioning, tagging, changelogs, container publishing, and signing - all in a transparent and auditable way.
The aim is to help engineering teams:
- Build trust in automation
- Deliver software faster and more safely
- Follow consistent engineering practices that scale across NHS products
Whether you're a developer, tester, or tech lead, this repository shows how you can go from Commit β Version β Build β Scan β Attest β Sign β Publish β Deploy β Release without ever losing confidence in what's running in production.
This approach also lays the foundation for feature toggling where new functionality is deployed but not immediately exposed to users. Versioning provides the traceability and control needed to manage these toggled changes safely, allowing teams to ship small, reversible updates, test them in production, and enable them gradually when ready.
Beyond automation and feature management, this template also provides base for secure software supply chain practices, including artefact hardening and provenance. By producing signed, tamper-evident build outputs (such as container images and packages) with a clear chain of custody, teams can:
- Strengthen cyber resilience by ensuring only verified and trusted artefacts reach production
- Improve incident response through auditable traceability from code to deployed artefact
- Reduce the risk of supply-chain compromise by verifying every component's source, build process, and integrity
This aligns directly with NHS ongoing work to strengthen the security posture of workloads and artefact provenance, ensuring that every deployment is both secure by design and provable by evidence.
Important
This repository is not intended to be used standalone. It should be built on top of and complement the NHS Repository Template, which defines the required baseline structure and configuration for all new repositories within the organisation.
- π Versioning (Release) Reference Template
- Overview
- Structure
- Prerequisites
- Design decisions and rationale
- Secure by design versioning (release)
- How to use this repository
- Outstanding
On every push or merged pull request to the main branch, this workflow:
- Authenticates securely using a short-lived GitHub App token
- Reads commit messages that follow Conventional Commits
- Calculates the next semantic version
- Updates a simple
VERSIONfile with that number and signs the commit - Creates a Git tag such as
v1.2.3 - Publishes a GitHub Release entry with automatically generated release notes
- Logs in to GitHub Container Registry (GHCR)
- Builds and pushes a versioned container image
π Result: every version is predictable, traceable, and fully automated, with no need to manually tag, bump, or write changelogs.
| File | Purpose |
|---|---|
.github/workflows/cicd-2-publish.yaml |
The core CI/CD workflow that performs the authenticated release, signing, and container publishing |
.releaserc |
Defines how semantic-release behaves, which plugins to use, what rules decide version bumps, and how commits are made |
VERSION |
A plain-text file containing the current version number (automatically maintained by the workflow) |
semantic-release runs as a series of stages, each handled by a plugin:
@semantic-release/commit-analyzerlooks at your commit messages and decides whether this is a patch, minor, or major bump@semantic-release/release-notes-generatorwrites clear, human-readable notes based on your commits@semantic-release/execupdates theVERSIONfile in your repo with the new version number@semantic-release/gitcommits that file change, signs it with GPG, and creates a tag@semantic-release/githubpublishes the new version as a GitHub Release with those notes
That's it - a clean, logical pipeline that turns commit messages into traceable software releases.
Repository variables define the static configuration needed by the workflow:
GH_VERSIONING_RELEASE_APP_ID- the numeric ID of the GitHub AppGIT_SIGNING_BOT_NAME- display name used for the signed commitsGIT_SIGNING_BOT_EMAIL- email address linked to the uploaded GPG key
Repository secrets provide the credentials and cryptographic materials required to sign releases:
GH_VERSIONING_RELEASE_APP_PRIVATE_KEY- the GitHub App's private key used to create short-lived auth tokensGIT_SIGNING_BOT_GPG_PRIVATE_KEY- private signing key of your release botGIT_SIGNING_BOT_GPG_PASSPHRASE- the key passphraseCOSIGN_PUBLIC_KEYCOSIGN_PRIVATE_KEYCOSIGN_PASSWORD
All of the above variables and secrets have an organisation-wide equivalent managed centrally by the NHS GitHub Admins. These defaults are automatically available to all repositories, ensuring consistent configuration, simplified onboarding, and alignment with NHS engineering and security standards.
Follow these steps to create and configure a minimalβpermission GitHub App that will authenticate the release workflow. This should be done for you by the NHS GitHub Admins. However, you can perform this setup yourself for testing purposes.
-
Create the App
- Go to GitHub App settings user Settings β Developer Settings β GitHub Apps β New GitHub App
- Name it something like "My Versioning App" (must be globally unique)
- Set the homepage URL to your repository
- Configure permissions
- Repository permissions:
- Contents: Read & write, needed to create tags and commit
VERSION - Issues: Read & write, enables adding release notes comments
- All other repository permissions: No access
- Contents: Read & write, needed to create tags and commit
- Organization permissions: None required
- Account permissions: None required
- Repository permissions:
- For the installation scope choose Only on this account (or organisation-wide if required)
-
Generate the private key
- After saving, click Generate a private key
- Copy the full
.pemfile contents (including BEGIN/END lines) - Store it securely, you'll need it to populate
GH_VERSIONING_RELEASE_APP_PRIVATE_KEY
-
Install the App
- Click Install App on the App page
- Choose your user account
- Select Only select repositories β picking this repository is recommended
-
Add repository variables & secrets
- Go to your repository β Settings β Secrets and variables β Actions
- Add the variables and secrets listed above in the Configuration section
-
Test
- Make a trivial commit e.g.
docs: test app token wiringtomain - In workflow logs confirm the "Generate GitHub App token" action step succeeds
After setup, this step in your workflow will issue short-lived authentication tokens automatically:
- name: Generate GitHub App token uses: actions/create-github-app-token@v2 with: app-id: ${{ vars.GH_VERSIONING_RELEASE_APP_ID }} private-key: ${{ secrets.GH_VERSIONING_RELEASE_APP_PRIVATE_KEY }}
- Make a trivial commit e.g.
GitHub verifies commits based on user or bot accounts, not apps. This means the release workflow needs a "bot" identity with an associated GPG key.
Steps:
-
Generate a key locally:
gpg --quick-generate-key "My Signing Bot <your-email@users.noreply.github.com>" ed25519 sign 1m ID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # your key ID
-
Get the public key
gpg --armor --export $ID -
Get the private key
gpg --armor --export-secret-keys $ID -
Add repository variables & secrets
- Go to your repository β Settings β Secrets and variables β Actions
- Add the variables and secrets listed above in the Configuration section
After that, all commits made by the workflow will appear as "Verified β " on GitHub.
These explanations are meant to help you and me to understand why each part of this setup exists, so none of us has to guess later.
Using a GitHub App offers significant security and governance advantages:
- Least privilege, the App's installation can be scoped to specific repos with tightly controlled permissions
- Ephemeral credentials, tokens are short-lived and automatically rotated, minimising the impact of leaks
- Easy revocation, uninstalling the App immediately cuts off access, no manual key rotation needed
- Auditability, actions performed by the App are attributed to its installation in GitHub logs
- Operational separation, avoids depending on a human user's PAT, which could carry excessive scopes or expire unexpectedly
In practice, this means safer automation with better traceability and compliance.
Using a single shared bot for an organisation is common practice across the industry. In a large organisation it's standard practice to maintain a single shared bot identity whose purpose is:
- signing commits/tags
- authoring automated release or dependency commits
- interacting via GitHub Apps
This simplifies:
- key management, one GPG keypair to rotate and protect
- auditing, centralised automation identity
- compliance, easier to trace automation actions across repos
Each repository or GitHub App workflow can then reuse that bot's GPG key and email for signing.
GitHub verifies GPG and SSH signatures against user or bot accounts, not apps. Because of that, the release workflow uses a dedicated bot identity that owns the signing key. The benefits are as follows:
- Verified commits and tags, GitHub recognises the signature as trusted and shows the green "Verified" badge
- Separation of authorship, automated releases are distinct from human commits
- Independent key rotation, the key can be rotated or revoked without affecting personal accounts
- Consistent auditing, every release is traceable to a single, identifiable automation user
The public GPG key must be uploaded to the GitHub profile of the account listed in GIT_SIGNING_BOT_EMAIL. This allows GitHub to associate the cryptographic signature with that bot's identity and display it as "Verified".
In large organisations, it is entirely appropriate and often preferable to use one shared bot account for signing commits and performing automated releases. This approach reduces key management overhead, simplifies auditing, and provides a single trusted automation identity across all repositories.
Note
The bot account does not require any repository, workflow or organisation permissions. It acts purely as a cryptographic identity, allowing GitHub to validate signatures and display the "Verified" badge. All actual automation and repository access is performed by the GitHub App or workflow tokens, not by the bot itself.
Tag signing can be valuable, but it adds complexity in non-interactive CI environments. Here's why it's disabled by default:
- Simplicity, signed annotated tags require a message, without it, Git tries to open an editor and fails in CI
- Reliability, lightweight tags work flawlessly with semantic-release, annotated tags can hang or error if GPG or message handling is misconfigured
- Sufficient provenance, signed commits combined with GitHub Releases already provide a trustworthy audit trail
If you later want to add signed annotated tags, you can do so safely once your workflow is stable, for example:
git tag -a -m "vX.Y.Z" vX.Y.Z && git push --force origin vX.Y.ZInstead of maintaining a growing Markdown changelog, this design treats GitHub Releases as the single source of truth. Each release contains its own autogenerated notes, which are easy to view, compare, or query via API. Advantages are:
- Single source of truth, release notes live where users expect them, in the Releases tab
- Cleaner history, release commits only touch the
VERSIONfile, avoiding noisy changelog diffs - Fewer merge conflicts, no simultaneous changelog edits across branches
- Better performance, no rewriting of a large (yes, it will grow with time) markdown file every release
- API-friendly, release data is structured and accessible via GitHub's API for dashboards or audits
If an on-disk changelog is ever needed (e.g. for packaged distributions), re-enable @semantic-release/changelog or build an export pipeline that generates one on demand.
When a single repository produces multiple container images, for example api or ui, GitHub's Container Registry (GHCR) imposes certain structural and permission constraints on how those images can be stored and tagged. Key points and reasoning:
- GitHub App tokens cannot publish to GHCR, app installation tokens do not carry package-level permissions for container publishing. To push images, use the built-in
${{ github.token }}, not the legacy${{ secrets.GITHUB_TOKEN }}, which automatically grants write access to your repository's package namespace - Registry scope is flat, the
${{ github.token }}can only publish to the registry path matching the repository's namespace by default, e.g.ghcr.io/owner/repo. Nested namespaces such asghcr.io/owner/repo/apiare not permitted when authenticating with default${{ github.token }} - Use tag naming to distinguish components, since subpaths are unavailable, encode both the component name and the version in the image tag. The recommended convention is
<component>-<version>, for exampleghcr.io/org/repo:api-1.2.3 - Common in monorepos, this approach works well when multiple related services/components share a single repository and deployment process. However, as the system evolves, it is advisable to separate services into distinct bounded contexts for improved autonomy and flow. See the Architect for Flow pattern for further guidance.
- Flat structure, fully compatible with GHCR permissions and the default
${{ github.token }} - Established precedent, aligns with common multi-variant image naming, e.g.
nginx:alpine-1.25,python:3.12-slim - Automation-friendly, uses a shared semantic version across all components
- Discoverable, easy to query, filter, and sort by component prefix
api-*,ui-*, etc.
Other semver-valid formats such as 1.2.3+api or api_v1.2.3 were evaluated, but api-1.2.3 offers the best balance of portability, readability, and compatibility with container tooling and CI/CD pipelines.
- The repository's package registry entry is created automatically when the first image is published. You should not need to create it manually. Once created, confirm that:
- The package is explicitly linked to the repository
- Under Manage Actions access, the repository appears in the list and the Role is set to Admin
- Inherit access from source repository is enabled
- The package visibility matches the repository's visibility (private or public)
- The GitHub App used for releases does not require Packages access as that capability comes from the ephemeral
${{ github.token }}used inside the workflow - However, the workflow itself must request the correct token scopes which must include
packages: write - Use
${{ github.token }}instead of the legacy${{ secrets.GITHUB_TOKEN }}, the former is guaranteed to exist in all workflow contexts and is the modern standard - In repository β Settings β Actions β General, ensure the following are configured:
- Read repository contents and packages permissions under Workflow permissions
This "flat registry with tagged components" model scales cleanly across repositories while remaining compliant with GitHub's authentication and namespace rules. It also provides a consistent, human-readable way to publish and manage multiple container images under one project.
As part of its secure software supply-chain workflow, this repository automatically generates a Software Bill of Materials (SBOM) and performs vulnerability scanning against each released container image. These steps provide transparency into dependencies and help identify potential risks early in the delivery process.
The SBOM is produced using Anchore Syft, which analyses the container image and lists all components, packages, and licences present. It is exported in the CycloneDX JSON format, an open, machine-readable standard widely used across industry and supported for artefact provenance.
Each SBOM:
- Captures the full dependency graph of the built image
- Includes component names, versions, and licence metadata
- Is stored as a workflow artefact for traceability and audit purposes
You can manually inspect or reuse the generated SBOM from the workflow artefacts, or upload it to internal analysis tools.
Example local generation:
syft ghcr.io/{{ repository }}:{{ tag }} -o cyclonedx-json > sbom.cdx.jsonImmediately after SBOM generation, the workflow runs Anchore Grype to scan for known vulnerabilities (CVEs). This ensures that any outdated or insecure components are surfaced before deployment.
The scan:
- Uses the SBOM as input, guaranteeing alignment with the built artefact
- Reports vulnerabilities with severity, package name, and fixed version (if available)
- Fails the workflow only for severe, fixable vulnerabilities (configurable via
severity-cutoffandonly-fixedoptions)
Example local scan:
grype sbom:sbom.cdx.json --only-fixed --fail-on mediumBenefits
- Transparency, provides a full inventory of what's inside each image
- Early risk detection, identifies CVEs before they reach runtime
- Compliance, aligns with NHS Secure by Design and OpenSSF best practices
- Traceability, complements provenance attestations and image signing
The combination of SBOM generation and CVE scanning forms a foundational layer of continuous assurance, enabling teams to understand, trust, and maintain the security of every artefact they ship.
In addition to commit signing, this repository also demonstrates how to sign and verify container images using Sigstore Cosign. Cosign provides cryptographic assurance that every published image originates from a trusted workflow and has not been altered after build. Each image pushed to the GitHub Container Registry (GHCR) is automatically signed as part of the release workflow. This produces tamper-evident OCI artefacts stored alongside the image, allowing anyone to independently verify its provenance. There are the following benefits:
- End-to-end provenance, extends the trusted chain of custody from commit to container
- Tamper evidence, every signature includes cryptographic metadata that cannot be forged or moved between images
- Transparency, the signature is also recorded in the public Sigstore Rekor transparency log for immutable auditability
- Alignment with NHS Secure by Design, strengthens cyber resilience by ensuring only verified and trusted artefacts reach production
To verify a signed image:
cosign verify --key cosign.pub ghcr.io/{{ repository }}:{{ tag }}The output confirms that:
- the signature matches the published public key,
- the digest corresponds to the exact build produced by the workflow, and
- the signature is recorded in the transparency log if enabled.
This ensures that every deployment can be proven authentic and traceable, a core requirement for secure software supply-chain assurance within NHS.
To further strengthen software supply-chain assurance, this repository also demonstrates how to generate and publish build provenance attestations using the actions/attest-build-provenance
GitHub Action. Provenance describes what was built, how, and by whom - providing cryptographic evidence that each artefact (for example, a container image) was produced by a trusted workflow. When enabled in the workflow, GitHub automatically creates an attestation record linked to the image digest. This record is cryptographically signed using the repository's OpenID Connect (OIDC) identity and stored securely within GitHub's Attestation store. Here are the benefits:
- Verified origin, attests that an artefact was built by a specific repository and workflow run
- Tamper resistance, signed using GitHub's OIDC token, ensuring authenticity and integrity
- Auditable provenance, metadata such as build parameters, commit SHA, and workflow run ID are recorded immutably
- Supply-chain compliance, aligns with SLSA Level 2+ provenance standards
The workflow must request id-token: write and attestations: write permissions to create attestations. Provenance always references the immutable image digest (sha256:...), not version tags, to ensure traceability. Attestations can be viewed and verified with the GitHub CLI:
cosign verify-attestation --key cosign.pub ghcr.io/{{ repository }}@sha256:{{ digest }}flowchart LR
A[Commit] --> B[Version]
B --> C[Build]
C --> D[Scan]
D --> E[Attest]
E --> F[Sign]
F --> G[Publish]
G --> H[Deploy]
H --> I[Release]
%% Styling
classDef core fill:#1f77b4,stroke:#0e3553,stroke-width:1px,color:#fff
classDef sec fill:#2ca02c,stroke:#124d12,stroke-width:1px,color:#fff
classDef audit fill:#ff7f0e,stroke:#663c00,stroke-width:1px,color:#fff
classDef release fill:#9467bd,stroke:#3d2a5e,stroke-width:1px,color:#fff
class A,B,C core
class D,E,F sec
class G,H audit
class I release
| Stage | Description | Tooling / Action | Outcome |
|---|---|---|---|
| Commit | Engineer merges a Conventional Commit to main or rather creates a Pull Request to accomplish it. The release bot signs commits automatically |
GitHub App + GPG | Verified β signed commit with authorship traceability |
| Version | Semantic version is calculated automatically based on commit messages | semantic-release |
Predictable versioning (v1.2.3), changelog, and tag created |
| Build | Application is packaged into a container image | OCI container (aka Docker) | Deterministic image tagged app-<version> and app-latest |
| Scan | Generate SBOM and CVE scan before release | Syft + Grype | CycloneDX SBOM + CVE visibility for compliance and early risk detection |
| Attest | Generate build provenance attestation linking code, build, and artefact digest | actions/attest-build-provenance |
Cryptographically signed π provenance record stored in GitHub Attestations |
| Sign | Sign container image and record signature in the transparency log | Sigstore Cosign + Rekor | Tamper-evident signature proving authenticity and integrity |
| Publish | Push signed, attested image to registry and update release notes | GitHub Releases + GHCR | Trusted artefact available for downstream consumption |
| Deploy | Change integrated with downstream environments up to production | GitHub Action (continuous deployment) | TBC |
| Release | Feature enabled to the end user | OpenFeature (feature toggling) | TBC |
-
Create a branch and commit changes using Conventional Commits, for example:
feat(ui): add user authentication -
Open a Pull Request and merge it into
main, ensure that the commit created as an effect of merging this PR contains the above message as this drives the semantic versioning -
The workflow will:
- Detect that the change type is
featand trigger a minor version bump - Update the
VERSIONfile - Commit and tag the new release
- Publish release notes automatically
- Detect that the change type is
You'll see the new tag and release appear on GitHub, both signed and verified (commit).
| Type | Example | Version bump | Explanation |
|---|---|---|---|
docs |
docs(readme): update section |
no bump | Documentation-only change, does not affect the application's behaviour or API |
style |
style(css): normalise headings |
no bump | Code style or formatting change (e.g. whitespace, lint fixes) - no functional impact |
chore |
chore(release): housekeeping |
no bump | Maintenance or tooling updates unrelated to user-facing code |
test |
test(ci): add smoke tests |
no bump | Adds or modifies tests, does not change runtime or API behaviour |
refactor |
refactor(ci): simplify logic |
patch | Code improvement or cleanup without changing behaviour, treated like a small fix |
perf |
perf(core): improve runtime |
patch | Performance enhancement without altering external behaviour, treated as a fix |
fix |
fix(ci): correct signing config |
patch | Corrects an existing issue, triggers a patch version bump (x.y.z β x.y.(z+1)) |
feat |
feat(ci): add exec plugin |
minor | Introduces a new, backward-compatible feature, triggers a minor version bump (x.y.z β x.(y+1).0) |
<type>[scope]!: |
feat(api)!: remove deprecated endpoint |
major | Introduces a breaking change (non-backward-compatible), triggers a major version bump (x.y.z β (x+1).0.0) |
This repository isn't just a demo, it's a living reference for how NHS teams can manage automated versioning and release engineering properly:
- Secure by default
- Fully automated
- Transparent and traceable
- Easy for newcomers to understand
If you're reading this and thinking "I'm not sure I understand all of it", that's ok. Just clone it, run it, and watch what happens. Each step is logged, readable, and reversible. The goal is not perfection, it's confidence!
- Validate that the workflow functions correctly for private repositories
- Explore manually created nested registry packages connected to the repository, for example
ghcr.io/owner/repo/api:0.0.1. However, this is only a nice-to-have, the preferred approach is to decompose the monorepo to support product and team autonomy when delivering services at a national scale. This is the alignment with modern DevOps and Conway's Law, promoting smaller, autonomous repositories that map to products and teams is the scalable, maintainable path for large, national services - Provide guidance on driving continuous deployment independently of version increments, reinforcing that semantic versioning expresses contract evolution rather than deployment cadence in iterative delivery environments