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
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