Skip to content

ci: PR-based, per-crate releases - #566

Open
JP-Ellis wants to merge 22 commits into
masterfrom
ci/release-plz
Open

JP-Ellis wants to merge 22 commits into
masterfrom
ci/release-plz

Conversation

@JP-Ellis

@JP-Ellis JP-Ellis commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Important

This PR stacks on #564. Two commits must be dropped before merge:

  • ci: TEMPORARY build release artifacts on the release-process PR
  • ci: TEMPORARY gate pact_ffi on the release-process PR

Together they let this PR run the full pact_ffi release build, so its artifacts can be checked before anything is tagged.

Goal

Replace the per-crate release.groovy scripts, run locally by named people, with a PR-based flow:

  1. Each crate has its own draft release PR, kept up to date from master.
  2. Merging a release PR tags the merge commit.
  3. The tag builds, publishes to crates.io through trusted publishing, and creates the GitHub release.

Crates release independently. Downstream FFI installers keep their URLs and asset names until 2027-06-30.

How it works

  • Release PRs. release-pr.yml runs on every push to master.
    • prepare runs release-plz update -p <crate> for each crate and keeps a draft release/<crate> PR.
    • Once a person pushes to a release PR, the bot stops regenerating it.
  • Tags. The tag job runs tag-releases.sh, which tags each crate whose version has no tag.
    • It tags the commit that set the version, which is the release PR's merge.
    • It pushes one tag per push, with the bot App's token, so the release workflow starts.
  • Release workflow. release.yml is generated by dist, with five marked hand edits. For pact_ffi and pact_verifier_cli it runs:
    1. build all eight targets
    2. publish to crates.io (publish-crate.yml)
    3. create the GitHub release
    4. republish legacy asset names (legacy-artifacts.yml)
    5. push the pact-verifier image (publish-containers.yml)
  • Library crates. Tags for the four library-only crates go through release-crate.yml: publish, then a GitHub release.
  • Release gate. Marking a pact_ffi or pact_verifier_cli release PR ready runs the same eight-target build, without publishing.
  • Checks on release PRs.
    • release-deps names any sibling crate that must be released first.
    • On ready PRs, cargo package builds the crate against its released siblings.

RELEASING.md describes the flow for maintainers.

Artifacts

  • Linux builds run in containers so the libraries keep their linkage.
    • gnu builds in manylinux_2_28, a glibc 2.28 floor. That is the oldest glibc among supported major distributions: the RHEL 8 family has 2.28, RHEL 9 and Amazon Linux 2023 2.34, Ubuntu 22.04 2.35, Debian 12 2.36. 0.5.11 needed 2.18; the distributions between 2.18 and 2.28 are all out of support.
    • musl builds in cross-rs's musl images on x64 runners. That keeps pact_ffi's .so linked against musl, and the CLI static.
  • check-linkage.sh checks every Linux library and the CLI before the legacy names are published.
  • Windows.
    • Both Windows targets build with MSVC on Windows runners, and link the runtime dynamically, as before.
    • dist's archives omit the import library pact_ffi.dll.lib. import-lib.sh rebuilds it from the DLL's export table. Against the 0.5.11 DLLs, the rebuilt libraries define the same symbols as rustc's.
  • Legacy names. They are checked against the asset lists of libpact_ffi-v0.5.11 and pact_verifier_cli-v1.3.5. For pact_ffi they go to a libpact_ffi-v<version> release, which never becomes "latest".
  • Docker. The image is one multi-platform manifest, tagged <version> and latest. The per-architecture tags <version>-amd64 and <version>-arm64v8 are no longer pushed.

