@rightkit/release 0.2.164 → 0.2.172
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +14 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +78 -46
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +315 -13
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +141 -22
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/local-publish.mjs +11 -1
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +283 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +26 -0
- package/rightkit-versions.json +110 -62
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/swap-helper-bundle.mjs +105 -0
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
package/android-ndk.mjs
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// right-release Android native step: Rust .so libraries built per ABI with cargo-ndk into the Gradle jniLibs directory, before
|
|
2
|
+
// Gradle runs. The plan is pure data (commands, never a shell string) so tests build and inspect it without running anything.
|
|
3
|
+
//
|
|
4
|
+
// Config (release: targets.android.ndk; dev: development.targets.android.ndk):
|
|
5
|
+
// ndk: {
|
|
6
|
+
// abis: ["arm64-v8a"], // release default; dev default is ["arm64-v8a", "x86_64"] (x86_64 is the emulator)
|
|
7
|
+
// jniLibs: "android/app/src/main/jniLibs", // GENERATED directory named exactly jniLibs, inside the app, never the app root
|
|
8
|
+
// manifestPath?: "rust/Cargo.toml", package?: "heardright-android", features?: ["a", "b"],
|
|
9
|
+
// apiLevel?: 26, // cargo-ndk --platform (minSdk)
|
|
10
|
+
// cwd?: ".", cmd?: "cargo", timeoutMs?,
|
|
11
|
+
// }
|
|
12
|
+
// Release builds always pass --release; dev builds use Cargo's built-in dev profile (no flag). Nothing else reaches cargo-ndk:
|
|
13
|
+
// no free-form arguments, so a release can never be redirected to a debug profile or a secret passed on argv.
|
|
14
|
+
import { rmSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
export const ANDROID_ABIS = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"];
|
|
18
|
+
export const ANDROID_RELEASE_DEFAULT_ABIS = ["arm64-v8a"];
|
|
19
|
+
export const ANDROID_DEV_DEFAULT_ABIS = ["arm64-v8a", "x86_64"];
|
|
20
|
+
|
|
21
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
23
|
+
const inside = (root, child) => { const rel = path.relative(path.resolve(root), path.resolve(child)); return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel); };
|
|
24
|
+
const KNOWN_KEYS = ["abis", "jniLibs", "manifestPath", "package", "features", "apiLevel", "cwd", "cmd", "timeoutMs"];
|
|
25
|
+
const FEATURE = /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/;
|
|
26
|
+
const PACKAGE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
|
|
27
|
+
|
|
28
|
+
// Returns the normalised ndk config, or throws an Error naming the problem. `root` is the app root.
|
|
29
|
+
export function validateAndroidNdk(ndk, { root, kind = "release" } = {}) {
|
|
30
|
+
if (!isObject(ndk)) throw new Error("ndk must be an object");
|
|
31
|
+
const unknown = Object.keys(ndk).filter((key) => !KNOWN_KEYS.includes(key));
|
|
32
|
+
if (unknown.length) throw new Error(`ndk has unsupported key(s) ${unknown.join(", ")}; cargo-ndk arguments are not free-form`);
|
|
33
|
+
const abis = ndk.abis === undefined ? [...(kind === "dev" ? ANDROID_DEV_DEFAULT_ABIS : ANDROID_RELEASE_DEFAULT_ABIS)] : ndk.abis;
|
|
34
|
+
if (!Array.isArray(abis) || abis.length === 0 || abis.some((abi) => !ANDROID_ABIS.includes(abi)) || new Set(abis).size !== abis.length) {
|
|
35
|
+
throw new Error(`ndk.abis must be a non-empty list of distinct ABIs from ${ANDROID_ABIS.join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
if (!nonEmpty(ndk.jniLibs) || path.isAbsolute(ndk.jniLibs) || path.win32.isAbsolute(ndk.jniLibs)) throw new Error("ndk.jniLibs must be a relative path inside the app");
|
|
38
|
+
const jniLibs = path.resolve(root, ndk.jniLibs);
|
|
39
|
+
if (!inside(root, jniLibs) || path.basename(jniLibs) !== "jniLibs") throw new Error("ndk.jniLibs must name a generated directory called jniLibs inside the app (it is emptied before every build)");
|
|
40
|
+
if (ndk.manifestPath !== undefined && (!nonEmpty(ndk.manifestPath) || path.isAbsolute(ndk.manifestPath) || path.win32.isAbsolute(ndk.manifestPath) || ndk.manifestPath.split(/[\\/]/).includes(".."))) {
|
|
41
|
+
throw new Error("ndk.manifestPath must be a relative path inside the app");
|
|
42
|
+
}
|
|
43
|
+
if (ndk.package !== undefined && (typeof ndk.package !== "string" || !PACKAGE.test(ndk.package))) throw new Error("ndk.package must be a Cargo package name");
|
|
44
|
+
if (ndk.features !== undefined && (!Array.isArray(ndk.features) || ndk.features.some((feature) => typeof feature !== "string" || !FEATURE.test(feature)))) throw new Error("ndk.features must be a list of Cargo feature names");
|
|
45
|
+
if (ndk.apiLevel !== undefined && (!Number.isInteger(ndk.apiLevel) || ndk.apiLevel < 21 || ndk.apiLevel > 99)) throw new Error("ndk.apiLevel must be an Android API level (21 or higher)");
|
|
46
|
+
if (ndk.cmd !== undefined && (!nonEmpty(ndk.cmd) || /\s/.test(ndk.cmd.trim()))) throw new Error("ndk.cmd must be one executable");
|
|
47
|
+
const cwd = path.resolve(root, ndk.cwd ?? ".");
|
|
48
|
+
if (path.resolve(root) !== cwd && !inside(root, cwd)) throw new Error("ndk.cwd must stay inside the app");
|
|
49
|
+
return {
|
|
50
|
+
abis, jniLibs, jniLibsRelative: String(ndk.jniLibs).replaceAll("\\", "/"), manifestPath: ndk.manifestPath, package: ndk.package,
|
|
51
|
+
features: ndk.features ?? [], apiLevel: ndk.apiLevel, cwd, cmd: ndk.cmd ?? "cargo", timeoutMs: ndk.timeoutMs,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The one cargo-ndk command. profile "release" adds --release; "dev" adds nothing (Cargo's built-in dev profile).
|
|
56
|
+
export function androidNdkCommand(ndk, { profile = "release" } = {}) {
|
|
57
|
+
if (profile !== "release" && profile !== "dev") throw new Error(`unsupported Android Cargo profile: ${profile}`);
|
|
58
|
+
const args = ["ndk"];
|
|
59
|
+
if (ndk.apiLevel !== undefined) args.push("--platform", String(ndk.apiLevel));
|
|
60
|
+
for (const abi of ndk.abis) args.push("-t", abi);
|
|
61
|
+
args.push("-o", ndk.jniLibs, "build");
|
|
62
|
+
if (profile === "release") args.push("--release");
|
|
63
|
+
if (ndk.manifestPath) args.push("--manifest-path", ndk.manifestPath);
|
|
64
|
+
if (ndk.package) args.push("-p", ndk.package);
|
|
65
|
+
if (ndk.features.length) args.push("--features", ndk.features.join(","));
|
|
66
|
+
return { cmd: ndk.cmd, args, cwd: ndk.cwd, ...(ndk.timeoutMs === undefined ? {} : { timeoutMs: ndk.timeoutMs }) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Runs the native step. `run(command)` executes the command (release.mjs: runCommand, which already honours --dry-run);
|
|
70
|
+
// `clean` empties the generated jniLibs directory first so a stale .so (or an ABI no longer configured) can never ship.
|
|
71
|
+
export async function runAndroidNdk({ ndk, profile = "release", dryRun = false, run, clean = (dir) => rmSync(dir, { recursive: true, force: true }), log = console.log }) {
|
|
72
|
+
const command = androidNdkCommand(ndk, { profile });
|
|
73
|
+
if (dryRun) log(`right-release: dry-run: cargo-ndk ${ndk.abis.join(",")} (${profile}) -> ${ndk.jniLibsRelative}`);
|
|
74
|
+
else clean(ndk.jniLibs);
|
|
75
|
+
await run(command);
|
|
76
|
+
return { command, abis: ndk.abis, jniLibs: ndk.jniLibs };
|
|
77
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// right-release Android upload: a sealed, upload-key-signed AAB to the Google Play INTERNAL track through the Play Developer
|
|
2
|
+
// API (androidpublisher v3): edits.insert -> edits.bundles.upload -> edits.tracks.update (internal) -> edits.commit.
|
|
3
|
+
// Build-free: it reads only the sealed release that `right-release build --platform android` wrote.
|
|
4
|
+
//
|
|
5
|
+
// Config (release config, top level; the explicit provider matches the other providers):
|
|
6
|
+
// distribution: {
|
|
7
|
+
// provider: "google-play",
|
|
8
|
+
// packageName: "com.orthiclabs.heardright", // the production applicationId (a dev/qa/test/debug package is refused)
|
|
9
|
+
// track: "internal", // the only track this command can write
|
|
10
|
+
// releaseStatus: "completed", // or "draft" (a draft release for a not-yet-published app)
|
|
11
|
+
// releaseName?: "HeardRight 1.2.3",
|
|
12
|
+
// serviceAccount: { credential: "play-service-account" }, // OS credential store, or { file: "~/keys/play.json" } outside the repo
|
|
13
|
+
// }
|
|
14
|
+
//
|
|
15
|
+
// Secrets: the service-account JSON (which holds a private key) comes from the same stores as the signing passwords
|
|
16
|
+
// (android-release.mjs: Keychain `rightkit-android-<name>` on macOS, a protected token file elsewhere, or a 0600 file outside the
|
|
17
|
+
// repository). It is parsed in memory only. The signed JWT and the OAuth access token live in memory, are never written to disk,
|
|
18
|
+
// never put on argv, never logged, and never part of an error message. Any ambient environment variable that carries key
|
|
19
|
+
// material is refused.
|
|
20
|
+
//
|
|
21
|
+
// Dry run is the default: everything local is verified and the exact API plan is printed; nothing is read from the secret store
|
|
22
|
+
// and no network call is made. `--apply` performs the upload.
|
|
23
|
+
import { createSign } from "node:crypto";
|
|
24
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
import { spawnSync } from "./background-process.mjs";
|
|
28
|
+
import { describeAabProblems, scanAabBuffer } from "./android-aab-scan.mjs";
|
|
29
|
+
import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
|
|
30
|
+
import { AndroidReleaseError, createAndroidCredentialStore, resolveAndroidSecret, validateSecretSource } from "./android-release.mjs";
|
|
31
|
+
import { assertArtifactUnchanged, digestBytes, verifyPublicationRelease } from "./publication-artifacts.mjs";
|
|
32
|
+
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
33
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
34
|
+
|
|
35
|
+
export const PLAY_PROVIDER = "google-play";
|
|
36
|
+
export const PLAY_TRACK = "internal";
|
|
37
|
+
export const PLAY_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
38
|
+
export const PLAY_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
39
|
+
export const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
|
|
40
|
+
export const PLAY_UPLOAD_API = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications";
|
|
41
|
+
export const PLAY_MARKER_NAME = "verified-play-internal.json";
|
|
42
|
+
|
|
43
|
+
const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
44
|
+
const TEST_SEGMENT = /^(?:dev|qa|test|debug)$/i;
|
|
45
|
+
const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
|
|
46
|
+
const DISTRIBUTION_KEYS = ["provider", "packageName", "track", "releaseStatus", "releaseName", "serviceAccount"];
|
|
47
|
+
|
|
48
|
+
export class PlayUploadError extends Error {
|
|
49
|
+
constructor(code, message) { super(`[${code}] ${message}`); this.name = "PlayUploadError"; this.code = code; }
|
|
50
|
+
}
|
|
51
|
+
const refuse = (code, message) => { throw new PlayUploadError(code, message); };
|
|
52
|
+
|
|
53
|
+
// ---- config ------------------------------------------------------------------------------------------------------
|
|
54
|
+
export function validatePlayDistribution(distribution) {
|
|
55
|
+
if (!distribution || typeof distribution !== "object" || Array.isArray(distribution)) refuse("PLAY_CONFIG_INVALID", "release config distribution is required");
|
|
56
|
+
if (distribution.provider !== PLAY_PROVIDER) refuse("PLAY_CONFIG_INVALID", `release config distribution.provider must be ${PLAY_PROVIDER} for an Android upload`);
|
|
57
|
+
const unknown = Object.keys(distribution).filter((key) => !DISTRIBUTION_KEYS.includes(key));
|
|
58
|
+
if (unknown.length) refuse("PLAY_CONFIG_INVALID", `distribution has unsupported key(s) ${unknown.join(", ")}; the service account is only ever a credential name or a file path`);
|
|
59
|
+
const packageName = String(distribution.packageName ?? "");
|
|
60
|
+
if (!PACKAGE_NAME.test(packageName)) refuse("PLAY_CONFIG_INVALID", "distribution.packageName must be the Android applicationId");
|
|
61
|
+
if (TEST_SEGMENT.test(packageName.split(".").at(-1))) refuse("PLAY_TEST_PACKAGE", `distribution.packageName ${packageName} is a dev/qa/test/debug package; those never go to Play`);
|
|
62
|
+
const track = distribution.track ?? PLAY_TRACK;
|
|
63
|
+
if (track !== PLAY_TRACK) refuse("PLAY_TRACK_REFUSED", `distribution.track must be ${PLAY_TRACK}; this command writes the internal track only`);
|
|
64
|
+
const releaseStatus = distribution.releaseStatus ?? "completed";
|
|
65
|
+
if (releaseStatus !== "completed" && releaseStatus !== "draft") refuse("PLAY_CONFIG_INVALID", "distribution.releaseStatus must be completed or draft");
|
|
66
|
+
if (distribution.releaseName !== undefined && (typeof distribution.releaseName !== "string" || !distribution.releaseName.trim() || distribution.releaseName.length > 120 || /[\u0000-\u001f]/.test(distribution.releaseName))) {
|
|
67
|
+
refuse("PLAY_CONFIG_INVALID", "distribution.releaseName must be a short single-line string");
|
|
68
|
+
}
|
|
69
|
+
let serviceAccount;
|
|
70
|
+
try { serviceAccount = validateSecretSource(distribution.serviceAccount, "distribution.serviceAccount"); } catch (error) { refuse("PLAY_CONFIG_INVALID", error.message); }
|
|
71
|
+
return { packageName, track, releaseStatus, releaseName: distribution.releaseName, serviceAccount };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The service-account JSON never arrives through the environment: refuse key material by value and the usual names, names only.
|
|
75
|
+
export function playEnvSecretNames(env = process.env) {
|
|
76
|
+
const names = [];
|
|
77
|
+
for (const [name, value] of Object.entries(env)) {
|
|
78
|
+
const text = String(value ?? "");
|
|
79
|
+
if (!text.trim()) continue;
|
|
80
|
+
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text) || /"private_key"\s*:/.test(text)) names.push(name);
|
|
81
|
+
else if (/^(?:PLAY_|GOOGLE_PLAY_|ANDROID_PUBLISHER_)\w*(?:KEY|SECRET|TOKEN|SERVICE_?ACCOUNT)\w*$/i.test(name)) names.push(name);
|
|
82
|
+
}
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
export function refusePlayEnvSecrets(env = process.env) {
|
|
86
|
+
const [name] = playEnvSecretNames(env);
|
|
87
|
+
if (name) refuse("PLAY_ENV_SECRET_REFUSED", `${name} is refused: the Play service account must never be passed in the environment; keep it in the OS credential store or a 0600 file named by distribution.serviceAccount`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- the plan (pure data, no secrets) -----------------------------------------------------------------------------
|
|
91
|
+
export function playUploadPlan({ distribution, aab }) {
|
|
92
|
+
const pkg = encodeURIComponent(distribution.packageName);
|
|
93
|
+
const release = { ...(distribution.releaseName ? { name: distribution.releaseName } : {}), status: distribution.releaseStatus, versionCodes: ["<versionCode from bundles.upload>"] };
|
|
94
|
+
return {
|
|
95
|
+
packageName: distribution.packageName,
|
|
96
|
+
track: distribution.track,
|
|
97
|
+
aab: { name: aab.name, sha256: aab.sha256, sizeBytes: aab.sizeBytes },
|
|
98
|
+
steps: [
|
|
99
|
+
{ id: "edits.insert", method: "POST", url: `${PLAY_API}/${pkg}/edits` },
|
|
100
|
+
{ id: "bundles.upload", method: "POST", url: `${PLAY_UPLOAD_API}/${pkg}/edits/<editId>/bundles?uploadType=media`, contentType: "application/octet-stream", bytes: aab.sizeBytes },
|
|
101
|
+
{ id: "tracks.update", method: "PUT", url: `${PLAY_API}/${pkg}/edits/<editId>/tracks/${distribution.track}`, body: { track: distribution.track, releases: [release] } },
|
|
102
|
+
{ id: "edits.commit", method: "POST", url: `${PLAY_API}/${pkg}/edits/<editId>:commit` },
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- authentication (service account -> short-lived access token) -------------------------------------------------
|
|
108
|
+
export function parseServiceAccount(text) {
|
|
109
|
+
let value;
|
|
110
|
+
try { value = JSON.parse(text); } catch { refuse("PLAY_SERVICE_ACCOUNT_INVALID", "the Play service account secret is not valid JSON"); }
|
|
111
|
+
if (!value || value.type !== "service_account" || typeof value.client_email !== "string" || !value.client_email.includes("@")
|
|
112
|
+
|| typeof value.private_key !== "string" || !/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value.private_key)) {
|
|
113
|
+
refuse("PLAY_SERVICE_ACCOUNT_INVALID", "the Play service account secret must be a service_account key file (client_email, private_key)");
|
|
114
|
+
}
|
|
115
|
+
// A tampered secret must not redirect the signed assertion to another host.
|
|
116
|
+
if (value.token_uri !== undefined && value.token_uri !== PLAY_TOKEN_URL) refuse("PLAY_SERVICE_ACCOUNT_INVALID", `the Play service account token_uri must be ${PLAY_TOKEN_URL}`);
|
|
117
|
+
return { clientEmail: value.client_email, privateKey: value.private_key };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const base64url = (input) => Buffer.from(input).toString("base64url");
|
|
121
|
+
export function signServiceAccountAssertion(account, { nowSeconds = Math.floor(Date.now() / 1000), sign = (data, key) => createSign("RSA-SHA256").update(data).sign(key) } = {}) {
|
|
122
|
+
const head = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
123
|
+
const claims = base64url(JSON.stringify({ iss: account.clientEmail, scope: PLAY_SCOPE, aud: PLAY_TOKEN_URL, iat: nowSeconds, exp: nowSeconds + 3600 }));
|
|
124
|
+
const data = `${head}.${claims}`;
|
|
125
|
+
return `${data}.${base64url(sign(data, account.privateKey))}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Credentials never survive into a message: bearer tokens, Google access tokens and anything JWT-shaped are masked.
|
|
129
|
+
const redact = (text) => text.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/ya29\.[\w.-]+/g, "[redacted]").replace(/[\w-]{16,}\.[\w-]{16,}\.[\w-]{8,}/g, "[redacted]");
|
|
130
|
+
|
|
131
|
+
// The Google error message only: never a request body, header or token.
|
|
132
|
+
async function failure(response, step) {
|
|
133
|
+
let detail = "";
|
|
134
|
+
try { const body = await response.json(); detail = redact(String(body?.error?.message ?? body?.error_description ?? body?.error ?? "")).replace(/\s+/g, " ").slice(0, 300); } catch { /* no body */ }
|
|
135
|
+
return new PlayUploadError("PLAY_API_FAILED", `${step} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function fetchAccessToken(account, { fetchImpl = fetch, nowSeconds, sign } = {}) {
|
|
139
|
+
const assertion = signServiceAccountAssertion(account, { nowSeconds, sign });
|
|
140
|
+
const response = await fetchImpl(PLAY_TOKEN_URL, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
143
|
+
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }).toString(),
|
|
144
|
+
signal: AbortSignal.timeout(60_000),
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) throw await failure(response, "Play OAuth token exchange");
|
|
147
|
+
const body = await response.json();
|
|
148
|
+
if (typeof body?.access_token !== "string" || !body.access_token) refuse("PLAY_API_FAILED", "Play OAuth token exchange returned no access token");
|
|
149
|
+
return body.access_token;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- the edit transaction -----------------------------------------------------------------------------------------
|
|
153
|
+
// An edit that fails after edits.insert is deleted so a half-built edit never lingers; the commit is the only publishing step.
|
|
154
|
+
export async function uploadToPlay({ distribution, aabBytes, accessToken, fetchImpl = fetch, log = console.log }) {
|
|
155
|
+
const pkg = encodeURIComponent(distribution.packageName);
|
|
156
|
+
const auth = { authorization: `Bearer ${accessToken}` };
|
|
157
|
+
const call = async (step, url, init, timeoutMs = 120_000) => {
|
|
158
|
+
const response = await fetchImpl(url, { ...init, headers: { ...auth, ...(init.headers ?? {}) }, signal: AbortSignal.timeout(timeoutMs) });
|
|
159
|
+
if (!response.ok) throw await failure(response, step);
|
|
160
|
+
return response.json();
|
|
161
|
+
};
|
|
162
|
+
const edit = await call("edits.insert", `${PLAY_API}/${pkg}/edits`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
163
|
+
const editId = String(edit?.id ?? "");
|
|
164
|
+
if (!/^[A-Za-z0-9_-]+$/.test(editId)) refuse("PLAY_API_FAILED", "edits.insert returned no edit id");
|
|
165
|
+
log(`right-release: play edit ${editId} opened for ${distribution.packageName}`);
|
|
166
|
+
try {
|
|
167
|
+
const bundle = await call("bundles.upload", `${PLAY_UPLOAD_API}/${pkg}/edits/${editId}/bundles?uploadType=media`,
|
|
168
|
+
{ method: "POST", headers: { "content-type": "application/octet-stream" }, body: aabBytes }, 30 * 60_000);
|
|
169
|
+
const versionCode = Number(bundle?.versionCode);
|
|
170
|
+
if (!Number.isInteger(versionCode) || versionCode <= 0) refuse("PLAY_API_FAILED", "bundles.upload returned no versionCode");
|
|
171
|
+
log(`right-release: play bundle uploaded, versionCode ${versionCode}`);
|
|
172
|
+
const release = { ...(distribution.releaseName ? { name: distribution.releaseName } : {}), status: distribution.releaseStatus, versionCodes: [String(versionCode)] };
|
|
173
|
+
await call("tracks.update", `${PLAY_API}/${pkg}/edits/${editId}/tracks/${distribution.track}`,
|
|
174
|
+
{ method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ track: distribution.track, releases: [release] }) });
|
|
175
|
+
await call("edits.commit", `${PLAY_API}/${pkg}/edits/${editId}:commit`, { method: "POST" });
|
|
176
|
+
log(`right-release: play edit ${editId} committed to the ${distribution.track} track`);
|
|
177
|
+
return { editId, versionCode };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
try { await fetchImpl(`${PLAY_API}/${pkg}/edits/${editId}`, { method: "DELETE", headers: auth, signal: AbortSignal.timeout(30_000) }); } catch { /* best effort */ }
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- authorisation and the command --------------------------------------------------------------------------------
|
|
185
|
+
const git = (cwd, args) => {
|
|
186
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
|
|
187
|
+
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
|
188
|
+
return result.stdout.trim();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Local checks only; no network, no secret. The sealed release is the only input: build-free by construction.
|
|
192
|
+
export async function authorizePlayUpload({ releaseId, configName = "right-release.config.mjs", cwd = process.cwd() }) {
|
|
193
|
+
if (!RELEASE_ID.test(releaseId ?? "")) refuse("PLAY_RELEASE_REQUIRED", "--release <sealed-id> is required");
|
|
194
|
+
const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
195
|
+
assertPrimaryReleaseCheckout(repoRoot);
|
|
196
|
+
const commit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
197
|
+
assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release upload" });
|
|
198
|
+
const configPath = path.resolve(cwd, configName);
|
|
199
|
+
if (!existsSync(configPath)) refuse("PLAY_CONFIG_INVALID", `release config is required: ${configName}`);
|
|
200
|
+
// The config is a module that runs on import: loaded only after the tree is proven clean.
|
|
201
|
+
const config = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
|
|
202
|
+
const distribution = validatePlayDistribution(config?.distribution);
|
|
203
|
+
const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "android");
|
|
204
|
+
const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot: path.dirname(configPath), commit, app: config.app, version: config.version, platform: "android", configPath, releaseId });
|
|
205
|
+
const entries = sealed.manifest.files.filter((entry) => entry.role === "aab");
|
|
206
|
+
if (entries.length !== 1) refuse("PLAY_AAB_MISSING", "the sealed release must hold exactly one aab entry");
|
|
207
|
+
const aab = sealed.artifacts.get(entries[0].name);
|
|
208
|
+
// A debug-signed or unpinned bundle is refused here again, from the sealed bytes, whatever the receipts say.
|
|
209
|
+
const scan = scanAabBuffer(aab.bytes, { allowContentOnlyDevtools: sealedManifestAllowsContentDevtools(sealed.manifest), label: entries[0].name });
|
|
210
|
+
assertUploadableScan(scan, sealed.manifest.signing?.uploadCertSha256);
|
|
211
|
+
return { repoRoot, sealed, sealedDir, configPath, config, distribution, aab, entry: entries[0] };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A scanned bundle may be uploaded only when it is clean, signed, not debug-signed, and signed by exactly the pinned upload certificate.
|
|
215
|
+
export function assertUploadableScan(scan, pinnedCertSha256) {
|
|
216
|
+
const problems = describeAabProblems(scan);
|
|
217
|
+
if (problems.length) refuse("PLAY_AAB_REFUSED", `the sealed AAB is refused:\n${problems.join("\n")}`);
|
|
218
|
+
if (!scan.signature) refuse("PLAY_AAB_UNSIGNED", "the sealed AAB has no readable upload-key signature");
|
|
219
|
+
if (scan.signature.debugCertificate) refuse("PLAY_AAB_DEBUG_SIGNED", "the sealed AAB is signed with the Android debug certificate; a debug-signed build is never uploaded");
|
|
220
|
+
if (!/^[0-9a-f]{64}$/.test(pinnedCertSha256 ?? "") || scan.signature.certSha256 !== pinnedCertSha256) refuse("PLAY_AAB_SIGNER_MISMATCH", "the sealed AAB signer is not the pinned upload certificate");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function runPlayUpload({
|
|
224
|
+
releaseId, configName, cwd = process.cwd(), apply = false, env = process.env, platform = process.platform, log = console.log,
|
|
225
|
+
fetchImpl = fetch, credentialStore, sign, nowSeconds, authorize = authorizePlayUpload,
|
|
226
|
+
}) {
|
|
227
|
+
refusePlayEnvSecrets(env);
|
|
228
|
+
const auth = await authorize({ releaseId, configName, cwd });
|
|
229
|
+
const aabEvidence = { name: auth.entry.name, sha256: auth.entry.sha256, sizeBytes: auth.entry.sizeBytes };
|
|
230
|
+
const plan = playUploadPlan({ distribution: auth.distribution, aab: aabEvidence });
|
|
231
|
+
const stateRoot = path.join(auth.repoRoot, ".right-release", "state", releaseId, "android");
|
|
232
|
+
const marker = path.join(stateRoot, PLAY_MARKER_NAME);
|
|
233
|
+
if (!apply) {
|
|
234
|
+
log(`right-release upload: dry-run (default; pass --apply to upload) ${releaseId} -> Play ${plan.track} track of ${plan.packageName}`);
|
|
235
|
+
log(JSON.stringify(plan, null, 2));
|
|
236
|
+
return { applied: false, plan };
|
|
237
|
+
}
|
|
238
|
+
if (existsSync(marker)) { log(`right-release upload: already uploaded ${releaseId} to the Play ${plan.track} track`); return { applied: false, alreadyUploaded: true, plan }; }
|
|
239
|
+
const store = credentialStore ?? createAndroidCredentialStore({ env, platform });
|
|
240
|
+
const account = parseServiceAccount(resolveAndroidSecret(auth.distribution.serviceAccount, { root: path.dirname(auth.configPath), store, label: "distribution.serviceAccount" }));
|
|
241
|
+
const bytes = assertArtifactUnchanged(auth.aab);
|
|
242
|
+
if (digestBytes(bytes) !== auth.entry.sha256) refuse("PLAY_AAB_REFUSED", "the sealed AAB bytes changed after verification");
|
|
243
|
+
const accessToken = await fetchAccessToken(account, { fetchImpl, nowSeconds, sign });
|
|
244
|
+
const result = await uploadToPlay({ distribution: auth.distribution, aabBytes: bytes, accessToken, fetchImpl, log });
|
|
245
|
+
mkdirSync(stateRoot, { recursive: true });
|
|
246
|
+
writeFileSync(marker, `${JSON.stringify({ schema: 1, releaseId, platform: "android", provider: PLAY_PROVIDER, packageName: plan.packageName, track: plan.track, versionCode: result.versionCode, editId: result.editId, aabSha256: aabEvidence.sha256, uploadedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
247
|
+
log(`right-release upload: uploaded ${releaseId} versionCode ${result.versionCode} to the Play ${plan.track} track of ${plan.packageName}`);
|
|
248
|
+
return { applied: true, plan, ...result };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Entry used by upload-release.mjs for --platform android. Exit code 0 on success, 1 on any refusal.
|
|
252
|
+
export async function main({ releaseId, configName, apply, dryRun }) {
|
|
253
|
+
if (apply && dryRun) { console.error("right-release upload: --apply and --dry-run are mutually exclusive"); return 2; }
|
|
254
|
+
try {
|
|
255
|
+
await runPlayUpload({ releaseId, configName, apply: apply === true });
|
|
256
|
+
return 0;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error instanceof PlayUploadError || error instanceof AndroidReleaseError) { console.error(`right-release upload: ${error.message}`); return 1; }
|
|
259
|
+
console.error(`right-release upload: ${error.message}`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
}
|