# Local releases The owner runs releases on the Mac. No CI system receives a signing key or a registry token. Tangled is the source of truth; the GitHub mirror moves only on releases. See SPEC §16.4–16.8 and `docs/agents/tangled.md`. ## Prerequisites - Install the tools pinned by `mise.toml` and workspace dependencies with `mise install` and `mise exec -- pnpm install --frozen-lockfile`. - Install the Android SDK used by `apps/android/app/build.gradle.kts`, including Build Tools 37.0.0 (`apksigner`). `mise` supplies `ANDROID_HOME`. - Run Docker with a buildx builder that supports `linux/amd64,linux/arm64` and the cache-only exporter. With Docker Desktop/OrbStack emulation, create a dedicated builder once with `docker buildx create --name pneumo-release --driver docker-container`, then set `export BUILDX_BUILDER=pneumo-release` in the release shell. This does not change the builder selected for other Docker work. - Configure `gh auth login` for `stoopkidddd` with permission to publish GHCR packages and releases in `stoopkidddd/pneumo`. The task obtains the token only through `gh auth token | docker login ghcr.io -u stoopkidddd --password-stdin`. - Configure SSH access to `origin` (Tangled) and `github` (`git@github.com:stoopkidddd/pneumo.git`). Only the release task pushes to the mirror, and only `main` plus the release tag. - Complete the one-time signing setup below and commit the public fingerprint. ## One-time signing setup (lead only) Release certificate SHA-256: `0E:FF:DB:A0:E8:59:C6:56:D1:F9:57:E7:92:96:CF:56:0A:B8:DD:B5:04:EF:81:C3:3B:A3:37:09:B2:BD:03:E5` The lead created the real keystore on the owner's Mac on 2026-11-01 with the script below. Never replace this fingerprint with a test certificate. A new keystore means a new fingerprint, and every installed APK must then be uninstalled before it can update. Run this on the owner's Mac once, through the pinned Node and Java tools. It creates `~/.config/pneumo/release.jks` (alias `pneumo`, RSA 4096, validity 10000 days) and `release.env`, both mode 600. Passwords are random and are neither printed nor passed on the command line. Do not enable shell tracing. ```sh mise exec -- node --input-type=module <<'JS' import { execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; process.umask(0o077); const directory = join(homedir(), '.config/pneumo'); const keystore = join(directory, 'release.jks'); const envFile = join(directory, 'release.env'); if (existsSync(keystore) || existsSync(envFile)) throw new Error('Signing files already exist'); mkdirSync(directory, { recursive: true, mode: 0o700 }); const signing = { ANDROID_KEYSTORE_PATH: keystore, ANDROID_KEYSTORE_PASSWORD: randomBytes(32).toString('hex'), ANDROID_KEY_ALIAS: 'pneumo', ANDROID_KEY_PASSWORD: randomBytes(32).toString('hex'), }; writeFileSync(envFile, Object.entries(signing).map(([key, value]) => `${key}=${value}\n`).join(''), { mode: 0o600, flag: 'wx' }); try { execFileSync('keytool', [ '-genkeypair', '-storetype', 'JKS', '-keystore', keystore, '-alias', 'pneumo', '-keyalg', 'RSA', '-keysize', '4096', '-validity', '10000', '-dname', 'CN=Pneumo', '-storepass:env', 'ANDROID_KEYSTORE_PASSWORD', '-keypass:env', 'ANDROID_KEY_PASSWORD', '-noprompt', ], { env: { ...process.env, ...signing }, stdio: 'pipe' }); chmodSync(keystore, 0o600); const certificate = execFileSync('keytool', [ '-list', '-v', '-keystore', keystore, '-alias', 'pneumo', '-storepass:env', 'ANDROID_KEYSTORE_PASSWORD', ], { env: { ...process.env, ...signing }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); const digest = /SHA256:\s*([A-Fa-f0-9:]+)/.exec(certificate)?.[1]; if (!digest) throw new Error('Certificate fingerprint missing'); console.log(`Release certificate SHA-256: \`${digest}\``); } catch { // Do not print third-party errors: they can contain child output or credentials. console.error('Signing setup failed; inspect the files locally before trying again'); process.exitCode = 1; } JS ``` `release.env` is a data file, not a shell script. It accepts exactly these four `KEY=value` entries, optional matching outer quotes, blank lines and comments. No `export`, expansion, escapes, interpolation or commands. Use an absolute keystore path. Do not source this file into the shell. The release task reads it and supplies its values only to the Gradle child, with `--no-daemon` and `--no-configuration-cache`. Gradle output is captured, not printed, so a child failure cannot echo a password. Other checks, Docker, Git and `gh` do not receive the signing variables. **Back up `~/.config/pneumo/` securely, including both files.** Keep the signing key on the owner's Mac for builds, never in git, CI, release assets or a shared log. Loss of the keystore or its passwords prevents signing compatible updates: users must uninstall the app, lose its local data, and pair again. A different certificate cannot update an installed copy. ## Cut a release 1. Merge the release changes through Tangled. In a clean `main` checkout, run `git pull --ff-only origin main`. Every `v*` tag must be a SemVer release tag. 2. On Tangled's Pipelines tab, confirm CI is green for this exact commit. The task prints a reminder but cannot confirm the web-only pipeline status for you. 3. Optionally run a full local rehearsal with the real key: `mise run release 0.1.0-rc.1 --dry-run`. 4. Publish with `mise run release 0.1.0-rc.1`. Both modes require `main`, a clean tree (including untracked files), equality with `origin/main`, a valid SemVer without a leading `v`, and a version strictly newer than the highest `v*` tag by SemVer precedence. The task fetches `origin/main` and tags, checks equality again, and runs `mise run check`. Dry-run skips none of these checks. Release commits are rechecked after checks and builds. Both modes build the multi-arch image with `VERSION=`, then a signed, minified APK with Gradle properties `releaseVersionName` and `releaseVersionCode`. The APK must pass `apksigner verify --print-certs` and match the fingerprint above before anything is written to `dist/release`. Successful builds write exactly `pneumo-.apk` and `SHA256SUMS` for that APK. Existing artifacts from other versions are not deleted and are not uploaded. Only after successful local verification does normal mode authenticate to GHCR, export/push the cached multi-arch build, create the annotated `v` tag, push the tag to Tangled, push `main` and the tag to the mirror, and create the mirror's GitHub Release with the two files. Notes contain commits since the previous release, install/checksum steps and the image reference. Versions with an `rc` first prerelease identifier (for example `0.1.0-rc.1`) get `--prerelease` and never move `latest`. Other versions also publish `latest`. SemVer build metadata uses `_` instead of `+` in the Docker tag because Docker disallows `+`; the full version is preserved in the image label, server and APK. Dry-run uses `--output=type=cacheonly`: no image export/push, Docker login, tag creation, Git push or GitHub Release. It prints the publication commands it would run without executing them. It still fetches from `origin` and leaves verified local APK/checksum artifacts and build caches. Publication commands are all in one explicit non-dry-run branch. A partial network publication failure is not rolled back automatically: inspect the registry, tags and release before taking any further action; do not blindly rerun with the same version. ### Android versions `versionName` is the complete version argument. `versionCode` is `major * 10000 + minor * 100 + patch`, ignoring the prerelease suffix and build metadata. The task requires Android's valid range of 1–2100000000. Components must stay below 100 for minor/patch if releases are to have distinct, increasing codes with this formula. Without the properties, Gradle keeps dev defaults `versionName = "0.0.1"`, `versionCode = 1`. With none of the signing variables, `assembleRelease` remains unsigned; partial signing configuration fails. `0.1.0-rc.1` and `0.1.0` both have code **100** (as does `0.1.0-rc.2`). With the same signing certificate, an equal-code APK installs as an update with data kept through `adb install -r` or the system installer. RC-to-final updates do not require uninstalling. The strictly-higher-code rule applies to Google Play uploads, not sideloading. Uninstalling first is needed only for a lower code or a different signing certificate; it loses local app data and requires pairing again. There is no separate RC numbering scheme. ## Verify a published image and APK ```sh docker pull ghcr.io/stoopkidddd/pneumo:0.1.0-rc.1 IMAGE=ghcr.io/stoopkidddd/pneumo:0.1.0-rc.1 mise run e2e:docker docker buildx imagetools inspect ghcr.io/stoopkidddd/pneumo:0.1.0-rc.1 # In the directory containing the downloaded APK and SHA256SUMS: shasum -a 256 -c SHA256SUMS mise exec -- "$HOME/Library/Android/sdk/build-tools/37.0.0/apksigner" verify --print-certs pneumo-0.1.0-rc.1.apk ``` The `IMAGE` smoke uses an already-pulled/local image, checks its version label, health response, web assets, non-root UID, size, direct/proxy encrypted transfers and clean shutdown. It builds only the local build-stage smoke client; it does not rebuild runtime images or remove the supplied image. Without `IMAGE`, `mise run e2e:docker` retains the local explicit/default-version build checks. ## Test-only signing overrides For an isolated rehearsal without the real key, create a throwaway keystore and mode-600 env file in a temporary directory using the same signing setup, with the directory changed to that temporary path. Do not create or modify `~/.config/pneumo/`. Delete the throwaway key, env file, logs and APKs afterwards. Use a disposable clean `main` clone with a local `origin` to avoid modifying the implementation branch or contacting Tangled during implementation checks. Set **both** overrides for that process: - `PNEUMO_RELEASE_ENV`: absolute path to the throwaway `release.env`. - `PNEUMO_RELEASE_CERT_SHA256`: the throwaway certificate's public SHA-256 digest. ```sh PNEUMO_RELEASE_ENV=/absolute/temp/release.env \ PNEUMO_RELEASE_CERT_SHA256="$TEST_CERT_SHA256" \ mise run release 0.1.0-rc.1 --dry-run ``` Set `TEST_CERT_SHA256` to the public digest printed during throwaway key setup. Install dependencies in the disposable clone rather than symlinking its `node_modules`: pnpm's task runner rejects a symlinked task-state directory. These overrides require each other and `--dry-run`; normal publication rejects them. They do not bypass branch, cleanliness, origin equality, version or checks. A wrong digest must exit nonzero and write no release artifacts. Capture the run output, scan it for both test passwords **without printing either**, and report only the match count (expected **0**). Do not print raw captured child errors.