apple signing: every signable target (extensions, App Group check), build keychain unlock over SSH, api confirm docs

This commit is contained in:
CRTkafa 2026-10-24 13:02:35 +08:00
parent 5b6e0992ca
commit 445ab1adbc
17 changed files with 832 additions and 42 deletions

View file

@ -43,6 +43,20 @@ export interface AppleSigning {
capabilities?: (CapabilityType | CapabilitySpec)[];
/** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */
extraBundleIds?: string[];
/** Override for the app extensions / widgets / watch app / App Clip that `signing ensure` finds in the Xcode project
* (every target's PRODUCT_BUNDLE_IDENTIFIER and entitlements): when set, these replace the detected ones. Each is
* registered as a bundle id, gets the capabilities its entitlements need (or `capabilities` here) and a profile. */
extensions?: ExtensionSpec[];
}
export interface ExtensionSpec {
bundleId: string;
/** A label for messages and the bundle id's name on the developer portal. */
name?: string;
/** Default: the capabilities of the target's entitlements file (none when there is none). */
capabilities?: (CapabilityType | CapabilitySpec)[];
/** App group ids the target's entitlements list (com.apple.security.application-groups); checked against the profile. */
appGroups?: string[];
}
export function validateSigning(s: AppleSigning | undefined): string[] {
@ -56,6 +70,14 @@ export function validateSigning(s: AppleSigning | undefined): string[] {
const t = typeof c === 'string' ? c : c?.type;
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`);
}
for (const x of s.extensions ?? []) {
if (!x || typeof x.bundleId !== 'string' || !/^[A-Za-z0-9.-]+$/.test(x.bundleId)) { e.push(`apple.signing.extensions: "${String(x?.bundleId)}" is not a bundle id`); continue; }
for (const c of x.capabilities ?? []) {
const t = typeof c === 'string' ? c : c?.type;
if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.extensions ${x.bundleId}: unknown capability "${String(t)}"`);
}
if (x.appGroups && (!Array.isArray(x.appGroups) || x.appGroups.some(g => typeof g !== 'string' || !/^group\.[A-Za-z0-9.-]+$/.test(g)))) e.push(`apple.signing.extensions ${x.bundleId}: appGroups are ids like group.com.example.app`);
}
for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`);
return e;
}
@ -158,19 +180,20 @@ export const profileName = (bundleId: string, type: ProfileType, date = new Date
export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string }
/** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan {
export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number; /** make a new profile even when one is reusable (after an App Group was ticked in the portal) */ refresh?: boolean }): ProfilePlan {
const now = o.now ?? Date.now();
const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE'
&& Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId))
.sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!));
if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
if (ok[0] && !o.refresh) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` };
const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed);
const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed);
const why = [
stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '',
managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '',
].filter(Boolean).join('; ') || `no ${o.type} profile`;
return { reuse: null, why, create: {
const why2 = ok[0] ? `--refresh-profile: a new profile instead of "${ok[0].attributes.name}"` : why;
return { reuse: null, why: why2, create: {
method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`,
body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type },
relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } },