gowalk-cicd 1.0.182 → 1.0.184
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.
- package/README.md +75 -2
- package/action/action.yml +19 -3
- package/action/scripts/apple_upload_handoff.cjs +34 -18
- package/action/scripts/apple_upload_handoff.py +4 -1
- package/action/scripts/apple_upload_proof.cjs +117 -0
- package/action/scripts/apple_upload_proof.py +157 -0
- package/action/scripts/apple_upload_recovery.cjs +126 -0
- package/action/scripts/apple_upload_verify.cjs +7 -21
- package/action/scripts/reconcile_apple_upload.cjs +7 -20
- package/action/scripts/upload_build.py +11 -11
- package/package.json +1 -1
- package/remedies.json +8 -2
package/README.md
CHANGED
|
@@ -1225,7 +1225,14 @@ commits completed part transfers with `uploaded: true`, following Apple's
|
|
|
1225
1225
|
The IPA endpoint rejected an optional `sourceFileChecksums` SHA_256 object even though
|
|
1226
1226
|
the generic schema lists that algorithm; the uploader no longer sends that attribute.
|
|
1227
1227
|
A completed file is reused only when its content-derived filename and size match, and
|
|
1228
|
-
any returned file checksum is verified.
|
|
1228
|
+
any returned file checksum is verified. New receipts retain locally computed SHA-256 and
|
|
1229
|
+
MD5 so Apple's returned checksum can be compared with the exact transferred bytes.
|
|
1230
|
+
|
|
1231
|
+
An upload summary can remain `PROCESSING` after Apple exposes a valid eligible build.
|
|
1232
|
+
Completion then requires the unique matching iOS build, marketing version, app and bundle,
|
|
1233
|
+
alongside the completed upload asset and matching checksum. A missing, pending, failed or
|
|
1234
|
+
ambiguous build remains unconfirmed. The receipt retains `upload_resource_state` separately
|
|
1235
|
+
from the verified completion state.
|
|
1229
1236
|
|
|
1230
1237
|
A failed upload emits the typed `apple_build_upload_failed` error annotation with schema
|
|
1231
1238
|
`gowalk-cicd/apple-build-upload-failed.v2`: `category` (`ipa_invalid`,
|
|
@@ -1235,11 +1242,77 @@ A failed upload emits the typed `apple_build_upload_failed` error annotation wit
|
|
|
1235
1242
|
the receipt's own identifiers (`cfBundleVersion`, `upload_id`, `file_id`, `state`,
|
|
1236
1243
|
`provider_status`, `provider_code`, `provider_pointer`). Exception text is never
|
|
1237
1244
|
published. The job also retains the exact exported IPA as `apple-ipa-<run>-<attempt>`
|
|
1238
|
-
(7 days)
|
|
1245
|
+
(7 days), copied without byte changes to the safe archive member `app.ipa`, so punctuation
|
|
1246
|
+
in the exported filename cannot block retention. A continuation can complete that upload instead of rebuilding
|
|
1239
1247
|
different bytes under the same number. Build numbers come from `/builds`,
|
|
1240
1248
|
`/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
|
|
1241
1249
|
reservation is never reused; delete such a reservation only after reading it back.
|
|
1242
1250
|
|
|
1251
|
+
When an iOS producer was cancelled **after** both `Build and transfer iOS binary` and
|
|
1252
|
+
`Retain Apple verification handoff` succeeded, version 1.0.183 provides explicit recovery:
|
|
1253
|
+
|
|
1254
|
+
```bash
|
|
1255
|
+
node .github/actions/swift-app/scripts/apple_upload_recovery.cjs \
|
|
1256
|
+
ORIGINAL_RUN_ID ORIGINAL_ATTEMPT DEPLOYMENT_ID HANDOFF_ARTIFACT_ID \
|
|
1257
|
+
> "$RUNNER_TEMP/apple-upload-recovery.json"
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
Run this released helper in an authorized `workflow_dispatch` Actions job on the same
|
|
1261
|
+
repository's default branch, after checking out the adopted tooling. The job needs
|
|
1262
|
+
`actions: read`, `contents: read`, `deployments: write`, `GITHUB_TOKEN: ${{ github.token }}`
|
|
1263
|
+
and the existing `ASC_KEY_P8`, `ASC_KEY_ID`, `ASC_ISSUER_ID` secrets (or the prepared ASC key
|
|
1264
|
+
path). Serialize the recovery workflow with normal Mobile Deploy using its exact concurrency
|
|
1265
|
+
group and `cancel-in-progress: false`; this prevents a deploy from starting between the
|
|
1266
|
+
last provenance read and the receipt write. Use this workflow policy and job environment:
|
|
1267
|
+
|
|
1268
|
+
```yaml
|
|
1269
|
+
on: workflow_dispatch
|
|
1270
|
+
permissions:
|
|
1271
|
+
contents: read
|
|
1272
|
+
actions: read
|
|
1273
|
+
deployments: write
|
|
1274
|
+
concurrency:
|
|
1275
|
+
group: mobile-deploy-${{ github.ref }}
|
|
1276
|
+
cancel-in-progress: false
|
|
1277
|
+
jobs:
|
|
1278
|
+
recover:
|
|
1279
|
+
if: ${{ github.ref_name == github.event.repository.default_branch }}
|
|
1280
|
+
runs-on: ubuntu-24.04
|
|
1281
|
+
timeout-minutes: 20
|
|
1282
|
+
env:
|
|
1283
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
1284
|
+
ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
|
|
1285
|
+
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
|
1286
|
+
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
|
1287
|
+
steps:
|
|
1288
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
|
1289
|
+
# Invoke the command above with the four verified original identifiers,
|
|
1290
|
+
# then retain its JSON with the pinned, guarded artifact uploader.
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
Retain `apple-upload-recovery.json` with the existing pinned, guarded artifact uploader.
|
|
1294
|
+
The command has no local receipt or caller-provided
|
|
1295
|
+
digest option: it downloads the exact original artifact, verifies GitHub's archive SHA-256,
|
|
1296
|
+
and accepts only its bounded `apple-verification.json` member. It authenticates the current
|
|
1297
|
+
dispatch separately from the original run, attempt, source, repository, branch and deployment.
|
|
1298
|
+
|
|
1299
|
+
Recovery requires a terminal cancelled original run and iOS producer with those two
|
|
1300
|
+
successful steps in order. A later completed attempt may exist; an active latest attempt
|
|
1301
|
+
refuses, including a retry observed immediately before writing the receipt. Fresh Apple
|
|
1302
|
+
readback must prove the exact completed upload and file, matching bytes, eligible `VALID`
|
|
1303
|
+
build and matching app. Only then can the original deployment become `uploaded`; repeating
|
|
1304
|
+
recovery verifies the same evidence without another write. No binary is rebuilt or uploaded,
|
|
1305
|
+
and the old handoff's metadata and release notes are never replayed. Missing, expired or
|
|
1306
|
+
mismatched evidence remains refused; do not relabel a receipt or impersonate the original
|
|
1307
|
+
`GITHUB_*` environment. Normal delivery still refuses cancelled producers.
|
|
1308
|
+
|
|
1309
|
+
Retain the recovery proof, then rerun the **whole original deployment workflow**. Its
|
|
1310
|
+
upload-once admission reuses the confirmed iOS receipt and skips the iOS binary. Follow that
|
|
1311
|
+
new attempt and any unrelated remaining jobs to completion; recovery does not change the
|
|
1312
|
+
original cancelled attempt's conclusion. Read current store state for remaining submission
|
|
1313
|
+
work without changing an already accepted submission. This exceptional command adds no
|
|
1314
|
+
normal delivery job, polling or latency.
|
|
1315
|
+
|
|
1243
1316
|
Flutter SDK setup retries interrupted manifest and archive GETs, with four attempts
|
|
1244
1317
|
within 60 seconds for manifests or 30 minutes for archives, with an 8 GiB archive bound. Retries resume private bytes
|
|
1245
1318
|
with HTTP Range; if the origin ignores Range, a bounded full restart is permitted. The completed archive
|
package/action/action.yml
CHANGED
|
@@ -825,17 +825,33 @@ runs:
|
|
|
825
825
|
# A failed REST upload keeps the exact exported IPA next to its receipt, so a
|
|
826
826
|
# continuation can resume or complete that same upload identity instead of
|
|
827
827
|
# rebuilding different bytes under the number the receipt names.
|
|
828
|
-
- name:
|
|
828
|
+
- name: Stage the exported IPA for upload recovery
|
|
829
|
+
id: ipa-recovery
|
|
829
830
|
if: ${{ always() && inputs.archive == 'true' && inputs.upload == 'true'
|
|
830
831
|
&& (steps.upload.outcome != 'success' || inputs.defer-provider-verification == 'true') }}
|
|
832
|
+
shell: bash
|
|
833
|
+
run: |
|
|
834
|
+
shopt -s nullglob
|
|
835
|
+
ipas=("$RUNNER_TEMP"/export/*.ipa)
|
|
836
|
+
if [ "${#ipas[@]}" -ne 1 ]; then
|
|
837
|
+
echo '::error::apple_ipa_recovery_export_count_invalid'
|
|
838
|
+
exit 1
|
|
839
|
+
fi
|
|
840
|
+
retained="$RUNNER_TEMP/apple-ipa-recovery"
|
|
841
|
+
mkdir -p "$retained"
|
|
842
|
+
cp "${ipas[0]}" "$retained/app.ipa"
|
|
843
|
+
cmp -s "${ipas[0]}" "$retained/app.ipa"
|
|
844
|
+
|
|
845
|
+
- name: Retain the exported IPA for upload recovery
|
|
846
|
+
if: ${{ always() && steps.ipa-recovery.outcome == 'success' }}
|
|
831
847
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
|
|
832
848
|
env:
|
|
833
849
|
NODE_OPTIONS: --require "${{ env.SWIFT_APP_ACTION }}/scripts/artifact_env.cjs"
|
|
834
850
|
with:
|
|
835
851
|
name: apple-ipa-${{ github.run_id }}-${{ github.run_attempt }}
|
|
836
|
-
path: ${{ runner.temp }}/
|
|
852
|
+
path: ${{ runner.temp }}/apple-ipa-recovery/app.ipa
|
|
837
853
|
compression-level: 0 # An IPA is already a ZIP; avoid duplicate compression on the Mac.
|
|
838
|
-
if-no-files-found:
|
|
854
|
+
if-no-files-found: error
|
|
839
855
|
retention-days: 7
|
|
840
856
|
|
|
841
857
|
# The whatsNew backstop. An empty "What's New" makes a version
|
|
@@ -9,22 +9,12 @@ const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
|
9
9
|
const id = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
|
|
10
10
|
const fail = code => { throw Error(`apple_upload_verify_${code}`); };
|
|
11
11
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (bytes.length > 2 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(digest || '') ||
|
|
16
|
-
createHash('sha256').update(bytes).digest('hex') !== digest) fail('handoff_digest');
|
|
17
|
-
const value = JSON.parse(bytes), owner = value.owner || {}, r = value.receipt || {}, m = value.metadata || {};
|
|
18
|
-
const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
19
|
-
if (value.schema !== SCHEMA || owner.repository !== env.GITHUB_REPOSITORY ||
|
|
12
|
+
function validate(value) {
|
|
13
|
+
const owner = value?.owner || {}, r = value?.receipt || {}, m = value?.metadata || {};
|
|
14
|
+
if (value?.schema !== SCHEMA ||
|
|
20
15
|
!/^[\w.-]+\/[\w.-]+$/.test(owner.repository || '') || !/^[a-f0-9]{40}$/.test(owner.source_sha || '') ||
|
|
21
|
-
|
|
22
|
-
owner.
|
|
23
|
-
!['push', 'workflow_dispatch'].includes(owner.event) || owner.event !== env.GITHUB_EVENT_NAME ||
|
|
24
|
-
!positive(owner.run_id) || owner.run_id !== Number(env.GITHUB_RUN_ID) || !positive(owner.run_attempt) ||
|
|
25
|
-
!positive(attempt) || !positive(Number(env.GITHUB_RUN_ATTEMPT)) ||
|
|
26
|
-
owner.run_attempt !== attempt || attempt > Number(env.GITHUB_RUN_ATTEMPT) ||
|
|
27
|
-
!positive(owner.deployment_id) || owner.deployment_id !== Number(env.MOBILE_RELEASE_DEPLOYMENT_ID)) {
|
|
16
|
+
!owner.ref?.startsWith('refs/heads/') || !['push', 'workflow_dispatch'].includes(owner.event) ||
|
|
17
|
+
!positive(owner.run_id) || !positive(owner.run_attempt) || !positive(owner.deployment_id)) {
|
|
28
18
|
fail('handoff_owner');
|
|
29
19
|
}
|
|
30
20
|
if (r.schema !== 'gowalk-cicd/apple-build-upload-receipt.v1' || r.stage !== 'transferred' ||
|
|
@@ -34,12 +24,29 @@ function read(path, digest, attempt, env = process.env) {
|
|
|
34
24
|
!/^[a-f0-9]{64}$/.test(r.sha256 || '') || r.fileName !== `${r.sha256}.ipa` || !positive(r.fileSize)) {
|
|
35
25
|
fail('handoff_receipt');
|
|
36
26
|
}
|
|
27
|
+
if (r.md5 !== undefined && (typeof r.md5 !== 'string' || !/^[a-f0-9]{32}$/.test(r.md5))) fail('handoff_receipt');
|
|
37
28
|
if (typeof m.enabled !== 'boolean' || typeof m.only_if_empty !== 'boolean' ||
|
|
38
29
|
typeof m.notes !== 'string' || Buffer.byteLength(m.notes) > 1024 * 1024 || m.notes.includes('\0') ||
|
|
39
30
|
!/^[A-Za-z0-9-]{2,35}$/.test(m.locale || '') || m.enabled && !id(m.version_id)) fail('handoff_metadata');
|
|
40
31
|
return value;
|
|
41
32
|
}
|
|
42
33
|
|
|
34
|
+
function read(path, digest, attempt, env = process.env) {
|
|
35
|
+
if (fs.statSync(path).size > 2 * 1024 * 1024) fail('handoff_size');
|
|
36
|
+
const bytes = fs.readFileSync(path);
|
|
37
|
+
if (bytes.length > 2 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(digest || '') ||
|
|
38
|
+
createHash('sha256').update(bytes).digest('hex') !== digest) fail('handoff_digest');
|
|
39
|
+
const value = validate(JSON.parse(bytes)), owner = value.owner;
|
|
40
|
+
const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
41
|
+
if (owner.repository !== env.GITHUB_REPOSITORY || owner.source_sha !== env.GITHUB_SHA ||
|
|
42
|
+
owner.ref !== env.GITHUB_REF || owner.ref !== `refs/heads/${event.repository?.default_branch}` ||
|
|
43
|
+
owner.event !== env.GITHUB_EVENT_NAME || owner.run_id !== Number(env.GITHUB_RUN_ID) ||
|
|
44
|
+
!positive(attempt) || !positive(Number(env.GITHUB_RUN_ATTEMPT)) ||
|
|
45
|
+
owner.run_attempt !== attempt || attempt > Number(env.GITHUB_RUN_ATTEMPT) ||
|
|
46
|
+
owner.deployment_id !== Number(env.MOBILE_RELEASE_DEPLOYMENT_ID)) fail('handoff_owner');
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
43
50
|
function transferred(job) {
|
|
44
51
|
if (!Array.isArray(job.steps)) return false;
|
|
45
52
|
const selected = ['Build and transfer iOS binary', 'Retain Apple verification handoff'].map(name =>
|
|
@@ -49,7 +56,7 @@ function transferred(job) {
|
|
|
49
56
|
return selected[0][0].number < selected[1][0].number;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
async function verifyProducer(value, artifactId, api = github) {
|
|
59
|
+
async function verifyProducer(value, artifactId, api = github, recoverCancelled = false) {
|
|
53
60
|
if (!positive(artifactId)) fail('artifact_identity');
|
|
54
61
|
const o = value.owner;
|
|
55
62
|
const run = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}`);
|
|
@@ -57,12 +64,21 @@ async function verifyProducer(value, artifactId, api = github) {
|
|
|
57
64
|
run.path !== '.github/workflows/deploy.yml' || run.event !== o.event ||
|
|
58
65
|
`refs/heads/${run.head_branch}` !== o.ref || run.repository?.full_name !== o.repository ||
|
|
59
66
|
run.head_repository?.full_name !== o.repository) fail('producer_run');
|
|
67
|
+
if (recoverCancelled) {
|
|
68
|
+
const latest = await api.request('GET', `/actions/runs/${o.run_id}`);
|
|
69
|
+
if (run.status !== 'completed' || run.conclusion !== 'cancelled' || latest.id !== o.run_id ||
|
|
70
|
+
latest.status !== 'completed' || !positive(latest.run_attempt) || latest.run_attempt < o.run_attempt ||
|
|
71
|
+
latest.head_sha !== o.source_sha || latest.path !== run.path || latest.event !== o.event ||
|
|
72
|
+
latest.head_branch !== run.head_branch || latest.repository?.full_name !== o.repository ||
|
|
73
|
+
latest.head_repository?.full_name !== o.repository) fail('cancelled_producer_run');
|
|
74
|
+
}
|
|
60
75
|
const result = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}/jobs?per_page=100`);
|
|
61
76
|
if (!Array.isArray(result.jobs) || result.jobs.length >= 100) fail('producer_jobs');
|
|
62
77
|
const jobs = result.jobs.filter(job => job.name === 'iOS TestFlight' || job.name?.endsWith('/ iOS TestFlight'));
|
|
63
78
|
// A later symbol-retention failure does not undo these exact completed steps.
|
|
64
79
|
// The producer's original failure remains red; only Apple delivery is reconciled here.
|
|
65
|
-
|
|
80
|
+
const conclusions = recoverCancelled ? ['cancelled'] : ['success', 'failure'];
|
|
81
|
+
if (jobs.length !== 1 || jobs[0].status !== 'completed' || !conclusions.includes(jobs[0].conclusion) ||
|
|
66
82
|
jobs[0].head_sha !== o.source_sha || !transferred(jobs[0])) fail('producer_job');
|
|
67
83
|
const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
|
|
68
84
|
if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== o.run_id ||
|
|
@@ -100,4 +116,4 @@ function metadata(value, env = process.env) {
|
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
118
|
|
|
103
|
-
module.exports = {read, verifyProducer, phase, metadata, fail};
|
|
119
|
+
module.exports = {read, validate, verifyProducer, phase, metadata, fail};
|
|
@@ -54,7 +54,10 @@ def prepare():
|
|
|
54
54
|
def write(path, handoff, receipt):
|
|
55
55
|
if receipt.get("stage") != "transferred" or "\n" in str(path) or "\r" in str(path):
|
|
56
56
|
raise ValueError("apple_handoff_transfer_unconfirmed")
|
|
57
|
-
|
|
57
|
+
fields = {name: receipt[name] for name in FIELDS}
|
|
58
|
+
if "md5" in receipt:
|
|
59
|
+
fields["md5"] = receipt["md5"]
|
|
60
|
+
handoff = {**handoff, "receipt": fields}
|
|
58
61
|
data = (json.dumps(handoff, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
59
62
|
path.write_bytes(data)
|
|
60
63
|
digest = hashlib.sha256(data).hexdigest()
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* Read-only proof of one transferred IPA, including Apple's separately processed build. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const identifier = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
|
|
4
|
+
|
|
5
|
+
function entity(response, fail, code) {
|
|
6
|
+
const row = response?.data;
|
|
7
|
+
if (!row || typeof row !== 'object' || Array.isArray(row) ||
|
|
8
|
+
row.attributes !== undefined && (!row.attributes || typeof row.attributes !== 'object') ||
|
|
9
|
+
row.relationships !== undefined && (!row.relationships || typeof row.relationships !== 'object')) fail(code);
|
|
10
|
+
return row;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function collection(response, fail, code) {
|
|
14
|
+
const rows = response?.data, total = response?.meta?.paging?.total;
|
|
15
|
+
if (!Array.isArray(rows) || rows.some(row => !row || typeof row !== 'object' || Array.isArray(row)) ||
|
|
16
|
+
rows.length >= 200 || !response.links || typeof response.links !== 'object' ||
|
|
17
|
+
Array.isArray(response.links) || response.links.next ||
|
|
18
|
+
total !== undefined && (!Number.isSafeInteger(total) || total !== rows.length)) fail(code);
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checksum(attributes, receipt, fail) {
|
|
23
|
+
const checksums = attributes.sourceFileChecksums;
|
|
24
|
+
if (checksums !== undefined && (!checksums || typeof checksums !== 'object' || Array.isArray(checksums))) {
|
|
25
|
+
fail('file_checksum_unverified');
|
|
26
|
+
}
|
|
27
|
+
const file = checksums?.file;
|
|
28
|
+
if (file === undefined) return;
|
|
29
|
+
const digest = file?.algorithm === 'SHA_256' ? receipt.sha256 :
|
|
30
|
+
file?.algorithm === 'MD5' ? receipt.md5 : null;
|
|
31
|
+
const width = file?.algorithm === 'SHA_256' ? 64 : 32;
|
|
32
|
+
if (typeof digest !== 'string' || !new RegExp(`^[a-f0-9]{${width}}$`).test(digest)) {
|
|
33
|
+
fail('file_checksum_unverified');
|
|
34
|
+
}
|
|
35
|
+
const values = [digest, digest.toUpperCase(), Buffer.from(digest, 'hex').toString('base64')];
|
|
36
|
+
if (!values.includes(file.hash)) fail('file_checksum_unverified');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function fileProof(receipt, get, fail) {
|
|
40
|
+
const response = await get(`/buildUploads/${receipt.upload_id}/buildUploadFiles`);
|
|
41
|
+
const assets = collection(response, fail, 'file_membership').filter(row => row.attributes?.assetType === 'ASSET');
|
|
42
|
+
if (assets.length !== 1 || assets[0].id !== receipt.file_id) fail('file_membership');
|
|
43
|
+
const file = entity(await get(`/buildUploadFiles/${receipt.file_id}`), fail, 'file_unverified');
|
|
44
|
+
const attrs = file.attributes || {};
|
|
45
|
+
if (file?.id !== receipt.file_id || attrs.assetType !== 'ASSET' ||
|
|
46
|
+
attrs.fileName !== receipt.fileName || attrs.fileSize !== receipt.fileSize) fail('file_unverified');
|
|
47
|
+
if (attrs.assetDeliveryState?.state === 'FAILED' || attrs.assetDeliveryState?.errors?.length) {
|
|
48
|
+
fail('processing_failed');
|
|
49
|
+
}
|
|
50
|
+
if (attrs.assetDeliveryState?.state !== 'COMPLETE') return false;
|
|
51
|
+
checksum(attrs, receipt, fail);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function inventoryBuild(receipt, get, fail) {
|
|
56
|
+
const query = new URLSearchParams({'filter[app]': receipt.app_id, 'filter[version]': receipt.cfBundleVersion,
|
|
57
|
+
include: 'preReleaseVersion,app', limit: '200'});
|
|
58
|
+
const response = await get(`/builds?${query}`), rows = collection(response, fail, 'build_inventory_unverified');
|
|
59
|
+
const included = response.included;
|
|
60
|
+
if (!Array.isArray(included) || included.some(row => !row || typeof row !== 'object' || Array.isArray(row))) {
|
|
61
|
+
fail('build_inventory_unverified');
|
|
62
|
+
}
|
|
63
|
+
const ids = new Set(), matches = [];
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
if (!identifier(row.id) || ids.has(row.id) || row.attributes?.version !== receipt.cfBundleVersion) {
|
|
66
|
+
fail('build_inventory_unverified');
|
|
67
|
+
}
|
|
68
|
+
ids.add(row.id);
|
|
69
|
+
const trainId = row.relationships?.preReleaseVersion?.data?.id;
|
|
70
|
+
const appId = row.relationships?.app?.data?.id;
|
|
71
|
+
const trains = included.filter(item => item.type === 'preReleaseVersions' && item.id === trainId);
|
|
72
|
+
const apps = included.filter(item => item.type === 'apps' && item.id === appId);
|
|
73
|
+
if (!identifier(trainId) || trains.length !== 1 || apps.length !== 1 || appId !== receipt.app_id ||
|
|
74
|
+
apps[0].attributes?.bundleId !== receipt.bundle_id) fail('build_inventory_unverified');
|
|
75
|
+
if (trains[0].attributes?.version === receipt.cfBundleShortVersionString &&
|
|
76
|
+
trains[0].attributes?.platform === 'IOS') matches.push(row.id);
|
|
77
|
+
}
|
|
78
|
+
if (matches.length > 1) fail('build_ambiguous');
|
|
79
|
+
return matches[0] || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function buildProof(buildId, receipt, get, fail) {
|
|
83
|
+
const build = entity(await get(`/builds/${buildId}`), fail, 'build_unverified');
|
|
84
|
+
const attrs = build.attributes || {};
|
|
85
|
+
if (build?.id !== buildId || attrs.version !== receipt.cfBundleVersion) fail('build_unverified');
|
|
86
|
+
if (['FAILED', 'INVALID'].includes(attrs.processingState)) fail('processing_failed');
|
|
87
|
+
if (attrs.processingState === 'PROCESSING') return null;
|
|
88
|
+
if (attrs.processingState !== 'VALID' || attrs.buildAudienceType !== 'APP_STORE_ELIGIBLE') {
|
|
89
|
+
fail('build_unverified');
|
|
90
|
+
}
|
|
91
|
+
const app = entity(await get(`/builds/${buildId}/app`), fail, 'app_unverified');
|
|
92
|
+
if (app?.id !== receipt.app_id || app.attributes?.bundleId !== receipt.bundle_id) fail('app_unverified');
|
|
93
|
+
const train = entity(await get(`/builds/${buildId}/preReleaseVersion`), fail, 'train_unverified');
|
|
94
|
+
if (!identifier(train?.id) || train.attributes?.platform !== 'IOS' ||
|
|
95
|
+
train.attributes?.version !== receipt.cfBundleShortVersionString) fail('train_unverified');
|
|
96
|
+
return {build_id: buildId, app_id: app.id, version: receipt.cfBundleShortVersionString,
|
|
97
|
+
build: receipt.cfBundleVersion, upload_id: receipt.upload_id};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function observe(receipt, get, fail) {
|
|
101
|
+
const upload = entity(await get(`/buildUploads/${receipt.upload_id}?include=build`), fail, 'upload_unverified');
|
|
102
|
+
const attrs = upload?.attributes || {}, state = attrs.state?.state;
|
|
103
|
+
if (upload?.id !== receipt.upload_id || attrs.platform !== 'IOS' ||
|
|
104
|
+
attrs.cfBundleVersion !== receipt.cfBundleVersion ||
|
|
105
|
+
attrs.cfBundleShortVersionString !== receipt.cfBundleShortVersionString) fail('upload_unverified');
|
|
106
|
+
if (state === 'FAILED' || attrs.state?.errors?.length) fail('processing_failed');
|
|
107
|
+
if (!['COMPLETE', 'PROCESSING'].includes(state)) return null;
|
|
108
|
+
if (!await fileProof(receipt, get, fail)) return null;
|
|
109
|
+
const relationship = upload.relationships?.build?.data;
|
|
110
|
+
if (relationship && !identifier(relationship.id)) fail('build_unverified');
|
|
111
|
+
const buildId = relationship?.id || await inventoryBuild(receipt, get, fail);
|
|
112
|
+
if (!buildId) return null;
|
|
113
|
+
const proof = await buildProof(buildId, receipt, get, fail);
|
|
114
|
+
return proof && {...proof, upload_resource_state: state};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {observe};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Read-only exact upload/file/build proof for the legacy synchronous Python entrypoint."""
|
|
2
|
+
import base64
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from upload_receipt import UploadError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def refuse(code):
|
|
9
|
+
raise UploadError(code, "Apple upload completion was not independently verified")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def entity(response, code):
|
|
13
|
+
row = response.get("data") if isinstance(response, dict) else None
|
|
14
|
+
if (not isinstance(row, dict) or not isinstance(row.get("attributes", {}), dict)
|
|
15
|
+
or not isinstance(row.get("relationships", {}), dict)):
|
|
16
|
+
refuse(code)
|
|
17
|
+
return row
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def collection(response, code):
|
|
21
|
+
if not isinstance(response, dict):
|
|
22
|
+
refuse(code)
|
|
23
|
+
rows, links = response.get("data"), response.get("links")
|
|
24
|
+
total = (response.get("meta") or {}).get("paging", {}).get("total")
|
|
25
|
+
if (not isinstance(rows, list) or any(not isinstance(row, dict)
|
|
26
|
+
or not isinstance(row.get("attributes", {}), dict)
|
|
27
|
+
or not isinstance(row.get("relationships", {}), dict) for row in rows)
|
|
28
|
+
or len(rows) >= 200 or not isinstance(links, dict) or links.get("next")
|
|
29
|
+
or total is not None and (type(total) is not int or total != len(rows))):
|
|
30
|
+
refuse(code)
|
|
31
|
+
return rows
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def checksum(attributes, receipt):
|
|
35
|
+
checksums = attributes.get("sourceFileChecksums", {})
|
|
36
|
+
if not isinstance(checksums, dict):
|
|
37
|
+
refuse("file_checksum_unverified")
|
|
38
|
+
if "file" not in checksums:
|
|
39
|
+
return
|
|
40
|
+
file = checksums["file"]
|
|
41
|
+
if not isinstance(file, dict):
|
|
42
|
+
refuse("file_checksum_unverified")
|
|
43
|
+
algorithm = file.get("algorithm")
|
|
44
|
+
digest = receipt.get("sha256") if algorithm == "SHA_256" else receipt.get("md5") if algorithm == "MD5" else None
|
|
45
|
+
width = 64 if algorithm == "SHA_256" else 32
|
|
46
|
+
if not isinstance(digest, str) or not re.fullmatch(r"[a-f0-9]{" + str(width) + "}", digest):
|
|
47
|
+
refuse("file_checksum_unverified")
|
|
48
|
+
values = (digest, digest.upper(), base64.b64encode(bytes.fromhex(digest)).decode())
|
|
49
|
+
if file.get("hash") not in values:
|
|
50
|
+
refuse("file_checksum_unverified")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def file_proof(receipt, api):
|
|
54
|
+
response = api("GET", f"/buildUploads/{receipt['upload_id']}/buildUploadFiles")
|
|
55
|
+
assets = [row for row in collection(response, "file_membership")
|
|
56
|
+
if (row.get("attributes") or {}).get("assetType") == "ASSET"]
|
|
57
|
+
if len(assets) != 1 or assets[0].get("id") != receipt["file_id"]:
|
|
58
|
+
refuse("file_membership")
|
|
59
|
+
file = entity(api("GET", f"/buildUploadFiles/{receipt['file_id']}"), "file_unverified")
|
|
60
|
+
attrs = file.get("attributes") or {}
|
|
61
|
+
if (file.get("id") != receipt["file_id"] or attrs.get("assetType") != "ASSET"
|
|
62
|
+
or any(attrs.get(key) != receipt[key] for key in ("fileName", "fileSize"))):
|
|
63
|
+
refuse("file_unverified")
|
|
64
|
+
state = attrs.get("assetDeliveryState") or {}
|
|
65
|
+
if not isinstance(state, dict):
|
|
66
|
+
refuse("file_unverified")
|
|
67
|
+
if state.get("state") == "FAILED" or state.get("errors"):
|
|
68
|
+
refuse("processing_failed")
|
|
69
|
+
if state.get("state") != "COMPLETE":
|
|
70
|
+
return False
|
|
71
|
+
checksum(attrs, receipt)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def inventory_build(receipt, api):
|
|
76
|
+
response = api("GET", "/builds", params={"filter[app]": receipt["app_id"],
|
|
77
|
+
"filter[version]": receipt["cfBundleVersion"], "include": "preReleaseVersion,app", "limit": 200})
|
|
78
|
+
rows = collection(response, "build_inventory_unverified")
|
|
79
|
+
included = response.get("included")
|
|
80
|
+
if (not isinstance(included, list) or any(not isinstance(item, dict)
|
|
81
|
+
or not isinstance(item.get("attributes", {}), dict) for item in included)):
|
|
82
|
+
refuse("build_inventory_unverified")
|
|
83
|
+
identifiers, matches = set(), []
|
|
84
|
+
for row in rows:
|
|
85
|
+
build_id = row.get("id")
|
|
86
|
+
if (not isinstance(build_id, str) or not re.fullmatch(r"[A-Za-z0-9-]{1,64}", build_id)
|
|
87
|
+
or build_id in identifiers
|
|
88
|
+
or (row.get("attributes") or {}).get("version") != receipt["cfBundleVersion"]):
|
|
89
|
+
refuse("build_inventory_unverified")
|
|
90
|
+
identifiers.add(build_id)
|
|
91
|
+
relationships = row.get("relationships") or {}
|
|
92
|
+
for name in ("preReleaseVersion", "app"):
|
|
93
|
+
link = relationships.get(name)
|
|
94
|
+
if (not isinstance(link, dict) or not isinstance(link.get("data"), dict)):
|
|
95
|
+
refuse("build_inventory_unverified")
|
|
96
|
+
train_id = ((relationships.get("preReleaseVersion") or {}).get("data") or {}).get("id")
|
|
97
|
+
app_id = ((relationships.get("app") or {}).get("data") or {}).get("id")
|
|
98
|
+
trains = [item for item in included if item.get("type") == "preReleaseVersions" and item.get("id") == train_id]
|
|
99
|
+
apps = [item for item in included if item.get("type") == "apps" and item.get("id") == app_id]
|
|
100
|
+
if (not isinstance(train_id, str) or not re.fullmatch(r"[A-Za-z0-9-]{1,64}", train_id)
|
|
101
|
+
or len(trains) != 1 or len(apps) != 1 or app_id != receipt["app_id"]
|
|
102
|
+
or (apps[0].get("attributes") or {}).get("bundleId") != receipt["bundle_id"]):
|
|
103
|
+
refuse("build_inventory_unverified")
|
|
104
|
+
attrs = trains[0].get("attributes") or {}
|
|
105
|
+
if attrs.get("version") == receipt["cfBundleShortVersionString"] and attrs.get("platform") == "IOS":
|
|
106
|
+
matches.append(build_id)
|
|
107
|
+
if len(matches) > 1:
|
|
108
|
+
refuse("build_ambiguous")
|
|
109
|
+
return matches[0] if matches else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_proof(build_id, receipt, api):
|
|
113
|
+
build = entity(api("GET", f"/builds/{build_id}"), "build_unverified")
|
|
114
|
+
attrs = build.get("attributes") or {}
|
|
115
|
+
if build.get("id") != build_id or attrs.get("version") != receipt["cfBundleVersion"]:
|
|
116
|
+
refuse("build_unverified")
|
|
117
|
+
if attrs.get("processingState") in ("FAILED", "INVALID"):
|
|
118
|
+
refuse("processing_failed")
|
|
119
|
+
if attrs.get("processingState") == "PROCESSING":
|
|
120
|
+
return None
|
|
121
|
+
if attrs.get("processingState") != "VALID" or attrs.get("buildAudienceType") != "APP_STORE_ELIGIBLE":
|
|
122
|
+
refuse("build_unverified")
|
|
123
|
+
app = entity(api("GET", f"/builds/{build_id}/app"), "app_unverified")
|
|
124
|
+
if app.get("id") != receipt["app_id"] or (app.get("attributes") or {}).get("bundleId") != receipt["bundle_id"]:
|
|
125
|
+
refuse("app_unverified")
|
|
126
|
+
train = entity(api("GET", f"/builds/{build_id}/preReleaseVersion"), "train_unverified")
|
|
127
|
+
train_attrs = train.get("attributes") or {}
|
|
128
|
+
if (not isinstance(train.get("id"), str) or not re.fullmatch(r"[A-Za-z0-9-]{1,64}", train["id"])
|
|
129
|
+
or train_attrs.get("platform") != "IOS"
|
|
130
|
+
or train_attrs.get("version") != receipt["cfBundleShortVersionString"]):
|
|
131
|
+
refuse("train_unverified")
|
|
132
|
+
return {"build_id": build_id}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def observe(receipt, api):
|
|
136
|
+
response = api("GET", f"/buildUploads/{receipt['upload_id']}", params={"include": "build"})
|
|
137
|
+
upload = entity(response, "upload_unverified")
|
|
138
|
+
attrs = upload.get("attributes") or {}
|
|
139
|
+
state = attrs.get("state") or {}
|
|
140
|
+
if not isinstance(state, dict):
|
|
141
|
+
refuse("upload_unverified")
|
|
142
|
+
if (upload.get("id") != receipt["upload_id"] or attrs.get("platform") != "IOS"
|
|
143
|
+
or any(attrs.get(key) != receipt[key] for key in ("cfBundleVersion", "cfBundleShortVersionString"))):
|
|
144
|
+
refuse("upload_unverified")
|
|
145
|
+
if state.get("state") == "FAILED" or state.get("errors"):
|
|
146
|
+
refuse("processing_failed")
|
|
147
|
+
if state.get("state") not in ("COMPLETE", "PROCESSING") or not file_proof(receipt, api):
|
|
148
|
+
return None, state.get("state")
|
|
149
|
+
link = (upload.get("relationships") or {}).get("build")
|
|
150
|
+
if link is not None and not isinstance(link, dict):
|
|
151
|
+
refuse("build_unverified")
|
|
152
|
+
relationship = (link or {}).get("data")
|
|
153
|
+
if relationship and (not isinstance(relationship, dict) or not isinstance(relationship.get("id"), str)
|
|
154
|
+
or not re.fullmatch(r"[A-Za-z0-9-]{1,64}", relationship["id"])):
|
|
155
|
+
refuse("build_unverified")
|
|
156
|
+
build_id = relationship["id"] if relationship else inventory_build(receipt, api)
|
|
157
|
+
return (build_proof(build_id, receipt, api) if build_id else None), state.get("state")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/* Explicit Actions recovery of a cancelled producer's authenticated handoff; never upload or metadata. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const {createHash} = require('node:crypto');
|
|
5
|
+
const {inflateRawSync} = require('node:zlib');
|
|
6
|
+
const github = require('./mobile_release_api.cjs');
|
|
7
|
+
const handoff = require('./apple_upload_handoff.cjs');
|
|
8
|
+
const verifier = require('./apple_upload_verify.cjs');
|
|
9
|
+
const LIMIT = 2 * 1024 * 1024;
|
|
10
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
11
|
+
const fail = code => handoff.fail(`recovery_${code}`);
|
|
12
|
+
|
|
13
|
+
// This archive contains one fixed JSON member. Parse in memory; never extract paths or trust a local receipt.
|
|
14
|
+
function unpack(bytes) {
|
|
15
|
+
try {
|
|
16
|
+
if (bytes.length > LIMIT) fail('artifact_size');
|
|
17
|
+
const end = bytes.lastIndexOf(Buffer.from('504b0506', 'hex'));
|
|
18
|
+
if (end < 0 || end + 22 + bytes.readUInt16LE(end + 20) !== bytes.length ||
|
|
19
|
+
bytes.readUInt16LE(end + 4) !== 0 || bytes.readUInt16LE(end + 6) !== 0 ||
|
|
20
|
+
bytes.readUInt16LE(end + 8) !== 1 || bytes.readUInt16LE(end + 10) !== 1) fail('archive_invalid');
|
|
21
|
+
const at = bytes.readUInt32LE(end + 16), directorySize = bytes.readUInt32LE(end + 12);
|
|
22
|
+
if (at + directorySize !== end || bytes.readUInt32LE(at) !== 0x02014b50) fail('archive_invalid');
|
|
23
|
+
const flags = bytes.readUInt16LE(at + 8), method = bytes.readUInt16LE(at + 10);
|
|
24
|
+
const packed = bytes.readUInt32LE(at + 20), size = bytes.readUInt32LE(at + 24);
|
|
25
|
+
const length = bytes.readUInt16LE(at + 28), name = bytes.subarray(at + 46, at + 46 + length);
|
|
26
|
+
const mode = (bytes.readUInt32LE(at + 38) >>> 16) & 0xf000;
|
|
27
|
+
if (name.toString('utf8') !== 'apple-verification.json' || flags & 1 || ![0, 8].includes(method) ||
|
|
28
|
+
![0, 0x8000].includes(mode) || size > LIMIT || packed > LIMIT ||
|
|
29
|
+
46 + length + bytes.readUInt16LE(at + 30) + bytes.readUInt16LE(at + 32) !== directorySize ||
|
|
30
|
+
bytes.readUInt16LE(at + 34) !== 0 || bytes.readUInt32LE(at + 42) !== 0 ||
|
|
31
|
+
bytes.readUInt32LE(0) !== 0x04034b50 || bytes.readUInt16LE(6) !== flags ||
|
|
32
|
+
bytes.readUInt16LE(8) !== method) fail('archive_invalid');
|
|
33
|
+
const localLength = bytes.readUInt16LE(26), start = 30 + localLength + bytes.readUInt16LE(28);
|
|
34
|
+
if (!bytes.subarray(30, 30 + localLength).equals(name) || start + packed > at) fail('archive_invalid');
|
|
35
|
+
const compressed = bytes.subarray(start, start + packed);
|
|
36
|
+
const content = method ? inflateRawSync(compressed, {maxOutputLength: LIMIT}) : compressed;
|
|
37
|
+
if (content.length !== size || content.length > LIMIT) fail('archive_invalid');
|
|
38
|
+
return content;
|
|
39
|
+
} catch { fail('archive_invalid'); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function download(artifact, env = process.env, fetcher = fetch) {
|
|
43
|
+
if (!positive(artifact?.id) || !/^sha256:[a-f0-9]{64}$/.test(artifact.digest || '') ||
|
|
44
|
+
!positive(artifact.size_in_bytes) || artifact.size_in_bytes > LIMIT ||
|
|
45
|
+
!/^[\w.-]+\/[\w.-]+$/.test(env.GITHUB_REPOSITORY || '')) fail('artifact_invalid');
|
|
46
|
+
const signal = AbortSignal.timeout(30000);
|
|
47
|
+
const response = await fetcher(`https://api.github.com/repos/${env.GITHUB_REPOSITORY}/actions/artifacts/${artifact.id}/zip`,
|
|
48
|
+
{redirect: 'manual', signal, headers: {Authorization: `Bearer ${env.GITHUB_TOKEN || ''}`,
|
|
49
|
+
'User-Agent': 'gowalk-cicd', Accept: 'application/vnd.github+json'}});
|
|
50
|
+
const url = new URL(response.headers.get('location') || 'https://invalid.invalid');
|
|
51
|
+
if (response.status !== 302 || url.protocol !== 'https:' || url.username || url.password ||
|
|
52
|
+
url.port || url.hash || !/(?:\.blob\.core\.windows\.net|\.githubusercontent\.com)$/.test(url.hostname)) {
|
|
53
|
+
fail('artifact_unavailable');
|
|
54
|
+
}
|
|
55
|
+
// The signed storage URL receives no repository credential, and cannot redirect again.
|
|
56
|
+
const body = await fetcher(url, {redirect: 'error', signal});
|
|
57
|
+
if (!body.ok || !body.body) fail('artifact_unavailable');
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
for await (const chunk of body.body) {
|
|
61
|
+
size += chunk.length;
|
|
62
|
+
if (size > LIMIT || size > artifact.size_in_bytes) fail('artifact_size');
|
|
63
|
+
chunks.push(chunk);
|
|
64
|
+
}
|
|
65
|
+
const bytes = Buffer.concat(chunks);
|
|
66
|
+
if (bytes.length !== artifact.size_in_bytes ||
|
|
67
|
+
`sha256:${createHash('sha256').update(bytes).digest('hex')}` !== artifact.digest) fail('artifact_digest');
|
|
68
|
+
return unpack(bytes);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function current(env, api) {
|
|
72
|
+
const runId = Number(env.GITHUB_RUN_ID), attempt = Number(env.GITHUB_RUN_ATTEMPT);
|
|
73
|
+
const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
|
|
74
|
+
const repository = env.GITHUB_REPOSITORY, branch = event.repository?.default_branch;
|
|
75
|
+
if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_EVENT_NAME !== 'workflow_dispatch' ||
|
|
76
|
+
!/^[\w.-]+\/[\w.-]+$/.test(repository || '') || !positive(runId) || !positive(attempt) ||
|
|
77
|
+
!branch || env.GITHUB_REF !== `refs/heads/${branch}` || !/^[a-f0-9]{40}$/.test(env.GITHUB_SHA || '')) {
|
|
78
|
+
fail('caller_invalid');
|
|
79
|
+
}
|
|
80
|
+
const run = await api.request('GET', `/actions/runs/${runId}/attempts/${attempt}`);
|
|
81
|
+
if (run.id !== runId || run.run_attempt !== attempt || run.status !== 'in_progress' ||
|
|
82
|
+
run.event !== 'workflow_dispatch' || run.head_branch !== branch || run.head_sha !== env.GITHUB_SHA ||
|
|
83
|
+
run.repository?.full_name !== repository || run.head_repository?.full_name !== repository ||
|
|
84
|
+
!/^\.github\/workflows\/[\w.-]+\.ya?ml$/.test(run.path || '') ||
|
|
85
|
+
env.GITHUB_WORKFLOW_REF !== `${repository}/${run.path}@${env.GITHUB_REF}`) fail('caller_unverified');
|
|
86
|
+
return {run_id: runId, run_attempt: attempt, repository, ref: env.GITHUB_REF};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function recover({runId, attempt, deploymentId, artifactId}, dependencies = {}) {
|
|
90
|
+
if (![runId, attempt, deploymentId, artifactId].every(positive)) fail('arguments');
|
|
91
|
+
const env = dependencies.env || process.env, api = dependencies.api || github;
|
|
92
|
+
const caller = await current(env, api);
|
|
93
|
+
if (runId === caller.run_id) fail('caller_invalid');
|
|
94
|
+
const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
|
|
95
|
+
if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== runId ||
|
|
96
|
+
artifact.name !== `apple-verification-${runId}-${attempt}`) fail('artifact_identity');
|
|
97
|
+
const bytes = await download(artifact, env, dependencies.fetch || fetch);
|
|
98
|
+
let parsed;
|
|
99
|
+
try { parsed = JSON.parse(bytes.toString('utf8')); } catch { fail('handoff_invalid'); }
|
|
100
|
+
const value = handoff.validate(parsed), owner = value.owner;
|
|
101
|
+
if (owner.repository !== caller.repository || owner.ref !== caller.ref || owner.run_id !== runId ||
|
|
102
|
+
owner.run_attempt !== attempt || owner.deployment_id !== deploymentId ||
|
|
103
|
+
owner.source_sha !== artifact.workflow_run?.head_sha) fail('handoff_owner');
|
|
104
|
+
const result = await verifier.verify(value, artifactId, 900, {...dependencies, api, recoverCancelled: true});
|
|
105
|
+
return {...result, schema: 'gowalk-cicd/apple-upload-recovered.v1', producer_run: runId,
|
|
106
|
+
repository: owner.repository, producer_source_sha: owner.source_sha,
|
|
107
|
+
producer_ref: owner.ref, producer_event: owner.event,
|
|
108
|
+
artifact_id: artifactId, artifact_digest: artifact.digest,
|
|
109
|
+
handoff_sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
110
|
+
ipa_sha256: value.receipt.sha256, file_id: value.receipt.file_id,
|
|
111
|
+
recovery_run: caller.run_id, recovery_attempt: caller.run_attempt};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function main() {
|
|
115
|
+
if (process.argv.length !== 6) fail('arguments');
|
|
116
|
+
const [runId, attempt, deploymentId, artifactId] = process.argv.slice(2).map(Number);
|
|
117
|
+
console.log(JSON.stringify(await recover({runId, attempt, deploymentId, artifactId})));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (require.main === module) main().catch(error => {
|
|
121
|
+
const code = /^(apple_upload_verify|apple_upload_reconcile|mobile_release)_[a-z0-9_]+$/.test(error?.message || '') ?
|
|
122
|
+
error.message : 'apple_upload_verify_recovery_unavailable';
|
|
123
|
+
console.error('::error title=apple_upload_recovery_refused::' + JSON.stringify({code}));
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
});
|
|
126
|
+
module.exports = {unpack, download, current, recover};
|
|
@@ -5,6 +5,7 @@ const {parseArgs} = require('node:util');
|
|
|
5
5
|
const {setTimeout: delay} = require('node:timers/promises');
|
|
6
6
|
const github = require('./mobile_release_api.cjs');
|
|
7
7
|
const apple = require('./reconcile_apple_upload.cjs');
|
|
8
|
+
const provider = require('./apple_upload_proof.cjs');
|
|
8
9
|
const handoff = require('./apple_upload_handoff.cjs');
|
|
9
10
|
|
|
10
11
|
function credential(env = process.env) {
|
|
@@ -15,25 +16,7 @@ function credential(env = process.env) {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
async function observe(receipt, get) {
|
|
18
|
-
|
|
19
|
-
const upload = response.data, attrs = upload?.attributes || {};
|
|
20
|
-
if (upload?.id !== receipt.upload_id || attrs.cfBundleVersion !== receipt.cfBundleVersion ||
|
|
21
|
-
attrs.cfBundleShortVersionString !== receipt.cfBundleShortVersionString) handoff.fail('upload_identity');
|
|
22
|
-
if (attrs.state?.state === 'FAILED') handoff.fail('processing_failed');
|
|
23
|
-
if (attrs.state?.state !== 'COMPLETE') return null;
|
|
24
|
-
const buildId = upload.relationships?.build?.data?.id;
|
|
25
|
-
if (!/^[A-Za-z0-9-]{1,64}$/.test(buildId || '')) handoff.fail('build_identity');
|
|
26
|
-
const buildPath = `/builds/${buildId}`, build = await get(buildPath);
|
|
27
|
-
if (['FAILED', 'INVALID'].includes(build.data?.attributes?.processingState)) handoff.fail('processing_failed');
|
|
28
|
-
if (build.data?.attributes?.processingState === 'PROCESSING') return null;
|
|
29
|
-
const files = await get(`/buildUploads/${receipt.upload_id}/buildUploadFiles`);
|
|
30
|
-
const assets = files.data?.filter(row => row.attributes?.assetType === 'ASSET');
|
|
31
|
-
if (!Array.isArray(assets) || files.links?.next || assets.length !== 1 || assets[0].id !== receipt.file_id) {
|
|
32
|
-
handoff.fail('file_membership');
|
|
33
|
-
}
|
|
34
|
-
// Preserve the existing independent complete-file, VALID/eligible build and actual app readbacks.
|
|
35
|
-
return apple.verifyApple(receipt, requested => requested === path ? response :
|
|
36
|
-
requested === buildPath ? build : get(requested));
|
|
19
|
+
return provider.observe(receipt, get, handoff.fail);
|
|
37
20
|
}
|
|
38
21
|
|
|
39
22
|
async function verify(value, artifactId, timeout, dependencies = {}) {
|
|
@@ -42,7 +25,7 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
|
|
|
42
25
|
const sleep = dependencies.sleep || delay, started = now(), deadline = started + timeout * 1000;
|
|
43
26
|
const key = dependencies.get ? null : credential();
|
|
44
27
|
const get = dependencies.get || (path => apple.appleGet(path, key, Math.max(1, Math.min(30000, deadline - now()))));
|
|
45
|
-
await handoff.verifyProducer(value, artifactId, api);
|
|
28
|
+
await handoff.verifyProducer(value, artifactId, api, dependencies.recoverCancelled === true);
|
|
46
29
|
await handoff.phase(value, api);
|
|
47
30
|
let proof;
|
|
48
31
|
do {
|
|
@@ -52,6 +35,8 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
|
|
|
52
35
|
await sleep(Math.min(10000, deadline - now()));
|
|
53
36
|
} while (now() < deadline);
|
|
54
37
|
if (!proof) handoff.fail('processing_pending');
|
|
38
|
+
// Explicit historical recovery rechecks its terminal producer before the only write.
|
|
39
|
+
if (dependencies.recoverCancelled === true) await handoff.verifyProducer(value, artifactId, api, true);
|
|
55
40
|
const prior = await handoff.phase(value, api);
|
|
56
41
|
if (prior === 'uploading') await api.status(value.owner.deployment_id, 'uploaded', 'success');
|
|
57
42
|
if (await handoff.phase(value, api) !== 'uploaded') handoff.fail('write_unconfirmed');
|
|
@@ -69,7 +54,8 @@ async function main() {
|
|
|
69
54
|
handoff.metadata(value);
|
|
70
55
|
const receiptPath = `${positionals[0]}.upload-receipt.json`;
|
|
71
56
|
fs.writeFileSync(receiptPath, JSON.stringify({...value.receipt, stage: 'complete', state: 'COMPLETE',
|
|
72
|
-
build_id: proof.build_id,
|
|
57
|
+
build_id: proof.build_id, upload_resource_state: proof.upload_resource_state,
|
|
58
|
+
processing_seconds: proof.processing_seconds}, null, 2) + '\n');
|
|
73
59
|
if (/[\r\n]/.test(receiptPath)) handoff.fail('receipt_path');
|
|
74
60
|
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
|
|
75
61
|
`verified=true\nbuild-id=${proof.build_id}\nproducer-attempt=${proof.producer_attempt}\n` +
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const github = require('./mobile_release_api.cjs');
|
|
6
|
+
const provider = require('./apple_upload_proof.cjs');
|
|
6
7
|
|
|
7
8
|
const RECEIPT = 'gowalk-cicd/apple-build-upload-receipt.v1';
|
|
8
9
|
const OWNER = 'gowalk-cicd/mobile-upload-owner.v1';
|
|
@@ -20,6 +21,9 @@ function readReceipt(path) {
|
|
|
20
21
|
!/^\d+(\.\d+){1,2}$/.test(data.cfBundleShortVersionString || '') ||
|
|
21
22
|
!/^[a-f0-9]{64}$/.test(data.sha256 || '') || data.fileName !== `${data.sha256}.ipa` ||
|
|
22
23
|
!Number.isSafeInteger(data.fileSize) || data.fileSize <= 0) refuse('receipt_invalid');
|
|
24
|
+
if (data.md5 !== undefined && (typeof data.md5 !== 'string' || !/^[a-f0-9]{32}$/.test(data.md5))) {
|
|
25
|
+
refuse('receipt_invalid');
|
|
26
|
+
}
|
|
23
27
|
return data;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -69,26 +73,9 @@ async function appleGet(path, key = {pem: process.env.ASC_KEY_P8, id: process.en
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
async function verifyApple(receipt, get = appleGet) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
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};
|
|
76
|
+
const proof = await provider.observe(receipt, get, refuse);
|
|
77
|
+
if (!proof) refuse('processing_pending');
|
|
78
|
+
return proof;
|
|
92
79
|
}
|
|
93
80
|
|
|
94
81
|
async function reconcile(deploymentId, runId, attempt, path, api = github, get = appleGet) {
|
|
@@ -18,6 +18,7 @@ import requests
|
|
|
18
18
|
import upload_checksums
|
|
19
19
|
import upload_receipt
|
|
20
20
|
import apple_upload_handoff
|
|
21
|
+
import apple_upload_proof
|
|
21
22
|
from upload_receipt import Receipt, UploadError
|
|
22
23
|
|
|
23
24
|
# Provider refusals the uploader classifies itself instead of letting the shared
|
|
@@ -53,12 +54,16 @@ def identity(ipa: Path) -> dict:
|
|
|
53
54
|
info = plistlib.loads(archive.read(names[0]))
|
|
54
55
|
except (zipfile.BadZipFile, plistlib.InvalidFileException, KeyError) as exc:
|
|
55
56
|
raise UploadError("ipa_invalid", "IPA is not a readable app archive") from exc
|
|
57
|
+
sha256, md5 = hashlib.sha256(), hashlib.md5()
|
|
56
58
|
with ipa.open("rb") as stream:
|
|
57
|
-
|
|
59
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
60
|
+
sha256.update(chunk)
|
|
61
|
+
md5.update(chunk)
|
|
62
|
+
digest = sha256.hexdigest()
|
|
58
63
|
return {"bundle_id": str(info["CFBundleIdentifier"]),
|
|
59
64
|
"cfBundleVersion": str(info["CFBundleVersion"]),
|
|
60
65
|
"cfBundleShortVersionString": str(info["CFBundleShortVersionString"]),
|
|
61
|
-
"sha256": digest, "fileName": digest + ".ipa", "fileSize": ipa.stat().st_size}
|
|
66
|
+
"sha256": digest, "md5": md5.hexdigest(), "fileName": digest + ".ipa", "fileSize": ipa.stat().st_size}
|
|
62
67
|
|
|
63
68
|
|
|
64
69
|
def upload_record(app_id: str, info: dict) -> dict:
|
|
@@ -176,17 +181,12 @@ def deliver(ipa: Path, report: Path, timeout: int, handoff: Path | None = None)
|
|
|
176
181
|
processing_started = time.monotonic()
|
|
177
182
|
deadline = processing_started + timeout
|
|
178
183
|
while True:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if state == "COMPLETE":
|
|
183
|
-
return receipt.update("complete", state=state, build_id=build_id,
|
|
184
|
+
proof, state = apple_upload_proof.observe(receipt.data, api)
|
|
185
|
+
if proof:
|
|
186
|
+
return receipt.update("complete", state="COMPLETE", upload_resource_state=state, **proof,
|
|
184
187
|
processing_seconds=round(time.monotonic() - processing_started, 3))
|
|
185
|
-
receipt.update("processing", state=state,
|
|
188
|
+
receipt.update("processing", state=state,
|
|
186
189
|
processing_seconds=round(time.monotonic() - processing_started, 3))
|
|
187
|
-
if state == "FAILED":
|
|
188
|
-
raise UploadError("processing_failed",
|
|
189
|
-
"Apple build upload failed processing; inspect the upload ID's provider errors")
|
|
190
190
|
if time.monotonic() >= deadline:
|
|
191
191
|
raise UploadError("processing_pending",
|
|
192
192
|
"Apple build processing is pending; reuse this upload ID on continuation")
|
package/package.json
CHANGED
package/remedies.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
{
|
|
3
3
|
"schema": "gowalk-cicd/apple-build-upload-failed.v2",
|
|
4
4
|
"code": "processing_pending",
|
|
5
|
-
"fixed_in": "1.0.
|
|
6
|
-
"remedy": "Keep the original IPA and apple-build-upload receipt.
|
|
5
|
+
"fixed_in": "1.0.184",
|
|
6
|
+
"remedy": "Keep the original IPA and apple-build-upload receipt. From 1.0.184 recovery stages unchanged bytes as app.ipa so punctuation in the export filename cannot block retention. A PROCESSING upload summary can complete only after fresh proof of its completed matching asset/checksum and unique valid eligible iOS build, exact app/bundle and marketing version. Future receipts carry local MD5 as well as SHA-256. Run reconcile_apple_upload.cjs only in an authorized Actions recovery job with the original deployment, run, attempt and retained receipt; it never uploads. Read its owner completion back, then rerun the whole original deploy workflow. Missing original bytes or checksum proof remain unverified; never invent the digest, re-upload another binary under the same number or hand-edit the owner."
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"schema": "gowalk-cicd/mobile-release-refused.v1",
|
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
"fixed_in": "1.0.132",
|
|
24
24
|
"remedy": "Read the original run, attempt and deployment receipt before retrying. A completed uploaded receipt is reused with its original evidence; finish its metadata and submission work. An uploading receipt is ambiguous even when a later owner refusal reports no provider attempt. Adopt the 1.0.132 action and workflow together to place future Android intent writes inside acquired Play ownership. For an existing receipt, prove the exact provider build/version or use supported retained-binary recovery; never relabel it building by hand or clear ownership on elapsed time."
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
"schema": "gowalk-cicd/mobile-release-refused.v1",
|
|
28
|
+
"code": "mobile_release_upload_unconfirmed_read_back_retained_binary_before_retry",
|
|
29
|
+
"fixed_in": "1.0.183",
|
|
30
|
+
"remedy": "Preserve the original upload owner and artifacts. If an iOS producer was cancelled after both Build and transfer iOS binary and Retain Apple verification handoff succeeded, use apple_upload_recovery.cjs RUN ATTEMPT DEPLOYMENT ARTIFACT from an authorized default-branch workflow_dispatch Actions job. It downloads and authenticates the original artifact, proves the terminal original and latest run, and reads fresh Apple upload/file/build/app evidence before reconciling that deployment. It never rebuilds, uploads or replays metadata. Retain its recovery proof, then rerun the whole original deployment workflow: admission reuses the uploaded receipt without another iOS binary. The old cancellation remains historical evidence; follow the new attempt to completion. Missing or mismatched evidence still refuses."
|
|
31
|
+
},
|
|
26
32
|
{
|
|
27
33
|
"schema": "gowalk-cicd/mobile-release-refused.v1",
|
|
28
34
|
"code": "mobile_release_http_500",
|