Wire Forge-native release infrastructure
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 4m14s
Build Site / Next.js Build (push) Failing after 6s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 5s
Lint Governance / BEP Metadata (push) Successful in 0s
Build: Android / Android Rust Core Stub (push) Failing after 23s
Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 4m14s
Build Site / Next.js Build (push) Failing after 6s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 5s
Lint Governance / BEP Metadata (push) Successful in 0s
Build: Android / Android Rust Core Stub (push) Failing after 23s
This commit is contained in:
parent
97c569fb35
commit
002bd382e9
199 changed files with 14268 additions and 185 deletions
164
Scripts/apple/create-asc-certificate.mjs
Executable file
164
Scripts/apple/create-asc-certificate.mjs
Executable file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
const crypto = globalThis.crypto ?? webcrypto;
|
||||
|
||||
function b64url(input) {
|
||||
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") out.help = true;
|
||||
else if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
|
||||
out[key] = value;
|
||||
i++;
|
||||
} else {
|
||||
out._.push(arg);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function makeJwt({ keyId, issuerId, keyPem }) {
|
||||
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
|
||||
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
||||
const pkcs8Der = Buffer.from(
|
||||
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
|
||||
"base64",
|
||||
);
|
||||
const key = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
pkcs8Der,
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
key,
|
||||
new TextEncoder().encode(input),
|
||||
);
|
||||
return `${input}.${b64url(Buffer.from(sig))}`;
|
||||
}
|
||||
|
||||
async function ascFetch(jwt, url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: {
|
||||
...(opts.headers ?? {}),
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function certificateFileName(type, id) {
|
||||
return `${type.toLowerCase().replace(/_/g, "-")}-${id}.cer`;
|
||||
}
|
||||
|
||||
function csrContentForApi(csrFile) {
|
||||
const bytes = fs.readFileSync(csrFile);
|
||||
const text = bytes.toString("ascii");
|
||||
const pemMatch = text.match(/-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/);
|
||||
if (pemMatch) {
|
||||
return pemMatch[1].replace(/\s+/g, "");
|
||||
}
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
console.log("Usage: create-asc-certificate.mjs --key-id ID --issuer-id ID --key-file AuthKey.p8 --certificate-type IOS_DISTRIBUTION --csr-file file.csr --out-dir DIR");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const keyId = args["key-id"] ?? process.env.APP_STORE_CONNECT_KEY_ID;
|
||||
const issuerId = args["issuer-id"] ?? process.env.APP_STORE_CONNECT_ISSUER_ID;
|
||||
const keyFile = args["key-file"] ?? process.env.APP_STORE_CONNECT_KEY_FILE;
|
||||
const certificateType = args["certificate-type"] ?? "IOS_DISTRIBUTION";
|
||||
const csrFile = args["csr-file"];
|
||||
const outDir = args["out-dir"] ?? ".";
|
||||
const outFile = args["out-file"];
|
||||
const metadataFile = args["metadata-file"];
|
||||
|
||||
if (!keyId || !issuerId || !keyFile || !csrFile) {
|
||||
throw new Error("required: --key-id --issuer-id --key-file --csr-file");
|
||||
}
|
||||
|
||||
const jwt = await makeJwt({
|
||||
keyId,
|
||||
issuerId,
|
||||
keyPem: fs.readFileSync(keyFile, "utf8"),
|
||||
});
|
||||
const csrContent = csrContentForApi(csrFile);
|
||||
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/certificates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "certificates",
|
||||
attributes: {
|
||||
certificateType,
|
||||
csrContent,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const cert = created?.data;
|
||||
const id = cert?.id;
|
||||
const content = cert?.attributes?.certificateContent;
|
||||
if (!id || !content) {
|
||||
throw new Error(`ASC response did not include certificate id/content: ${JSON.stringify(created).slice(0, 2000)}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const certificatePath = outFile ?? path.join(outDir, certificateFileName(certificateType, id));
|
||||
fs.writeFileSync(certificatePath, Buffer.from(content, "base64"));
|
||||
|
||||
const metadata = {
|
||||
id,
|
||||
certificateType: cert?.attributes?.certificateType ?? certificateType,
|
||||
displayName: cert?.attributes?.displayName,
|
||||
name: cert?.attributes?.name,
|
||||
platform: cert?.attributes?.platform,
|
||||
expirationDate: cert?.attributes?.expirationDate,
|
||||
serialNumber: cert?.attributes?.serialNumber,
|
||||
certificatePath,
|
||||
};
|
||||
if (metadataFile) {
|
||||
fs.writeFileSync(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
249
Scripts/apple/google-kms-csr.py
Executable file
249
Scripts/apple/google-kms-csr.py
Executable file
|
|
@ -0,0 +1,249 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build an Apple-compatible PKCS#10 CSR backed by Google Cloud KMS.
|
||||
|
||||
The script intentionally implements only the RSA/SHA-256 subset Burrow needs for
|
||||
Apple Developer ID and iOS Distribution certificate requests. It fetches the KMS
|
||||
public key, builds CertificationRequestInfo locally, asks KMS to sign that DER
|
||||
payload, and writes a standard PEM CSR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_PROJECT = "project-88c23ce9-918a-470a-b33"
|
||||
DEFAULT_LOCATION = "global"
|
||||
DEFAULT_KEYRING = "burrow-identity"
|
||||
DEFAULT_KEY = "apple-developer-id-application"
|
||||
DEFAULT_VERSION = "1"
|
||||
DEFAULT_COMMON_NAME = "Burrow Developer ID Application"
|
||||
DEFAULT_EMAIL = "release@burrow.net"
|
||||
|
||||
|
||||
def der_length(length: int) -> bytes:
|
||||
if length < 0:
|
||||
raise ValueError("negative DER length")
|
||||
if length < 0x80:
|
||||
return bytes([length])
|
||||
encoded = length.to_bytes((length.bit_length() + 7) // 8, "big")
|
||||
return bytes([0x80 | len(encoded)]) + encoded
|
||||
|
||||
|
||||
def der(tag: int, body: bytes) -> bytes:
|
||||
return bytes([tag]) + der_length(len(body)) + body
|
||||
|
||||
|
||||
def der_sequence(*items: bytes) -> bytes:
|
||||
return der(0x30, b"".join(items))
|
||||
|
||||
|
||||
def der_set(*items: bytes) -> bytes:
|
||||
return der(0x31, b"".join(items))
|
||||
|
||||
|
||||
def der_integer(value: int) -> bytes:
|
||||
if value < 0:
|
||||
raise ValueError("negative INTEGER is not supported")
|
||||
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
|
||||
if raw[0] & 0x80:
|
||||
raw = b"\x00" + raw
|
||||
return der(0x02, raw)
|
||||
|
||||
|
||||
def der_null() -> bytes:
|
||||
return der(0x05, b"")
|
||||
|
||||
|
||||
def der_bit_string(value: bytes) -> bytes:
|
||||
return der(0x03, b"\x00" + value)
|
||||
|
||||
|
||||
def der_utf8(value: str) -> bytes:
|
||||
return der(0x0C, value.encode("utf-8"))
|
||||
|
||||
|
||||
def der_printable(value: str) -> bytes:
|
||||
return der(0x13, value.encode("ascii"))
|
||||
|
||||
|
||||
def der_ia5(value: str) -> bytes:
|
||||
return der(0x16, value.encode("ascii"))
|
||||
|
||||
|
||||
def der_oid(dotted: str) -> bytes:
|
||||
parts = [int(part) for part in dotted.split(".")]
|
||||
if len(parts) < 2 or parts[0] > 2 or parts[1] > 39:
|
||||
raise ValueError(f"unsupported OID: {dotted}")
|
||||
body = bytes([40 * parts[0] + parts[1]])
|
||||
for value in parts[2:]:
|
||||
if value < 0:
|
||||
raise ValueError(f"negative OID component: {dotted}")
|
||||
encoded = [value & 0x7F]
|
||||
value >>= 7
|
||||
while value:
|
||||
encoded.append(0x80 | (value & 0x7F))
|
||||
value >>= 7
|
||||
body += bytes(reversed(encoded))
|
||||
return der(0x06, body)
|
||||
|
||||
|
||||
def name_attribute(oid: str, value_der: bytes) -> bytes:
|
||||
return der_set(der_sequence(der_oid(oid), value_der))
|
||||
|
||||
|
||||
def subject_name(common_name: str, email_address: str, country: str | None) -> bytes:
|
||||
attrs: list[bytes] = []
|
||||
if country:
|
||||
if len(country) != 2 or not country.isalpha():
|
||||
raise ValueError("--country must be a two-letter ISO country code")
|
||||
attrs.append(name_attribute("2.5.4.6", der_printable(country.upper())))
|
||||
if email_address:
|
||||
attrs.append(name_attribute("1.2.840.113549.1.9.1", der_ia5(email_address)))
|
||||
attrs.append(name_attribute("2.5.4.3", der_utf8(common_name)))
|
||||
return der_sequence(*attrs)
|
||||
|
||||
|
||||
def pem_to_der(pem: bytes) -> bytes:
|
||||
lines = [
|
||||
line.strip()
|
||||
for line in pem.splitlines()
|
||||
if line and not line.startswith(b"-----")
|
||||
]
|
||||
if not lines:
|
||||
raise ValueError("PEM input did not contain base64 data")
|
||||
return base64.b64decode(b"".join(lines), validate=True)
|
||||
|
||||
|
||||
def pem_wrap(label: str, body: bytes) -> str:
|
||||
encoded = base64.b64encode(body).decode("ascii")
|
||||
lines = [f"-----BEGIN {label}-----"]
|
||||
lines.extend(encoded[i : i + 64] for i in range(0, len(encoded), 64))
|
||||
lines.append(f"-----END {label}-----")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def fetch_public_key(args: argparse.Namespace, output: Path) -> None:
|
||||
command = [
|
||||
args.gcloud,
|
||||
"kms",
|
||||
"keys",
|
||||
"versions",
|
||||
"get-public-key",
|
||||
args.kms_version,
|
||||
"--location",
|
||||
args.kms_location,
|
||||
"--keyring",
|
||||
args.kms_keyring,
|
||||
"--key",
|
||||
args.kms_key,
|
||||
"--output-file",
|
||||
str(output),
|
||||
]
|
||||
if args.gcloud_project:
|
||||
command.extend(["--project", args.gcloud_project])
|
||||
run(command)
|
||||
|
||||
|
||||
def kms_sign(args: argparse.Namespace, payload: bytes) -> bytes:
|
||||
with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp:
|
||||
payload_path = Path(tmp) / "certification-request-info.der"
|
||||
signature_path = Path(tmp) / "signature.b64"
|
||||
payload_path.write_bytes(payload)
|
||||
command = [
|
||||
args.gcloud,
|
||||
"kms",
|
||||
"asymmetric-sign",
|
||||
"--location",
|
||||
args.kms_location,
|
||||
"--keyring",
|
||||
args.kms_keyring,
|
||||
"--key",
|
||||
args.kms_key,
|
||||
"--version",
|
||||
args.kms_version,
|
||||
"--digest-algorithm",
|
||||
"sha256",
|
||||
"--input-file",
|
||||
str(payload_path),
|
||||
"--signature-file",
|
||||
str(signature_path),
|
||||
]
|
||||
if args.gcloud_project:
|
||||
command.extend(["--project", args.gcloud_project])
|
||||
run(command)
|
||||
signature = signature_path.read_bytes()
|
||||
try:
|
||||
return base64.b64decode(signature.strip(), validate=True)
|
||||
except ValueError:
|
||||
return signature
|
||||
|
||||
|
||||
def build_csr(args: argparse.Namespace, spki_der: bytes) -> bytes:
|
||||
cri = der_sequence(
|
||||
der_integer(0),
|
||||
subject_name(args.common_name, args.email_address, args.country),
|
||||
spki_der,
|
||||
b"\xA0\x00",
|
||||
)
|
||||
signature = kms_sign(args, cri)
|
||||
signature_algorithm = der_sequence(
|
||||
der_oid("1.2.840.113549.1.1.11"),
|
||||
der_null(),
|
||||
)
|
||||
return der_sequence(cri, signature_algorithm, der_bit_string(signature))
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate an Apple-compatible CSR backed by a Google Cloud KMS RSA signing key.",
|
||||
)
|
||||
parser.add_argument("--gcloud-project", default=os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("BURROW_GOOGLE_PROJECT_ID") or DEFAULT_PROJECT)
|
||||
parser.add_argument("--kms-location", default=os.environ.get("BURROW_KMS_LOCATION", DEFAULT_LOCATION))
|
||||
parser.add_argument("--kms-keyring", default=os.environ.get("BURROW_KMS_KEYRING", DEFAULT_KEYRING))
|
||||
parser.add_argument("--kms-key", default=os.environ.get("BURROW_APPLE_KMS_KEY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_KMS_KEY", DEFAULT_KEY))
|
||||
parser.add_argument("--kms-version", default=os.environ.get("BURROW_KMS_VERSION", DEFAULT_VERSION))
|
||||
parser.add_argument("--common-name", default=os.environ.get("BURROW_APPLE_CSR_COMMON_NAME") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COMMON_NAME", DEFAULT_COMMON_NAME))
|
||||
parser.add_argument("--email-address", default=os.environ.get("BURROW_APPLE_CSR_EMAIL") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_EMAIL", DEFAULT_EMAIL))
|
||||
parser.add_argument("--country", default=os.environ.get("BURROW_APPLE_CSR_COUNTRY") or os.environ.get("BURROW_APPLE_DEVELOPER_ID_CSR_COUNTRY", "US"))
|
||||
parser.add_argument("--public-key-pem", help="Existing KMS public-key PEM path. If omitted, gcloud fetches it.")
|
||||
parser.add_argument("--public-key-output", help="Write the fetched/used public key PEM to this path.")
|
||||
parser.add_argument("--output", default="developer-id-application.certSigningRequest")
|
||||
parser.add_argument("--gcloud", default=os.environ.get("GCLOUD", "gcloud"))
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
output = Path(args.output)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="burrow-kms-csr.") as tmp:
|
||||
public_key_path = Path(args.public_key_pem) if args.public_key_pem else Path(tmp) / "kms-public.pem"
|
||||
if not args.public_key_pem:
|
||||
fetch_public_key(args, public_key_path)
|
||||
public_key_pem = public_key_path.read_bytes()
|
||||
spki_der = pem_to_der(public_key_pem)
|
||||
csr = build_csr(args, spki_der)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(pem_wrap("CERTIFICATE REQUEST", csr), encoding="ascii")
|
||||
if args.public_key_output:
|
||||
public_output = Path(args.public_key_output)
|
||||
public_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
public_output.write_bytes(public_key_pem)
|
||||
print(f"wrote {output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
327
Scripts/apple/sync-provisioning-profiles.mjs
Executable file
327
Scripts/apple/sync-provisioning-profiles.mjs
Executable file
|
|
@ -0,0 +1,327 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
const crypto = globalThis.crypto ?? webcrypto;
|
||||
|
||||
function b64url(input) {
|
||||
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
||||
return buf
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") out.help = true;
|
||||
else if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const value = argv[i + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
|
||||
out[key] = value;
|
||||
i++;
|
||||
} else {
|
||||
out._.push(arg);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function makeJwt({ keyId, issuerId, keyPem }) {
|
||||
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
|
||||
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
||||
const pkcs8Der = Buffer.from(
|
||||
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
|
||||
"base64",
|
||||
);
|
||||
const key = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
pkcs8Der,
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
key,
|
||||
new TextEncoder().encode(input),
|
||||
);
|
||||
return `${input}.${b64url(Buffer.from(sig))}`;
|
||||
}
|
||||
|
||||
async function ascFetch(jwt, url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: {
|
||||
...(opts.headers ?? {}),
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
body = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function truthy(value) {
|
||||
return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function safeFileName(identifier, suffix) {
|
||||
return `${identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase()}${suffix}`;
|
||||
}
|
||||
|
||||
function bundleDisplayName(identifier, appId) {
|
||||
return identifier === appId ? "Burrow App" : "Burrow Network Extension";
|
||||
}
|
||||
|
||||
async function findCertificate(jwt, preferredTypes, preferredId) {
|
||||
const certs = await ascFetch(
|
||||
jwt,
|
||||
"https://api.appstoreconnect.apple.com/v1/certificates?limit=200",
|
||||
);
|
||||
const data = certs.data ?? [];
|
||||
if (preferredId) {
|
||||
const cert = data.find((item) => item?.id === preferredId);
|
||||
if (!cert) throw new Error(`unable to locate certificate id ${preferredId} via ASC API`);
|
||||
const type = cert?.attributes?.certificateType;
|
||||
if (preferredTypes.length && !preferredTypes.includes(type)) {
|
||||
throw new Error(`certificate ${preferredId} has type ${type}, expected ${preferredTypes.join(" or ")}`);
|
||||
}
|
||||
return cert;
|
||||
}
|
||||
for (const type of preferredTypes) {
|
||||
const cert = data.find((item) => item?.attributes?.certificateType === type);
|
||||
if (cert) return cert;
|
||||
}
|
||||
throw new Error(`unable to locate certificate type ${preferredTypes.join(" or ")} via ASC API`);
|
||||
}
|
||||
|
||||
async function resolveBundle(jwt, target, { createMissing, platform }) {
|
||||
const bundleResp = await ascFetch(
|
||||
jwt,
|
||||
`https://api.appstoreconnect.apple.com/v1/bundleIds?limit=5&filter[identifier]=${encodeURIComponent(target.identifier)}`,
|
||||
);
|
||||
let bundle = (bundleResp.data ?? []).find(
|
||||
(item) => item?.attributes?.identifier === target.identifier,
|
||||
);
|
||||
if (bundle || !createMissing) return bundle;
|
||||
|
||||
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIds", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "bundleIds",
|
||||
attributes: {
|
||||
identifier: target.identifier,
|
||||
name: target.bundleName ?? target.name,
|
||||
platform,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
bundle = created?.data;
|
||||
if (bundle?.id) {
|
||||
process.stdout.write(`created bundle id ${target.identifier} (${platform})\n`);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
async function listCapabilityTypes(jwt, bundleId) {
|
||||
const resp = await ascFetch(
|
||||
jwt,
|
||||
`https://api.appstoreconnect.apple.com/v1/bundleIds/${bundleId}/bundleIdCapabilities`,
|
||||
);
|
||||
return new Set((resp.data ?? []).map((item) => item?.attributes?.capabilityType).filter(Boolean));
|
||||
}
|
||||
|
||||
async function enableCapability(jwt, bundleId, capabilityType, settings) {
|
||||
await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "bundleIdCapabilities",
|
||||
attributes: {
|
||||
capabilityType,
|
||||
...(settings ? { settings } : {}),
|
||||
},
|
||||
relationships: {
|
||||
bundleId: {
|
||||
data: {
|
||||
id: bundleId,
|
||||
type: "bundleIds",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function profileIncludesCertificate(jwt, profileId, certificateId) {
|
||||
const resp = await ascFetch(
|
||||
jwt,
|
||||
`https://api.appstoreconnect.apple.com/v1/profiles/${profileId}/certificates?limit=200`,
|
||||
);
|
||||
return (resp.data ?? []).some((item) => item?.id === certificateId);
|
||||
}
|
||||
|
||||
async function ensureCapabilities(jwt, bundle, capabilities) {
|
||||
const existing = await listCapabilityTypes(jwt, bundle.id);
|
||||
for (const capability of capabilities) {
|
||||
if (existing.has(capability.type)) continue;
|
||||
try {
|
||||
await enableCapability(jwt, bundle.id, capability.type, capability.settings);
|
||||
process.stdout.write(`enabled ${capability.type} for ${bundle.attributes.identifier}\n`);
|
||||
} catch (err) {
|
||||
process.stderr.write(`warning: could not enable ${capability.type} for ${bundle.attributes.identifier}: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTarget(jwt, cert, profileType, target, outDir, options) {
|
||||
const bundle = await resolveBundle(jwt, target, options);
|
||||
if (!bundle) throw new Error(`bundleId not found in ASC: ${target.identifier}`);
|
||||
|
||||
if (options.enableCapabilities && target.capabilities?.length) {
|
||||
await ensureCapabilities(jwt, bundle, target.capabilities);
|
||||
}
|
||||
|
||||
const existing = await ascFetch(
|
||||
jwt,
|
||||
`https://api.appstoreconnect.apple.com/v1/profiles?limit=10&filter[profileType]=${encodeURIComponent(profileType)}&filter[name]=${encodeURIComponent(target.name)}`,
|
||||
);
|
||||
let profileId = (existing.data ?? []).find((item) => item?.attributes?.name === target.name)?.id;
|
||||
if (profileId && options.replaceCertificateMismatch) {
|
||||
const includesCertificate = await profileIncludesCertificate(jwt, profileId, cert.id);
|
||||
if (!includesCertificate) {
|
||||
await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
process.stdout.write(`deleted stale profile ${target.name}; it did not include certificate ${cert.id}\n`);
|
||||
profileId = undefined;
|
||||
}
|
||||
}
|
||||
if (!profileId) {
|
||||
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "profiles",
|
||||
attributes: { name: target.name, profileType },
|
||||
relationships: {
|
||||
bundleId: { data: { type: "bundleIds", id: bundle.id } },
|
||||
certificates: { data: [{ type: "certificates", id: cert.id }] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
profileId = created?.data?.id;
|
||||
}
|
||||
if (!profileId) throw new Error(`failed to resolve/create profile for ${target.identifier}`);
|
||||
|
||||
const profile = await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`);
|
||||
const content = profile?.data?.attributes?.profileContent;
|
||||
if (!content) throw new Error(`profileContent missing for ${target.name} (${profileId})`);
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const profileBytes = Buffer.from(content, "base64");
|
||||
const profilePath = path.join(outDir, target.file);
|
||||
fs.writeFileSync(profilePath, profileBytes);
|
||||
if (target.mobileFile) {
|
||||
fs.writeFileSync(path.join(outDir, target.mobileFile), profileBytes);
|
||||
}
|
||||
process.stdout.write(`synced ${target.identifier} -> ${profilePath}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
console.log("Usage: sync-provisioning-profiles.mjs --platform ios|macos|all --key-id ID --issuer-id ID --key-file AuthKey.p8 --out-dir DIR [--ios-certificate-id ID] [--macos-certificate-id ID] [--replace-certificate-mismatch true]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const platform = args.platform ?? "all";
|
||||
const keyId = args["key-id"];
|
||||
const issuerId = args["issuer-id"];
|
||||
const keyFile = args["key-file"];
|
||||
const outDir = args["out-dir"] ?? ".forgejo/actions/export";
|
||||
const appId = args["app-id"] ?? "com.hackclub.burrow";
|
||||
const networkId = args["network-id"] ?? `${appId}.network`;
|
||||
const iosCertificateId = args["ios-certificate-id"] ?? process.env.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID;
|
||||
const macosCertificateId = args["macos-certificate-id"] ?? process.env.BURROW_DEVELOPER_ID_CERTIFICATE_ID;
|
||||
const createMissing = truthy(args["create-missing-bundle-ids"]);
|
||||
const enableCapabilities = createMissing || truthy(args["enable-capabilities"]);
|
||||
const replaceCertificateMismatch = truthy(args["replace-certificate-mismatch"]);
|
||||
const bundlePlatform = args["bundle-platform"] ?? "UNIVERSAL";
|
||||
const associatedDomains = (args["associated-domains"] ?? "applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const appCapabilities = [
|
||||
{ type: "NETWORK_EXTENSIONS" },
|
||||
{ type: "APP_GROUPS" },
|
||||
{
|
||||
type: "ASSOCIATED_DOMAINS",
|
||||
settings: [{ key: "ASSOCIATED_DOMAIN_IDS", options: associatedDomains.map((key) => ({ key, enabled: true })) }],
|
||||
},
|
||||
];
|
||||
const extensionCapabilities = [
|
||||
{ type: "NETWORK_EXTENSIONS" },
|
||||
{ type: "APP_GROUPS" },
|
||||
];
|
||||
const options = { createMissing, enableCapabilities, replaceCertificateMismatch, platform: bundlePlatform };
|
||||
|
||||
if (!keyId || !issuerId || !keyFile) throw new Error("required: --key-id --issuer-id --key-file");
|
||||
|
||||
const jwt = await makeJwt({ keyId, issuerId, keyPem: fs.readFileSync(keyFile, "utf8") });
|
||||
|
||||
if (platform === "ios" || platform === "all") {
|
||||
const cert = await findCertificate(jwt, ["IOS_DISTRIBUTION", "DISTRIBUTION", "APPLE_DISTRIBUTION"], iosCertificateId);
|
||||
const targets = [appId, networkId].map((identifier) => ({
|
||||
identifier,
|
||||
name: `${identifier}.app-store`,
|
||||
bundleName: bundleDisplayName(identifier, appId),
|
||||
file: safeFileName(identifier, "appstore.provisionprofile"),
|
||||
mobileFile: safeFileName(identifier, "appstore.mobileprovision"),
|
||||
capabilities: identifier === appId ? appCapabilities : extensionCapabilities,
|
||||
}));
|
||||
for (const target of targets) {
|
||||
await syncTarget(jwt, cert, "IOS_APP_STORE", target, outDir, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "macos" || platform === "all") {
|
||||
const cert = await findCertificate(jwt, ["DEVELOPER_ID_APPLICATION"], macosCertificateId);
|
||||
const targets = [appId, networkId].map((identifier) => ({
|
||||
identifier,
|
||||
name: `${identifier}.developer-id`,
|
||||
bundleName: bundleDisplayName(identifier, appId),
|
||||
file: safeFileName(identifier, "developerid.provisionprofile"),
|
||||
capabilities: identifier === appId ? appCapabilities : extensionCapabilities,
|
||||
}));
|
||||
for (const target of targets) {
|
||||
await syncTarget(jwt, cert, "MAC_APP_DIRECT", target, outDir, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err?.stack || String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue