burrow/Scripts/apple/sync-provisioning-profiles.mjs
Conrad Kramer 002bd382e9
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
Wire Forge-native release infrastructure
2026-06-07 05:51:12 -07:00

327 lines
12 KiB
JavaScript
Executable file

#!/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);
});