import fs from "fs";
import path from "path";
import crypto from "crypto";
interface UploadConfig {
issuerId: string;
privateKeyId: string;
privateKeyPath: string;
appId: string;
ipaPath: string;
version: string;
buildNumber: string;
}
// Generate JWT token for App Store Connect API authentication
function generateJWT(
issuerId: string,
privateKeyId: string,
privateKeyPath: string
): string {
const privateKey = fs.readFileSync(privateKeyPath, "utf8");
// Create header
const header = {
alg: "ES256",
kid: privateKeyId,
typ: "JWT",
};
// Create payload
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: issuerId,
iat: now,
exp: now + 20 * 60, // 20 minutes
aud: "appstoreconnect-v1",
};
// Encode header and payload
const encodedHeader = Buffer.from(JSON.stringify(header))
.toString("base64url");
const encodedPayload = Buffer.from(JSON.stringify(payload))
.toString("base64url");
// Sign with private key
const sign = crypto.createSign("SHA256");
sign.update(`${encodedHeader}.${encodedPayload}`);
sign.end();
const signature = sign.sign(privateKey, "base64url");
return `${encodedHeader}.${encodedPayload}.${signature}`;
}
// Make authenticated API request
async function apiRequest(
method: string,
endpoint: string,
token: string,
body?: any
): Promise<any> {
const url = `https://api.appstoreconnect.apple.com${endpoint}`;
const options: RequestInit = {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`API request failed: ${response.status} ${response.statusText}\n${errorText}`
);
}
return response.json();
}
// Upload IPA to App Store Connect
async function uploadIPA(config: UploadConfig): Promise<void> {
console.log("š± Starting iOS app upload to App Store Connect...");
// Generate JWT token
console.log("š Generating JWT token...");
const token = generateJWT(
config.issuerId,
config.privateKeyId,
config.privateKeyPath
);
console.log("ā
JWT token generated");
// Step 1: Create a build
console.log("š Creating build entry...");
const buildResponse = await apiRequest("POST", "/v1/builds", token, {
data: {
type: "builds",
attributes: {
version: config.version,
buildNumber: config.buildNumber,
},
relationships: {
app: {
data: {
type: "apps",
id: config.appId,
},
},
},
},
});
const buildId = buildResponse.data?.id;
if (!buildId) {
throw new Error("Failed to create build");
}
console.log(`ā
Build created: ${buildId}`);
// Step 2: Read IPA file
console.log("š Reading IPA file...");
const ipaBuffer = fs.readFileSync(config.ipaPath);
const ipaSize = ipaBuffer.length;
console.log(`ā
IPA file size: ${(ipaSize / 1024 / 1024).toFixed(2)} MB`);
// Step 3: Reserve upload space
console.log("š Reserving upload space...");
const reserveResponse = await apiRequest(
"POST",
`/v1/builds/${buildId}/appBuildFiles`,
token,
{
data: {
type: "appBuildFiles",
attributes: {
fileName: path.basename(config.ipaPath),
fileSize: ipaSize,
},
},
}
);
const uploadId = reserveResponse.data?.id;
if (!uploadId) {
throw new Error("Failed to reserve upload space");
}
console.log(`ā
Upload reserved: ${uploadId}`);
// Step 4: Get upload URLs
console.log("š Getting upload URLs...");
const uploadDetailsResponse = await apiRequest(
"GET",
`/v1/builds/${buildId}/appBuildFiles/${uploadId}`,
token
);
const uploadOperations =
uploadDetailsResponse.data?.attributes?.uploadOperations;
if (!uploadOperations || uploadOperations.length === 0) {
throw new Error("No upload operations available");
}
console.log(`ā
Got ${uploadOperations.length} upload operation(s)`);
// Step 5: Upload binary in parts
console.log("ā¬ļø Uploading binary...");
for (const operation of uploadOperations) {
const { url, method, requestHeaders, offset, length } = operation;
const chunk = ipaBuffer.subarray(offset, offset + length);
const uploadHeaders: Record<string, string> = {
...requestHeaders,
"Content-Length": length.toString(),
};
const uploadResponse = await fetch(url, {
method,
headers: uploadHeaders,
body: chunk,
});
if (!uploadResponse.ok) {
throw new Error(
`Upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`
);
}
console.log(
`ā
Uploaded part: ${offset}-${offset + length} (${(length / 1024 / 1024).toFixed(2)} MB)`
);
}
// Step 6: Calculate checksum
console.log("š Calculating checksum...");
const checksum = crypto.createHash("md5").update(ipaBuffer).digest("base64");
console.log(`ā
Checksum: ${checksum}`);
// Step 7: Commit upload
console.log("āļø Committing upload...");
await apiRequest(
"PATCH",
`/v1/builds/${buildId}/appBuildFiles/${uploadId}`,
token,
{
data: {
type: "appBuildFiles",
id: uploadId,
attributes: {
uploaded: true,
sourceFileChecksum: checksum,
},
},
}
);
console.log("ā
Upload committed successfully");
console.log("\nš Upload complete!");
console.log(
`š Check build status at: https://appstoreconnect.apple.com/apps/${config.appId}/testflight/ios`
);
console.log(
"ā³ Apple will process your build. You'll receive an email when it's ready."
);
}
// Main execution
const config: UploadConfig = {
issuerId: process.env.ISSUER_ID || "",
privateKeyId: process.env.PRIVATE_KEY_ID || "",
privateKeyPath: process.env.PRIVATE_KEY_PATH || "",
appId: process.env.APP_ID || "",
ipaPath: process.argv[2] || "./app.ipa",
version: process.argv[3] || "1.0.0",
buildNumber: process.argv[4] || "1",
};
if (!config.issuerId || !config.privateKeyId || !config.privateKeyPath) {
console.error(
"ā Missing required environment variables: ISSUER_ID, PRIVATE_KEY_ID, PRIVATE_KEY_PATH"
);
process.exit(1);
}
uploadIPA(config).catch((error) => {
console.error("ā Upload failed:", error.message);
process.exit(1);
});