feat(release): automate iOS and Android store releases (#158807)

Add manually triggered iOS Store Release and Android Store Release workflows with the same local release commands. Derive store versions and OpenAI release notes at build time, retain recovery artifacts, and avoid generated metadata commits.

Pin queued releases to their dispatch commit, qualify native iOS behavior before signing, and improve Android screenshots and interrupted artifact recovery.

Validation includes focused release/planner tests, workflow and type checks, native iOS qualification, Android screenshot capture, and historical release-note exercises. CI failure attribution and remaining hosted release verification are documented in the PR.

Closes #158806
This commit is contained in:
Josh Avant 2026-09-26 06:28:29 -05:00 • committed by GitHub
parent 11a11ef164
commit cd2724e432
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 7696 additions and 11409 deletions

View file

@ -0,0 +1,96 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { parseArgs } from "node:util";
import {
ANDROID_RELEASE_REF_PREFIX,
resolveAndroidPublicBaselines,
resolveAndroidStorePlan,
validateAndroidStorePlan,
} from "./lib/android-store-version.ts";
import { resolveAndroidVersion } from "./lib/android-version.ts";
function main(): void {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
root: { type: "string" },
plan: { type: "string" },
remote: { type: "string", default: "origin" },
},
});
const command = positionals[0];
if (
positionals.length !== 1 ||
!["plan", "validate", "validate-baselines"].includes(command ?? "")
) {
throw new Error(
"Usage: android-release-plan.ts plan|validate|validate-baselines [--plan PATH] [--root DIR] [--remote origin]. Google Play snapshot JSON is read from stdin.",
);
}
const rootDir = path.resolve(values.root ?? ".");
const git = (...args: string[]) =>
execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 16 * 1024 * 1024,
}).trim();
const refs = git("ls-remote", "--refs", values.remote!, `${ANDROID_RELEASE_REF_PREFIX}/*`)
.split("\n")
.filter(Boolean)
.map((row) => {
const [sha, ref, extra] = row.split(/\s+/u);
if (
!sha ||
!/^[a-f0-9]{40}$/u.test(sha) ||
!ref?.startsWith(`${ANDROID_RELEASE_REF_PREFIX}/`) ||
extra
) {
throw new Error(`Invalid Android source ref response: ${row}.`);
}
return { sha, ref };
});
const snapshot: unknown = JSON.parse(readFileSync(0, "utf8"));
const saved = values.plan
? validateAndroidStorePlan(JSON.parse(readFileSync(values.plan, "utf8")))
: null;
if (command !== "plan" && !saved) {
throw new Error(`${command} requires --plan.`);
}
if (command === "validate-baselines") {
const actual = resolveAndroidPublicBaselines(snapshot, refs);
if (JSON.stringify(actual) !== JSON.stringify(saved!.releaseNotesBaselines)) {
throw new Error(
"Google Play production releases changed after release notes were generated. Prepare a new Android release before uploading.",
);
}
process.stdout.write(`${JSON.stringify(actual)}\n`);
return;
}
const pinned = resolveAndroidVersion(rootDir);
const packageJson = JSON.parse(readFileSync(path.join(rootDir, "package.json"), "utf8")) as {
version: string;
};
const plan = resolveAndroidStorePlan({
gatewayVersion: packageJson.version,
pinnedVersion: pinned.canonicalVersion,
pinnedVersionCode: pinned.versionCode,
sourceSha: git("rev-parse", "HEAD"),
snapshot,
refs,
});
if (command === "validate" && JSON.stringify(plan) !== JSON.stringify(saved)) {
throw new Error(
"Google Play release identity, next codes, or production baselines changed after preparation. Prepare a new Android release before uploading.",
);
}
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
}
try {
main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}