gowalk-cicd 1.0.184 → 1.0.186
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 +26 -10
- package/action/action.yml +6 -58
- package/action/scripts/apple_upload_handoff.cjs +4 -9
- package/action/scripts/apple_upload_handoff_history.cjs +29 -0
- package/action/scripts/apple_upload_proof.cjs +1 -1
- package/action/scripts/apple_upload_proof.py +2 -0
- package/action/scripts/apple_upload_recovery.cjs +14 -7
- package/action/scripts/apple_upload_recovery_evidence.cjs +38 -0
- package/action/scripts/local_validation.cjs +88 -0
- package/action/scripts/local_validation_core.cjs +84 -0
- package/android-action/action.yml +2 -35
- package/bin/cli.mjs +7 -0
- package/docs/CI_PERFORMANCE.md +13 -57
- package/docs/CONFIGURATION.md +5 -1
- package/docs/LOCAL_VALIDATION.md +61 -0
- package/package.json +3 -2
- package/remedies.json +2 -2
- package/src/config/workflows.mjs +12 -6
- package/templates/apple-upload-recovery.yml +96 -0
- package/templates/history.json.gz +0 -0
package/README.md
CHANGED
|
@@ -5,6 +5,12 @@ optional backend deploy. It supports native Swift/SwiftUI iOS projects, native K
|
|
|
5
5
|
Android projects, and Flutter apps. Provision encrypted repository secrets, validate a
|
|
6
6
|
task candidate, and promote its release intent. Store builds and uploads run only in GitHub Actions.
|
|
7
7
|
|
|
8
|
+
Tests must run locally, never in GitHub Actions. Run only the focused tests needed for the
|
|
9
|
+
changed behavior before pushing. The iOS and Android actions never run tests or Flutter analysis;
|
|
10
|
+
legacy `run-tests`, `test-command`, and `test-destination` inputs are accepted but ignored,
|
|
11
|
+
including `run-tests: 'true'`. Build, signing, upload, and delivery health checks remain in Actions.
|
|
12
|
+
See [local validation](docs/LOCAL_VALIDATION.md) for focused execution, receipts and CI verification.
|
|
13
|
+
|
|
8
14
|
App-specific workflow settings live in `.gowalk-cicd.yml`. The installer migrates supported
|
|
9
15
|
existing settings once and regenerates managed workflows without replacing custom behavior.
|
|
10
16
|
See [configuration](docs/CONFIGURATION.md).
|
|
@@ -25,6 +31,8 @@ Writes three core things into your repo:
|
|
|
25
31
|
|
|
26
32
|
It also writes:
|
|
27
33
|
|
|
34
|
+
- `.github/workflows/apple-upload-recovery.yml` — explicit verification-only recovery for a retained iOS upload;
|
|
35
|
+
it checks the original producer and Apple build before completing that deployment, without rebuilding or uploading
|
|
28
36
|
- `.github/workflows/mobile-candidate.yml` — shipping binaries for ready task PRs
|
|
29
37
|
(dispatch-only while candidate artifacts are off)
|
|
30
38
|
- `.github/workflows/warm-android-cache.yml` — only while Android candidates are enabled: after this
|
|
@@ -106,7 +114,7 @@ download segment. A slow segment becomes a cache miss and normal SDK setup
|
|
|
106
114
|
continues normally; SDK setup errors still fail the
|
|
107
115
|
job. This bounds individual cache segments, not the total cache or SDK download,
|
|
108
116
|
and keeps existing cache keys. A successfully installed SDK is saved before app
|
|
109
|
-
|
|
117
|
+
builds run, so a later build failure does not force another cold SDK download.
|
|
110
118
|
Failed setup never saves an SDK cache; pub dependencies retain their job-end save.
|
|
111
119
|
Pull-request runs (`refs/pull/*`, which includes every candidate build) restore these caches
|
|
112
120
|
but never save them; task branches and the default branch still save. When the caller names
|
|
@@ -787,7 +795,6 @@ configuration for either:
|
|
|
787
795
|
|---|---|---|
|
|
788
796
|
| Detected by | `pubspec.yaml` at the repo root | `settings.gradle(.kts)` + `gradlew` at the root or under `android/` |
|
|
789
797
|
| App module | `android/app` | the one module applying `com.android.application` (version-catalog aliases are resolved; a module named `app` wins a tie against a wear/automotive sibling) |
|
|
790
|
-
| Tests (`run-tests`) | `flutter analyze` + `flutter test` | `gradlew test` (all variants, all modules) |
|
|
791
798
|
| Build | `flutter build appbundle --release --obfuscate --split-debug-info` | `<module>:bundleRelease` |
|
|
792
799
|
| Toolchain installed | Flutter + JDK 17 | JDK 21, or 17 on an old Kotlin — see [Choosing the JDK](#choosing-the-jdk) |
|
|
793
800
|
|
|
@@ -1049,7 +1056,6 @@ schemes, pass inputs in `.github/workflows/deploy.yml`:
|
|
|
1049
1056
|
with:
|
|
1050
1057
|
scheme: MyAppRelease
|
|
1051
1058
|
bundle-id: com.example.myapp
|
|
1052
|
-
run-tests: 'false'
|
|
1053
1059
|
uses-non-exempt-encryption: 'false'
|
|
1054
1060
|
```
|
|
1055
1061
|
|
|
@@ -1065,9 +1071,9 @@ common ones:
|
|
|
1065
1071
|
| `team-id` | Override the auto-detected team ID |
|
|
1066
1072
|
| `app-store-apple-id` | Numeric ASC app ID (override auto-lookup) |
|
|
1067
1073
|
| `certificate-cap-policy` | Deprecated and ignored: CI never creates or revokes certificates. |
|
|
1068
|
-
| `run-tests`
|
|
1074
|
+
| `run-tests`, `test-command`, `test-destination` | Deprecated and ignored: tests run locally only. |
|
|
1069
1075
|
| `uses-non-exempt-encryption` | Value for `ITSAppUsesNonExemptEncryption` |
|
|
1070
|
-
| `archive` | `false` to
|
|
1076
|
+
| `archive` | `false` to skip archive, export, and upload |
|
|
1071
1077
|
| `upload` | `false` to archive but not upload to TestFlight |
|
|
1072
1078
|
| `manage-app-store-version` | `false` to upload without creating/editing the App Store release slot |
|
|
1073
1079
|
| `app-store-whats-new` | Inline "What's New" text (overrides files) |
|
|
@@ -1225,7 +1231,10 @@ commits completed part transfers with `uploaded: true`, following Apple's
|
|
|
1225
1231
|
The IPA endpoint rejected an optional `sourceFileChecksums` SHA_256 object even though
|
|
1226
1232
|
the generic schema lists that algorithm; the uploader no longer sends that attribute.
|
|
1227
1233
|
A completed file is reused only when its content-derived filename and size match, and
|
|
1228
|
-
any returned file checksum is verified.
|
|
1234
|
+
any nonnull returned whole-file checksum is verified. An explicit
|
|
1235
|
+
`sourceFileChecksums.file: null` means that the optional whole-file checksum is absent;
|
|
1236
|
+
a composite checksum does not substitute for a whole-IPA digest.
|
|
1237
|
+
New receipts retain locally computed SHA-256 and
|
|
1229
1238
|
MD5 so Apple's returned checksum can be compared with the exact transferred bytes.
|
|
1230
1239
|
|
|
1231
1240
|
An upload summary can remain `PROCESSING` after Apple exposes a valid eligible build.
|
|
@@ -1248,8 +1257,11 @@ different bytes under the same number. Build numbers come from `/builds`,
|
|
|
1248
1257
|
`/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
|
|
1249
1258
|
reservation is never reused; delete such a reservation only after reading it back.
|
|
1250
1259
|
|
|
1251
|
-
When an iOS
|
|
1252
|
-
`Retain Apple verification handoff` succeeded, version 1.0.
|
|
1260
|
+
When an iOS deployment failed or was cancelled **after** both `Build and transfer iOS binary`
|
|
1261
|
+
and `Retain Apple verification handoff` succeeded, version 1.0.185 provides the managed
|
|
1262
|
+
`apple-upload-recovery.yml` workflow. Dispatch it on the repository's default branch with the
|
|
1263
|
+
original `producer-run`, `producer-attempt`, `deployment-id` and `handoff-artifact-id` inputs.
|
|
1264
|
+
It calls the released helper with those four validated identifiers:
|
|
1253
1265
|
|
|
1254
1266
|
```bash
|
|
1255
1267
|
node .github/actions/swift-app/scripts/apple_upload_recovery.cjs \
|
|
@@ -1296,7 +1308,8 @@ digest option: it downloads the exact original artifact, verifies GitHub's archi
|
|
|
1296
1308
|
and accepts only its bounded `apple-verification.json` member. It authenticates the current
|
|
1297
1309
|
dispatch separately from the original run, attempt, source, repository, branch and deployment.
|
|
1298
1310
|
|
|
1299
|
-
Recovery requires a terminal
|
|
1311
|
+
Recovery requires a terminal original run: either cancelled with its cancelled iOS producer,
|
|
1312
|
+
or failed with a completed successful or failed iOS producer. Both paths require those two
|
|
1300
1313
|
successful steps in order. A later completed attempt may exist; an active latest attempt
|
|
1301
1314
|
refuses, including a retry observed immediately before writing the receipt. Fresh Apple
|
|
1302
1315
|
readback must prove the exact completed upload and file, matching bytes, eligible `VALID`
|
|
@@ -1306,10 +1319,13 @@ and the old handoff's metadata and release notes are never replayed. Missing, ex
|
|
|
1306
1319
|
mismatched evidence remains refused; do not relabel a receipt or impersonate the original
|
|
1307
1320
|
`GITHUB_*` environment. Normal delivery still refuses cancelled producers.
|
|
1308
1321
|
|
|
1322
|
+
The managed workflow retains `apple-recovered-<recovery-run>-<recovery-attempt>` with the
|
|
1323
|
+
unchanged `apple-verification.json`, its `.verified.json` proof and derived `.upload-receipt.json`.
|
|
1324
|
+
The proof identifies the original producer and the current recovery caller separately.
|
|
1309
1325
|
Retain the recovery proof, then rerun the **whole original deployment workflow**. Its
|
|
1310
1326
|
upload-once admission reuses the confirmed iOS receipt and skips the iOS binary. Follow that
|
|
1311
1327
|
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
|
|
1328
|
+
original failed or cancelled attempt's conclusion. Read current store state for remaining submission
|
|
1313
1329
|
work without changing an already accepted submission. This exceptional command adds no
|
|
1314
1330
|
normal delivery job, polling or latency.
|
|
1315
1331
|
|
package/action/action.yml
CHANGED
|
@@ -69,9 +69,8 @@ inputs:
|
|
|
69
69
|
default: "false"
|
|
70
70
|
archive:
|
|
71
71
|
description: >
|
|
72
|
-
Set to 'false' to skip archive + export + upload entirely.
|
|
73
|
-
|
|
74
|
-
secrets. When 'false', `upload` is ignored.
|
|
72
|
+
Set to 'false' to skip archive + export + upload entirely.
|
|
73
|
+
When 'false', `upload` is ignored.
|
|
75
74
|
required: false
|
|
76
75
|
default: "true"
|
|
77
76
|
manage-app-store-version:
|
|
@@ -82,21 +81,15 @@ inputs:
|
|
|
82
81
|
required: false
|
|
83
82
|
default: "true"
|
|
84
83
|
run-tests:
|
|
85
|
-
description:
|
|
84
|
+
description: DEPRECATED (ignored). Tests run locally, never in GitHub Actions.
|
|
86
85
|
required: false
|
|
87
|
-
default: ""
|
|
86
|
+
default: "false"
|
|
88
87
|
test-command:
|
|
89
|
-
description:
|
|
90
|
-
Shell command used for the simulator test stage. Defaults to running
|
|
91
|
-
`bash Scripts/run-tests.sh` when that file exists, otherwise
|
|
92
|
-
`xcodebuild test` against the chosen scheme on a booted simulator with
|
|
93
|
-
code-signing disabled. Override to run a custom runner.
|
|
88
|
+
description: DEPRECATED (ignored). Custom test commands are never executed.
|
|
94
89
|
required: false
|
|
95
90
|
default: ""
|
|
96
91
|
test-destination:
|
|
97
|
-
description:
|
|
98
|
-
Destination passed to the default `xcodebuild test` invocation when
|
|
99
|
-
`test-command` is empty and no `Scripts/run-tests.sh` is present.
|
|
92
|
+
description: DEPRECATED (ignored). Simulator tests run locally only.
|
|
100
93
|
required: false
|
|
101
94
|
default: ""
|
|
102
95
|
uses-non-exempt-encryption:
|
|
@@ -276,9 +269,6 @@ runs:
|
|
|
276
269
|
INPUT_USES_NON_EXEMPT: ${{ inputs.uses-non-exempt-encryption }}
|
|
277
270
|
INPUT_WHATS_NEW: ${{ inputs.app-store-whats-new }}
|
|
278
271
|
INPUT_LOCALE: ${{ inputs.app-store-locale }}
|
|
279
|
-
INPUT_RUN_TESTS: ${{ inputs.run-tests }}
|
|
280
|
-
INPUT_TEST_COMMAND: ${{ inputs.test-command }}
|
|
281
|
-
INPUT_TEST_DESTINATION: ${{ inputs.test-destination }}
|
|
282
272
|
run: |
|
|
283
273
|
# read_config.py runs FIRST (it derives every other value), so it must
|
|
284
274
|
# install its full dep set inline. All later steps reuse these.
|
|
@@ -320,48 +310,6 @@ runs:
|
|
|
320
310
|
key: ${{ steps.native_cache_scope.outputs.key }}
|
|
321
311
|
path: ${{ steps.native_cache_scope.outputs.paths }}
|
|
322
312
|
|
|
323
|
-
- name: Run simulator tests
|
|
324
|
-
if: ${{ (inputs.run-tests || env.CFG_RUN_TESTS) == 'true' && inputs.candidate-binary == '' }}
|
|
325
|
-
shell: bash
|
|
326
|
-
env:
|
|
327
|
-
PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
|
|
328
|
-
WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
|
|
329
|
-
SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
|
|
330
|
-
CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
|
|
331
|
-
TEST_COMMAND: ${{ inputs.test-command || env.CFG_TEST_COMMAND }}
|
|
332
|
-
TEST_DESTINATION: >-
|
|
333
|
-
${{ inputs.test-destination || env.CFG_TEST_DESTINATION
|
|
334
|
-
|| 'platform=iOS Simulator,name=iPhone 15' }}
|
|
335
|
-
run: |
|
|
336
|
-
set -o pipefail
|
|
337
|
-
python3 "$SWIFT_APP_ACTION/scripts/native_dependency_guard.py" "$GITHUB_WORKSPACE" \
|
|
338
|
-
--container "${WORKSPACE:-$PROJECT}"
|
|
339
|
-
if [ -n "$TEST_COMMAND" ]; then
|
|
340
|
-
echo "Running custom test command: $TEST_COMMAND"
|
|
341
|
-
bash -c "$TEST_COMMAND"
|
|
342
|
-
elif [ -f "Scripts/run-tests.sh" ]; then
|
|
343
|
-
echo "Running Scripts/run-tests.sh"
|
|
344
|
-
bash Scripts/run-tests.sh
|
|
345
|
-
else
|
|
346
|
-
if [ -n "$WORKSPACE" ]; then
|
|
347
|
-
PROJ_ARGS=(-workspace "$WORKSPACE")
|
|
348
|
-
else
|
|
349
|
-
PROJ_ARGS=(-project "$PROJECT")
|
|
350
|
-
fi
|
|
351
|
-
echo "Running xcodebuild test on $TEST_DESTINATION"
|
|
352
|
-
xcodebuild \
|
|
353
|
-
"${PROJ_ARGS[@]}" \
|
|
354
|
-
-scheme "$SCHEME" \
|
|
355
|
-
-configuration "$CONFIGURATION" \
|
|
356
|
-
-destination "$TEST_DESTINATION" \
|
|
357
|
-
-disableAutomaticPackageResolution -skipPackageUpdates \
|
|
358
|
-
CODE_SIGNING_ALLOWED=NO \
|
|
359
|
-
CODE_SIGNING_REQUIRED=NO \
|
|
360
|
-
CODE_SIGN_IDENTITY="" \
|
|
361
|
-
DEVELOPMENT_TEAM="" \
|
|
362
|
-
test
|
|
363
|
-
fi
|
|
364
|
-
|
|
365
313
|
- name: Stage ASC API key for authenticated REST uploads
|
|
366
314
|
if: ${{ inputs.archive == 'true' }}
|
|
367
315
|
shell: bash
|
|
@@ -4,6 +4,7 @@ const fs = require('node:fs');
|
|
|
4
4
|
const {createHash} = require('node:crypto');
|
|
5
5
|
const {join} = require('node:path');
|
|
6
6
|
const github = require('./mobile_release_api.cjs');
|
|
7
|
+
const {historicalProducer, uniqueArtifact} = require('./apple_upload_handoff_history.cjs');
|
|
7
8
|
const SCHEMA = 'gowalk-cicd/apple-upload-handoff.v1';
|
|
8
9
|
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
9
10
|
const id = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
|
|
@@ -64,26 +65,20 @@ async function verifyProducer(value, artifactId, api = github, recoverCancelled
|
|
|
64
65
|
run.path !== '.github/workflows/deploy.yml' || run.event !== o.event ||
|
|
65
66
|
`refs/heads/${run.head_branch}` !== o.ref || run.repository?.full_name !== o.repository ||
|
|
66
67
|
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
|
-
}
|
|
68
|
+
if (recoverCancelled) await historicalProducer(o, run, api, fail);
|
|
75
69
|
const result = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}/jobs?per_page=100`);
|
|
76
70
|
if (!Array.isArray(result.jobs) || result.jobs.length >= 100) fail('producer_jobs');
|
|
77
71
|
const jobs = result.jobs.filter(job => job.name === 'iOS TestFlight' || job.name?.endsWith('/ iOS TestFlight'));
|
|
78
72
|
// A later symbol-retention failure does not undo these exact completed steps.
|
|
79
73
|
// The producer's original failure remains red; only Apple delivery is reconciled here.
|
|
80
|
-
const conclusions = recoverCancelled ? ['cancelled'] : ['success', 'failure'];
|
|
74
|
+
const conclusions = recoverCancelled && run.conclusion === 'cancelled' ? ['cancelled'] : ['success', 'failure'];
|
|
81
75
|
if (jobs.length !== 1 || jobs[0].status !== 'completed' || !conclusions.includes(jobs[0].conclusion) ||
|
|
82
76
|
jobs[0].head_sha !== o.source_sha || !transferred(jobs[0])) fail('producer_job');
|
|
83
77
|
const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
|
|
84
78
|
if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== o.run_id ||
|
|
85
79
|
artifact.workflow_run?.head_sha !== o.source_sha ||
|
|
86
80
|
artifact.name !== `apple-verification-${o.run_id}-${o.run_attempt}`) fail('producer_artifact');
|
|
81
|
+
if (recoverCancelled) await uniqueArtifact(o, artifact, api, fail);
|
|
87
82
|
}
|
|
88
83
|
|
|
89
84
|
async function phase(value, api = github) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Recheck one terminal historical producer and its unique retained handoff. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
4
|
+
|
|
5
|
+
async function historicalProducer(owner, run, api, fail) {
|
|
6
|
+
const latest = await api.request('GET', `/actions/runs/${owner.run_id}`);
|
|
7
|
+
const terminal = ['success', 'failure', 'cancelled', 'timed_out', 'action_required',
|
|
8
|
+
'neutral', 'skipped', 'stale', 'startup_failure'];
|
|
9
|
+
if (run.status !== 'completed' || !['cancelled', 'failure'].includes(run.conclusion) ||
|
|
10
|
+
latest.id !== owner.run_id || latest.status !== 'completed' || !terminal.includes(latest.conclusion) ||
|
|
11
|
+
!positive(latest.run_attempt) || latest.run_attempt < owner.run_attempt ||
|
|
12
|
+
latest.run_attempt === owner.run_attempt && latest.conclusion !== run.conclusion ||
|
|
13
|
+
latest.head_sha !== owner.source_sha || latest.path !== run.path || latest.event !== owner.event ||
|
|
14
|
+
latest.head_branch !== run.head_branch || latest.repository?.full_name !== owner.repository ||
|
|
15
|
+
latest.head_repository?.full_name !== owner.repository) fail('historical_producer_run');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function uniqueArtifact(owner, artifact, api, fail) {
|
|
19
|
+
const result = await api.request('GET', `/actions/runs/${owner.run_id}/artifacts?per_page=100`);
|
|
20
|
+
if (!Array.isArray(result.artifacts) || result.artifacts.length >= 100 ||
|
|
21
|
+
!Number.isSafeInteger(result.total_count) || result.total_count !== result.artifacts.length) fail('producer_artifact');
|
|
22
|
+
const selected = result.artifacts.filter(row => row.name === artifact.name);
|
|
23
|
+
if (selected.length !== 1 || selected[0].id !== artifact.id || selected[0].expired !== false ||
|
|
24
|
+
selected[0].workflow_run?.id !== owner.run_id || selected[0].workflow_run?.head_sha !== owner.source_sha ||
|
|
25
|
+
!/^sha256:[a-f0-9]{64}$/.test(artifact.digest || '') || selected[0].digest !== artifact.digest ||
|
|
26
|
+
!positive(artifact.size_in_bytes) || selected[0].size_in_bytes !== artifact.size_in_bytes) fail('producer_artifact');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = {historicalProducer, uniqueArtifact};
|
|
@@ -25,7 +25,7 @@ function checksum(attributes, receipt, fail) {
|
|
|
25
25
|
fail('file_checksum_unverified');
|
|
26
26
|
}
|
|
27
27
|
const file = checksums?.file;
|
|
28
|
-
if (file === undefined) return;
|
|
28
|
+
if (file === undefined || file === null) return;
|
|
29
29
|
const digest = file?.algorithm === 'SHA_256' ? receipt.sha256 :
|
|
30
30
|
file?.algorithm === 'MD5' ? receipt.md5 : null;
|
|
31
31
|
const width = file?.algorithm === 'SHA_256' ? 64 : 32;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Explicit Actions recovery of a
|
|
1
|
+
/* Explicit Actions recovery of a terminal producer's authenticated handoff; never upload or metadata. */
|
|
2
2
|
'use strict';
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const {createHash} = require('node:crypto');
|
|
@@ -6,6 +6,7 @@ const {inflateRawSync} = require('node:zlib');
|
|
|
6
6
|
const github = require('./mobile_release_api.cjs');
|
|
7
7
|
const handoff = require('./apple_upload_handoff.cjs');
|
|
8
8
|
const verifier = require('./apple_upload_verify.cjs');
|
|
9
|
+
const {pinnedArtifact, evidence} = require('./apple_upload_recovery_evidence.cjs');
|
|
9
10
|
const LIMIT = 2 * 1024 * 1024;
|
|
10
11
|
const positive = value => Number.isSafeInteger(value) && value > 0;
|
|
11
12
|
const fail = code => handoff.fail(`recovery_${code}`);
|
|
@@ -83,7 +84,8 @@ async function current(env, api) {
|
|
|
83
84
|
run.repository?.full_name !== repository || run.head_repository?.full_name !== repository ||
|
|
84
85
|
!/^\.github\/workflows\/[\w.-]+\.ya?ml$/.test(run.path || '') ||
|
|
85
86
|
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
|
+
return {run_id: runId, run_attempt: attempt, repository, ref: env.GITHUB_REF,
|
|
88
|
+
source_sha: env.GITHUB_SHA, workflow_path: run.path, event: run.event};
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
async function recover({runId, attempt, deploymentId, artifactId}, dependencies = {}) {
|
|
@@ -91,7 +93,7 @@ async function recover({runId, attempt, deploymentId, artifactId}, dependencies
|
|
|
91
93
|
const env = dependencies.env || process.env, api = dependencies.api || github;
|
|
92
94
|
const caller = await current(env, api);
|
|
93
95
|
if (runId === caller.run_id) fail('caller_invalid');
|
|
94
|
-
const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
|
|
96
|
+
const artifact = structuredClone(await api.request('GET', `/actions/artifacts/${artifactId}`));
|
|
95
97
|
if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== runId ||
|
|
96
98
|
artifact.name !== `apple-verification-${runId}-${attempt}`) fail('artifact_identity');
|
|
97
99
|
const bytes = await download(artifact, env, dependencies.fetch || fetch);
|
|
@@ -101,20 +103,25 @@ async function recover({runId, attempt, deploymentId, artifactId}, dependencies
|
|
|
101
103
|
if (owner.repository !== caller.repository || owner.ref !== caller.ref || owner.run_id !== runId ||
|
|
102
104
|
owner.run_attempt !== attempt || owner.deployment_id !== deploymentId ||
|
|
103
105
|
owner.source_sha !== artifact.workflow_run?.head_sha) fail('handoff_owner');
|
|
104
|
-
const result = await verifier.verify(value, artifactId, 900,
|
|
105
|
-
|
|
106
|
+
const result = await verifier.verify(value, artifactId, 900,
|
|
107
|
+
{...dependencies, api: pinnedArtifact(api, artifact), recoverCancelled: true});
|
|
108
|
+
const proof = {...result, schema: 'gowalk-cicd/apple-upload-recovered.v1', producer_run: runId,
|
|
106
109
|
repository: owner.repository, producer_source_sha: owner.source_sha,
|
|
107
110
|
producer_ref: owner.ref, producer_event: owner.event,
|
|
108
111
|
artifact_id: artifactId, artifact_digest: artifact.digest,
|
|
109
112
|
handoff_sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
110
113
|
ipa_sha256: value.receipt.sha256, file_id: value.receipt.file_id,
|
|
111
|
-
recovery_run: caller.run_id, recovery_attempt: caller.run_attempt
|
|
114
|
+
recovery_run: caller.run_id, recovery_attempt: caller.run_attempt,
|
|
115
|
+
recovery_source_sha: caller.source_sha, recovery_ref: caller.ref,
|
|
116
|
+
recovery_event: caller.event, recovery_workflow_path: caller.workflow_path};
|
|
117
|
+
if (dependencies.writeEvidence === true) Object.assign(proof, evidence(bytes, value, proof, env));
|
|
118
|
+
return proof;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
async function main() {
|
|
115
122
|
if (process.argv.length !== 6) fail('arguments');
|
|
116
123
|
const [runId, attempt, deploymentId, artifactId] = process.argv.slice(2).map(Number);
|
|
117
|
-
console.log(JSON.stringify(await recover({runId, attempt, deploymentId, artifactId})));
|
|
124
|
+
console.log(JSON.stringify(await recover({runId, attempt, deploymentId, artifactId}, {writeEvidence: true})));
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
if (require.main === module) main().catch(error => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* Preserve the exact authenticated handoff and record separately owned verification evidence. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const {join} = require('node:path');
|
|
5
|
+
const handoff = require('./apple_upload_handoff.cjs');
|
|
6
|
+
const fail = code => handoff.fail(`recovery_${code}`);
|
|
7
|
+
|
|
8
|
+
function pinnedArtifact(api, original) {
|
|
9
|
+
return {...api, request: async (method, path, ...args) => {
|
|
10
|
+
const result = await api.request(method, path, ...args);
|
|
11
|
+
if (path === `/actions/artifacts/${original.id}` && (result.id !== original.id ||
|
|
12
|
+
result.digest !== original.digest || result.size_in_bytes !== original.size_in_bytes ||
|
|
13
|
+
result.expired !== false || result.name !== original.name ||
|
|
14
|
+
result.workflow_run?.id !== original.workflow_run.id ||
|
|
15
|
+
result.workflow_run?.head_sha !== original.workflow_run.head_sha)) fail('artifact_changed');
|
|
16
|
+
return result;
|
|
17
|
+
}};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function evidence(bytes, value, proof, env) {
|
|
21
|
+
if (!env.RUNNER_TEMP || /[\r\n]/.test(env.RUNNER_TEMP)) fail('evidence_path');
|
|
22
|
+
const directory = join(env.RUNNER_TEMP, 'apple-upload-recovery');
|
|
23
|
+
fs.mkdirSync(directory, {recursive: true, mode: 0o700});
|
|
24
|
+
const original = join(directory, 'apple-verification.json');
|
|
25
|
+
const receipt = `${original}.upload-receipt.json`, verified = `${original}.verified.json`;
|
|
26
|
+
fs.writeFileSync(original, bytes, {mode: 0o600});
|
|
27
|
+
if (!fs.readFileSync(original).equals(bytes)) fail('evidence_identity');
|
|
28
|
+
fs.writeFileSync(receipt, JSON.stringify({...value.receipt, stage: 'complete', state: 'COMPLETE',
|
|
29
|
+
build_id: proof.build_id, upload_resource_state: proof.upload_resource_state,
|
|
30
|
+
processing_seconds: proof.processing_seconds}, null, 2) + '\n', {mode: 0o600});
|
|
31
|
+
fs.writeFileSync(verified, JSON.stringify(proof, null, 2) + '\n', {mode: 0o600});
|
|
32
|
+
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT,
|
|
33
|
+
`verified=true\nbuild-id=${proof.build_id}\nartifact-directory=${directory}\n` +
|
|
34
|
+
`handoff-path=${original}\nreceipt-path=${receipt}\nverified-proof-path=${verified}\n`);
|
|
35
|
+
return {artifact_directory: directory, handoff_path: original, receipt_path: receipt, verified_proof_path: verified};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {pinnedArtifact, evidence};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests run on the owned workspace; Actions verifies an authenticated exact-source result only.
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
const { mkdirSync, rmSync, writeFileSync } = require('node:fs');
|
|
5
|
+
const { dirname, relative, resolve, isAbsolute } = require('node:path');
|
|
6
|
+
const { SCHEMA, CONTEXT, command, identity, readReceipt, sameSource, github, expectedAuthor,
|
|
7
|
+
verifyStatus, digest } = require('./local_validation_core.cjs');
|
|
8
|
+
|
|
9
|
+
function localOnly() {
|
|
10
|
+
if (process.env.GITHUB_ACTIONS === 'true') throw new Error('tests_forbidden_in_github_actions');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function run(args) {
|
|
14
|
+
localOnly();
|
|
15
|
+
if (args[0] !== '--receipt' || args[2] !== '--' || !args[3]) throw new Error('receipt_and_command_required');
|
|
16
|
+
const path = resolve(args[1]);
|
|
17
|
+
const root = command('git', ['rev-parse', '--show-toplevel']);
|
|
18
|
+
const nested = relative(root, path);
|
|
19
|
+
if (nested !== '..' && !nested.startsWith('../') && !isAbsolute(nested)) {
|
|
20
|
+
throw new Error('receipt_must_be_outside_checkout');
|
|
21
|
+
}
|
|
22
|
+
const source = identity();
|
|
23
|
+
rmSync(path, { force: true });
|
|
24
|
+
const argv = args.slice(3);
|
|
25
|
+
const result = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit' });
|
|
26
|
+
if (result.error || result.status !== 0) throw new Error('local_validation_command_failed');
|
|
27
|
+
sameSource(source, identity());
|
|
28
|
+
const receipt = { schema: SCHEMA, ...source, command_sha256: digest(argv) };
|
|
29
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
30
|
+
writeFileSync(path, JSON.stringify(receipt) + '\n', { mode: 0o600 });
|
|
31
|
+
console.log(JSON.stringify({ ok: true, result: 'local_validation_passed', ...receipt }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function publish(args) {
|
|
35
|
+
localOnly();
|
|
36
|
+
if (args[0] !== '--receipt' || args.length !== 2) throw new Error('receipt_required');
|
|
37
|
+
const receipt = readReceipt(args[1]);
|
|
38
|
+
sameSource(receipt, identity());
|
|
39
|
+
const commit = github(`repos/${receipt.repository}/git/commits/${receipt.head_sha}`);
|
|
40
|
+
if (commit.tree.sha !== receipt.tree_sha) throw new Error('remote_tree_mismatch');
|
|
41
|
+
const status = github(`repos/${receipt.repository}/statuses/${receipt.head_sha}`, {
|
|
42
|
+
state: 'success', context: CONTEXT, description: `v1:${receipt.tree_sha}:${receipt.command_sha256}`,
|
|
43
|
+
});
|
|
44
|
+
verifyStatus(status, receipt.tree_sha, github('user').login);
|
|
45
|
+
console.log(JSON.stringify({ ok: true, result: 'local_validation_published', ...receipt }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function check(args) {
|
|
49
|
+
localOnly();
|
|
50
|
+
if (args[0] !== '--receipt' || args[2] !== '--sha' || args.length !== 4) throw new Error('receipt_and_sha_required');
|
|
51
|
+
const receipt = readReceipt(args[1]);
|
|
52
|
+
if (receipt.head_sha !== args[3]) throw new Error('receipt_head_mismatch');
|
|
53
|
+
sameSource(receipt, identity());
|
|
54
|
+
console.log(JSON.stringify({ ok: true, result: 'local_validation_current', ...receipt }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function verify(args) {
|
|
58
|
+
if (args[0] !== '--sha' || args.length !== 2 || !/^[a-f0-9]{40}$/.test(args[1])) {
|
|
59
|
+
throw new Error('explicit_head_sha_required');
|
|
60
|
+
}
|
|
61
|
+
const sha = args[1], repository = process.env.GITHUB_REPOSITORY;
|
|
62
|
+
const author = expectedAuthor(sha, repository);
|
|
63
|
+
const tree = github(`repos/${repository}/git/commits/${sha}`).tree.sha;
|
|
64
|
+
if (command('git', ['rev-parse', 'HEAD^{tree}']) !== tree) throw new Error('checkout_tree_mismatch');
|
|
65
|
+
let status;
|
|
66
|
+
for (let attempt = 0; attempt <= 6; attempt += 1) {
|
|
67
|
+
// The combined /status summary omits creator; full records authenticate the issuer.
|
|
68
|
+
const statuses = github(`repos/${repository}/commits/${sha}/statuses?per_page=100`);
|
|
69
|
+
status = statuses.find(item => item.context === CONTEXT);
|
|
70
|
+
if (status || attempt === 6) break;
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
72
|
+
}
|
|
73
|
+
verifyStatus(status, tree, author);
|
|
74
|
+
console.log(JSON.stringify({ ok: true, result: 'local_validation_verified', head_sha: sha, tree_sha: tree }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function main() {
|
|
78
|
+
const [operation, ...args] = process.argv.slice(2);
|
|
79
|
+
const actions = { run, check, publish, verify };
|
|
80
|
+
if (!Object.hasOwn(actions, operation)) throw new Error('run_check_publish_or_verify_required');
|
|
81
|
+
await actions[operation](args);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main().catch(error => {
|
|
85
|
+
const code = /^[a-z][a-z0-9_]+$/.test(error.message) ? error.message : 'invalid_local_validation_input';
|
|
86
|
+
console.error(`local_validation_refused: ${code}`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { createHash } = require('node:crypto');
|
|
2
|
+
const { execFileSync } = require('node:child_process');
|
|
3
|
+
const { readFileSync } = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const SCHEMA = 'gowalk-cicd/local-validation.v1';
|
|
6
|
+
const CONTEXT = 'local-validation';
|
|
7
|
+
|
|
8
|
+
function command(program, args) {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync(program, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000 }).trim();
|
|
11
|
+
}
|
|
12
|
+
catch { throw new Error(`${program}_command_failed`); }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function repositoryFromRemote(remote, ssh = args => command('ssh', args)) {
|
|
16
|
+
const https = remote.match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
17
|
+
if (https) return https[1];
|
|
18
|
+
const scp = remote.match(/^(?:([\w.-]+)@)?([A-Za-z0-9][\w.-]*):([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
19
|
+
const url = remote.match(/^ssh:\/\/(?:([\w.-]+)@)?([A-Za-z0-9][\w.-]*)(?::(22))?\/([\w.-]+\/[\w.-]+?)(?:\.git)?$/);
|
|
20
|
+
const [user, host, repository] = scp ? scp.slice(1) : url ? [url[1], url[2], url[4]] : [];
|
|
21
|
+
if (!repository) throw new Error('github_repository_required');
|
|
22
|
+
if (host === 'github.com' && user === 'git') return repository;
|
|
23
|
+
const config = Object.fromEntries(ssh(['-G', ...(user ? ['-l', user] : []), host])
|
|
24
|
+
.split('\n').map(line => line.trim().split(/\s+/, 2)));
|
|
25
|
+
if (config.hostname !== 'github.com' || config.user !== 'git' || config.port !== '22') {
|
|
26
|
+
throw new Error('github_repository_required');
|
|
27
|
+
}
|
|
28
|
+
return repository;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function identity() {
|
|
32
|
+
const repository = repositoryFromRemote(command('git', ['remote', 'get-url', 'origin']));
|
|
33
|
+
if (command('git', ['status', '--porcelain'])) throw new Error('clean_checkout_required');
|
|
34
|
+
return { repository, head_sha: command('git', ['rev-parse', 'HEAD']),
|
|
35
|
+
tree_sha: command('git', ['rev-parse', 'HEAD^{tree}']) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readReceipt(path) {
|
|
39
|
+
const receipt = JSON.parse(readFileSync(path, 'utf8'));
|
|
40
|
+
if (receipt.schema !== SCHEMA || !/^[\w.-]+\/[\w.-]+$/.test(receipt.repository)
|
|
41
|
+
|| !/^[a-f0-9]{40}$/.test(receipt.head_sha) || !/^[a-f0-9]{40}$/.test(receipt.tree_sha)
|
|
42
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.command_sha256)) throw new Error('invalid_local_validation_receipt');
|
|
43
|
+
return receipt;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sameSource(receipt, source) {
|
|
47
|
+
for (const field of ['repository', 'head_sha', 'tree_sha']) {
|
|
48
|
+
if (receipt[field] !== source[field]) throw new Error('local_validation_source_changed');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function github(path, fields = {}) {
|
|
53
|
+
const args = ['api', path];
|
|
54
|
+
for (const [key, value] of Object.entries(fields)) args.push('-f', `${key}=${value}`);
|
|
55
|
+
return JSON.parse(command('gh', args));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function expectedAuthor(sha, repository, event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))) {
|
|
59
|
+
if (process.env.GITHUB_REPOSITORY !== repository) throw new Error('repository_mismatch');
|
|
60
|
+
if (event.pull_request) {
|
|
61
|
+
const pr = event.pull_request;
|
|
62
|
+
if (pr.head.repo.full_name !== repository || pr.head.sha !== sha) throw new Error('same_repository_head_required');
|
|
63
|
+
return pr.user.login;
|
|
64
|
+
}
|
|
65
|
+
if (process.env.GITHUB_EVENT_NAME === 'workflow_dispatch') {
|
|
66
|
+
if (process.env.GITHUB_SHA !== sha || event.repository?.full_name !== repository) {
|
|
67
|
+
throw new Error('dispatch_head_required');
|
|
68
|
+
}
|
|
69
|
+
return event.sender.login;
|
|
70
|
+
}
|
|
71
|
+
if (event.after !== sha || process.env.GITHUB_SHA !== sha) throw new Error('push_head_required');
|
|
72
|
+
return event.sender.login;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function verifyStatus(status, tree, author) {
|
|
76
|
+
if (!status || status.state !== 'success' || status.creator?.login !== author
|
|
77
|
+
|| !new RegExp(`^v1:${tree}:[a-f0-9]{64}$`).test(status.description)) {
|
|
78
|
+
throw new Error('successful_author_local_validation_required');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { SCHEMA, CONTEXT, command, repositoryFromRemote, identity, readReceipt, sameSource,
|
|
83
|
+
github, expectedAuthor, verifyStatus,
|
|
84
|
+
digest: args => createHash('sha256').update(JSON.stringify(args)).digest('hex') };
|
|
@@ -25,11 +25,9 @@ inputs:
|
|
|
25
25
|
required: false
|
|
26
26
|
default: ""
|
|
27
27
|
run-tests:
|
|
28
|
-
description:
|
|
29
|
-
Run the project's analyzer and unit tests before the release build
|
|
30
|
-
(flutter analyze + flutter test, or the Gradle module's unit tests)
|
|
28
|
+
description: DEPRECATED (ignored). Tests and analysis run locally, never in GitHub Actions.
|
|
31
29
|
required: false
|
|
32
|
-
default: "
|
|
30
|
+
default: "false"
|
|
33
31
|
dart-defines:
|
|
34
32
|
description: >-
|
|
35
33
|
Compile-time Dart defines for a Flutter app, one `KEY=VALUE` per line
|
|
@@ -110,23 +108,6 @@ runs:
|
|
|
110
108
|
shell: bash
|
|
111
109
|
run: flutter pub get
|
|
112
110
|
|
|
113
|
-
- name: Analyze Flutter app
|
|
114
|
-
if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
|
|
115
|
-
shell: bash
|
|
116
|
-
run: flutter analyze
|
|
117
|
-
|
|
118
|
-
# `flutter test` hard-fails with "Test directory \"test\" not found" when a repo
|
|
119
|
-
# has no tests, which is not a real failure. Skip instead of blowing up the deploy.
|
|
120
|
-
- name: Test Flutter app
|
|
121
|
-
if: ${{ steps.config.outputs.project_kind == 'flutter' && inputs.run-tests == 'true' }}
|
|
122
|
-
shell: bash
|
|
123
|
-
run: |
|
|
124
|
-
if [ -d test ]; then
|
|
125
|
-
flutter test --no-pub
|
|
126
|
-
else
|
|
127
|
-
echo "No test/ directory; skipping flutter test."
|
|
128
|
-
fi
|
|
129
|
-
|
|
130
111
|
# Dart obfuscation is mandatory for Flutter apps: every release build is
|
|
131
112
|
# compiled with --obfuscate, and there is deliberately no input or repo
|
|
132
113
|
# variable that switches it off. --split-debug-info is not optional
|
|
@@ -217,20 +198,6 @@ runs:
|
|
|
217
198
|
# additionally overrides them through the Variant API so the resolved
|
|
218
199
|
# versionCode holds even when there is no literal — see
|
|
219
200
|
# scripts/version_override.init.gradle.
|
|
220
|
-
# `test` is the aggregate task ("Run unit tests for all variants") and is the
|
|
221
|
-
# only one guaranteed to exist: AGP only creates testXUnitTest tasks for the
|
|
222
|
-
# variants it enables, and AGP 9 disables the release unit test variant by
|
|
223
|
-
# default, so `:app:testReleaseUnitTest` is simply absent in many projects.
|
|
224
|
-
# Running it unqualified also mirrors `flutter test`, which covers the whole
|
|
225
|
-
# package rather than one module.
|
|
226
|
-
- name: Test Android app
|
|
227
|
-
if: ${{ steps.config.outputs.project_kind == 'gradle' && inputs.run-tests == 'true' }}
|
|
228
|
-
shell: bash
|
|
229
|
-
working-directory: ${{ steps.config.outputs.gradle_root || '.' }}
|
|
230
|
-
run: |
|
|
231
|
-
chmod +x ./gradlew
|
|
232
|
-
./gradlew test --console=plain --stacktrace
|
|
233
|
-
|
|
234
201
|
# ANDROID_BUILD_NUMBER is already in the environment: resolve_android.py
|
|
235
202
|
# exported it through GITHUB_ENV. ANDROID_BUILD_NAME is only set when the
|
|
236
203
|
# caller actually pinned a build-name — empty means "leave versionName
|
package/bin/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -8,6 +9,11 @@ const __dirname = dirname(__filename);
|
|
|
8
9
|
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
10
|
|
|
10
11
|
const args = process.argv.slice(2);
|
|
12
|
+
if (args[0] === 'validate') {
|
|
13
|
+
const helper = join(__dirname, '../action/scripts/local_validation.cjs');
|
|
14
|
+
const result = spawnSync(process.execPath, [helper, ...args.slice(1)], { stdio: 'inherit' });
|
|
15
|
+
process.exit(result.status ?? 1);
|
|
16
|
+
}
|
|
11
17
|
if (args[0] === 'release') {
|
|
12
18
|
const platform = args[1] || 'both';
|
|
13
19
|
if (args.length > 2 || !['ios', 'android', 'both'].includes(platform)) {
|
|
@@ -51,6 +57,7 @@ What it writes:
|
|
|
51
57
|
|
|
52
58
|
Options:
|
|
53
59
|
release [ios|android|both] Prepare a mobile release intent on the task branch
|
|
60
|
+
validate run|check|publish|verify Run local focused checks or verify their authenticated source receipt
|
|
54
61
|
--dry-run Print what would happen without writing files
|
|
55
62
|
-v, --version Show version number
|
|
56
63
|
-h, --help Show this help message
|
package/docs/CI_PERFORMANCE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Native validation and delivery time
|
|
2
2
|
|
|
3
|
-
Keep routine push-to-verified delivery within 2–5 minutes. Preserve
|
|
4
|
-
|
|
3
|
+
Keep routine push-to-verified delivery within 2–5 minutes. Preserve required delivery checks and
|
|
4
|
+
verify the local candidate evidence. Measure regressions and fix the slow stage in the same task; lowering a
|
|
5
5
|
timeout, skipping a failed test or calling a transferred binary production-ready is not a fix.
|
|
6
6
|
Cold toolchain setup and provider processing are recorded separately; neither is hidden from
|
|
7
7
|
total wall time or billed runner time. Compile one frozen candidate and promote its verified
|
|
@@ -37,61 +37,17 @@ version and all helper hashes, writes `DEST/release.json`, and outputs `scripts`
|
|
|
37
37
|
`version`, and `integrity`. Scripts retain their package-relative directory layout. Neither mode
|
|
38
38
|
reads credentials or connects to a store. The receipt schema is `gowalk-cicd/native-tooling.v1`.
|
|
39
39
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"external_context_keys": ["backend"]
|
|
52
|
-
}
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
Use `input_paths: ["."]` for unknown project layouts; include every source, test, asset, script,
|
|
56
|
-
lockfile, configuration and resource consumed by a reviewed narrower suite. `.github/`, the
|
|
57
|
-
validated configuration and receipt helper bytes always participate. Symlinks, submodules and
|
|
58
|
-
dirty selected inputs cannot establish reusable proof. Generated temporary configurations are
|
|
59
|
-
supported; their canonical contents participate just like a committed configuration.
|
|
60
|
-
|
|
61
|
-
Pass `NATIVE_VALIDATION_CONTEXT` as a JSON object whose keys match `context_keys`, with nonempty
|
|
62
|
-
string values read from actual execution state. Include the resolved package version/integrity,
|
|
63
|
-
full selected Xcode build, SDK/runtime, device matrix and consumed variables. A floating runner
|
|
64
|
-
label is not a toolchain identity; describe the actual Mac before lookup when necessary.
|
|
65
|
-
|
|
66
|
-
Every external key requires a fresh `sha256:<64 hex characters>` witness for the exact backend
|
|
67
|
-
deployment/flow revision or canonical relevant response. An origin URL is not an external-state
|
|
68
|
-
witness. Missing proof means full validation. For suites with inherently changing external data,
|
|
69
|
-
run those integration checks afresh outside the reusable portion. Unknown custom scripts or
|
|
70
|
-
undeclared network dependencies keep full coverage; never guess that they are unchanged.
|
|
71
|
-
|
|
72
|
-
```bash
|
|
73
|
-
node "$SCRIPTS/native_validation.cjs" lookup .factory/native-validation.json
|
|
74
|
-
# After every declared test and coverage check succeeded:
|
|
75
|
-
node "$SCRIPTS/native_validation.cjs" record .factory/native-validation.json
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
`lookup` needs repository `actions:read`, `GITHUB_TOKEN`, and a checkout containing the prior
|
|
79
|
-
producer commit. Its outputs are `reused`, `reuse-reason`, `fingerprint`, `artifact-name` and,
|
|
80
|
-
on a hit, `source-run`/`source-sha`. Missing external context returns a miss without a fingerprint.
|
|
81
|
-
No completed match or unavailable API proof runs full coverage immediately; it never waits for
|
|
82
|
-
another workflow. Missing local producer history is also a safe miss.
|
|
83
|
-
|
|
84
|
-
`record` runs inside the producing job after tests, writing `receipt-path` and `artifact-name`.
|
|
85
|
-
Retain that single `native-validation.json` with the pinned `actions/upload-artifact`, seven-day
|
|
86
|
-
retention and the existing artifact process scope. For matrix suites, a collector records only
|
|
87
|
-
after verifying every shard; configure every exact required job and successful step. A later
|
|
88
|
-
lookup verifies the completed successful same-repository run, matching workflow, every named
|
|
89
|
-
job/step, immutable artifact digest and an independently recomputed producer source tree. The
|
|
90
|
-
receipt contains hashes and run identity, never context values or credentials.
|
|
91
|
-
|
|
92
|
-
Keep the existing required `task/**` push check. A cheap admission may reuse prior success only
|
|
93
|
-
under this proof; its required final check must require either that proof or successful fresh
|
|
94
|
-
coverage. Do not replace the required push check with an unrelated PR merge-ref status.
|
|
40
|
+
## Validate locally, verify the source receipt in Actions
|
|
41
|
+
|
|
42
|
+
Automated tests never run in GitHub Actions, including self-hosted jobs. Run only impacted checks
|
|
43
|
+
in the owned workspace and publish an authenticated receipt for the frozen clean candidate.
|
|
44
|
+
[Local validation](LOCAL_VALIDATION.md) defines the `gowalk-cicd validate` commands and proof.
|
|
45
|
+
Actions verifies that source proof and keeps build, signing, artifact, upload and health checks.
|
|
46
|
+
Existing custom app workflows require explicit migration: the installer does not infer which
|
|
47
|
+
parts of an arbitrary shell script are tests or rewrite mixed build/test execution blindly.
|
|
48
|
+
The older native-validation artifact reuse helpers remain for compatibility; they do not authorize
|
|
49
|
+
new test execution in Actions. Preserve required check names while replacing their execution with
|
|
50
|
+
local proof verification, and describe that behavior honestly in the job steps.
|
|
95
51
|
|
|
96
52
|
## Cache work is part of delivery cost
|
|
97
53
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -62,7 +62,11 @@ Compose service must pass `BACKEND_PUBLIC_BASE_URL` into its container and use i
|
|
|
62
62
|
The same named repository inputs reach the feature-branch backend preview in `deploy.yml`.
|
|
63
63
|
|
|
64
64
|
The supported managed files are `deploy.yml`, `mobile-candidate.yml`,
|
|
65
|
-
`warm-android-cache.yml`, `deploy-web.yml` and `deploy-backend.yml`.
|
|
65
|
+
`apple-upload-recovery.yml`, `warm-android-cache.yml`, `deploy-web.yml` and `deploy-backend.yml`.
|
|
66
|
+
The installer adds the dispatch-only `apple-upload-recovery.yml` when the rendered mobile
|
|
67
|
+
deployment supports iOS; explicit Android-only deployment excludes it. It preserves recovery
|
|
68
|
+
workflow overrides and removes only an unmodified generated copy when iOS is no longer supported.
|
|
69
|
+
The installer no longer adds
|
|
66
70
|
`deploy-web.yml`, and adds `warm-android-cache.yml` only while candidate artifacts are enabled
|
|
67
71
|
and `mobile.platforms` is not `ios`;
|
|
68
72
|
it removes such a file that is still the unmodified generated one with no overrides,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Local validation and Actions integrity
|
|
2
|
+
|
|
3
|
+
Automated tests run in the owned workspace, never in GitHub Actions, including self-hosted
|
|
4
|
+
jobs. Select only the focused checks affected by the change. Do not repeat a full regression
|
|
5
|
+
on every push, on merge, or before publication. Actions still builds, signs, uploads, checks
|
|
6
|
+
artifacts and verifies deployment health; those operations are not automated test suites.
|
|
7
|
+
|
|
8
|
+
Commit the complete candidate first. Use a durable receipt path outside the checkout:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx --yes gowalk-cicd@1.0.186 validate run --receipt "$TASK_OUTPUT/local-validation.json" -- \
|
|
12
|
+
node --test test/changed-behavior.test.mjs
|
|
13
|
+
npx --yes gowalk-cicd@1.0.186 validate check --receipt "$TASK_OUTPUT/local-validation.json" --sha "$HEAD_SHA"
|
|
14
|
+
git push origin HEAD
|
|
15
|
+
npx --yes gowalk-cicd@1.0.186 validate publish --receipt "$TASK_OUTPUT/local-validation.json"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`TASK_OUTPUT` is task-owned durable storage; `HEAD_SHA` is the candidate's `git rev-parse HEAD`.
|
|
19
|
+
The local command is executed directly, with the supplied arguments. Use the project's existing
|
|
20
|
+
focused runner when several checks belong together. `run` refuses Actions and a dirty checkout,
|
|
21
|
+
removes an old receipt at that path, and writes a new one only after the command exits successfully
|
|
22
|
+
and HEAD, repository, tree and checkout cleanliness remain unchanged. `check` verifies that result
|
|
23
|
+
without executing commands or contacting GitHub. `publish` checks it again, reads the remote commit
|
|
24
|
+
tree and posts an authenticated `local-validation` commit status. GitHub authentication uses `gh`.
|
|
25
|
+
|
|
26
|
+
The receipt schema is `gowalk-cicd/local-validation.v1`, with `repository`, `head_sha`, `tree_sha`
|
|
27
|
+
and `command_sha256`. The command digest is SHA-256 of the actual argument vector encoded as JSON.
|
|
28
|
+
The full committed tree includes dependency lockfiles and configuration. This proof does not claim
|
|
29
|
+
to describe untracked installed dependencies or the whole local machine. The status description is
|
|
30
|
+
`v1:<tree SHA-1>:<command SHA-256>`; receipts and statuses contain no command text or child logs.
|
|
31
|
+
|
|
32
|
+
Actions verifies the explicitly selected head, using the helper vendored by the installer:
|
|
33
|
+
|
|
34
|
+
```yaml
|
|
35
|
+
permissions:
|
|
36
|
+
contents: read
|
|
37
|
+
statuses: read
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
|
40
|
+
- name: Verify local candidate validation
|
|
41
|
+
env:
|
|
42
|
+
GH_TOKEN: ${{ github.token }}
|
|
43
|
+
VALIDATION_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
44
|
+
run: node .github/actions/swift-app/scripts/local_validation.cjs verify --sha "$VALIDATION_HEAD"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`verify` executes no tests. A PR requires same-repository head identity and a successful status
|
|
48
|
+
issued by its author; a push requires its exact event SHA and sender. Manual workflow dispatch verifies
|
|
49
|
+
the selected repository, explicit SHA against `GITHUB_SHA`, and the dispatch sender. The checked-out tree must equal
|
|
50
|
+
that head's tree, including on a PR merge ref. If main advanced and changed the merge tree, update
|
|
51
|
+
the task branch and validate the new candidate locally. Missing, failed, wrong-author or mismatched
|
|
52
|
+
proof refuses. A missing status gets six five-second retries for publication immediately after push.
|
|
53
|
+
|
|
54
|
+
The CLI also accepts `gowalk-cicd validate verify --sha ...`. The installed script and its sibling
|
|
55
|
+
`local_validation_core.cjs` avoid fetching another package in app jobs. Legacy composite inputs
|
|
56
|
+
`run-tests`, `test-command` and `test-destination` are accepted but ignored; composites execute no tests.
|
|
57
|
+
Custom app workflows and required status names need explicit migration. The installer preserves
|
|
58
|
+
unknown app-authored execution rather than guessing how to split an arbitrary mixed build/test script.
|
|
59
|
+
|
|
60
|
+
For this package, `pr.yml` verifies local proof, release version and the npm archive. Main publication
|
|
61
|
+
packages and publishes that checked artifact without npm lifecycle hooks or a repeated regression.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gowalk-cicd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.186",
|
|
4
4
|
"description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"docs/BACKEND_SSH_GATE.md",
|
|
43
43
|
"docs/BACKEND_NATIVE.md",
|
|
44
44
|
"docs/CONFIGURATION.md",
|
|
45
|
-
"docs/CI_PERFORMANCE.md"
|
|
45
|
+
"docs/CI_PERFORMANCE.md",
|
|
46
|
+
"docs/LOCAL_VALIDATION.md"
|
|
46
47
|
],
|
|
47
48
|
"keywords": [
|
|
48
49
|
"ios",
|
package/remedies.json
CHANGED
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
{
|
|
27
27
|
"schema": "gowalk-cicd/mobile-release-refused.v1",
|
|
28
28
|
"code": "mobile_release_upload_unconfirmed_read_back_retained_binary_before_retry",
|
|
29
|
-
"fixed_in": "1.0.
|
|
30
|
-
"remedy": "Preserve the original upload owner and artifacts. If
|
|
29
|
+
"fixed_in": "1.0.185",
|
|
30
|
+
"remedy": "Preserve the original upload owner and artifacts. If the original iOS deployment failed or was cancelled after Build and transfer iOS binary and Retain Apple verification handoff succeeded in order, dispatch the managed apple-upload-recovery.yml on the default branch with original producer-run, producer-attempt, deployment-id and handoff-artifact-id. It authenticates the original artifact and unchanged handoff, terminal original and latest run, owner, and fresh Apple upload/file/build/app evidence. A null optional file checksum is absent; present whole-file checksums remain strictly verified. It never rebuilds, uploads, replays metadata or rewrites the original handoff. Retain the original handoff and derived completion proof with distinct current recovery caller, then rerun the whole original deployment: admission reuses the uploaded receipt without another iOS binary. The old failure or cancellation remains historical evidence; follow the new attempt to completion. Missing or mismatched evidence remains refused."
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"schema": "gowalk-cicd/mobile-release-refused.v1",
|
package/src/config/workflows.mjs
CHANGED
|
@@ -36,11 +36,11 @@ function render(source, normalized, file) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const WARM = 'warm-android-cache.yml';
|
|
39
|
-
const
|
|
39
|
+
const RECOVERY = 'apple-upload-recovery.yml';
|
|
40
|
+
const MANAGED = ['deploy.yml', 'mobile-candidate.yml', 'deploy-web.yml', 'deploy-backend.yml', WARM, RECOVERY];
|
|
40
41
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// Anything customized stays and keeps upgrading.
|
|
42
|
+
// Retire generated workflows only while their locked bytes are unchanged and they have no overrides.
|
|
43
|
+
// App-owned customizations stay and keep upgrading when their automatic use is disabled.
|
|
44
44
|
function retired(root, file, state) {
|
|
45
45
|
if (state.migrating || state.config.workflows[file]?.overrides?.length) return false;
|
|
46
46
|
const previous = load(root, `.github/workflows/${file}`);
|
|
@@ -87,8 +87,14 @@ export function planWorkflows(root, templateDir, files, version) {
|
|
|
87
87
|
if (!MANAGED.includes(file)) throw new MigrationRepair('unknown_managed_workflow', CONFIG);
|
|
88
88
|
}
|
|
89
89
|
const removed = selected.filter(file => file === 'deploy-web.yml' && retired(root, file, state));
|
|
90
|
-
const writes = selected.filter(file => !removed.includes(file) && file
|
|
90
|
+
const writes = selected.filter(file => !removed.includes(file) && ![WARM, RECOVERY].includes(file))
|
|
91
91
|
.map(file => upgradeWorkflow(root, file, templateDir, state));
|
|
92
|
+
const recovery = selected.includes('deploy.yml') && state.config.mobile?.platforms !== 'android';
|
|
93
|
+
if (recovery || (selected.includes(RECOVERY) && !retired(root, RECOVERY, state))) {
|
|
94
|
+
writes.push(upgradeWorkflow(root, RECOVERY, templateDir, state));
|
|
95
|
+
} else if (selected.includes(RECOVERY)) {
|
|
96
|
+
removed.push(RECOVERY);
|
|
97
|
+
}
|
|
92
98
|
// Only verified candidate keys can request trusted post-release dependency priming on either OS.
|
|
93
99
|
const candidates = selected.includes('mobile-candidate.yml')
|
|
94
100
|
&& state.config.mobile?.['candidate-artifacts'] !== false;
|
|
@@ -106,7 +112,7 @@ export function planWorkflows(root, templateDir, files, version) {
|
|
|
106
112
|
for (const [file, entry] of Object.entries(state.config.workflows)) {
|
|
107
113
|
for (const change of entry.overrides) assertReferences(change.value, file, change.path);
|
|
108
114
|
}
|
|
109
|
-
writes.push(...planValidation(root, [...selected, WARM]));
|
|
115
|
+
writes.push(...planValidation(root, [...selected, WARM, RECOVERY]));
|
|
110
116
|
state.lock.package = version;
|
|
111
117
|
const config = state.text && equal(readYaml(state.text, CONFIG).value, state.config)
|
|
112
118
|
? state.text
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
name: Recover Apple upload
|
|
2
|
+
on:
|
|
3
|
+
workflow_dispatch:
|
|
4
|
+
inputs:
|
|
5
|
+
producer-run:
|
|
6
|
+
description: Original Mobile Deploy run ID
|
|
7
|
+
required: true
|
|
8
|
+
type: string
|
|
9
|
+
producer-attempt:
|
|
10
|
+
description: Original Mobile Deploy run attempt
|
|
11
|
+
required: true
|
|
12
|
+
type: string
|
|
13
|
+
deployment-id:
|
|
14
|
+
description: Original iOS release deployment ID
|
|
15
|
+
required: true
|
|
16
|
+
type: string
|
|
17
|
+
handoff-artifact-id:
|
|
18
|
+
description: Original Apple verification handoff artifact ID
|
|
19
|
+
required: true
|
|
20
|
+
type: string
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
actions: read
|
|
25
|
+
deployments: write
|
|
26
|
+
|
|
27
|
+
# Serialize the original owner update with ordinary Mobile Deploy; recovery never uploads.
|
|
28
|
+
concurrency:
|
|
29
|
+
group: mobile-deploy-${{ github.ref }}
|
|
30
|
+
cancel-in-progress: false
|
|
31
|
+
|
|
32
|
+
jobs:
|
|
33
|
+
recover:
|
|
34
|
+
name: Verify the original Apple upload
|
|
35
|
+
if: >-
|
|
36
|
+
${{ github.event_name == 'workflow_dispatch'
|
|
37
|
+
&& github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
|
38
|
+
runs-on: ubuntu-24.04
|
|
39
|
+
timeout-minutes: 20
|
|
40
|
+
env:
|
|
41
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
42
|
+
APPLE_RECOVERY_PRODUCER_RUN: ${{ inputs.producer-run }}
|
|
43
|
+
APPLE_RECOVERY_PRODUCER_ATTEMPT: ${{ inputs.producer-attempt }}
|
|
44
|
+
APPLE_RECOVERY_DEPLOYMENT_ID: ${{ inputs.deployment-id }}
|
|
45
|
+
APPLE_RECOVERY_HANDOFF_ARTIFACT_ID: ${{ inputs.handoff-artifact-id }}
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
48
|
+
- name: Validate original upload identifiers
|
|
49
|
+
shell: bash
|
|
50
|
+
run: |
|
|
51
|
+
for value in "$APPLE_RECOVERY_PRODUCER_RUN" "$APPLE_RECOVERY_PRODUCER_ATTEMPT" \
|
|
52
|
+
"$APPLE_RECOVERY_DEPLOYMENT_ID" "$APPLE_RECOVERY_HANDOFF_ARTIFACT_ID"; do
|
|
53
|
+
if [[ ! "$value" =~ ^[1-9][0-9]*$ ]]; then
|
|
54
|
+
echo "::error title=apple_upload_recovery_failed::invalid_recovery_identifiers"
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
done
|
|
58
|
+
- name: Prepare Apple API access
|
|
59
|
+
env:
|
|
60
|
+
CI_ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
|
|
61
|
+
CI_ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
|
62
|
+
CI_ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
|
63
|
+
run: |
|
|
64
|
+
umask 077
|
|
65
|
+
mkdir -p creds
|
|
66
|
+
if [ -n "${CI_ASC_KEY_P8:-}" ]; then
|
|
67
|
+
: "${CI_ASC_KEY_ID:?ASC_KEY_ID secret is required with ASC_KEY_P8}"
|
|
68
|
+
: "${CI_ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required with ASC_KEY_P8}"
|
|
69
|
+
printf '%s' "$CI_ASC_KEY_P8" > "creds/AuthKey_${CI_ASC_KEY_ID}_Issuer_${CI_ASC_ISSUER_ID}.p8"
|
|
70
|
+
fi
|
|
71
|
+
python3 -m pip install --quiet --break-system-packages PyJWT cryptography requests
|
|
72
|
+
PYTHONPATH=.github/actions/swift-app/scripts python3 - <<'PY'
|
|
73
|
+
import os
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
from read_config import emit_credentials
|
|
76
|
+
emit_credentials(Path(os.environ['GITHUB_ENV']), Path(os.environ['GITHUB_WORKSPACE']))
|
|
77
|
+
PY
|
|
78
|
+
- name: Verify the original upload and reconcile its owner
|
|
79
|
+
id: recover
|
|
80
|
+
run: |
|
|
81
|
+
node .github/actions/swift-app/scripts/apple_upload_recovery.cjs \
|
|
82
|
+
"$APPLE_RECOVERY_PRODUCER_RUN" "$APPLE_RECOVERY_PRODUCER_ATTEMPT" \
|
|
83
|
+
"$APPLE_RECOVERY_DEPLOYMENT_ID" "$APPLE_RECOVERY_HANDOFF_ARTIFACT_ID"
|
|
84
|
+
- name: Retain verified Apple recovery proof
|
|
85
|
+
if: ${{ steps.recover.outputs.verified == 'true' }}
|
|
86
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
87
|
+
env:
|
|
88
|
+
NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
|
|
89
|
+
with:
|
|
90
|
+
name: apple-recovered-${{ github.run_id }}-${{ github.run_attempt }}
|
|
91
|
+
path: |
|
|
92
|
+
${{ runner.temp }}/apple-upload-recovery/apple-verification.json
|
|
93
|
+
${{ runner.temp }}/apple-upload-recovery/apple-verification.json.verified.json
|
|
94
|
+
${{ runner.temp }}/apple-upload-recovery/apple-verification.json.upload-receipt.json
|
|
95
|
+
if-no-files-found: error
|
|
96
|
+
retention-days: 7
|
|
Binary file
|