.github/workflows/release.yml, which checks the tagged commit, builds every target and creates the release as a draft. You test the draft, publish it, and then publish the Python and Node packages by hand with the release’s digests in them. No workflow publishes to PyPI or npm.
Cut a release
1
Prepare the last commit
In the commit you will tag:
- Set the new version in all five places:
python/pyproject.toml,python/apostate/config.py,npm/package.json,npm/src/index.tsand.github/release/artifact-policy.json(package_version). - Refresh the patch digests in
build/MANIFEST.lock. The command rewritespatch_series_sha256andpatch_contents_sha256and nothing else, so run it after the last patch edit. - If the composed profiles changed, refresh the golden digests.
sync-packages.py --check fails and lists the five values while they differ:2
Tag and push
Tag that commit
vMAJOR.MINOR.PATCH and push the tag:3
Wait for the build
Each platform builds on its own runner, all at once, and takes hours. If one fails, re-run the failed jobs. The platforms that finished keep their artifacts for 7 days and are not rebuilt.
4
Test and publish the draft
Download the archives, launch each on its platform, and launch the Linux archives in a GPU-less container as well. Then publish the release:
5
Publish the packages
Write the release’s digests into both packages, commit, and publish them (below).
APOSTATE_BUILD_TARGETS, a comma-separated list such as macos-arm64,linux-x64, limits a release to those platforms. Unset, it means all four. To rebuild an existing tag, run the workflow by hand with that tag as release_tag and confirm set to true:
APPLE_* repository secrets are set (Sign on macOS). Without them it publishes an unsigned macOS archive, and the job log says sign-macos: no signing identity configured; the bundle stays unsigned.
What CI checks
release.yml runs four jobs.
- Release gate, on a hosted Ubuntu runner. The tag must match
vMAJOR.MINOR.PATCHand name the checked-out commit. Thenscripts/validate-release-baseline.py --release(the patch digests inbuild/MANIFEST.lockmatch the series),scripts/test_profile_resolver.pywithAPOSTATE_REQUIRE_NATIVE_GOLDENS=1, and the Python and Node package tests. - Setup resolves the platforms from
APOSTATE_BUILD_TARGETSwithscripts/resolve-build-targets.sh. - Build runs
.github/workflows/build-target.ymlonce per platform: every step on Build from source from an empty workspace, the resolver and baseline checks again,scripts/smoke-binary.sh, signing on macOS, packaging, upload, and a GitHub build-provenance attestation for the archive. - Publish downloads every archive and manifest and runs
scripts/verify-release-inputs.sh. It requires exactly one archive and one manifest per built platform, and checks each manifest’s field set, its package and Chromium versions against the release policy, itscatalogue_versionandsource_revisionagainst the tagged commit, and itssha256against the archive. Thengh release create --verify-tag --draftcreates the draft.
verify-release-inputs.sh also runs outside CI, on any directory of archive and manifest pairs, such as a nightly build’s artifacts. The second argument is the exact set of platforms the directory holds. Pass any as the revision to accept whatever commit the manifests name:
verified <platform> <archive> for each pair and release inputs verified: <platforms> at the end. It needs Bash 4 or later.
Refresh the golden profile digests
scripts/test_profile_resolver.py checks that the Python copy of the compositor, scripts/profile_resolver.py, composes the same bytes as the browser. It pins one SHA-256 per persona (GOLDEN_PROFILES) over the profile the browser composes for --fingerprint=12345, and the list of sections that profile has (GOLDEN_SECTIONS). Take the digests from a native build. Digests recomputed with the resolver would make the test compare it with itself.
Cores and memory are capped at the host’s, so use a host like GOLDEN_HOST, which is macOS on Apple silicon with 14 cores and 36 GiB. Every child process carries the composed profile in its --apostate-profile switch, so read it from ps while the browser runs. The loop picks the processes by their user data directory, so other Apostate browsers on the host do not interfere:
GOLDEN_SECTIONS, the test skips and names them. With APOSTATE_REQUIRE_NATIVE_GOLDENS=1 it fails instead, which is how the release gate runs it.
Archives and manifests
Each platform gets one archive namedapostate-<chromium version>-<platform>, .tar.zst for Linux and .zip for macOS and Windows:
An archive holds the browser, the licence,
build/MANIFEST.lock and the profile resources. Beside it is <archive>.manifest.json, with exactly the fields release/manifest.schema.json allows:
Do not replace a published archive with different bytes. Cut a new version.
Publish the packages
The packages download the browser and check it against a manifest they carry. To give them the new release’s digests:sync-packages.py hashes every archive again, checks each manifest against the release policy, and writes release-manifest.json into python/apostate/assets/ and npm/assets/. It also copies the profile schema, the catalogue, the country-to-locale table and the font packs into both packages. Commit the changes, then build and publish both packages. The Python commands need the build and twine packages from PyPI.
The MCP server
The MCP server,@heretic-tech/apostate-mcp in mcp/, has its own version in mcp/package.json, and sync-packages.py does not touch it. It depends on @heretic-tech/apostate ^0.4.3, so a new 0.4.x Node package reaches it without an MCP release. When mcp/ changes, or the Node package’s minor version changes, raise that range and the MCP version, run its test, and publish it. The first publish of a scoped package needs --access public: