Skip to main content
A release is a GitHub release that holds one archive and one manifest per platform. Pushing a version tag starts .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.ts and .github/release/artifact-policy.json (package_version).
  • Refresh the patch digests in build/MANIFEST.lock. The command rewrites patch_series_sha256 and patch_contents_sha256 and 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).
The repository variable 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:
The macOS build signs and notarizes when the six 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.
  1. Release gate, on a hosted Ubuntu runner. The tag must match vMAJOR.MINOR.PATCH and name the checked-out commit. Then scripts/validate-release-baseline.py --release (the patch digests in build/MANIFEST.lock match the series), scripts/test_profile_resolver.py with APOSTATE_REQUIRE_NATIVE_GOLDENS=1, and the Python and Node package tests.
  2. Setup resolves the platforms from APOSTATE_BUILD_TARGETS with scripts/resolve-build-targets.sh.
  3. Build runs .github/workflows/build-target.yml once 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.
  4. 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, its catalogue_version and source_revision against the tagged commit, and its sha256 against the archive. Then gh release create --verify-tag --draft creates 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:
It prints 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:
Run on an Apple M4 Max with 14 cores and 36 GiB against the 0.4.3 release browser, it prints the digests the test holds:
Paste each digest and section list into the test. A digest that differs while the sections match fails the test. When the sections differ from 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 named apostate-<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:
The MCP server needs Node 22 or later. MCP server documents its options.

Verify a download

Each release archive has a GitHub build-provenance attestation from its build job, and both packages check an archive’s SHA-256 against its manifest before extracting it. Verify a download has the commands for checking an archive and a signed macOS bundle.