Commit sequence

  1. chore: reset crate versions to their last releases: each version equals its last tag, so the first run tags nothing.
  2. build: patch pact_matching by path alone: a version requirement in [patch] breaks cargo update once release PRs bump the crate.
  3. build: configure release-plz for per-crate releases: registry mode, the changelog template, changelog-section.sh, and the new tools in mise.toml.
  4. ci: block release PRs that need an unreleased workspace crate
  5. ci: maintain one draft release PR per crate and tag merged releases
  6. ci: publish crates through crates.io trusted publishing
  7. ci: build releases with dist
  8. ci: republish pre-dist asset names during a transition period
  9. ci: publish the pact-verifier image after each CLI release
  10. ci: release library-only crates from their tags
  11. chore: remove manual release tooling: the groovy and shell release scripts, Cross.toml, and the Conan recipes. The Conan remote is deactivated, and the recipe has not moved past 0.0.4 since 2021.
  12. fix(ci): tag the commit that set each released version
  13. fix(ci): guard tagging and release PRs against crates.io state
  14. fix(ci): build Linux releases in containers to keep their linkage
  15. fix(ci): give each musl crate its own CRT linkage, drop manual release-pr runs
  16. ci: TEMPORARY build release artifacts on the release-process PR: drop before merge.
  17. feat(ci): check release gate artifacts locally with legacy-artifacts.sh
  18. docs: describe checking a release gate build before merge
  19. fix(ci): plan a release gate build for the release PR's own tag: without a tag, dist plans both dist crates together and refuses their unrelated versions. This PR's first build caught it.
  20. ci: TEMPORARY gate pact_ffi on the release-process PR: drop before merge.
  21. fix(ci): install xz in the musl build containers: dist's installer needs xz, and the cross-rs images lack it.
  22. fix(ci): run the header build from pact_ffi's directory: dist runs extra-artifact builds from the package directory.

Alternatives weighed

  • release-plz git_only mode. It opens the git repository at the Cargo workspace directory without searching parent directories, so it fails with the workspace in rust/. Registry mode works from rust/. refactor: move the Cargo workspace to the repository root #565 proposes moving the workspace to the root, separately.
  • release-plz release for tagging. In registry mode it would decide by crates.io, which lags the tag by a full release build. tag-releases.sh decides by tags.
  • Building musl on Ubuntu or in Alpine.
    • On Ubuntu, a musl cdylib links glibc's libc.so.6.
    • On arm64 runners, GitHub has no Alpine Node for JavaScript actions.
  • dist's default cargo-xwin for aarch64 Windows. It cross-compiles on Linux, unlike every release so far.

Before merge

  • Driver release. fix(driver): move to zip 9, since every zip 8 release is yanked pact-plugins#118 must be released. cargo-semver-checks resolves pact-plugin-driver from crates.io, and every 1.2.x release requires zip 8.x, which is all yanked.
  • Full artifact check. Done on run 38863282720: all eight targets and the headers built; the legacy names match; gnu tops out at GLIBC_2.28; musl needs musl's libc.so and libgcc_s.so.1, like 0.5.11; every build used rustc 1.99.1. The temporary commits run the pact_ffi release build on this PR, draft or not. Run the artifact check on that run with tag pact_ffi-v0.5.11. pact_verifier_cli is first built by its own release PR's gate.
  • Drop both temporary commits, and rebase onto master after ci: consolidate and speed up CI #564 merges.

Maintainer setup

  • crates.io trusted publishers. For each of the six crates, add one with repository pact-foundation/pact-reference and environment crates-io.

    • Workflow release.yml for pact_ffi and pact_verifier_cli.
    • Workflow release-crate.yml for pact_models, pact_matching, pact_consumer and pact_verifier.
  • crates-io environment. Create it.

  • Bot App. Install PACT_FOUNDATION_BOT on this repository, with contents and pull-request write access.

  • Branch protection. Allow the bot to push release/* branches and tags. Require CI complete.

  • Container registries.

    • Make DOCKER_HUB_USERNAME and DOCKER_HUB_TOKEN available to this repository.
    • Give the repository write access to the ghcr.io/pact-foundation/pact-verifier package.
  • Migration tags. Push them before merging:

    git fetch --tags origin
    git tag pact_models-v1.3.16 'libpact_models-v1.3.16^{}'
    git tag pact_matching-v2.0.11 'libpact_matching-v2.0.11^{}'
    git tag pact_ffi-v0.5.11 'libpact_ffi-v0.5.11^{}'
    git push origin pact_models-v1.3.16 pact_matching-v2.0.11 pact_ffi-v0.5.11

    Without them, the first tag run would find these versions untagged. tag-releases.sh refuses to tag a version already on crates.io, but the tags are needed for release-deps and the changelog links.

First release: pact_ffi, live

The first release is the rehearsal, so check each step before taking the next.

  1. Merge this PR.
    • Watch the Release PR workflow on master.
    • tag creates no tags.
    • prepare opens draft release/<crate> PRs for crates with changes.
  2. Review release/pact_ffi.
    • Check the version and the changelog section; edit the branch if needed.
    • Mark it ready. test.yml runs, including release-deps and cargo package, and so does the eight-target release gate.
  3. Check the gate's artifacts with the artifact check, using the new version's tag name.
  4. Merge release/pact_ffi.
    • The tag job pushes pact_ffi-v<version>.
    • The release workflow runs plan, build, host, publish, announce and the legacy artifacts.
  5. Verify.
    • crates.io lists pact_ffi <version>.

    • Release pact_ffi-v<version> has eight archives, with .sha256 files, plus pact.h and pact-cpp.h.

    • Release libpact_ffi-v<version> is not marked latest, and its assets match the expected list:

      diff .github/scripts/tests/legacy-expected-ffi.txt \
           <(gh release view "libpact_ffi-v<version>" --json assets --jq '.assets[].name' | sort)
    • A downstream installer can fetch the new version. pact-python and pact-js build releases/download/libpact_ffi-v<version>/<name>.

If a step fails

Failure State Recovery
Build Tag only; nothing published Transient: re-run the failed jobs. Code fix: release the next patch through a new release PR.
Publish Tag and build; no crates.io version, no GitHub release Fix the cause, e.g. the trusted publisher config, then re-run the failed jobs.
Announce Published to crates.io; no GitHub release Re-run the failed jobs. Publish skips a version already on crates.io.
Legacy artifacts GitHub release done; legacy names missing Fix, then run the Legacy artifacts workflow manually with the tag. A linkage failure here means the release's own archives are mislinked too.
tag job Release PR merged, no tag Re-run it, or push any commit to master. The script tags the release merge commit either way.

Re-run failed jobs only. A full re-run of the release workflow fails at release creation once the release exists.

Checking a release build's artifacts

This runs against a release gate run, or this PR's own run while the temporary commit is in place. It needs gh, LLVM (llvm-dlltool, llvm-readobj) and readelf. On macOS, use Homebrew's llvm.

run=<release workflow run id>
gh run download "$run" -R pact-foundation/pact-reference -p 'artifacts-*' -D gate
mkdir -p gate-flat
find gate -type f \( -name '*.tar.xz' -o -name '*.zip' -o -name 'pact*.h' \) -exec cp {} gate-flat/ \;

LEGACY_ARCHIVES=gate-flat .github/scripts/legacy-artifacts.sh pact_ffi-v<version> out-ffi
diff .github/scripts/tests/legacy-expected-ffi.txt <(ls out-ffi | sort)

LEGACY_ARCHIVES=gate-flat .github/scripts/legacy-artifacts.sh pact_verifier_cli-v<version> out-cli
diff .github/scripts/tests/legacy-expected-cli.txt <(ls out-cli | sort)

gh run view "$run" -R pact-foundation/pact-reference --log | grep -c 'rustc 1.99.1'

legacy-artifacts.sh fails if any of these is wrong:

  • a musl library doesn't link musl
  • the CLI isn't static
  • a gnu artifact needs a glibc newer than 2.28
  • a Windows import library can't be rebuilt

The two diffs print nothing when the asset names match. Every build job's log should show rustc 1.99.1, because 1.99.0 miscompiled C bool returns.

@JP-Ellis
JP-Ellis added this pull request to stack #567 October 16, 2026 06:34
Base automatically changed from ci/overhaul to master October 17, 2026 01:21
Release tooling now treats a version as released when its tag exists.
The pre-bumped versions (one patch ahead of each tag) would be tagged
and released on the first run. Release PRs now carry each bump.

Re-resolving the lockfile after the reset moves a few Windows-only
dependencies from windows-sys 0.61 to 0.60, within their declared
ranges.

Assisted-by: Claude Code:claude-opus-5-5
A version requirement in a [patch.crates-io] entry has to match the
local crate. Release PRs bump pact_matching, so the requirement would
break `cargo update` on every minor or major bump. The path alone is
enough, as it already is for pact_models.

Assisted-by: Claude Code:claude-opus-5-5
release-plz computes each crate's next version and changelog from the
conventional commits since its last crates.io release. It runs in
registry mode: git_only mode opens the git repository at the Cargo
workspace directory, and fails with the workspace in rust/. Tagging,
publishing and GitHub releases stay with the workflows.

New changelog sections go above the existing entries, under a
`# Changelog` header that replaces the old git-log instructions.

changelog-section.sh extracts one version's notes for release PR bodies
and GitHub releases. It stops at the next section heading, including
the single-`#` headings of entries written before release-plz.

mise pins release-plz, cargo-semver-checks and dist.

Assisted-by: Claude Code:claude-opus-5-5
A crate's published manifest requires its siblings at the versions in
its Cargo.toml. If such a version has no tag, that sibling has not
been released, and publishing would fail after the artifacts were
built. The check names the sibling to release first.

It runs on draft release PRs too, since it only reads cargo metadata
and tags.

Assisted-by: Claude Code:claude-opus-5-5
release-plz supports only a single release PR, so crates could not ship
independently. A matrix now runs `release-plz update -p <crate>` and
keeps a draft `release/<crate>` PR. Once someone pushes to that PR, the
bot leaves it alone, so manual edits to versions or changelogs survive
until merge.

On every push to master, tag-releases.sh tags each crate whose version
has no tag yet. A crate's version only moves when its release PR
merges, so a missing tag marks a merged release. Each tag goes out in
its own push with the bot App's token, so the release workflow starts.

The check reads tags, not crates.io. Between the tag and the publish,
the registry lags master, and a registry-based check would tag twice.

Assisted-by: Claude Code:claude-opus-5-5
The tag workflow calls this after every artifact has built, because a
crates.io publish cannot be undone. A version already on crates.io is
skipped, so a re-run after a later failure succeeds. Forks can set
PUBLISH_DRY_RUN to rehearse a release.

`--locked` builds the packaged crate against the workspace lockfile, so
the published crate is verified with the dependency versions CI tested.

Assisted-by: Claude Code:claude-opus-5-5
dist builds pact_ffi (shared and static libraries plus generated
headers) and pact_verifier_cli on native runners for all eight targets,
replacing the cross/QEMU release scripts. The pinned toolchain from
rust-toolchain.toml builds every target; dist adds each target to it.

On a tag push, dist builds everything first, then publishes the crate,
then creates the GitHub release. Ready release PRs for these two crates
run the same build as a gate. Other PRs, and tags of library-only
crates, never do.

Two settings keep the artifacts as they were:
- pact_ffi's musl builds link the C runtime dynamically, because rustc
  drops a cdylib on a crt-static target. dist forces +crt-static
  through RUSTFLAGS, so build-setup.yml overrides it with
  CARGO_ENCODED_RUSTFLAGS.
- Windows builds link the MSVC runtime dynamically (msvc-crt-static =
  false), as releases always have.

dist reads `repository` to find the GitHub repo and can't parse the
blob URLs, so both crates now name the repository root.

The headers need nightly cbindgen in the global-artifacts job, which
build-setup steps don't reach. That job carries a marked hand edit
installing cbindgen from a prebuilt binary; build-headers.sh installs a
minimal nightly when missing.

Assisted-by: Claude Code:claude-opus-5-5
Downstream installers download `libpact_ffi-v<version>/<legacy name>`
and `pact-verifier-<os>-<arch>.gz`. The job repackages dist's archives,
with no rebuild, into those names. For pact_ffi it publishes them
under a `libpact_ffi-v<version>` tag and release, which never becomes
"latest". It self-disables on 2027-06-30.

dist's Windows archives hold the DLL and the static library but not
the DLL's import library (`pact_ffi.dll.lib`). import-lib.sh rebuilds
it from the DLL's export table with llvm-dlltool, marking exports
outside the executable section as DATA. Against the 0.5.11 release
DLLs, the rebuilt libraries define the same symbols as rustc's, for
x86_64 and aarch64.

legacy-artifacts.test.sh runs the script against archives in dist's
layout through a stub `gh`, and checks the names it produces against
the asset lists of libpact_ffi-v0.5.11 and pact_verifier_cli-v1.3.5.

Assisted-by: Claude Code:claude-opus-5-5
The image was built and pushed by hand. It now builds for amd64 and
arm64 from the release's static musl archives, and is pushed to Docker
Hub and GHCR as `<version>` and `latest`. The per-architecture tags
(`-amd64`, `-arm64v8`) are replaced by one multi-platform manifest.

Assisted-by: Claude Code:claude-opus-5-5
dist only builds crates with artifacts. Tags for pact_models,
pact_matching, pact_consumer and pact_verifier publish the crate, then
create a GitHub release from the changelog section.

Assisted-by: Claude Code:claude-opus-5-5
Releases now go through per-crate release PRs, tag-triggered dist
builds and trusted publishing; RELEASING.md describes the flow.

The Conan recipes are removed. Their remote
(pactfoundation.jfrog.io) is deactivated, and the recipe version had
not advanced past 0.0.4 since 2021.

pact_verifier's own release scripts go too: they packaged a shared
library that no release has shipped in years. The crates.io ownership
steps in CONTRIBUTING.md are gone, since trusted publishing releases
without a personal token.

Assisted-by: Claude Code:claude-opus-5-5
The Release PR workflow shares one concurrency group, and GitHub drops
a pending run when a newer one queues. The run that catches up then
starts at a later commit, and tagging HEAD would put the release tag
past the release PR's merge.

tag-releases.sh now tags the most recent commit on master's
first-parent line whose diff wrote the crate's current `version` line:
the release PR's squash commit or merge commit.

Assisted-by: Claude Code:claude-opus-5-5
Three gaps between the tags and the registry:

- A version on crates.io without a tag means a migration tag was never
  pushed, or a manual publish. tag-releases.sh used to tag it, on
  whatever commit last set the version, and start a release of a
  version that already exists. For pact_ffi 0.5.11 that release would
  overwrite the real libpact_ffi-v0.5.11 legacy assets. It now warns and
  skips.
- Between a release tag and its publish, a full release build, release
  PRs regenerated against a registry that lacks the new version. The
  prepare job now waits for the publish.
- release-deps checked only that sibling requirements name tagged
  versions. A crate using a sibling's unreleased code passed, and failed
  only at `cargo publish`, after its tag. Ready release PRs now run
  `cargo package`, which builds against the released siblings.

crate-published.sh holds the sparse-index lookup that publish-crate.yml
used inline; CRATES_INDEX_URL points the tests at a local index.

Assisted-by: Claude Code:claude-opus-5-5
dist's default Linux runners changed what the libraries link against:

- A musl cdylib linked on a glibc host needs glibc's libc.so.6, so
  pact_ffi's musl `.so` would not load on Alpine. The musl targets now
  build in cross-rs's musl toolchain images, which built every musl
  release so far, on x64 runners. GitHub has no Alpine Node for its
  arm64 runners, so an Alpine container there could not run checkout.
- Ubuntu 22.04 and 24.04 runners raise the glibc floor to 2.34 or more.
  The gnu targets now build in manylinux_2_28 (AlmaLinux 8): glibc 2.28,
  the oldest among supported major distributions (RHEL 8 and its
  rebuilds). The 0.5.11 libraries needed 2.18; distributions older than
  RHEL 8 are out of support.

check-linkage.sh asserts both: musl libraries link musl and the CLI is
static, and gnu artifacts need glibc 2.28 at most. legacy-artifacts.sh
runs it before packaging, so a mislinked library never reaches the
names downstream installers download.

Also in the dist config:
- aarch64-pc-windows-msvc stays on a Windows runner with MSVC; dist
  would otherwise cross-compile it with cargo-xwin.
- The GitHub release is created after the crates.io publish
  (github-release = "announce"), so a failed publish leaves no release.
- The generated workflow pins its actions by commit.
- No step installs protoc: pact-plugin-driver compiles its protobufs
  only when PACT_PLUGIN_BUILD_PROTOBUFS is set.

Assisted-by: Claude Code:claude-opus-5-5
…e-pr runs

The musl CRT override was job-wide and keyed on the tag. dist compiles
every dist crate in a job, so a pact_verifier_cli job built pact_ffi
without the override, and a release gate PR could not get both crates
right. With precise-builds each crate gets its own cargo invocation, and
dist-cargo.sh, the cargo dist runs in musl jobs, applies the dynamic CRT
to pact_ffi's alone.

dist-cargo.sh also turns the `cargo zigbuild` that dist picks for
aarch64-musl in an x86_64 container back into `cargo build`, so the
cross-rs image's musl toolchain links it. dist would otherwise try to
pip-install cargo-zigbuild, which the Ubuntu-based image cannot, and zig
would replace the toolchain the image exists to provide.

release-pr.yml loses its manual trigger. Every case it served already
recovers on its own: failed runs re-run, closed release PRs return on
the next master push, and missing tags are created then too. Run on
another branch, it would have tagged that branch's versions and started
a release.

Assisted-by: Claude Code:claude-opus-5-5
Drop this commit before merging.

The release workflow builds on pull requests without publishing: host,
publish, announce and the post-announce jobs need a tag. Letting this
PR's branch through runs the full eight-target dist build, in the real
containers and runners, so its artifacts can be checked before anything
is tagged.

Assisted-by: Claude Code:claude-opus-5-5
With LEGACY_ARCHIVES pointing at a directory of dist archives, such as a
downloaded release gate run, legacy-artifacts.sh reads them there in
place of a release. The same run checks every Linux library's linkage,
rebuilds the Windows import libraries and produces the legacy names, all
before anything is tagged.

Assisted-by: Claude Code:claude-opus-5-5
Assisted-by: Claude Code:claude-opus-5-5
Without a tag, dist plans every dist crate at once. With pr-run-mode
"upload" it then needs one coherent announcement, and pact_ffi and
pact_verifier_cli never share a version, so the gate failed at `dist
plan`. The plan job now derives `<crate>-v<version>` from the release
PR's branch and the crate's manifest, and plans and builds that tag.
Nothing publishes: a pull request still skips host and every later job.

Assisted-by: Claude Code:claude-opus-5-5
Drop this commit before merging, with the other TEMPORARY commit.

The release-process PR's branch names no crate; this maps it to
pact_ffi, the first crate to release.

Assisted-by: Claude Code:claude-opus-5-5
dist's installer unpacks a .tar.xz, and the cross-rs musl images ship
no xz, so both musl jobs failed at "Install dist". The musl setup step
now installs xz-utils when it is missing.

Assisted-by: Claude Code:claude-opus-5-5
dist runs an extra-artifacts build command from the package's
directory, so the repository-relative `rust/pact_ffi/build-headers.sh`
did not exist there and the global artifacts job failed. A local
`dist build --artifacts=global` now produces pact.h and pact-cpp.h.

Assisted-by: Claude Code:claude-opus-5-5
@JP-Ellis
JP-Ellis marked this pull request as ready for review October 17, 2026 03:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant