Skip to content

Latest commit

 

History

History
333 lines (268 loc) · 20.1 KB

release-process.md

File metadata and controls

333 lines (268 loc) · 20.1 KB

Release Process

How a release is cut: what triggers it, what each of the six build legs produces, how the packages are verified, and what the process still does not check.

RELEASE.md is the history of what shipped, not the procedure — and since 1.6.0 it is also not the body of any Release. publish slices that file by tag through tests/tools/release_notes.py, which emits a six-platform download table, the ## vX.Y.Z section for that tag, and a pointer to CHANGELOG.md. This page is the procedure.

Trigger

.github/workflows/release.yml runs on either:

  • a push of a v* tag, or
  • workflow_dispatch, which takes a tag input (it must already exist) and a dry_run boolean (default false).

A re-run is idempotent by design, and this is load-bearing: the first attempt usually fails on one platform, and the fix is to re-run just that one. publish uploads with gh release upload --clobber, and edits the Release rather than erroring if it already exists.

dry_run: true builds and packages everything and then reports what would be published, without touching any public ref. Use it to rehearse.

The six legs

Job Runner Toolchain
mingw-x64 windows-latest MSYS2 MINGW64 (GCC)
mingw-arm64 windows-11-arm MSYS2 CLANGARM64 (clang, libc++, not libstdc++)
msvc-x64 windows-2022 Visual Studio 17 2022, x64
msvc-arm64 windows-11-arm Visual Studio 17 2022, arm64
linux-x64 ubuntu-22.04 GCC
linux-arm64 ubuntu-22.04-arm GCC

mingw-x64 is the primary artifact: its dependencies are fully static, so it runs on any 64-bit Windows without shipping extra DLLs.

mingw-arm64 is not "the same leg with a different -A". MSYS2 has no aarch64 GCC, so arm64 MinGW means clang in a libc++ + libunwind environment rather than libstdc++. Its self-containment assertion matters more than x64's, not less.

Each leg builds a Release tree and a Debug tree, runs the tests, asserts the product's shape, packages both into one zip, and uploads it as an artifact.

The four single-configuration legs build two separate trees and pass the second as --debug-tree (build-mingw + build-mingw-dbg, build-linux + build-linux-dbg). The two MSVC legs pass the same tree twice — --tree build-msvc --config Release --debug-tree build-msvc — because the Visual Studio generator is multi-config and holds both configurations in one tree. So --debug-tree does not imply a second build directory; it names where the Debug artifacts can be found.

The static recipe

Every leg configures with plain cmake and passes two switches that local development does not:

-DBUILD_SHARED_LIBS=OFF -DUVCPP_BUILD_LIBUV_FROM_SOURCE=ON

The first makes the FetchContent dependencies static (otherwise llhttp.dll); the second makes libuv static by building it from source (otherwise the system import library gives you libuv-1.dll). Passing only one still ships an extra DLL. There is no .dll-side symptom of getting this wrong that a naive check would catch, which is why the assertion below is a whitelist.

Self-containment is asserted as a whitelist

The MinGW legs run objdump -p over the DLL and require every imported module to be Windows itself — KERNEL32, WS2_32, CRYPT32, api-ms-win-* and so on. Anything outside that list fails the leg.

It is a whitelist rather than a blacklist on purpose, and the reason is a real escape: an early blacklist grepped for libgcc|libstdc|libwinpthread and missed OpenSSL, because MSYS2 installs both libssl.a and libssl.dll.a and find_library prefers the .dll.a. The build quietly gained a libssl-3-x64.dll dependency and the assertion passed it. The two failure directions are not symmetric: a whitelist that is too strict blocks a release (visible, one line to fix), while a blacklist that is too loose ships a package that will not start on a clean machine.

Both the Release and Debug trees are checked. They are two independent configures, so a switch omitted from one produces an extra DLL in that one only — and it would be the Debug build, the one a user reaches for exactly when they are already debugging something.

Per-leg self-verification

