Cutting a release publishes eleven artefacts in one shot from a single
v<X.Y.Z> git tag:
| # | Artefact | Registry | Ordering |
|---|---|---|---|
| 1 | xqvm |
crates.io | before xqasm, xqcli |
| 2 | xqasm |
crates.io | before xqcli |
| 3 | xqcli |
crates.io | last Rust crate |
| 4 | xqffi abi3 wheels + sdist |
PyPI | before peers (2 abi3 wheels + sdist) |
| 5 | xqvm_py sdist |
PyPI | before xquad |
| 6 | xqcp sdist |
PyPI | before xquad |
| 7 | xqsa sdist |
PyPI | before xquad |
| 8 | xquad sdist |
PyPI | last -- depends on 4-7 |
| 9 | GitLab Release notes | GitLab | last -- release:notes |
Triggered by .gitlab/ci/release.yml; see that file for the exact
ordering and rules. xqffi the Rust crate stays publish = false
(cdylib-only, consumed via PyPI). The release-notes step generates
notes from conventional-commit history via git-cliff (config in
cliff.toml) and creates the GitLab Release page; no
in-tree CHANGELOG.md exists -- the release page is the canonical
view.
Protected CI variable (set once in Settings → CI/CD → Variables; masked + protected):
CARGO_REGISTRY_TOKEN-- crates.io API token fromops@postquant.xyz. Scope toxqvm,xqasm,xqclipublish-new- publish-update.
PyPI Trusted Publishing (OIDC) -- no long-lived token in CI. Each
of the 5 PyPI projects (xqffi, xqvm_py, xqcp, xqsa, xquad)
must have a GitLab Trusted Publisher configured at
pypi.org/manage/project/<name>/settings/publishing/ pointing at:
namespace = quip.network
project = xquad
pipeline = .gitlab-ci.yml
env = release
The matching release environment must exist in Settings → CI/CD →
Environments, restricted to protected tags v*.
Before cutting a tag:
-
Watch
release:validate. It runs on every pipeline -- MR, push, and the tag pipeline itself -- using the same setuprelease:pypiuses. If it is red onmain, do not merge the release MR: merging is the tagging action, andrelease:auto-tagpushes the tag within seconds of the merge, so there is no window between the two in which to decide not to tag.If a tag does get cut against a red tree, nothing is lost and nothing leaks. The tag pipeline runs
release:validateagain ahead ofrelease:crates, so it fails at the same step with no registry touched. Delete the tag, fix the underlying issue, and re-cut. -
Verify Substrate pallet coordination. Crate renames force major-version bumps downstream. Ping the pallet team before the first
xqvm 0.1.0release so their pins move atomically; for non-breaking bumps (0.1.x → 0.1.y) a ping is courtesy. -
Verify workspace version is bumped. Run
make check-version-sites TAG=vX.Y.Z; it compares every version site in the tree against the tag and names the ones that disagree.make list-version-sitesprints the full list. Between releases main carries the next version with a prerelease suffix, so this step is normally a matter of dropping that suffix rather than choosing a new number.The tag pipeline runs the same comparison inside
release:validate, so a tag cut against an unbumped tree fails beforerelease:cratesand nothing reaches a registry. Running it here is how you find out before the tag exists rather than after. -
Preview release notes with
make changelog-release VERSION=vX.Y.Z. The outputCHANGELOG.mdis gitignored; it lets you sanity-check what the GitLab Release page will say before tagging. The render should contain exactly one## [section, for this release alone --make changelog-release VERSION=vX.Y.Z STRIP=all OUTPUT=-prints it to stdout so you can count. Zero sections means an rc tag in the range is being treated as a release boundary when it should not be; more than one means the range leaked an earlier release's commits in.make check-release-notesis the automated version of this same check, run against every past release inverify:policy. If a conventional-commit subject was poorly worded, fix it on the relevant feature branch and re-merge before cutting the tag. -
Run the release checks locally before pushing with
make check-release(ormake preflight-release, the same thing under a preflight-shaped name; it is deliberately kept out of plainmake preflight). Needsmaturin,twine, anduvonPATH-- it is the samemake -k check-releasethatrelease:validateruns in CI.
- Settings → Repository → Protected tags -- add pattern
v*, allowed to create: Maintainers. This restricts who can push tags manually and ensures the CI-created tag is protected. - Settings → CI/CD → Variables -- add
GITLAB_API_TOKEN(project access token,apiscope, masked).write_repositoryis the minimum needed to push tags; fullapiis simpler to configure. Used only byrelease:auto-tagto look up the merged MR and push the tag. - Settings → Merge requests → Approvals -- enable "Require code
owner approval" for the
mainbranch and set approvals required to 2. This enforces that all MRs go through both@kleczkowskiand@meganathanmanish.
# 1. Create a release branch. The branch name must match release/vX.Y.Z
# exactly -- the CI auto-tag job matches the merge SHA against the MR
# API to find this branch name.
git checkout -b release/vX.Y.Z main
# 2. Bump versions in every manifest, then regenerate the lockfiles.
# `make list-version-sites` prints every site and its current value;
# that list lives in scripts/check-version-sites.py and is not
# repeated here, so the prose cannot fall behind the check. In shape
# it is: every crate manifest, the two workspace dependency aliases in
# Cargo.toml, every pyproject [project] version, xqvm_py/__init__.py,
# every `==X.Y.Z` peer pin including xquad's optional-dependencies,
# and the lockfiles below.
# Then regenerate: `cargo check` (Cargo.lock) and `uv lock` (uv.lock).
# `uv lock` is not optional bookkeeping here -- `make check-uv-lock`
# (`uv lock --check`) runs in `verify:python` and `preflight-py` and
# fails the moment uv.lock is stale against any pyproject.toml, so a
# version bump that forgets it fails CI on this branch rather than
# drifting silently. Also run `cargo update -p xqvm
# --manifest-path fixtures/pallet-xqvm/Cargo.toml` (standalone
# workspace with its own lock, which check-uv-lock does not reach).
# That one is enforced too, but by a different guard: the fixture
# takes xqvm by path, and `make test-substrate-fixture` builds it with
# `cargo test --locked`, so skipping this `cargo update` hard-fails
# test:substrate in CI and `make preflight-rs` locally with a lockfile
# error rather than drifting until the version-site guard notices.
# Then confirm: `make check-version-sites TAG=vX.Y.Z`.
git commit -s -am "chore: bump workspace to X.Y.Z"
# Main carries `X.Y.Z-dev` (Rust) / `X.Y.Z.devN` (Python) between
# releases, so this step usually just drops the suffix. The suffix no
# longer carries the resolution argument it was introduced with:
# `check-crate-publish` packages every workspace member into a scratch
# tree and resolves each against the locally packaged siblings rather
# than against crates.io, so a workspace version that names an
# already-published release can no longer pull a sibling from
# crates.io in place of the local source. See the `check-crate-publish`
# comment in the Makefile for what that target does and why. Keep the
# suffix as the convention that says main is unreleased. After
# tagging, open a follow-up that bumps main to the next `-dev` version.
#
# The two ecosystems spell prereleases differently and always have:
# Cargo wants SemVer (`0.4.0-dev`, `0.3.0-rc1`), Python wants PEP 440
# (`0.4.0.dev0`, `0.3.0rc1`). One version number, two spellings.
# 3. Push and open an MR using the "release" template.
git push -u origin release/vX.Y.ZTitle the MR release: vX.Y.Z. The project squashes on merge with
squash_commit_template = %{title}, so the title becomes a commit
subject and verify:policy checks it against the commit grammar --
release is a type in scripts/commit-grammar.sh for exactly this
reason, and git-cliff drops it.
verify:policy checks the title on the merge request's own pipelines,
via scripts/check-mr-title.sh, so a non-conforming title fails while
correcting it is still cheap. It used to fail only once a merge train
had started, because the squash commit carrying the title existed only
on the train ref; disabling the train left the title unchecked
everywhere until the explicit check replaced that coverage.
One limitation to know about when retitling a release MR: GitLab starts pipelines on push, not on title edits. A title changed after the last push is not rechecked, so retitle before your final push rather than after.
If one slips through anyway, the main pipeline catches it after the
merge. verify:policy finds an empty commit range there and checks
what the push landed instead, which for a merge is the squash commit
carrying the title, so a bad subject fails loudly within minutes. It is
only a detection: the commit is on main and cannot be amended, and a
subject that fails the grammar is dropped from the rendered notes by
filter_unconventional. Expect the gap when previewing with
make changelog-release VERSION=vX.Y.Z STRIP=all OUTPUT=/dev/stdout,
and add the entry by hand on the GitLab Release page.
Open the MR targeting main. Both @kleczkowski and @meganathanmanish
must approve. After approval, merge using any strategy -- squash and
merge commit are both supported. release:auto-tag detects the merged
MR by matching CI_COMMIT_SHA against both squash_commit_sha and
merge_commit_sha in the GitLab MR API.
The merge triggers release:auto-tag on main, which pushes tag
vX.Y.Z. The tag then fires the rest of the release pipeline.
release:auto-tag carries needs: [], so it does not wait for the
merge commit's own verify / test / hardware / docs jobs -- the
tag lands within seconds of the merge, not an hour later. That is
deliberate: the publish gate is release:validate on the tag
pipeline, which every publishing job hangs off by needs:. Waiting
here would only add a third run of a check that has already passed on
this MR, while leaving the tag itself hostage to a flaky hardware job
on the merge commit -- a release that never happens and never goes
red. See the job's comment in .gitlab/ci/release.yml.
If you need to tag without a release MR (e.g., hotfix or RC):
# Bump every version site to X.Y.Z first -- step 2 of the release MR
# flow above. This path has no MR and no review, so nothing else will
# catch an unbumped tree before the tag exists.
make check-version-sites TAG=vX.Y.Z
git tag -s vX.Y.Z -m "xquad vX.Y.Z"
git push origin vX.Y.ZThe tag push triggers the release pipeline identically, including the
same version-site comparison inside release:validate. A tag cut
against an unbumped tree therefore fails ahead of release:crates with
nothing published: delete the tag, bump, and re-cut, as under "If a tag
does get cut against a red tree" above.
Release candidates take this path, and the bump is not optional for
them either. Main carries the next version with a prerelease suffix, so
cutting vX.Y.Z-rc1 means moving every site to X.Y.Z-rc1 (Cargo) /
X.Y.ZrcN (Python) first, and opening the follow-up back to the -dev
version afterwards.
An rc tag runs the full pipeline through release:crates and
release:pypi -- both share the .on-release-tag rule, so rc
artefacts publish the same as any other tag -- but release:notes
carries its own, narrower rule and does not fire on a tag matching
-rc. An rc tag publishes crates and wheels with no GitLab Release
page. This mirrors cliff.toml's tag_pattern, which does not treat
an rc tag as a release boundary either: the rc's commits fold into the
following non-rc release's notes instead of getting a page of their
own that the real release would then have to absorb a second time.
The tag push triggers a fully-automatic pipeline in stage release:
release:validate-- the same job that ran on the release MR's own pipeline, using the same setuprelease:pypiuses.make -k check-releasechecks every version site against$CI_COMMIT_TAG, dry-runs all three crates, and builds,twine checks and smoke-installs all five Python distributions against the tagged commit. The version check is the one part that does nothing on the MR pipeline, where there is no tag to compare against, so the tag pipeline is the first run that exercises it. Nothing is uploaded; this is the gate that catches manifest / license / metadata regressions before any registry sees them -- and because it already ran green on the MR, a broken release setup fails there instead of mid-release. Its last step is a HEAD request againstRELEASE_CLI_URL, the same pinned URLrelease:notesfetchesrelease-clifrom below -- see that job's entry for why.release:crates--cargo publishforxqvm→xqasm→xqcli, in topological order. Fires automatically oncerelease:validatepasses.release:pypi--maturin build+twine upload(OIDC) forxqffi, thenuv build+twine uploadforxqvm_py/xqcp/xqsa/xquad. Fires automatically oncerelease:cratespasses (needs:enforces ordering so PyPI cannot run before crates.io).release:notes-- git-cliff renders the GitLab Release page from the conventional-commit history, scoped to the range between this tag and its nearest non-rc predecessor, so the page carries exactly this release's own section rather than every release reachable from history. Skipped entirely on an rc tag (see "Release candidates" above). Runs afterrelease:pypiso the announcement page goes live only once all artefacts are on the registries. It fetchesrelease-clifrom the sameRELEASE_CLI_URLthatrelease:validateprobes on every pipeline, so a 404 or an unreachable registry now surfaces on a merge request instead of here, last in the tag-only publish chain. It is also the last job, so artefacts are already live and a re-run is safe.
Watch the pipeline. If release:pypi fails, rerun only that job:
twine upload --skip-existing makes a re-run a no-op for anything
already on PyPI. If release:crates fails partway, do not simply
rerun it -- cargo has no --skip-existing, so the retry dies on the
first crate it already published; publish the remaining crates by
hand from the tagged commit instead. Do not retag unless the failure
was a version mistake.
If release:validate fails, no registry has been touched; fix the
underlying issue, force-push to the tag's commit (or move the tag),
and rerun the pipeline.
-
Verify on the registries. All four crates (xqvm, xqasm, xqcli, and -- eventually, once we publish it -- xqffi's cdylib) should show
vX.Y.Zwithin a minute of pipeline completion; all five Python distributions (xqffi,xqvm_py,xqcp,xqsa,xquad) on PyPI within seconds. -
Smoke-test the install. In a fresh venv on your workstation:
python3.13 -m venv /tmp/xquad-smoke source /tmp/xquad-smoke/bin/activate pip install "xquad==X.Y.Z" python -c "import xquad; from xquad import vm, asm; v = vm.VM(); print('ok')"
-
Yank superseded broken releases. If this release exists to replace an unusable one -- v0.3.1's Python wheels carried no importable package directory (QUI-1020) -- yank the old version once the smoke test above passes: the Releases tab under
pypi.org/manage/project/<name>/, andcargo yank --version X.Y.Z <crate>for crates.io. A yank keeps a resolver from selecting the broken version for a fresh install. It does not move anyone who already installed it, and it does not affect an existing environment that already satisfies a requirement -- the exact peer pins in eachpyproject.tomlare what force those forward. -
Notify the pallet team if this was a major bump they're blocked on.
cargo publishfails with "version already exists": someone already published that version. Either bump to the next one or retag to the existing commit on the registry side (rare).twine uploadfails with 400 File already exists: partial previous upload. The--skip-existingflag should make re-runs a no-op; if not, check PyPI and either bump the version or delete the uploaded file (within 24h) and retry.release:pypiruns butpip install xquadstill fails: PyPI index propagation can take a few minutes for the first release of a new package name. Retry after 5 min before digging further.release:validateis green but the publish jobs still fail on the tag:release:validateruns in check mode and needs no registry token on any ref, so a green run there does not prove the publish jobs have what they need.CARGO_REGISTRY_TOKENis a masked, protected project variable, so it is exposed only on protected refs -- an MR pipeline never sees it. The PyPI OIDCid_tokensare minted only forrelease:pypi, the one job carryingenvironment: release, which is itself restricted to protectedv*tags. Check thatCARGO_REGISTRY_TOKENhasProtected: yes, that the tag matches the protectedv*pattern, and that each PyPI project's Trusted Publisher listsenv = release.
- macOS and Windows prebuilt wheels.
pip install xquadships prebuilt abi3 wheels for linux-x86_64 and linux-aarch64 (CPython= 3.13). macOS and Windows users can still install from PyPI -- pip falls back to the published sdist and builds
xqffifrom source, which requires a Rust toolchain (rustc >= 1.85). Native macOS/Windows wheels would eliminate that requirement but need platform-specific CI runners. - Automated version bumps. No
cargo-release/hatch versionintegration yet; versions are edited by hand per the step above.make check-version-sitesverifies the result but does not produce it, so a bump is still as many edits as there are sites. - Signed tags + signed artefacts. Tags are expected to be git-
signed (
git tag -s); crates.io / PyPI artefact signing (sigstore cosign, PEP 740) is not wired. Tracked separately.