Versioning OpenAPI Specs with Git Tags
This guide is part of API Versioning & Deprecation in OpenAPI within OpenAPI & AsyncAPI Schema Authoring. It covers making the specification a versioned, immutable artifact rather than a file on a branch — so a documentation build, an SDK release, and a mock server can all name the exact contract they were built from. The need becomes obvious the first time someone asks “which version of the API does this SDK support?” and the only honest answer is “whatever was on main that afternoon”.
Problem & Context
A specification maintained as a file on a branch has no version in any useful sense. info.version: 2.7.0 sits in the document, gets edited by hand when someone remembers, and drifts from reality within a release or two — usually because two pull requests both bumped it and one lost the merge.
The consequences are all downstream. An SDK generated from main includes endpoints that are merged but not deployed, so its users get compilation-clean methods that return 404. A mock server built from the same branch answers with fields the running API does not send yet. A documentation build publishes a reference for an API that does not exist for another two weeks. In each case the artifact is internally consistent and wrong, and nothing detects it because there is no notion of “the released contract” to compare against.
Git already solves this problem for source code, and a specification is source code. The fix is to stop treating info.version as content and start treating it as build output derived from a tag — at which point every downstream consumer can pin to something immutable.
Step-by-Step Solution
1. Stop hand-editing info.version
Replace the maintained value with an obvious placeholder so nobody is tempted to update it, and so an unprocessed document is easy to spot:
openapi: 3.1.0
info:
title: Acme Billing API
# Injected at build time from the Git tag. Never edit this by hand.
version: 0.0.0-dev
2. Tag the contract on release
Use annotated tags — they carry a message, an author, and a date, and unlike lightweight tags they are objects you can inspect later:
git tag -a v2.7.0 -m 'Add invoice search; deprecate GET /invoices/search'
git push origin v2.7.0
3. Inject the version at build time
Strip the leading v and write it into the document as part of producing the artifact:
VERSION="${GITHUB_REF_NAME#v}" # v2.7.0 -> 2.7.0
yq -i ".info.version = \"${VERSION}\"" openapi/api.yaml
npx @redocly/cli@2 bundle openapi/api.yaml -o "dist/api-${VERSION}.yaml"
Confirm the injection worked before publishing anything:
yq -r '.info.version' "dist/api-${VERSION}.yaml"
2.7.0
4. Publish an immutable artifact per tag
Two destinations with different guarantees. The versioned path is written once and never again; latest moves:
# Immutable — refuse to overwrite an existing version.
if aws s3api head-object --bucket acme-specs --key "specs/${VERSION}/api.yaml" 2>/dev/null; then
echo "ERROR: specs/${VERSION}/api.yaml already exists — versions are immutable" >&2
exit 1
fi
aws s3 cp "dist/api-${VERSION}.yaml" "s3://acme-specs/specs/${VERSION}/api.yaml" \
--cache-control 'public, max-age=31536000, immutable'
# Moving pointer — convenient for humans, never for builds.
aws s3 cp "dist/api-${VERSION}.yaml" 's3://acme-specs/specs/latest/api.yaml' \
--cache-control 'public, max-age=300, must-revalidate'
The existence check is the part that makes “immutable” real. Without it, a re-run of a release job silently replaces a published contract, and every consumer who pinned to that version gets different bytes than they validated against.
5. Pin every consumer to a version
Docs builds, SDK generation, and mock servers all fetch a pinned version. A branch reference anywhere in this list reintroduces the original problem:
# scripts/fetch-spec.sh — used by the docs build, the SDK job, and the mock
set -euo pipefail
: "${API_SPEC_VERSION:?set API_SPEC_VERSION, e.g. 2.7.0}"
curl -fsSL "https://specs.acme.com/specs/${API_SPEC_VERSION}/api.yaml" -o openapi/api.yaml
echo "Using contract ${API_SPEC_VERSION}"
Complete Working Example
A release workflow that tags, injects, publishes immutably, and refuses to clobber:
# .github/workflows/publish-spec.yml
name: Publish contract
on:
push:
tags: ['v*.*.*']
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Derive the version from the tag
id: v
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Validate before anything else
run: npx @redocly/cli@2 lint openapi/api.yaml
- name: Inject the version and bundle
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
yq -i ".info.version = \"${VERSION}\"" openapi/api.yaml
mkdir -p dist
npx @redocly/cli@2 bundle openapi/api.yaml -o "dist/api.yaml"
test "$(yq -r '.info.version' dist/api.yaml)" = "${VERSION}"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.SPECS_ROLE_ARN }}
aws-region: eu-west-1
- name: Publish immutably
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
if aws s3api head-object --bucket acme-specs \
--key "specs/${VERSION}/api.yaml" >/dev/null 2>&1; then
echo "::error::specs/${VERSION}/api.yaml already exists; versions are immutable"
exit 1
fi
aws s3 cp dist/api.yaml "s3://acme-specs/specs/${VERSION}/api.yaml" \
--cache-control 'public, max-age=31536000, immutable'
aws s3 cp dist/api.yaml 's3://acme-specs/specs/latest/api.yaml' \
--cache-control 'public, max-age=300, must-revalidate'
- name: Record the release
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
echo "Published contract ${VERSION}" >> "$GITHUB_STEP_SUMMARY"
echo "https://specs.acme.com/specs/${VERSION}/api.yaml" >> "$GITHUB_STEP_SUMMARY"
Expected summary on a successful run:
Published contract 2.7.0
https://specs.acme.com/specs/2.7.0/api.yaml
Note that yq -i edits the working copy rather than committing it. That is deliberate: the version belongs to the artifact, not to the repository, and committing an injected value back to the branch recreates exactly the hand-maintained drift this replaces.
Gotchas & Edge Cases
A re-run of the release job. Retrying a failed release is normal, and without the existence check it silently republishes different bytes under a version consumers have already pinned. Fail loudly instead, and cut a new patch version if the artifact genuinely needs to change.
Tags are mutable if you let them be. git tag -f and a force push will move a tag, which breaks the guarantee that a version names one commit. Protect release tags in the repository settings so the immutability of the artifact matches the immutability of its source.
Pre-release and build metadata. Semver allows 2.8.0-rc.1, and publishing release candidates is useful — but make sure downstream tooling either accepts them or filters them out. An SDK release job that treats every tag as production will publish a release candidate to the public registry.
The latest pointer sneaks into a build. It will happen: someone copies a URL from the portal into a CI job. Detect it rather than relying on discipline — have the fetch script reject a version string of latest outright, so the mistake fails immediately instead of producing a subtly wrong artifact months later.
Long cache lifetimes on an immutable path are safe; on latest they are not. The versioned artifact can be cached for a year because its content can never change. The latest pointer must revalidate, or consumers see an old contract while believing they have the newest.
Keep a machine-readable index of published versions. Consumers, and your own tooling, repeatedly need to answer “what versions exist and which is current?” — and scraping a bucket listing is a poor way to do it. Publish a small JSON index alongside the artifacts, regenerated on every release, listing each version with its release date and its support status. It costs one extra upload and immediately enables things that are otherwise awkward: a version switcher in the portal, an SDK release job that refuses to build against an unsupported contract, and a dashboard showing how far behind each internal consumer has fallen.
Treat the tag message as release-note material. An annotated tag already carries a message, and if you write it as a summary of what changed in the contract rather than as “release 2.7.0”, it becomes the first draft of the changelog entry. Combined with a specification diff between the previous tag and this one, that gives you an accurate, low-effort record of every contract change — which is exactly what Generating API changelogs from OpenAPI diff automates.
FAQ
Should info.version match the API’s major version?
No. The major version in the URL path changes when the API breaks compatibility; info.version changes on every published specification edit, including documentation-only ones. They answer different questions — “which contract shape is this?” versus “which revision of the description is this?” — and conflating them makes both useless.
Why not just point consumers at the spec on the main branch?
Because main contains unreleased changes. A docs build or SDK generation that reads main will document endpoints that are not deployed yet, and the mismatch surfaces as a support ticket rather than a build failure. Pinning makes the mismatch impossible.
What if a release ships no contract change?
Tag it anyway and publish an identical artifact. A gap in the version sequence is more confusing than a no-op release, and consumers pinning to a version need that version to exist.
Related
- API Versioning & Deprecation in OpenAPI — the parent guide and the deprecation lifecycle.
- Documenting deprecated endpoints in OpenAPI — marking what a new version retires.
- Breaking Change Detection & Spec Diffing — diffing one published version against the next.
- Semver policy for OpenAPI-driven SDKs — choosing which number to bump.