gowalk-cicd 1.0.163 → 1.0.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 1.0.163
1
+ 1.0.164
@@ -0,0 +1,119 @@
1
+ /* Close a timed-out iOS upload only after the original owner and Apple both prove completion. */
2
+ 'use strict';
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+ const github = require('./mobile_release_api.cjs');
6
+
7
+ const RECEIPT = 'gowalk-cicd/apple-build-upload-receipt.v1';
8
+ const OWNER = 'gowalk-cicd/mobile-upload-owner.v1';
9
+
10
+ function refuse(code) { throw Error(`apple_upload_reconcile_${code}`); }
11
+
12
+ function readReceipt(path) {
13
+ let data;
14
+ try { data = JSON.parse(fs.readFileSync(path, 'utf8')); } catch { refuse('receipt_unavailable'); }
15
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
16
+ if (data?.schema !== RECEIPT || data.stage !== 'processing' ||
17
+ data.failure?.category !== 'processing_pending' ||
18
+ !/^\d+$/.test(data.app_id || '') || !id(data.upload_id) || !id(data.file_id) ||
19
+ !/^[a-z0-9.]+$/.test(data.bundle_id || '') || !/^\d+$/.test(data.cfBundleVersion || '') ||
20
+ !/^\d+(\.\d+){1,2}$/.test(data.cfBundleShortVersionString || '') ||
21
+ !/^[a-f0-9]{64}$/.test(data.sha256 || '') || data.fileName !== `${data.sha256}.ipa` ||
22
+ !Number.isSafeInteger(data.fileSize) || data.fileSize <= 0) refuse('receipt_invalid');
23
+ return data;
24
+ }
25
+
26
+ async function verifyOwner(deploymentId, runId, attempt, api = github) {
27
+ if (!Number.isSafeInteger(deploymentId) || deploymentId <= 0 ||
28
+ !Number.isSafeInteger(runId) || runId <= 0 ||
29
+ !Number.isSafeInteger(attempt) || attempt <= 0) refuse('input_invalid');
30
+ const deployment = await api.request('GET', `/deployments/${deploymentId}`);
31
+ const owner = deployment.payload || {};
32
+ if (deployment.id !== deploymentId || deployment.task !== 'mobile-upload' ||
33
+ !/^mobile-ios-[a-f0-9]{64}$/.test(deployment.environment || '') ||
34
+ owner.schema !== OWNER || owner.platform !== 'ios' ||
35
+ owner.fingerprint !== deployment.environment.slice('mobile-ios-'.length) ||
36
+ owner.run_id !== runId || owner.run_attempt !== attempt ||
37
+ !/^[a-f0-9]{40}$/.test(owner.source_sha || '') || deployment.sha !== owner.source_sha) {
38
+ refuse('owner_invalid');
39
+ }
40
+ const run = await api.request('GET', `/actions/runs/${runId}/attempts/${attempt}`);
41
+ if (run.head_sha !== owner.source_sha || run.path !== '.github/workflows/deploy.yml' ||
42
+ run.event !== 'push' || run.head_branch !== 'main' ||
43
+ run.status !== 'completed' || run.conclusion !== 'failure') refuse('run_invalid');
44
+ const statuses = await api.request('GET', `/deployments/${deploymentId}/statuses?per_page=1`);
45
+ const latest = statuses[0] || {};
46
+ if (latest.state === 'success' && latest.description === 'uploaded') return 'reused';
47
+ if (latest.state !== 'in_progress' || latest.description !== 'uploading') refuse('phase_invalid');
48
+ return 'pending';
49
+ }
50
+
51
+ async function appleGet(path) {
52
+ if (!path.startsWith('/') || path.includes('..')) refuse('path_invalid');
53
+ const now = Math.floor(Date.now() / 1000);
54
+ const encoded = value => Buffer.from(JSON.stringify(value)).toString('base64url');
55
+ const header = encoded({alg: 'ES256', kid: process.env.ASC_KEY_ID, typ: 'JWT'});
56
+ const payload = encoded({iss: process.env.ASC_ISSUER_ID, iat: now, exp: now + 1200,
57
+ aud: 'appstoreconnect-v1'});
58
+ let signature;
59
+ try {
60
+ signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`),
61
+ {key: process.env.ASC_KEY_P8, dsaEncoding: 'ieee-p1363'}).toString('base64url');
62
+ } catch { refuse('apple_key_invalid'); }
63
+ const response = await fetch(`https://api.appstoreconnect.apple.com/v1${path}`, {
64
+ headers: {Authorization: `Bearer ${header}.${payload}.${signature}`, Accept: 'application/json'},
65
+ signal: AbortSignal.timeout(30000),
66
+ });
67
+ if (!response.ok) refuse(`apple_http_${response.status}`);
68
+ try { return await response.json(); } catch { refuse('apple_response_invalid'); }
69
+ }
70
+
71
+ async function verifyApple(receipt, get = appleGet) {
72
+ const upload = (await get(`/buildUploads/${receipt.upload_id}?include=build`)).data;
73
+ const attributes = upload?.attributes || {};
74
+ const buildId = upload?.relationships?.build?.data?.id;
75
+ if (upload?.id !== receipt.upload_id || attributes.state?.state !== 'COMPLETE' ||
76
+ attributes.cfBundleVersion !== receipt.cfBundleVersion ||
77
+ attributes.cfBundleShortVersionString !== receipt.cfBundleShortVersionString ||
78
+ !/^[A-Za-z0-9-]{1,64}$/.test(buildId || '')) refuse('upload_unverified');
79
+ const file = (await get(`/buildUploadFiles/${receipt.file_id}`)).data;
80
+ if (file?.id !== receipt.file_id || file.attributes?.assetType !== 'ASSET' ||
81
+ file.attributes.fileName !== receipt.fileName ||
82
+ file.attributes.fileSize !== receipt.fileSize ||
83
+ file.attributes.assetDeliveryState?.state !== 'COMPLETE') refuse('file_unverified');
84
+ const build = (await get(`/builds/${buildId}`)).data;
85
+ if (build?.id !== buildId || build.attributes?.processingState !== 'VALID' ||
86
+ build.attributes?.buildAudienceType !== 'APP_STORE_ELIGIBLE' ||
87
+ build.attributes?.version !== receipt.cfBundleVersion) refuse('build_unverified');
88
+ const app = (await get(`/builds/${buildId}/app`)).data;
89
+ if (app?.id !== receipt.app_id || app.attributes?.bundleId !== receipt.bundle_id) refuse('app_unverified');
90
+ return {build_id: buildId, app_id: app.id, version: receipt.cfBundleShortVersionString,
91
+ build: receipt.cfBundleVersion, upload_id: receipt.upload_id};
92
+ }
93
+
94
+ async function reconcile(deploymentId, runId, attempt, path, api = github, get = appleGet) {
95
+ const receipt = readReceipt(path);
96
+ const phase = await verifyOwner(deploymentId, runId, attempt, api);
97
+ const proof = await verifyApple(receipt, get);
98
+ if (phase === 'pending') await api.status(deploymentId, 'uploaded', 'success');
99
+ const statuses = await api.request('GET', `/deployments/${deploymentId}/statuses?per_page=1`);
100
+ if (statuses[0]?.state !== 'success' || statuses[0]?.description !== 'uploaded') {
101
+ refuse('write_unconfirmed');
102
+ }
103
+ return {schema: 'gowalk-cicd/apple-upload-reconciled.v1', state: phase === 'pending' ? 'landed' : 'reused',
104
+ deployment_id: deploymentId, ...proof};
105
+ }
106
+
107
+ async function main() {
108
+ const [deployment, run, attempt, path] = process.argv.slice(2);
109
+ const result = await reconcile(Number(deployment), Number(run), Number(attempt), path);
110
+ console.log(JSON.stringify(result));
111
+ }
112
+
113
+ if (require.main === module) main().catch(error => {
114
+ const code = /^apple_upload_reconcile_[a-z0-9_]+$/.test(error.message) ? error.message :
115
+ 'apple_upload_reconcile_unavailable';
116
+ console.error('::error title=apple_upload_reconcile_refused::' + JSON.stringify({code}));
117
+ process.exitCode = 1;
118
+ });
119
+ module.exports = {readReceipt, verifyOwner, appleGet, verifyApple, reconcile};
@@ -1 +1 @@
1
- 1.0.163
1
+ 1.0.164
@@ -1 +1 @@
1
- 1.0.163
1
+ 1.0.164
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.163",
3
+ "version": "1.0.164",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
package/remedies.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "schema": "gowalk-cicd/apple-build-upload-failed.v2",
4
+ "code": "processing_pending",
5
+ "fixed_in": "1.0.164",
6
+ "remedy": "Keep the original IPA and apple-build-upload receipt. If Apple later validates the exact upload, run reconcile_apple_upload.cjs in an authorized GitHub Actions recovery job with the original deployment, run, attempt and artifact receipt. It proves the failed owner, completed file, valid build and matching app before marking the deployment uploaded. Rerun the whole original deploy workflow after the readback; never upload a second binary or hand-edit the deployment receipt."
7
+ },
2
8
  {
3
9
  "schema": "gowalk-cicd/mobile-release-refused.v1",
4
10
  "code": "mobile_release_phase_owner_mismatch",