mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
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.
This commit is contained in:
1 parent
5f77b06044
commit
9da44217f7
6 files changed
+321
-3
No files matched your search
@@ -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<version>` for the annotated Git tag.
|
||||||
|
- Write artifacts beneath ignored `dist/releases/v<version>/`; 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 <kind>` or set an
|
||||||
|
explicit version with `--version <x.y.z>`. 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<version>`. 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(<platform>): record v<version> build <number>`. 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<version>` 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.
|
||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+105
@@ -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}`);
|
||||||
@@ -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.
|
verification plus a native acceptance pass before starting a release build.
|
||||||
|
|
||||||
Before a build from the current upstream branch, fetch, require a clean worktree,
|
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
|
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||||
projects, so routine release builds must package those checked-in sources. Do not
|
build a clean, scoped release-preparation commit that descends directly from that
|
||||||
run `expo prebuild` first; native regeneration is a separate reviewed change.
|
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.
|
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
||||||
Never infer upload or submission authority from build authorization. Store upload
|
Never infer upload or submission authority from build authorization. Store upload
|
||||||
|
|||||||
Reference in new issue
Block a user