From 9da44217f7bd5c62754e5e7834dcbd027e751273 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 23:22:28 +0100 Subject: [PATCH] feat(release): add generation workflow Add a guarded mobile release skill that synchronizes public versions, writes on-brand notes, verifies signed iOS and Android artifacts, and tags the verified release commit. --- .codex/skills/generate-release/SKILL.md | 116 ++++++++++++++++++ .../generate-release/agents/openai.yaml | 4 + .../references/artifact-verification.md | 39 ++++++ .../references/release-notes-voice.md | 51 ++++++++ .../generate-release/scripts/set_version.mjs | 105 ++++++++++++++++ .codex/skills/release-mobile/SKILL.md | 9 +- 6 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 .codex/skills/generate-release/SKILL.md create mode 100644 .codex/skills/generate-release/agents/openai.yaml create mode 100644 .codex/skills/generate-release/references/artifact-verification.md create mode 100644 .codex/skills/generate-release/references/release-notes-voice.md create mode 100755 .codex/skills/generate-release/scripts/set_version.mjs diff --git a/.codex/skills/generate-release/SKILL.md b/.codex/skills/generate-release/SKILL.md new file mode 100644 index 0000000..4643851 --- /dev/null +++ b/.codex/skills/generate-release/SKILL.md @@ -0,0 +1,116 @@ +--- +name: generate-release +description: Generate a signed Sauci mobile release by bumping the public version, writing on-brand release notes, building and verifying iOS and Android binaries, committing release metadata, and tagging the release commit. Use for an actual production mobile release; use release-mobile for preparation, one-platform builds, uploads, or submission work that does not require the complete release lifecycle. +--- + +# Generate release + +Create a reproducible release whose tag identifies the reviewed release source +and final platform build metadata. Read `apps/mobile/AGENTS.md`, +`docs/releasing.md`, and the `release-mobile` and `commit` skills before acting. Read +[references/release-notes-voice.md](references/release-notes-voice.md) before +drafting notes and [references/artifact-verification.md](references/artifact-verification.md) +before accepting a build. + +A request to generate or create a release authorizes the scoped version edits, +release notes, local builds, release commits, and one local annotated tag. It +does not authorize pushing commits or tags, uploading binaries, submitting a +store release, changing signing credentials, or regenerating native projects. + +## Defaults + +- Build both iOS and Android production binaries unless the user narrows the + platforms. +- Increment the patch version unless the user specifies `minor`, `major`, or an + exact SemVer version. +- Use `v` for the annotated Git tag. +- Write artifacts beneath ignored `dist/releases/v/`; never commit + binaries, credentials, logs, or build directories. + +## Preflight + +Fetch the remote and inspect the worktree, branch, upstream, existing tags, +toolchains, EAS authentication, and signing availability without changing them. +Require all of the following: + +- a named branch, normally `main` for a production release; +- a clean starting worktree with `HEAD` equal to its fetched upstream; +- the previous reachable `v[0-9]*` release tag and no collision for the next tag; +- consistent public versions across `app.json`, Android `versionName`, and every + iOS `MARKETING_VERSION` (`scripts/set_version.mjs --check`); +- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS; +- the configured production API/Auth/RevenueCat environment, without printing + secret values. + +Do not switch branches, discard work, repair credentials, push, or weaken branch +protection merely to pass preflight. Stop with the exact blocker. Never run +`expo prebuild` as part of this workflow. + +## Prepare the release + +1. Derive the next version with `scripts/set_version.mjs --bump ` or set an + explicit version with `--version `. The script updates the checked-in + public-version sources but deliberately leaves platform build numbers to EAS. +2. Review the complete first-parent and ordinary commit diff from the previous + reachable release tag to the starting commit. Inspect the actual product diff + for every user-facing claim; commit subjects alone are not evidence. +3. Add the new entry immediately below the title in `docs/release-notes.md`. + Describe user-visible value, meaningful fixes, privacy or reliability changes, + and only claims supported by the diff. Omit internal migrations, refactors, + CI, infrastructure, dependency churn, and implementation jargon unless the + user experiences a direct result. Use a temporary `Build numbers pending + artifact verification` line until the binaries have been inspected; never + guess or predict build numbers. +4. Run `npm run verify:full` and the native acceptance required by the + `release-mobile` skill. A missing prerequisite is a failure/limitation, not a + pass. +5. Review the scoped diff and create `chore(release): prepare v`. Do not + stage unrelated paths. This clean release-preparation commit must descend + directly from the fetched upstream commit. + +## Build and capture platform metadata + +Build from the clean prepared source using the package scripts in +`apps/mobile/package.json`, with `EAS_LOCAL_BUILD_ARTIFACTS_DIR` set to the +versioned artifact directory. When building both platforms, build Android first, +then iOS. + +After each build: + +1. Inspect the worktree before doing anything else. Accept only the documented + EAS build-number changes in `app.json`, that platform's native version source, + and the iOS widget plist when applicable. Treat any implementation, resource, + dependency, or native-project regeneration as a failed build. +2. Independently inspect the binary for package/bundle identifier, public + version, platform build number, production endpoint configuration where + inspectable, and signing identity/fingerprint. Require the embedded public + version to equal the requested version. +3. Record the verified platform build number in the new release-note entry. +4. Commit only the expected metadata and release-note update as + `chore(): record v build `. The worktree must be + clean before starting the next platform build. + +Do not accept a successful EAS exit code as proof of a valid artifact. If either +requested platform fails, do not tag a partial release. + +## Tag and prove the result + +Re-run focused version checks and `npm run verify:fast`. Confirm every requested +artifact exists, has a SHA-256 digest, passed independent version/signature +inspection, and was produced from the release commit chain. Require a clean +worktree, then create the annotated local tag `v` at `HEAD`. + +Read back the tag object and prove: + +- the tag resolves to `HEAD`; +- the tag version equals all public-version sources and embedded artifacts; +- recorded build numbers equal the embedded platform build numbers; +- no binary or ignored build output entered the commit; +- the release commits descend from the fetched starting branch commit; +- for a two-platform release, the Android artifact's source commit differs from + the final tag only by the later verified iOS build metadata. + +Report the branch, tag, commit, release-note summary, artifact paths, versions, +build numbers, signatures, SHA-256 digests, checks run, and any environmental +limitations. State explicitly that the tag and commits remain local unless a +separate authorized push was completed. diff --git a/.codex/skills/generate-release/agents/openai.yaml b/.codex/skills/generate-release/agents/openai.yaml new file mode 100644 index 0000000..f26406e --- /dev/null +++ b/.codex/skills/generate-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Generate Release" + short_description: "Bump, build, note, and tag Sauci releases" + default_prompt: "Use $generate-release to create the next signed Sauci mobile release for iOS and Android." diff --git a/.codex/skills/generate-release/references/artifact-verification.md b/.codex/skills/generate-release/references/artifact-verification.md new file mode 100644 index 0000000..cb7f82b --- /dev/null +++ b/.codex/skills/generate-release/references/artifact-verification.md @@ -0,0 +1,39 @@ +# Release artifact verification + +Use the strongest locally available platform tools and record the commands and +results. Never print provisioning profiles, keys, tokens, or environment values. + +## Android AAB + +Require a production `.aab`. Inspect its manifest with `bundletool dump manifest` +or Android SDK `apkanalyzer`, and verify: + +- package `com.sauci.app`; +- `versionName` equals the release version; +- `versionCode` equals the recorded Android build number. + +Verify the archive signature with `jarsigner -verify -verbose -certs`. Compare the +signing certificate SHA-1 to the production fingerprint documented in +`docs/releasing.md`; do not copy credentials into commands or reports. Calculate +the artifact SHA-256 with `shasum -a 256`. + +## iOS IPA + +Extract the `.ipa` into a new temporary directory created with `mktemp -d`. Read +the application `Info.plist` with `/usr/libexec/PlistBuddy` or `plutil` and verify: + +- `CFBundleIdentifier` is `com.sauci.app`; +- `CFBundleShortVersionString` equals the release version; +- `CFBundleVersion` equals the recorded iOS build number. + +Use `codesign -dvvv` and `codesign --verify --deep --strict` on the extracted app +to inspect and verify signing without exposing the embedded profile. Calculate the +original `.ipa` SHA-256 with `shasum -a 256`. Remove only the explicit temporary +extraction directory after inspection. + +## Source correspondence + +For both platforms, retain the build command, artifact path, build completion +time, and digest in the final report. Compare the post-build tracked diff with the +documented allowed version files. A binary with the right version but unexpected +source regeneration, wrong signing, or an unverifiable manifest is not accepted. diff --git a/.codex/skills/generate-release/references/release-notes-voice.md b/.codex/skills/generate-release/references/release-notes-voice.md new file mode 100644 index 0000000..f991422 --- /dev/null +++ b/.codex/skills/generate-release/references/release-notes-voice.md @@ -0,0 +1,51 @@ +# Sauci release-note voice + +Sauci sounds warm, confident, inviting, and quietly playful. It is a premium +couples app about private connection and shared curiosity: supportive without +being clinical, polished without sounding corporate, and intimate without being +graphic or presumptuous. + +## Shape + +Use a short opening sentence followed by three to six compact bullets. Lead with +the change that most improves a couple's experience. Combine related work into +one benefit rather than mirroring the commit list. Include a brief closing line +only when it adds warmth or useful context. + +In `docs/release-notes.md`, use: + +```markdown +## v1.2.3 — 29 August 2026 +_iOS build 44 · Android build 48_ + +A little more ease, clarity, and connection in every check-in. + +- **Closer conversations** — Find the prompts that feel right for both of you. +- **Smoother moments** — Chats and matches now feel quicker and more reliable. +- **More control** — Clearer choices help keep your shared space comfortable. +``` + +Adapt the words to the evidence; do not reuse this copy when those claims are not +present in the release. If only one platform was requested, record only its build. + +## Language + +- Address the couple's experience with `you`, `your partner`, `together`, or + `your shared space` where natural. +- Prefer concrete benefits: `pick up where you left off`, `find the right prompt`, + `keep conversations flowing`, `stay in control`. +- Use restrained warmth. One gentle phrase is enough; avoid forced innuendo, + breathless hype, exclamation marks, emoji, and claims that Sauci will improve or + fix a relationship. +- Prefer `new`, `smoother`, `clearer`, `more private`, and `more reliable` to + `revolutionary`, `game-changing`, `massive`, or `best ever`. +- Explain a fix as the experience restored, not the defect mechanics. Name a + security or privacy change plainly when users need to understand it. + +## Evidence boundary + +Release notes describe shipped behavior only. Do not claim a feature from a plan, +flagged-off path, migration, test, or commit title without confirming the runtime +path in the diff and release configuration. Do not expose internal service names, +database details, moderation logic, security-sensitive implementation, or content +that the product intentionally keeps private between partners. diff --git a/.codex/skills/generate-release/scripts/set_version.mjs b/.codex/skills/generate-release/scripts/set_version.mjs new file mode 100755 index 0000000..8e67b4c --- /dev/null +++ b/.codex/skills/generate-release/scripts/set_version.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, "../../../.."); +const files = { + app: path.join(repoRoot, "apps/mobile/app.json"), + android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"), + ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"), +}; + +const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/; + +function fail(message) { + console.error(`set_version: ${message}`); + process.exit(1); +} + +function parseVersion(value, label) { + const match = semverPattern.exec(value); + if (!match) fail(`${label} must be an exact numeric SemVer version (x.y.z)`); + return match.slice(1).map(Number); +} + +function readState() { + const appRaw = fs.readFileSync(files.app, "utf8"); + const androidRaw = fs.readFileSync(files.android, "utf8"); + const iosRaw = fs.readFileSync(files.ios, "utf8"); + const appVersion = JSON.parse(appRaw).expo?.version; + const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map( + (match) => match[1], + ); + const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map( + (match) => match[1].trim(), + ); + + if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version"); + if (androidVersions.length !== 1) fail("expected exactly one Android versionName"); + if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values"); + + const versions = [appVersion, ...androidVersions, ...iosVersions]; + versions.forEach((version) => parseVersion(version, `configured version ${version}`)); + if (new Set(versions).size !== 1) { + fail(`public versions disagree: ${versions.join(", ")}`); + } + + return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length }; +} + +function nextVersion(current, bump) { + const [major, minor, patch] = parseVersion(current, "current version"); + if (bump === "major") return `${major + 1}.0.0`; + if (bump === "minor") return `${major}.${minor + 1}.0`; + if (bump === "patch") return `${major}.${minor}.${patch + 1}`; + fail("--bump must be patch, minor, or major"); +} + +const args = process.argv.slice(2); +const check = args.length === 1 && args[0] === "--check"; +const bumpIndex = args.indexOf("--bump"); +const versionIndex = args.indexOf("--version"); + +if (!check && (bumpIndex === -1) === (versionIndex === -1)) { + fail("use --check, --bump patch|minor|major, or --version x.y.z"); +} + +const state = readState(); +if (check) { + console.log(`public version ${state.current} is consistent across app.json, Android, and ${state.iosCount} iOS settings`); + process.exit(0); +} + +const desired = + bumpIndex !== -1 + ? nextVersion(state.current, args[bumpIndex + 1]) + : (parseVersion(args[versionIndex + 1], "--version"), args[versionIndex + 1]); + +if (desired === state.current) fail(`requested version is already ${state.current}`); +const currentParts = parseVersion(state.current, "current version"); +const desiredParts = parseVersion(desired, "requested version"); +const increases = desiredParts.some( + (part, index) => part > currentParts[index] && desiredParts.slice(0, index).every((value, prior) => value === currentParts[prior]), +); +if (!increases) fail(`requested version ${desired} must be greater than ${state.current}`); + +const appRaw = state.appRaw.replace( + /(\"version\"\s*:\s*\")([^\"]+)(\")/, + `$1${desired}$3`, +); +const androidRaw = state.androidRaw.replace( + /(\bversionName\s+["'])([^"']+)(["'])/, + `$1${desired}$3`, +); +const iosRaw = state.iosRaw.replace( + /(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g, + `$1${desired}$3`, +); + +fs.writeFileSync(files.app, appRaw); +fs.writeFileSync(files.android, androidRaw); +fs.writeFileSync(files.ios, iosRaw); +console.log(`updated public version ${state.current} -> ${desired}`); diff --git a/.codex/skills/release-mobile/SKILL.md b/.codex/skills/release-mobile/SKILL.md index f7151cd..6638d97 100644 --- a/.codex/skills/release-mobile/SKILL.md +++ b/.codex/skills/release-mobile/SKILL.md @@ -10,9 +10,12 @@ scoped instructions. Confirm target platform and release profile. Run repository verification plus a native acceptance pass before starting a release build. Before a build from the current upstream branch, fetch, require a clean worktree, -and prove `HEAD` matches its upstream. Sauci tracks its native iOS and Android -projects, so routine release builds must package those checked-in sources. Do not -run `expo prebuild` first; native regeneration is a separate reviewed change. +and prove `HEAD` matches its upstream. The `generate-release` skill may instead +build a clean, scoped release-preparation commit that descends directly from that +fetched upstream; between its multi-platform builds it commits only independently +verified EAS version metadata. Sauci tracks its native iOS and Android projects, +so routine release builds must package those checked-in sources. Do not run +`expo prebuild` first; native regeneration is a separate reviewed change. Use the package scripts in `apps/mobile/package.json`; they create artifacts only. Never infer upload or submission authority from build authorization. Store upload