CI and Release
Status: Current Last updated: 2026-10-27 (commit c0e233d0)
Pre-Merge Verification
Run the shared local gate from Developer Verification Checks:
just gate
This runs the checks used by per-push CI, including doctests, both Rust
workspaces, generated-artifact currency, and the book. Wait for GitHub Actions
on the exact pushed commit before announcing it as ready. Release-only checks
run separately through just release-lint. That recipe first checks app versions
and the changelog section/link, before formatting checks and compiler checks, so a
missing release entry fails before compilation.
Generated artifact drift
After changing grammar, spec, or a registry, run just regen, then just test.
The regeneration recipe builds derived artifacts in dependency order; currency
tests detect stale output. Never hand-edit generated artifacts.
See Spec Workflow and spec/AGENTS.md for the current
source-of-truth guidance.
Release Process
TalkBank/chatter is the public release source of truth. release.yml
(cargo-dist) builds the CLI artifacts and publishes the GitHub Release;
release-desktop.yml, called from it, creates the unpublished draft and adds
the desktop installers first. Signing differs by platform as described below.
The full publication order is in docs/strategy/coordinated-release.md.
Cutting a release: the two-command procedure
The version literal lives in many places (the workspace version, every
internal path-dep pin, the desktop package.json, the CHANGELOG section),
and the tag is the release trigger, so both steps are mechanized and
fail-closed. Hand-editing version fields or tagging with raw git tag is
how releases break (v0.1.1 shipped a desktop version mismatch; v0.5.0
tagged a bump commit before its CI reported and the desktop build died on
drift CI would have caught). The procedure:
just release-bump X.Y.Zrewrites the canonical[workspace.package] version, everypath = "crates/…"pin, andpackage.jsonand both root-version fields inpackage-lock.json, then refreshes both Rust lockfiles (root +spec/). The app-version check exercises this command against temporary manifests and lockfiles before checking the checkout, including independent drift in each lockfile version and preservation of dependency versions. The command accepts normalX.Y.Zversions: ASCII numeric components without leading zeros, as defined by SemVer. It does not trim input or treat an empty bump argument as a check. Stored canonical versions use the same admission. An optional--release-tagmust agree before any version file is written; identity rejection preserves all files. The Python 3.11+ command parses the Cargo manifest before preparing an edit. It updates the explicit workspace-package version and inline internal dependency version fields, retaining quotes, comments and line endings. Missing, malformed or ambiguous fields refuse before writing; unsupported field layouts are reported rather than guessed. The prepared TOML is checked against exactly the owned version-key changes before it can be written. Check mode detects internal dependency-pin drift, and fix mode repairs it. Cargo, both npm documents and changelog input are prepared before any file changes. Missing fields, non-string or invalid version values, malformed JSON and duplicate keys refuse the whole preparation without writes. Every replacement is then written and closed in a private temporary directory beside its destination before publication starts. Staging failures leave all originals unchanged. Destinations must be regular files, not symlinks; replacement preserves permission bits, but not inode identity or other metadata. Other hardlinks keep their original content. Publication replaces files sequentially: a later rename failure can leave earlier replacements applied. Inspect the version files before retrying such a failure. This is not a multi-file transaction or a crash-durability guarantee.- Write the
## [X.Y.Z]CHANGELOG section and its comparison link. The app-version check requires them; it does not write release notes for you. - Format, run
just release-lintandjust gate, then squash the commits since the last push into one release commit whose message is the CHANGELOG section. Verify that the squashed tree retains the checked content and, with maintainer authorization for that candidate, push and wait for CI on that commit. Reuse the receipt when its checked inputs are unchanged; a squash alone is not a reason to rerun the gate. Push rarely; preserve already-published commits instead of rewriting history at release time. - With release authorization,
just release-tag X.Y.Ztags and pushesvX.Y.Z, refusing on a dirty tree, an unpushed HEAD, any version-copy drift, a missing CHANGELOG section, or CI/Cross-platform not yet green on the exact tagged commit.
After the tag: release-tag-dispatch.yml dispatches release.yml for that
tag, which builds, runs the desktop publication job, publishes and then adds
the app banner; verify the release page carries the CLI archives, the LSP
standalone artifacts, the desktop installers and the CHANGELOG notes before
announcing.
Workflows that actually exist in this repo
| Workflow | Purpose | Notes |
|---|---|---|
.github/workflows/ci.yml | Main build/test/book CI | Primary shared signal on pushes and PRs |
.github/workflows/cross-platform.yml | Cross-platform build coverage | Supplements the main CI workflow |
.github/workflows/crates-io-foundation.yml | First-wave crates.io readiness | Checks foundation-crate metadata, package surfaces, hold-backs, and publish order |
.github/workflows/release-tag-dispatch.yml | Tag trigger | On a version tag push, dispatches release.yml for that tag |
.github/workflows/release.yml | cargo-dist release automation | Generated by dist (dist generate; never hand-edit). Builds CLI artifacts, calls the desktop publish job, then publishes the draft in its announce step |
.github/workflows/release-desktop.yml | Desktop installer release automation | dist publish job: creates the draft with dist’s announcement title and CHANGELOG body, builds and uploads the installers and updater bundles, and verifies the candidate; workflow_dispatch runs build-only |
.github/workflows/release-app-banner.yml | Release page banner | dist post-announce job: prepends the desktop app banner to the published release notes |
.github/workflows/release-lint.yml | Release-time lint | just release-lint: clippy over both workspaces plus the feature-off build. Runs on a version tag and on workflow_dispatch, never per push |
.github/workflows/clippy-rolling.yml | New-stable clippy lints | Weekly scheduled clippy, so a new stable’s lints surface within days |
Current release stance
Cross-platform workspace verification uses Cargo’s normal Windows dynamic C
runtime through a job-local TAURI_CONFIG override. Tauri 2.7’s static-runtime
build shim otherwise shadows msvcrt.lib for unrelated workspace doctests.
Doctests remain enabled. This override does not apply to desktop release
packaging, which retains Tauri’s static-runtime default; passing workspace tests
does not replace verifying the packaged Windows artifact.
release.ymlis about workspace artifact packaging via cargo-dist, not about crates.io publication.- The first-wave crates.io path is documented separately in
Crates.io Publication and is checked by
just crates-io-foundation-checkplus.github/workflows/crates-io-foundation.yml.
Desktop release workflow: how the release jobs compose
dist-workspace.toml sets create-release = false, github-release = "announce", publish-jobs = ["./release-desktop"] and post-announce-jobs = ["./release-app-banner"]. After the CLI artifacts build, release.yml calls
release-desktop.yml, which creates the draft release with dist’s announcement
title and body, builds the Tauri installers and updater bundles, uploads them
and verifies the candidate. Only when that job succeeds does the announce step
upload the CLI archives, checksums and installer scripts and publish the draft;
the banner job then edits the published notes. Nothing polls, and a failed
desktop build leaves an unpublished draft. Installer compatibility consumes the
completed global Actions artifact, not originals that are uploaded only during
announcement. Only derived legacy aliases are uploaded beforehand and verified
by downloading their bytes; release-bound admission keeps pending inputs distinct
from uploaded assets. The final gate and CI execute the actual preparation,
verification and announcement commands with a stateful transport regression.
See Coordinated release publication
for the full phase and partial-draft recovery contract. Complete recovery requires
both CLI and LSP product inventories; a plan omitting either product cannot
certify the draft as complete. Each product must reference executable archives
covering all five CLI/LSP targets:
macOS Apple Silicon and Intel, Linux ARM64 and x86-64 musl, and Windows x86-64.
Each archive must declare its targets and the product’s expected executable;
pointing the LSP record at a CLI archive or removing a target from both the plan
and uploaded assets cannot establish completeness. This remains declared target
inventory admission, not archive-content, native platform, provenance or
installation acceptance.
The updater producer and candidate verifier share one complete platform inventory:
macOS Apple Silicon and Intel, Windows x86-64 and Linux x86-64. Missing, duplicate
or unknown platforms, shared bundle filenames across platforms, blank signatures
and unsafe asset basenames reject before a manifest can be emitted. Target
triples must match the supported inventory exactly. This checks metadata
consistency; it does not inspect executable architecture or verify signatures.
Producer and verifier also share release-tag admission: a literal v followed
by a SemVer core and optional prerelease, with no build metadata. Leading zeros
in numeric identifiers, empty prerelease identifiers and trailing whitespace
reject. The updater version must equal the admitted tag without v; conflicting
metadata is refused before the generator opens its output file.
Two platform notes baked into the workflow:
- macOS: Tauri signs, notarizes, and staples the
.app, but NOT the.dmgit wraps around it. The workflow therefore submits the.dmgitself to the notary service and staples it, then verifiescodesign,spctl, andstapler validateon both artifacts. The signing identity is supplied via environment, never hardcoded intauri.conf.json. - Windows / Linux: artifacts are currently unsigned by decision; see
docs/strategy/distribution-and-signing.md(“Decisions, 2026-06-12”) and the SmartScreen guidance in the install docs.
Release secrets (Actions secrets on this repository)
The macOS jobs of release-desktop.yml use:
| Secret | Content |
|---|---|
APPLE_CERTIFICATE | base64-encoded Developer ID Application .p12 |
APPLE_CERTIFICATE_PASSWORD | password for the .p12 |
APPLE_SIGNING_IDENTITY | full identity string, Developer ID Application: <Name> (<TEAMID>) |
APPLE_API_KEY | App Store Connect API key ID (notarization) |
APPLE_API_ISSUER | App Store Connect issuer ID |
APPLE_API_KEY_CONTENT | contents of the AuthKey_*.p8 file |
Every desktop platform also uses TAURI_SIGNING_PRIVATE_KEY and
TAURI_SIGNING_PRIVATE_KEY_PASSWORD to sign updater bundles. These are
separate from Apple code signing and notarization. Their matching public key
is committed in apps/chatter-desktop/src-tauri/tauri.conf.json under
plugins.updater.pubkey; the private key must never enter the repository.
An unsigned Windows or Linux installer does not mean its updater bundle can
omit this signature.
The cargo-dist CLI signing path uses the separate CODESIGN_CERTIFICATE,
CODESIGN_CERTIFICATE_PASSWORD and CODESIGN_IDENTITY secrets. Maintainers
must verify access to each required credential before an authorized release;
the presence of a secret name does not prove its value is usable. Do not print
secret values in logs or paste them into issues.
Replacing an Apple certificate or notary key updates the corresponding secrets. Do not treat replacing the updater key as the same operation: existing installations trust their embedded public key, so a key change needs a reviewed update/reinstallation migration. Handover must preserve the updater key and verify the successor’s access without publishing it. Repository secrets do not automatically transfer to a new repository.
The development loop
The loop exists because a single parser fix can cost a day to the process around it rather than to the fix.
- Inner loop:
just test. Write the failing test or the type change first, then make it green.clippyandfmtare run before a release, not per edit. - After any change under
grammar/,spec/or a registry:just regen, thenjust test. Every derived artifact has a currency test, and they are far cheaper to satisfy together than one gate run at a time. - Before committing: review the final diff once.
- Before pushing:
just gateon the final candidate content. It runs the per-push checks locally; still inspect hosted CI on the exact pushed commit. The pre-push hook refuses a push without the stamp; the stamp hashes tree content, so a gate run on uncommitted changes stays valid once the same bytes are committed. Clippy and the feature-off build arejust release-lint, run before a release. - Releasing: use the candidate-bound procedure above. A content-preserving squash reuses its valid receipt; changed checked inputs require another gate. Push and tag only with their respective maintainer authorization, and preserve already-pushed history.
Functional tests use the repository’s finite specs and reference corpus; they do not require private transcript data. Building still requires the documented tools and dependencies. Publishing additionally needs GitHub access, the signing credentials above and candidate-bound runtime verification.
This page last changed: 2026-10-27 (commit c0e233d0). The whole book last changed: 2026-10-27 (commit 5bfee2f9).