Beyond the import whitelist, each leg asserts what only it can see:

  • MSVC arm64 asserts the generator and platform are exactly Visual Studio 17 2022 / arm64. A drifting runner image would silently change the ABI and make this leg incompatible with msvc-x64.
  • MSVC legs assert a list of UVCPP_* cache entries are ON. find_package(OpenSSL QUIET) silently sets UVCPP_ENABLE_OPENSSL to OFF when it cannot find OpenSSL, and the generated header then honestly reports 0 — so a package with no SSL at all still builds, passes its tests and packages cleanly. .github/scripts/win-openssl-deps.sh runs first specifically so the leg does not proceed without an OpenSSL: it either finds one (the image's own copy counts, and it names the path and version it found) or exits 1. A bare choco install openssl used to stand in for that, but it stopped working on both Windows images — see doc/ci-guide.md §6.
  • The ABI assertions use sed -n, not grep. GitHub's shell: bash runs under -eo pipefail, so a grep with no match returns non-zero, -e aborts the whole step, and the echo / ::error lines never execute — leaving a bare "exit code 1" with no explanation.

What package_release.py does that cmake --install cannot

The packager deliberately bypasses cmake --install and reproduces the install layout by hand. The reason is that libuv arrives via FetchContent_MakeAvailable, which registers install rules referencing a libuv.dll that is never built — and those rules are ordered before this project's. The install step therefore aborts as a whole and produces none of this project's headers or libraries.

The packager also verifies its own output, and these checks are not decoration:

  • Debug artifacts carry CodeView. Each Debug DLL must contain an RSDS debug directory naming its own .pdb; zero RSDS hits means /DEBUG did not take effect.
  • The .pdb matches the .dll. A .pdb is matched by GUID + age, recorded in both the PE debug directory and the PDB stream. A mismatch means the .pdb belongs to a different build, and a debugger would either refuse it or silently use wrong symbols.
  • Headers get a BOM if they need one. See build-guide.md.

What publish checks that no single leg can

publish needs all six legs, merges their artifacts, and runs one cross-leg gate: every zip must contain a debug library (uvcppd.* / libuvcppd.*) and a uvcpp-debug.pc, and every -msvc- zip must additionally contain uvcppd.pdb.

This exists because the packager cannot catch it. If a leg forgets --debug-tree, the packager prints "this package does not contain a debug build" and succeeds — each package is verified on its own leg, so nothing there notices that one package differs from the other five. The result would be five platforms with debug artifacts and one without.

The MSVC .pdb requirement is stated separately because MinGW and Linux embed debug info in the shared library and have no separate symbol file. Do not ask all six for the same shape.

Then it builds the Release body from the tag and creates or updates the Release:

python3 tests/tools/release_notes.py --tag "$TAG" --repo "$GITHUB_REPOSITORY" \
  --out "$RUNNER_TEMP/release-notes.md"
gh release create "$TAG" --title "libuvcpp $TAG" --notes-file "$RUNNER_TEMP/release-notes.md"
# or, if it already exists:
gh release edit "$TAG" --title "libuvcpp $TAG" --notes-file "$RUNNER_TEMP/release-notes.md"
gh release upload "$TAG" dist/*.zip --clobber

The slicing step runs unconditionally, so dry_run rehearses it too: the thing most likely to be exercised for the first time on release day is the step that reads the tag. It exits 0 when a body was produced, 1 when a criterion failed (no section for that tag; the header claims to be a release but disagrees with the tag), and 3 when a premise is missing (RELEASE.md unreadable, tag not of the form vX.Y.Z). 3 means "not judged", not "green" — as everywhere else in tests/tools/, a non-zero exit fails the job and the distinction only matters when reading the log.

Before 1.6.0 this step did not exist and --notes-file RELEASE.md published the entire eight-hundred-line archive as every Release's body: a reader arriving at the page had to find "what is new in this version" themselves, and the one thing they most wanted — which six packages this version has, and where to click — was not in the file at all. check_ci_layout.py criterion 13 fails if release_notes.py stops being invoked or if --notes-file RELEASE.md comes back.

The version in the zip names and in uvcpp.pc's Version field is read by the packager from src/uvcpp/uvcpp_version.h. It is not written anywhere in the workflow — an early env: UVCPP_VERSION: '1.1.0' sat in the file with zero references, still saying 1.1.0 when the tag was v1.1.5, and was removed rather than left available as a false "source of truth".

Cutting a release by hand

  1. Bump the major/minor and flip the release flag in src/uvcpp/uvcpp_version.h: set UVCPP_VERSION_MAJOR/MINOR/PATCH, and set UVCPP_VERSION_IS_RELEASE to 1.
  2. Update the four version strings in the two READMEs. Each README carries the version twice — a shields badge and a bold Version / 版本 line.
  3. Run python tests/tools/check_doc_versions.py. It asserts the README strings equal the one in the source tree, so steps 1 and 2 cannot drift apart unnoticed. It runs in CI on every push.
  4. Add this version's entry to CHANGELOG.md. That file is the single changelog now — the two READMEs only point at it. Add the version to the per-version list at the end and, when the release folds in development lines, say so in the topic section each folded change belongs to.
  5. Add the new version's section to RELEASE.md — a ## vX.Y.Z 重点 (Highlights) heading. That section, and only that section, becomes the Release body, so write it to stand alone: it is what a reader sees directly under the download table, with no lead-in from the file's preamble. Everything at or below ## 以下为归档 is archive and never published. Rehearse the slice locally first — python3 tests/tools/release_notes.py --tag vX.Y.Z | head -40 — and check it against the real dist/*.zip names rather than against the naming formula.
  6. Commit, tag vX.Y.Z, and push the tag.

Between releases, the tree sits at <next>-dev: each feature push bumps the patch level and keeps UVCPP_VERSION_IS_RELEASE at 0, so the tree reports e.g. 1.2.1-dev. The major/minor bump and the IS_RELEASE flip happen together in the release commit.

Re-configure after changing the version header. cmake --build rebuilds nothing: the value is read at configure time and the library does not include the header. Re-run cmake -S . -B <tree>, and check the version string in the generated uvcppConfig.cmake rather than the artifacts' timestamps.

Distro packages and the package repositories

Since 1.7.4 a release also publishes installable packages and keeps two signed repositories on GitHub Pages, so apt install libuvcpp-dev and dnf install libuvcpp-devel work without downloading a zip. Three extra steps ride on the same workflow; none of them changes the six zips.

A second build tree (build-pkg), on the two Linux legs only. It is configured like the release legs — same module switches, same static OpenSSL — with three deliberate differences:

release legs build-pkg
libuv built from source, linked statically system libuv1-dev, libuv.so.1
zlib CMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON system zlib1g-dev, libz.so.1
SONAME off (UVCPP_SET_SOVERSION defaults to OFF) on — libuvcpp.so.1.7.4 → .so.1 → .so

The reason the package has to link the system libuv is the public headers: they #include <uv.h>, and uvcpp_loop wraps uv_loop_t. A package that quietly carried its own libuv would put two instances in one process as soon as a consumer compiled against libuv1-dev. The zips are the opposite trade: self-contained, no system libuv, and therefore never installed into /usr/lib. Mixing the two is the hazard.

build-pkg is built and tested (ctest, same exclusions as the release leg) before anything is packaged; that run is the only place this configuration is exercised. Two assertions follow the build: the .so's DT_NEEDED must contain libuv.so.1, libz.so.1, libstdc++.so.6, libgcc_s.so.1, libc.so.6 and none of ssl/crypto/brotli/llhttp/nghttp2/ngtcp2/nghttp3/sqlite3/mysqlclient/pq/odbc, and nm -D -u must show no unresolved mysql_/PQ/SQL[A-Z] symbols. The install prefix is then checked to contain none of include/nlohmann, include/libuv, zlib.h, zconf.h — those belong to the -dev packages' dependencies, and shipping them too makes dpkg refuse the install with a file conflict.

packaging/nfpm/make_packages.py turns that prefix into libuvcpp1 / libuvcpp-dev (.deb) and libuvcpp1 / libuvcpp-devel (.rpm), using nfpm pinned by version and sha256 in packaging/nfpm/nfpm.pin. Keep the pin: a floating packager turns one tag into two different packages.

The Windows installers are built by the two MSVC legs from the stage directory package_release.py already produced — packaging/windows/libuvcpp.iss does not re-list the files, it packs the whole directory. That stage has already passed the import-table and debug-CRT checks; a second file list would be a second truth. Signing is enabled by secrets (WINDOWS_CERT_PFX_BASE64, WINDOWS_CERT_PASSWORD); without them the installer is still built, unsigned, with a ::warning — a missing certificate is a configuration problem, not a build failure.

publish-repo (needs: [linux-x64, linux-arm64], skipped on dry_run) assembles the gh-pages tree with packaging/repo/publish.py: apt/ gets InRelease, Release.gpg and per-architecture Packages; rpm/ gets repodata/ plus a detached repomd.xml.asc. When GPG_PRIVATE_KEY is absent the job skips the whole publish with a warning instead of failing the release or publishing an unsigned repository — the packages and zips still ship, and the apt/dnf channels simply do not exist until the key is configured (the next real release creates them). The .rpm files themselves are not signed (the trust anchor is repomd.xml), so the shipped .repo file says gpgcheck=0 + repo_gpgcheck=1. Signing the packages instead would have to happen before the Release upload, or the same version's sha256 would differ between the two copies.

The site is incremental: publish-repo checks out the previous gh-pages, adds the new packages, rebuilds the indexes over the whole tree, and prunes to the newest --keep (default 5) versions per package per architecture — GitHub Pages caps a site at 1 GB.

One-time setup before the first release that uses these channels:

  1. Create the repository signing key and put the private key and fingerprint in the repository secrets GPG_PRIVATE_KEY and GPG_KEY_ID. The export commands are in packaging/README.md (the "首次发布前的一次性准备" section).
  2. Convert the Windows code-signing PFX to base64 and store it plus its password as WINDOWS_CERT_PFX_BASE64 / WINDOWS_CERT_PASSWORD.
  3. After the first publish-repo run creates gh-pages, point Settings → Pages at that branch. Until then the site 404s; the job itself is green.

All three are done for this repository: the channels went live with v1.7.4 (gh-pages at 7dffd00, signed by 0D7F 9D96 … 7C3A 011C), and both installers are signed with the self-signed certificate — signtool reports success, while SmartScreen still warns because the certificate is not from a public CA.

Known gaps

These are recorded rather than fixed. They are real, and a reader should not assume the process covers them.

Nothing asserts the tag matches the version header — almost. Push v1.3.0 against a tree whose header says 1.2.0 and every leg builds happily, the packages are named 1.2.0, and the Release is published under the tag v1.3.0. Since 1.6.0 release_notes.py checks one direction: if the header says it is a release (UVCPP_VERSION_IS_RELEASE non-zero) and its version disagrees with --tag, it exits 1 and publish stops before creating anything.

The remaining hole is deliberate, not an oversight. The check is skipped when the header reports a development tree, because master always carries the next version as -dev and re-publishing a historical tag (workflow_dispatch with a tag that already exists, to reformat an old Release) legitimately runs on exactly that tree — a stricter rule would block the path this document tells you to take. The symptom still surfaces, one step later: the download table's URLs are built from the tag while the asset names come from the header, so tagging v1.6.0 over a 1.5.3-dev tree publishes a table of 404s.

check_doc_versions.py has a --this-is-a-release flag ("judge by release rules, ignoring the header's flag") that would be the right tool for the strict form of this assertion, but it has no call sites — not in CI, not in the workflows. It was added for manual use and never wired in.

--tag v1.0.0 cannot produce a body. RELEASE.md has no v1.0.0 section — that tag predates the per-version sections, and it has no GitHub Release either — so release_notes.py exits 1 with that reason. Every tag that does have a Release slices fine: v1.1.0 through v1.5.0 were each verified against the live asset list, including v1.1.0, whose heading uses the older ## 新增模块 (New in v1.1.0) shape instead of ## vX.Y.Z 重点 (the tool matches on "the heading contains the version marker", not on the heading's wording, for exactly this reason).

macOS cannot run this chain. package_release.py has no macOS key in its PLATFORMS table, so there is no macOS leg and no macOS package. The library itself builds on macOS — see build-guide.md — but it is not released from here.

msvc-x64 is pinned to windows-2022, not windows-latest. windows-latest now points at a windows-2025-vs2026 image containing only VS 2026, where the hard-coded -G "Visual Studio 17 2022" fails outright. The alternative — dropping -G and letting CMake choose — was rejected because it would tie the released ABI to GitHub's image rotation schedule. This is a deliberate choice, not a defect to be tidied away.

The two MSVC legs ship different OpenSSL major versions. msvc-x64 installs OpenSSL via choco install openssl with no version pin, which currently yields 4.x; msvc-arm64 builds OpenSSL 3.5.8 from source because choco has no arm64 package. Choco does not carry 3.5.8, so the two cannot be aligned through choco. This is accepted status quo, not a bug: the OpenSSL major version is not part of this library's ABI, and the alternatives (vendoring, or dropping the arm64 OpenSSL build) are worse. Note that the OPENSSL_VERSION cache variable is an empty shell — the version in the log is whatever happened to be installed, not something the workflow controls.

See also