@rightkit/release 0.2.165 → 0.2.173
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 +16 -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 +93 -47
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +298 -5
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +105 -16
- 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/install-tauri-devid.mjs +172 -0
- package/inventory-check.mjs +1 -1
- package/local-publish.mjs +11 -1
- package/mac-package-recipe.mjs +304 -0
- 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-recipe-targets.mjs +95 -0
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/project-type.mjs +1 -1
- 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 +285 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +27 -1
- package/rightkit-versions.json +111 -63
- 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/stage-sidecar-command.mjs +41 -0
- package/stage-sidecar.mjs +424 -0
- package/swap-helper-bundle.mjs +105 -0
- package/swift-mac-dmg.mjs +2 -1
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
- package/win-package-recipe.mjs +321 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// right-release Android lane: a signed Android App Bundle (AAB) built by Gradle, with the same release-tier refusals as the
|
|
2
|
+
// desktop lanes. Nothing here is specific to one app: the Gradle command, the keystore and the AAB path come from the
|
|
3
|
+
// app's release config (`targets.android`).
|
|
4
|
+
//
|
|
5
|
+
// Config (every key is checked by validateAndroidTarget; unknown signing keys are refused so a typo cannot smuggle a secret):
|
|
6
|
+
// targets.android = {
|
|
7
|
+
// signed: true,
|
|
8
|
+
// signingContract: "android-aab-upload-key-v1",
|
|
9
|
+
// gradle: { cmd: "./gradlew", args: [":app:bundleRelease"], cwd: "android", timeoutMs?, env? }, // must run bundle<Variant>Release
|
|
10
|
+
// ndk?: { abis?: ["arm64-v8a"], jniLibs: "android/app/src/main/jniLibs", manifestPath?, package?, features?, apiLevel? }, // android-ndk.mjs: cargo-ndk before Gradle
|
|
11
|
+
// aab: { file: "android/app/build/outputs/bundle/release/app-release.aab" },
|
|
12
|
+
// artifacts: ["android/app/build/outputs/bundle/release/app-release.aab"], // seals the AAB
|
|
13
|
+
// signing: {
|
|
14
|
+
// keystore: { path: "~/keys/app-upload.jks", alias: "upload" }, // outside the repository
|
|
15
|
+
// storePassword: { credential: "app-upload-store" }, // OS credential store, or
|
|
16
|
+
// keyPassword: { file: "~/keys/app-upload-key.pass" }, // a 0600 file; optional (defaults to storePassword)
|
|
17
|
+
// expectedCertSha256: "<64 hex>", // SHA-256 of the upload certificate
|
|
18
|
+
// },
|
|
19
|
+
// }
|
|
20
|
+
//
|
|
21
|
+
// Secrets: a keystore or key password never appears in the environment, in an argument or in a log line.
|
|
22
|
+
// - Any ambient environment variable that looks like a signing password is refused on every host (not only managed hosts).
|
|
23
|
+
// - The passwords are read from the OS credential store (macOS Keychain `rightkit-android-<name>`, a protected token file
|
|
24
|
+
// on Windows and Linux) or from a file outside the repository (mode 0600 on POSIX), then written to a transient
|
|
25
|
+
// 0600 `signing.properties` in a private temp directory. Gradle gets only the file's PATH, in
|
|
26
|
+
// RIGHTKIT_ANDROID_SIGNING_PROPERTIES; the app's signingConfig reads storeFile/storePassword/keyAlias/keyPassword from it.
|
|
27
|
+
// - The file is zeroed and removed when Gradle ends, on failure, and on process exit.
|
|
28
|
+
//
|
|
29
|
+
// After the build the AAB is read in-process (android-aab-scan.mjs): QA/debug markers in every .so, web-bundle mocks,
|
|
30
|
+
// one upload-key signature whose certificate matches expectedCertSha256 (never the Android debug certificate). A receipt
|
|
31
|
+
// with the AAB digest is written; androidSealEntry() turns it into the sealed-manifest `files` entry.
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { spawnSync } from "./background-process.mjs";
|
|
37
|
+
import { describeAabProblems, extractNativeLibs, scanAab } from "./android-aab-scan.mjs";
|
|
38
|
+
import { runAndroidNdk, validateAndroidNdk } from "./android-ndk.mjs";
|
|
39
|
+
|
|
40
|
+
export const ANDROID_SIGNING_CONTRACT = "android-aab-upload-key-v1";
|
|
41
|
+
export const ANDROID_SIGNING_PROPERTIES_ENV = "RIGHTKIT_ANDROID_SIGNING_PROPERTIES";
|
|
42
|
+
export const ANDROID_RECEIPT_NAME = "android-aab.json";
|
|
43
|
+
export const ANDROID_ENV_SECRET_REFUSED = "ANDROID_ENV_SECRET_REFUSED";
|
|
44
|
+
export const ANDROID_SECRET_MISSING = "ANDROID_SECRET_MISSING";
|
|
45
|
+
|
|
46
|
+
export class AndroidReleaseError extends Error {
|
|
47
|
+
constructor(code, message) { super(message); this.name = "AndroidReleaseError"; this.code = code; }
|
|
48
|
+
}
|
|
49
|
+
const refuse = (code, message) => { throw new AndroidReleaseError(code, message); };
|
|
50
|
+
|
|
51
|
+
// ---- environment secrets -----------------------------------------------------------------------------------------
|
|
52
|
+
// Signing passwords are refused when found in the ambient environment, on every host. Names only, never values.
|
|
53
|
+
const SECRET_ENV_NAME = /^(?:ANDROID_|KEYSTORE|KEY_?STORE|STORE_|KEY_|RELEASE_|SIGNING_|UPLOAD_|ORG_GRADLE_PROJECT_)\w*(?:PASS|PWD)/i;
|
|
54
|
+
const OPTION_ENV_NAMES = ["GRADLE_OPTS", "JAVA_OPTS", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS"];
|
|
55
|
+
const SECRET_OPTION_VALUE = /pass(?:word|phrase)?\s*=|signing\.(?:store|key)|storepass|keypass/i;
|
|
56
|
+
|
|
57
|
+
export function androidEnvSecretNames(env = process.env) {
|
|
58
|
+
const names = [];
|
|
59
|
+
for (const [name, value] of Object.entries(env)) {
|
|
60
|
+
if (!String(value ?? "").trim()) continue;
|
|
61
|
+
if (SECRET_ENV_NAME.test(name)) names.push(name);
|
|
62
|
+
else if (OPTION_ENV_NAMES.includes(name.toUpperCase()) && SECRET_OPTION_VALUE.test(String(value))) names.push(name);
|
|
63
|
+
}
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function refuseAndroidEnvSecrets(env = process.env) {
|
|
68
|
+
const [name] = androidEnvSecretNames(env);
|
|
69
|
+
if (name) {
|
|
70
|
+
refuse(ANDROID_ENV_SECRET_REFUSED, `${name} is refused: an Android signing secret must never be passed in the environment; `
|
|
71
|
+
+ "keep it in the OS credential store or a 0600 file named by targets.android.signing (see android-release.mjs)");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- config validation -------------------------------------------------------------------------------------------
|
|
76
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
77
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
|
|
78
|
+
const expandHome = (value) => value === "~" ? os.homedir() : /^~[\\/]/.test(value) ? path.join(os.homedir(), value.slice(2)) : value;
|
|
79
|
+
const slashed = (value) => String(value).replaceAll("\\", "/").replace(/^\.\//, "");
|
|
80
|
+
const insideRoot = (root, file) => { const rel = path.relative(path.resolve(root), path.resolve(file)); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
|
|
81
|
+
|
|
82
|
+
const GRADLE_RELEASE_TASK = /^(?::?[\w.-]+:)*bundle(?:[A-Z][A-Za-z0-9]*)?Release$/;
|
|
83
|
+
const GRADLE_SECRET_ARG = /pass(?:word|phrase)?\s*=|storepass|keypass|signing\.(?:store|key)\.|debuggable/i;
|
|
84
|
+
const GRADLE_SECRET_ENV = /pass|pwd|secret|token|keystore/i;
|
|
85
|
+
const SIGNING_KEYS = ["keystore", "storePassword", "keyPassword", "expectedCertSha256"];
|
|
86
|
+
|
|
87
|
+
export function validateSecretSource(value, label) {
|
|
88
|
+
if (typeof value === "string") throw new Error(`${label} must be { credential } or { file }; an inline secret is refused`);
|
|
89
|
+
if (!isObject(value)) throw new Error(`${label} must be { credential: "<name>" } or { file: "<path>" }`);
|
|
90
|
+
const keys = Object.keys(value);
|
|
91
|
+
const unknown = keys.filter((key) => key !== "credential" && key !== "file");
|
|
92
|
+
if (unknown.length) throw new Error(`${label} has unsupported key(s) ${unknown.join(", ")}; a secret comes only from the OS credential store or a file`);
|
|
93
|
+
if (keys.length !== 1) throw new Error(`${label} must name exactly one of credential or file`);
|
|
94
|
+
if (keys[0] === "credential" && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(value.credential))) throw new Error(`${label}.credential must be a lowercase name (letters, digits, hyphens)`);
|
|
95
|
+
if (keys[0] === "file" && !nonEmpty(value.file)) throw new Error(`${label}.file must be a path`);
|
|
96
|
+
return { ...value };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Returns the normalised target, or throws an Error whose message names the problem. `root` is the app root.
|
|
100
|
+
export function validateAndroidTarget(target, { root }) {
|
|
101
|
+
if (!isObject(target)) throw new Error("has no target");
|
|
102
|
+
if (target.signed !== true) throw new Error("must declare signed: true");
|
|
103
|
+
if (target.signingContract !== ANDROID_SIGNING_CONTRACT) throw new Error(`must declare signingContract: ${ANDROID_SIGNING_CONTRACT}`);
|
|
104
|
+
|
|
105
|
+
const gradle = target.gradle;
|
|
106
|
+
if (!isObject(gradle) || !nonEmpty(gradle.cmd) || !Array.isArray(gradle.args) || gradle.args.length === 0 || gradle.args.some((arg) => typeof arg !== "string")) {
|
|
107
|
+
throw new Error("must declare gradle: { cmd, args[] } (the command that builds the release bundle)");
|
|
108
|
+
}
|
|
109
|
+
if (/\s/.test(gradle.cmd.trim())) throw new Error("gradle.cmd must be one executable; put its arguments in gradle.args");
|
|
110
|
+
if (gradle.envFiles !== undefined) throw new Error("gradle.envFiles is refused: no secret may reach Gradle through the environment");
|
|
111
|
+
for (const name of Object.keys(gradle.env ?? {})) {
|
|
112
|
+
if (GRADLE_SECRET_ENV.test(name)) throw new Error(`gradle.env.${name} is refused: signing secrets are never passed through the environment`);
|
|
113
|
+
}
|
|
114
|
+
const bad = gradle.args.find((arg) => GRADLE_SECRET_ARG.test(arg));
|
|
115
|
+
if (bad !== undefined) throw new Error("gradle.args carries a password, signing or debuggable property; the signing passwords come only from targets.android.signing");
|
|
116
|
+
const tasks = gradle.args.filter((arg) => !arg.startsWith("-") && !/[=/\\]/.test(arg));
|
|
117
|
+
const debugTask = tasks.find((task) => /debug/i.test(task));
|
|
118
|
+
if (debugTask) throw new Error(`gradle task ${debugTask} is a debug build; a release builds bundle<Variant>Release`);
|
|
119
|
+
const task = tasks.find((candidate) => GRADLE_RELEASE_TASK.test(candidate));
|
|
120
|
+
if (!task) throw new Error("gradle.args must run a bundle<Variant>Release task (for example :app:bundleRelease)");
|
|
121
|
+
|
|
122
|
+
if (!isObject(target.aab) || !nonEmpty(target.aab.file) || !/\.aab$/i.test(target.aab.file)) throw new Error("must declare aab: { file } naming the .aab Gradle produces");
|
|
123
|
+
const declared = (target.artifacts ?? []).map((artifact) => slashed(typeof artifact === "string" ? artifact : artifact?.file));
|
|
124
|
+
if (!declared.includes(slashed(target.aab.file))) throw new Error("artifacts must list aab.file so the bundle is checked and sealed");
|
|
125
|
+
|
|
126
|
+
const signing = target.signing;
|
|
127
|
+
if (!isObject(signing)) throw new Error("must declare signing: { keystore, storePassword, expectedCertSha256 }");
|
|
128
|
+
const unknown = Object.keys(signing).filter((key) => !SIGNING_KEYS.includes(key));
|
|
129
|
+
if (unknown.length) throw new Error(`signing has unsupported key(s) ${unknown.join(", ")}; secrets are never inline`);
|
|
130
|
+
const keystore = signing.keystore;
|
|
131
|
+
if (!isObject(keystore) || !nonEmpty(keystore.path) || !nonEmpty(keystore.alias) || /[\u0000-\u001f]/.test(keystore.alias)) throw new Error("signing.keystore must be { path, alias }");
|
|
132
|
+
if (Object.keys(keystore).some((key) => key !== "path" && key !== "alias")) throw new Error("signing.keystore accepts only path and alias");
|
|
133
|
+
const keystorePath = path.resolve(root, expandHome(keystore.path));
|
|
134
|
+
if (insideRoot(root, keystorePath)) throw new Error("signing.keystore.path must be outside the repository: an upload key never lives in the app tree");
|
|
135
|
+
if (/^debug\.keystore$/i.test(path.basename(keystorePath)) || /^androiddebugkey$/i.test(keystore.alias)) throw new Error("the Android debug keystore cannot sign a release");
|
|
136
|
+
const storePassword = validateSecretSource(signing.storePassword, "signing.storePassword");
|
|
137
|
+
const keyPassword = signing.keyPassword === undefined ? null : validateSecretSource(signing.keyPassword, "signing.keyPassword");
|
|
138
|
+
const pin = String(signing.expectedCertSha256 ?? "").replace(/:/g, "").toLowerCase();
|
|
139
|
+
if (!/^[0-9a-f]{64}$/.test(pin)) throw new Error("signing.expectedCertSha256 must be the SHA-256 (64 hex digits) of the upload certificate");
|
|
140
|
+
|
|
141
|
+
const ndk = target.ndk === undefined ? null : validateAndroidNdk(target.ndk, { root, kind: "release" });
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
gradle: { ...gradle, args: [...gradle.args] },
|
|
145
|
+
ndk,
|
|
146
|
+
task,
|
|
147
|
+
aab: { file: target.aab.file },
|
|
148
|
+
signing: { keystorePath, alias: keystore.alias, storePassword, keyPassword, expectedCertSha256: pin },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- secret sources ----------------------------------------------------------------------------------------------
|
|
153
|
+
function readKeychainSecret({ service, account }) {
|
|
154
|
+
const result = spawnSync("security", ["find-generic-password", "-a", account, "-s", service, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
155
|
+
if (result.error || result.status !== 0) return undefined;
|
|
156
|
+
return result.stdout;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A secret file: regular file, and on POSIX not readable by group or others. A missing file is undefined.
|
|
160
|
+
export function readSecretFile(file, { platform = process.platform } = {}) {
|
|
161
|
+
let stat;
|
|
162
|
+
try { stat = statSync(file); } catch (error) {
|
|
163
|
+
if (error.code === "ENOENT") return undefined;
|
|
164
|
+
throw new Error("secret file is unreadable");
|
|
165
|
+
}
|
|
166
|
+
if (!stat.isFile()) throw new Error("secret path is not a regular file");
|
|
167
|
+
if (platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("secret file must not be readable by group or others (chmod 600)");
|
|
168
|
+
try { return readFileSync(file, "utf8"); } catch { throw new Error("secret file is unreadable"); }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function androidSecretStoreCommand(name, platform = process.platform) {
|
|
172
|
+
if (platform === "darwin") return `security add-generic-password -a "$USER" -s rightkit-android-${name} -U -w`;
|
|
173
|
+
if (platform === "win32") {
|
|
174
|
+
return `powershell -NoProfile -Command "$s = Read-Host -AsSecureString 'Android ${name}'; $d = Join-Path $env:APPDATA 'RightKit'; New-Item -ItemType Directory -Force -Path $d | Out-Null; [IO.File]::WriteAllText((Join-Path $d 'android-${name}.token'), [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)))"`;
|
|
175
|
+
}
|
|
176
|
+
return `umask 077; mkdir -p ~/.config/RightKit; read -rs p && printf %s "$p" > ~/.config/RightKit/android-${name}.token`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Same storage pattern as protected-credentials.mjs: macOS Keychain generic password (account $USER), otherwise a
|
|
180
|
+
// protected token file. Injectable so tests never touch a real Keychain or profile directory.
|
|
181
|
+
export function createAndroidCredentialStore({ env = process.env, platform = process.platform, readKeychain = readKeychainSecret, readFile = readSecretFile } = {}) {
|
|
182
|
+
return {
|
|
183
|
+
platform,
|
|
184
|
+
read(name) {
|
|
185
|
+
let value;
|
|
186
|
+
if (platform === "darwin") value = readKeychain({ service: `rightkit-android-${name}`, account: env.USER || os.userInfo().username });
|
|
187
|
+
else if (platform === "win32") value = readFile(path.win32.join(env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "RightKit", `android-${name}.token`));
|
|
188
|
+
else value = readFile(path.join(env.XDG_CONFIG_HOME || path.join(env.HOME || os.homedir(), ".config"), "RightKit", `android-${name}.token`));
|
|
189
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
190
|
+
if (!trimmed) refuse(ANDROID_SECRET_MISSING, `Android signing secret "${name}" is not in the protected credential store; store it once with: ${androidSecretStoreCommand(name, platform)}`);
|
|
191
|
+
return trimmed;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function resolveAndroidSecret(source, { root, store, label, readFile = readSecretFile }) {
|
|
197
|
+
if (source.credential) return store.read(source.credential);
|
|
198
|
+
const file = path.resolve(root, expandHome(source.file));
|
|
199
|
+
if (insideRoot(root, file)) refuse("ANDROID_SECRET_IN_REPOSITORY", `${label} file must be outside the repository`);
|
|
200
|
+
let value;
|
|
201
|
+
try { value = readFile(file); } catch (error) { refuse("ANDROID_SECRET_UNREADABLE", `${label}: ${error.message}`); }
|
|
202
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
203
|
+
if (!trimmed) refuse(ANDROID_SECRET_MISSING, `${label} file is missing or empty`);
|
|
204
|
+
return trimmed;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- transient signing properties --------------------------------------------------------------------------------
|
|
208
|
+
// java.util.Properties.load(InputStream) reads ISO-8859-1 and treats these characters specially.
|
|
209
|
+
export function escapeJavaProperty(value) {
|
|
210
|
+
const text = String(value);
|
|
211
|
+
let out = "";
|
|
212
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
213
|
+
const code = text.charCodeAt(i); const ch = text[i];
|
|
214
|
+
if (ch === "\\") out += "\\\\";
|
|
215
|
+
else if (ch === "\n") out += "\\n";
|
|
216
|
+
else if (ch === "\r") out += "\\r";
|
|
217
|
+
else if (ch === "\t") out += "\\t";
|
|
218
|
+
else if (ch === "\f") out += "\\f";
|
|
219
|
+
else if (code < 0x20 || code > 0x7e) out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
220
|
+
else if ("=: #!".includes(ch)) out += `\\${ch}`;
|
|
221
|
+
else out += ch;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function createSigningProperties({ keystore, alias, storePassword, keyPassword, tmpRoot = os.tmpdir() }) {
|
|
227
|
+
const dir = mkdtempSync(path.join(tmpRoot, "rightkit-android-sign-"));
|
|
228
|
+
const file = path.join(dir, "signing.properties");
|
|
229
|
+
const content = [`storeFile=${escapeJavaProperty(keystore)}`, `storePassword=${escapeJavaProperty(storePassword)}`, `keyAlias=${escapeJavaProperty(alias)}`, `keyPassword=${escapeJavaProperty(keyPassword)}`, ""].join("\n");
|
|
230
|
+
try {
|
|
231
|
+
writeFileSync(file, content, { flag: "wx", mode: 0o600 });
|
|
232
|
+
try { chmodSync(dir, 0o700); } catch { /* best effort on hosts without POSIX modes */ }
|
|
233
|
+
} catch {
|
|
234
|
+
rmSync(dir, { recursive: true, force: true });
|
|
235
|
+
throw new AndroidReleaseError("ANDROID_SIGNING_PROPERTIES_FAILED", "cannot write the transient signing properties file");
|
|
236
|
+
}
|
|
237
|
+
const length = Buffer.byteLength(content);
|
|
238
|
+
let done = false;
|
|
239
|
+
const cleanup = () => {
|
|
240
|
+
if (done) return;
|
|
241
|
+
done = true;
|
|
242
|
+
try { writeFileSync(file, Buffer.alloc(length)); } catch { /* already gone */ }
|
|
243
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
244
|
+
};
|
|
245
|
+
return { file, dir, cleanup };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- verification, receipt and seal entry ------------------------------------------------------------------------
|
|
249
|
+
const sha256File = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
250
|
+
|
|
251
|
+
export function verifyAabSigner(signature, signing, now = new Date()) {
|
|
252
|
+
if (!signature) refuse("ANDROID_AAB_UNSIGNED", "the AAB has no readable upload-key signature");
|
|
253
|
+
if (signature.debugCertificate) refuse("ANDROID_AAB_DEBUG_SIGNED", "the AAB is signed with the Android debug certificate");
|
|
254
|
+
if (signature.certSha256 !== signing.expectedCertSha256) {
|
|
255
|
+
refuse("ANDROID_AAB_SIGNER_MISMATCH", `the AAB signer certificate ${signature.certSha256} is not signing.expectedCertSha256 ${signing.expectedCertSha256}`);
|
|
256
|
+
}
|
|
257
|
+
if (signature.notAfter != null && signature.notAfter <= now.getTime()) refuse("ANDROID_AAB_CERT_EXPIRED", "the AAB signer certificate has expired");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function androidSealEntry(receipt) {
|
|
261
|
+
return { role: "aab", name: receipt.file.name, sha256: receipt.file.sha256, sizeBytes: receipt.file.sizeBytes };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function writeAndroidReceipt(dir, receipt) {
|
|
265
|
+
mkdirSync(dir, { recursive: true });
|
|
266
|
+
const file = path.join(dir, ANDROID_RECEIPT_NAME);
|
|
267
|
+
writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
268
|
+
return file;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// For the sealing step: the receipt must describe exactly the AAB that is about to be sealed.
|
|
272
|
+
export function verifyAndroidReceipt(receiptPath, aabFile) {
|
|
273
|
+
const receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
|
|
274
|
+
if (receipt.schema !== 1 || receipt.contract !== ANDROID_SIGNING_CONTRACT) throw new Error("not an Android AAB receipt");
|
|
275
|
+
if (path.basename(aabFile) !== receipt.file?.name) throw new Error("AAB receipt names a different file");
|
|
276
|
+
if (statSync(aabFile).size !== receipt.file.sizeBytes || sha256File(aabFile) !== receipt.file.sha256) throw new Error("AAB bytes differ from the build receipt");
|
|
277
|
+
return receipt;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---- the lane ----------------------------------------------------------------------------------------------------
|
|
281
|
+
// runGradle(command, extraEnv): runs the configured Gradle command with extraEnv added (release.mjs: runCommand).
|
|
282
|
+
// scanFiles(label, files, { requireBinary, prePack }): release.mjs's assertNoQaControlBinaries (QA/debug rules plus the
|
|
283
|
+
// release-switch binary layer); it refuses the release itself.
|
|
284
|
+
export async function runAndroidRelease({
|
|
285
|
+
root, app = "app", target, dryRun = false, env = process.env, platform = process.platform,
|
|
286
|
+
runGradle, runNdk = runGradle, resolveArtifact = (file) => path.resolve(root, file), scanFiles, receiptDir,
|
|
287
|
+
allowContentOnlyDevtools = false, credentialStore, tmpRoot = os.tmpdir(), now = () => new Date(), log = console.log,
|
|
288
|
+
}) {
|
|
289
|
+
refuseAndroidEnvSecrets(env);
|
|
290
|
+
const aab = resolveArtifact(target.aab.file);
|
|
291
|
+
const { signing } = target;
|
|
292
|
+
if (dryRun) {
|
|
293
|
+
log(`right-release: dry-run: android ${target.task} -> ${aab}; signing passwords would come from ${signing.storePassword.credential ? "the credential store" : "a file"}, never the environment`);
|
|
294
|
+
if (target.ndk) await runAndroidNdk({ ndk: target.ndk, profile: "release", dryRun: true, run: (command) => runNdk(command, {}), log });
|
|
295
|
+
await runGradle(target.gradle, {});
|
|
296
|
+
return { dryRun: true, aab };
|
|
297
|
+
}
|
|
298
|
+
if (!existsSync(signing.keystorePath) || !statSync(signing.keystorePath).isFile()) refuse("ANDROID_KEYSTORE_MISSING", "the configured upload keystore file is missing");
|
|
299
|
+
const store = credentialStore ?? createAndroidCredentialStore({ env, platform });
|
|
300
|
+
const storePassword = resolveAndroidSecret(signing.storePassword, { root, store, label: "signing.storePassword" });
|
|
301
|
+
const keyPassword = signing.keyPassword ? resolveAndroidSecret(signing.keyPassword, { root, store, label: "signing.keyPassword" }) : storePassword;
|
|
302
|
+
|
|
303
|
+
// A stale bundle from an earlier build must never be mistaken for this build's output.
|
|
304
|
+
rmSync(aab, { force: true });
|
|
305
|
+
// Native libraries first (no secret involved): cargo-ndk --release per ABI into the generated jniLibs directory.
|
|
306
|
+
if (target.ndk) await runAndroidNdk({ ndk: target.ndk, profile: "release", run: (command) => runNdk(command, {}), log });
|
|
307
|
+
const props = createSigningProperties({ keystore: signing.keystorePath, alias: signing.alias, storePassword, keyPassword, tmpRoot });
|
|
308
|
+
const onExit = () => props.cleanup();
|
|
309
|
+
process.on("exit", onExit); // release.mjs ends a failed step with process.exit(); the secret file must not outlive it
|
|
310
|
+
try {
|
|
311
|
+
log(`right-release: android ${target.task} (signing properties passed by path only)`);
|
|
312
|
+
await runGradle(target.gradle, { [ANDROID_SIGNING_PROPERTIES_ENV]: props.file });
|
|
313
|
+
} finally {
|
|
314
|
+
props.cleanup();
|
|
315
|
+
process.removeListener("exit", onExit);
|
|
316
|
+
}
|
|
317
|
+
if (!existsSync(aab) || !statSync(aab).isFile()) refuse("ANDROID_AAB_MISSING", `Gradle finished but ${target.aab.file} was not produced`);
|
|
318
|
+
|
|
319
|
+
const scan = scanAab(aab, { allowContentOnlyDevtools });
|
|
320
|
+
const problems = describeAabProblems(scan);
|
|
321
|
+
if (problems.length) {
|
|
322
|
+
throw new AndroidReleaseError("ANDROID_AAB_REFUSED", `${app} android AAB ${path.basename(aab)} refused: it must be a signed bundle with no QA control server, devtools, remote debugging or browser-QA mock; ${problems.length} finding(s):\n${problems.join("\n")}`);
|
|
323
|
+
}
|
|
324
|
+
verifyAabSigner(scan.signature, signing, now());
|
|
325
|
+
// Same shared rules as the desktop lanes, over the finished package and over the native libraries inside it.
|
|
326
|
+
scanFiles?.("android AAB", [aab], { requireBinary: true, prePack: true });
|
|
327
|
+
const libDir = mkdtempSync(path.join(tmpRoot, "rightkit-android-libs-"));
|
|
328
|
+
try { scanFiles?.("android AAB native libraries", extractNativeLibs(aab, libDir), { requireBinary: true, prePack: true }); }
|
|
329
|
+
finally { rmSync(libDir, { recursive: true, force: true }); }
|
|
330
|
+
|
|
331
|
+
const receipt = {
|
|
332
|
+
schema: 1,
|
|
333
|
+
contract: ANDROID_SIGNING_CONTRACT,
|
|
334
|
+
app,
|
|
335
|
+
builtAt: now().toISOString(),
|
|
336
|
+
gradleTask: target.task,
|
|
337
|
+
file: { name: path.basename(aab), sha256: sha256File(aab), sizeBytes: statSync(aab).size },
|
|
338
|
+
signer: { alias: signing.alias, block: scan.signature.block, certSha256: scan.signature.certSha256, subject: scan.signature.subject },
|
|
339
|
+
scan: { entries: scan.entries, webFiles: scan.webFiles, nativeLibs: scan.nativeLibs.map(({ name, sha256 }) => ({ name, sha256 })) },
|
|
340
|
+
};
|
|
341
|
+
const receiptPath = writeAndroidReceipt(receiptDir ?? path.join(root, ".right-release", "receipts"), receipt);
|
|
342
|
+
log(`right-release: android AAB ${receipt.file.name} sha256 ${receipt.file.sha256} signed by ${receipt.signer.certSha256}; receipt ${receiptPath}`);
|
|
343
|
+
return { dryRun: false, aab, receipt, receiptPath, sealEntry: androidSealEntry(receipt) };
|
|
344
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Test-only builders for tiny fixture AABs (ZIP files) and PKCS#7 signature blocks. Nothing here is a real certificate.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { deflateRawSync } from "node:zlib";
|
|
4
|
+
import { crc32 } from "./android-aab-scan.mjs";
|
|
5
|
+
import { QA_CONTROL_MARKER } from "./qa-control-scan.mjs";
|
|
6
|
+
|
|
7
|
+
export const ELF = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
|
8
|
+
export const filler = (n) => Buffer.alloc(n, 0x90);
|
|
9
|
+
export const cleanLib = () => Buffer.concat([ELF, filler(256), Buffer.from("RIGHTKIT-QA-CONTROL")]);
|
|
10
|
+
export const markedLib = () => Buffer.concat([ELF, filler(64), Buffer.from(QA_CONTROL_MARKER), filler(64)]);
|
|
11
|
+
|
|
12
|
+
export function derLength(length) {
|
|
13
|
+
if (length < 0x80) return Buffer.from([length]);
|
|
14
|
+
const bytes = [];
|
|
15
|
+
for (let n = length; n > 0; n = Math.floor(n / 256)) bytes.unshift(n % 256);
|
|
16
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
17
|
+
}
|
|
18
|
+
export const der = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), derLength(body.length), body]); };
|
|
19
|
+
|
|
20
|
+
// A stand-in "certificate": any DER SEQUENCE. `subject` text lets a test plant the debug-certificate marker.
|
|
21
|
+
export const fakeCertificate = (subject = "CN=Fixture Upload Key") => der(0x30, der(0x0c, Buffer.from(subject)));
|
|
22
|
+
export const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
23
|
+
|
|
24
|
+
// PKCS#7 SignedData: ContentInfo { OID, [0] { SignedData { version, digestAlgorithms, contentInfo, [0] certificates, signerInfos } } }
|
|
25
|
+
export function pkcs7(certificates) {
|
|
26
|
+
const oid = der(0x06, Buffer.from([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02]));
|
|
27
|
+
const signedData = der(0x30,
|
|
28
|
+
der(0x02, Buffer.from([1])),
|
|
29
|
+
der(0x31),
|
|
30
|
+
der(0x30, der(0x06, Buffer.from([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01]))),
|
|
31
|
+
der(0xa0, ...certificates),
|
|
32
|
+
der(0x31));
|
|
33
|
+
return der(0x30, oid, der(0xa0, signedData));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// entries: [{ name, data, method?: "store" | "deflate" }]; returns the ZIP bytes (no ZIP64, no comment).
|
|
37
|
+
export function zip(entries) {
|
|
38
|
+
const locals = []; const centrals = []; let offset = 0;
|
|
39
|
+
for (const { name, data, method = "deflate" } of entries) {
|
|
40
|
+
const nameBytes = Buffer.from(name); const raw = Buffer.from(data);
|
|
41
|
+
const body = method === "store" ? raw : deflateRawSync(raw);
|
|
42
|
+
const local = Buffer.alloc(30);
|
|
43
|
+
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(method === "store" ? 0 : 8, 8);
|
|
44
|
+
local.writeUInt32LE(crc32(raw), 14); local.writeUInt32LE(body.length, 18); local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(nameBytes.length, 26);
|
|
45
|
+
const central = Buffer.alloc(46);
|
|
46
|
+
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(method === "store" ? 0 : 8, 10);
|
|
47
|
+
central.writeUInt32LE(crc32(raw), 16); central.writeUInt32LE(body.length, 20); central.writeUInt32LE(raw.length, 24); central.writeUInt16LE(nameBytes.length, 28); central.writeUInt32LE(offset, 42);
|
|
48
|
+
locals.push(local, nameBytes, body); centrals.push(central, nameBytes);
|
|
49
|
+
offset += local.length + nameBytes.length + body.length;
|
|
50
|
+
}
|
|
51
|
+
const directory = Buffer.concat(centrals);
|
|
52
|
+
const end = Buffer.alloc(22);
|
|
53
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
|
|
54
|
+
end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);
|
|
55
|
+
return Buffer.concat([...locals, directory, end]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const FIXTURE_CERT = fakeCertificate();
|
|
59
|
+
export const FIXTURE_CERT_SHA256 = sha256Hex(FIXTURE_CERT);
|
|
60
|
+
|
|
61
|
+
// A well-formed, signed AAB. `override` replaces or (with null) removes entries by name; `extra` appends entries.
|
|
62
|
+
export function aab({ libs, certificate = FIXTURE_CERT, override = {}, extra = [], signed = true } = {}) {
|
|
63
|
+
const base = [
|
|
64
|
+
{ name: "BundleConfig.pb", data: Buffer.from("config") },
|
|
65
|
+
{ name: "base/manifest/AndroidManifest.xml", data: Buffer.from("manifest") },
|
|
66
|
+
{ name: "base/assets/index.js", data: Buffer.from("console.log('release')") },
|
|
67
|
+
...(libs ?? [
|
|
68
|
+
{ name: "base/lib/arm64-v8a/libapp.so", data: cleanLib() },
|
|
69
|
+
{ name: "base/lib/x86_64/libapp.so", data: cleanLib(), method: "store" },
|
|
70
|
+
]),
|
|
71
|
+
...(signed ? [
|
|
72
|
+
{ name: "META-INF/MANIFEST.MF", data: Buffer.from("Manifest-Version: 1.0\n") },
|
|
73
|
+
{ name: "META-INF/UPLOAD.SF", data: Buffer.from("Signature-Version: 1.0\n") },
|
|
74
|
+
{ name: "META-INF/UPLOAD.RSA", data: pkcs7([certificate]) },
|
|
75
|
+
] : []),
|
|
76
|
+
];
|
|
77
|
+
const entries = base.filter((entry) => override[entry.name] !== null).map((entry) => (override[entry.name] ? { ...entry, data: override[entry.name] } : entry));
|
|
78
|
+
return zip([...entries, ...extra]);
|
|
79
|
+
}
|