Releases & deployment#
How a version of Specgate is cut and published — to npm, to the container registry and to this documentation site — and what support it gets.
Note
GitHub Actions are switched off on the rsaglobaltech organisation, to
save the free-minutes quota — every workflow except pages.yml, which
publishes this site. The process below describes what the workflows do when
they run; How a release is done today is what
a maintainer actually does meanwhile.
What gets published#
| Artefact | Registry | Workflow | Status |
|---|---|---|---|
@rsaglobaltech/specgate | npm, public | publish-npm.yml | Live — 0.19.1 is latest |
@rsaglobaltech/specgate | GitHub Packages | publish-github-packages.yml | Workflow disabled between releases; newest 0.19.1 |
| CLI image | ghcr.io | publish-docker.yml | Workflow disabled between releases; newest image 0.19.1, linux/amd64 and linux/arm64 |
| MCP server | inside the CLI package | publish-npm.yml | Live: specgate mcp serve |
| Documentation site and pack registry | GitHub Pages | pages.yml | Live — see Deployment of the site |
specgate-maven-plugin | Maven Central or an internal Nexus | none yet | C7-05 |
specgate-gradle-plugin | Gradle Plugin Portal or an internal repo | none yet | C7-06 |
specgate-vscode | VS Code Marketplace | none yet | C7-07 |
@specgate/lsp-server | npm | none yet | C7-08, blocked on C6-03 |
The two @rsaglobaltech/specgate rows are one coordinate in two registries, on
purpose. Public npm is the default; routing the scope with
@rsaglobaltech:registry=https://npm.pkg.github.com gets the GitHub copy.
The Specgate rename — done, and what it left behind#
The package was renamed from create-spec-driven-app to
@rsaglobaltech/specgate in
ADR-0024. The old
package is deprecated, not unpublished — every version stays installable, so a
pinned dependency keeps resolving with a warning. The unscoped name specgate
is unavailable: npm compares names without punctuation, so it collides with
spec-gate and the registry answers 403, which reads like a token problem and
is not one. Docker images moved to ghcr.io/<owner>/specgate; tags published
under the old image name are never rebuilt. Since
ADR-0032 nothing else keeps the old
name: no binary alias, and the on-disk format, cache and environment variables
are all specgate.
Versioning#
Semantic versioning. The CLI and the companion packages version independently; a CLI release does not force a bump of the VS Code extension.
Tags are vX.Y.Z. The publish workflows trigger on v*, and the two tags
that predate this document (0.1.0-beta.1, 0.1.4) have no v prefix, which
is why the tag-triggered path had never once fired. Do not create unprefixed
tags.
Support policy#
Which versions get fixes. Pre-1.0, only the latest minor. Backports to an older minor are not promised — the upgrade path is forward, and pretending otherwise would be a commitment one maintainer cannot keep. The same table is in SECURITY.md for vulnerabilities specifically.
From 1.0 on, this is a commitment and not an intention. One supported line at a time, plus the previous minor for six months from the day its successor ships. Security fixes and correctness fixes both; a feature does not get backported.
What it costs: for six months after every minor, two lines need a release
path, and a fix on main has to be evaluated against the previous minor too.
If that ever stops being sustainable, the honest move is to change this
paragraph in a release and say so under Breaking — not to quietly stop.
Node.js. package.json declares >=22 and CI tests Node 22 and 24 across
Linux, macOS and Windows — the floor and the current LTS, so a break at either
end shows up. Raising the floor is breaking for the CLI, never a quiet
minor: an npx invocation that used to work and now refuses to run is breaking,
whatever the changelog calls it.
The floor moved from 20 to 22 in August 2026, because Node 20 left LTS maintenance that April and testing against an unsupported runtime proves nothing. The rule from here: the floor is a maintained LTS. When one leaves maintenance the floor moves in the next release, and the changelog says so under Breaking.
Docker. Images are tagged X.Y.Z and latest, for linux/amd64 and
linux/arm64. While publish-docker.yml is disabled, latest is the newest
image that was built — 0.17.10 — not the newest npm version. A published
tag is never rebuilt in place: re-running a workflow against an old tag checks
out the workflow as it was at that tag. Pin X.Y.Z in CI.
Compatibility windows#
Three version numbers travel with a project, and they are checked, not merely recorded. Both gates below were added after finding that the fields were written by the CLI and read by nothing.
| Field | Where | This CLI supports | On mismatch |
|---|---|---|---|
schema_version | pack.yaml | up to 1.5.0 | A pack declaring a newer schema is rejected with the reason and the upgrade command |
specops_version | .specops.lock | 1 | A lockfile from a newer CLI is rejected on read |
pack version | .specops.lock | any | Pinned; a content digest change fails the build — see supply chain |
Older is always readable. A pack on an older schema, or a lockfile with no
specops_version at all, works unchanged — those files predate the field, and
refusing them would strand existing projects to enforce a rule invented later.
Only newer than this CLI is refused, because schemas/pack.schema.json sets
additionalProperties: false: a newer minor is genuinely unreadable here, not
merely unfamiliar. Failing at the top with "upgrade the CLI" beats failing
twenty lines in with "unknown property".
Bumping the pack schema is a two-step release, in this order:
- Ship the CLI that understands the new schema — raise
PACK_SCHEMA_VERSIONinpackages/core/src/domain/PackSpec.tsin the same change as the field. - Only then publish packs that use it.
Reverse the order and every shipped pack becomes uninstallable on the released
CLI. A unit test asserts that no pack in packs/ declares a schema newer than
the CLI supports, so getting this backwards fails CI rather than users.
Cutting a release#
Update
CHANGELOG.md. Every user-visible change gets a line; group by Added / Changed / Fixed / Removed.Bump
versioninpackage.json.Re-record the landing page's terminal:
npm run build && npm run docs:terminal, then update the version indocs/index.html. The recording carries the version it was made from and a test compares the two, so skipping this fails the build rather than shipping a page that claims the wrong release.Open a PR to
develop— never straight tomain, which is protected: the thirteen required checks must pass.Merge into
develop, wait for CI ondevelopitself, then fast-forwardmainto it. A release is cut from amainthat has been green the whole way, not from one that was red for the twenty minutes it took to notice.Three of
main's required checks (npm audit,CodeQL,SBOM and licences) only run on pull requests, so open a PRdevelop → mainfirst: it runs all of them on the release commit. Then push that exact commit:bash git push origin <release-sha>:refs/heads/main # fast-forward only, never --forceQuote the refspec:
"${R}:refs/heads/main". In zsh,$R:refs/…applies the:rmodifier and no longer names the commit. Read the output.Only once
git rev-parse origin/mainis the release commit, tag itvX.Y.Zand push the tag. On 0.10.0 the tag went out while the push tomainhad failed.publish-npm.ymlfires, and publishes only after the E2E workflow passes on all three platforms.Write the GitHub release notes from the changelog entry.
Run the E2E against what the registry serves:
curl -sfLO https://registry.npmjs.org/@rsaglobaltech/specgate/-/specgate-X.Y.Z.tgz && npm run e2e -- --tarball specgate-X.Y.Z.tgz. The CDN can answer 404 for a few minutes afterdist-tagsalready says the new version; wait, do not republish.
How a release is done today#
With Actions switched off, nothing above runs by itself, so the maintainer runs the gate and switches on the publish workflows for exactly one tag. This is how 0.19.1 shipped:
Run everything the workflows would, on the release branch:
bash npm run build && npm run test:all # the gate publish-npm.yml runs npm run lint && npm run format:check npm run docs:terminal:check && npm run docs:journey:check npm run pack:dry-run node e2e/run.mjs # the E2E the npm workflow gates onChangelog, version, recordings — steps 1–3 of Cutting a release. Say in the pull request that CI did not run and what was run instead.
develop, thenmain— merge intodevelop, open thedevelop → mainpull request, then fast-forwardmainto that exact commit withgit push origin "${R}:refs/heads/main".Publish — enable the publish workflows, tag, push the tag. All three (
publish-npm.yml,publish-github-packages.yml,publish-docker.yml) trigger on the samev*tag:bash for f in publish-npm publish-github-packages publish-docker; do gh workflow enable $f.yml; done git tag -a vX.Y.Z <release-sha> -m "vX.Y.Z" git push origin vX.Y.ZThe tag runs the E2E journeys on Linux, macOS and Windows, then
test:all, then publishes with provenance.Check the registry, then switch the workflow off again:
bash npm view @rsaglobaltech/specgate dist-tags # latest: X.Y.Z npx @rsaglobaltech/specgate@X.Y.Z --version for f in publish-npm publish-github-packages publish-docker; do gh workflow disable $f.yml; doneOnly npm gates on the E2E (Windows included): 0.19.0 failed it after the other two published, so it shipped as 0.19.1 — a published tag is never rebuilt.
Release notes —
gh release create vX.Y.Z --notes-file <changelog entry>.
Pre-releases#
Run publish-github-packages.yml manually (workflow_dispatch) with
dist_tag: beta and a version like 0.2.0-beta.1. Pre-releases never go to
the public npm registry with the latest tag.
Before you tag — the gate#
publish-npm.yml gates on npm run test:all and a pack:dry-run, so a broken
tag cannot publish. Run the same things locally first — finding it here costs a
commit, finding it in the workflow costs a version number:
npm run verify # typecheck · eslint · tests · pack dry-run
npm run format:check # prettier — CI runs it, verify does not
npm run test:all # every suite, including BDD and the package tests
npm run e2e # the packed tarball, installed and driven end to end
npm pack --dry-run # inspect the tarball contentsnpm run e2e (e2e/run.mjs) packs this checkout, installs the tarball into an
empty directory and runs the journeys a team takes against the installed
binary: adopt, new → test → done --strict, a stale matrix, a hand-kept
matrix migrated, ci init, the harness, and an upgrade from the last published
version (--skip-network leaves that one out). It is also the E2E workflow,
on every pull request and on all three platforms, and publish-npm.yml will
not publish a tag unless it passes.
Check the tarball carries what files in package.json lists — bin/,
dist/bin/, dist/scripts/, dist/packages/core/,
dist/packages/mcp-spec-driven/src/, templates/, examples/, vendor/,
README.md and CHANGELOG.md — and nothing else — no .local, no dist/packages/** for
unpublished packages, no coverage output.
Verifying a published release#
npx @rsaglobaltech/specgate@X.Y.Z --version
npx @rsaglobaltech/specgate@X.Y.Z init --yes --dry-run # in an empty directory
npx @rsaglobaltech/specgate@X.Y.Z draft # explains the brief it needsAfter the release#
Write the GitHub release notes from the changelog entry, leave an empty
## [Unreleased] at the top of CHANGELOG.md, and open the next milestone.
Deployment of the site#
pages.yml is the one workflow that stays on: the repository is public, so it
costs no minutes. It deploys with GitHub's Pages actions (no gh-pages branch) on every push to main
that touches any of docs/**, packs/**, packages/pack-registry/**,
scripts/docs/**, package.json, schemas/**, .github/workflows/pages.yml
or README.md — a fix to how the site renders changes no file under docs/.
It can also be run by hand (workflow_dispatch).
A run builds the TypeScript, then writes into _site/:
- The documentation site —
node dist/scripts/docs/build-site.js --out _siterenders every page underdocs/and copies the landing page and assets. - This repository's own coverage report —
/report.html, fromspecgate report— a snapshot from the last deploy. - The pack registry —
/packs/index.htmland/packs/manifest.json, from every pack underpacks/.
Custom domain#
To serve it from a custom domain such as packs.spec-driven.dev:
- Add a repository variable named
SITE_CNAMEset to the domain (Settings → Secrets and variables → Actions → Variables). The workflow only writes aCNAMEfile when the variable exists, so the deployment is safe before the domain is configured. - Point DNS at GitHub Pages — see https://docs.github.com/pages/configuring-a-custom-domain-for-your-github-pages-site.
- Enable HTTPS in Settings → Pages once the certificate is issued.
- Re-run the workflow so the
CNAMEfile is published.
Local preview#
npm run build
node dist/scripts/docs/build-site.js --out _site # without --out it writes ./_site
node dist/packages/pack-registry/src/build.js --out _site/packs
npx serve _site # http://localhost:3000/ — delete _site/ afterVerifying a deployment#
curl -sI https://rsaglobaltech.github.io/specgate/packs/manifest.json | head -1
# expect: HTTP/2 200
curl -s https://rsaglobaltech.github.io/specgate/packs/manifest.json | jq '.packs | length'
# expect: as many packs as packs/ holds — find packs -name pack.yaml | wc -l