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.tomland workspace dependencies withmise installandmise exec -- pnpm install --frozen-lockfile. - Install the Android SDK used by
apps/android/app/build.gradle.kts, including Build Tools 37.0.0 (apksigner).misesuppliesANDROID_HOME. - Run Docker with a buildx builder that supports
linux/amd64,linux/arm64and the cache-only exporter. With Docker Desktop/OrbStack emulation, create a dedicated builder once withdocker buildx create --name pneumo-release --driver docker-container, then setexport BUILDX_BUILDER=pneumo-releasein the release shell. This does not change the builder selected for other Docker work. - Configure
gh auth loginforstoopkiddddwith permission to publish GHCR packages and releases instoopkidddd/pneumo. The task obtains the token only throughgh auth token | docker login ghcr.io -u stoopkidddd --password-stdin. - Configure SSH access to
origin(Tangled) andgithub(git@github.com:stoopkidddd/pneumo.git). Only the release task pushes to the mirror, and onlymainplus 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.
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 #
- Merge the release changes through Tangled. In a clean
maincheckout, rungit pull --ff-only origin main. Everyv*tag must be a SemVer release tag. - 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.
- Optionally run a full local rehearsal with the real key:
mise run release 0.1.0-rc.1 --dry-run. - 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=<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-<version>.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<version> 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 #
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 throwawayrelease.env.PNEUMO_RELEASE_CERT_SHA256: the throwaway certificate's public SHA-256 digest.
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.