gowalk-cicd 1.0.181 → 1.0.183

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 CHANGED
@@ -652,6 +652,19 @@ bounded to 16 KiB. No provider message, path, URL, track or credential is republ
652
652
  These observations never authorize a retry. Missing commit output does not prove no mutation,
653
653
  and a reported commit does not prove review submission. Unknown failures remain unclassified.
654
654
 
655
+ The wrapper also writes validated `gowalk-cicd/play-upload-evidence.v1` JSON outside its
656
+ private upload directory: `attempt-1.json`, optional `attempt-2.json`, and `terminal.json`.
657
+ Each record binds the GitHub run/attempt, source SHA, compiled version code (the optional
658
+ `versionCode` input), and immutable uploader identity. Missing bindings are null, never inferred.
659
+ Attempt records add observed HTTP status/RPC codes and exact known message classes from complete,
660
+ bounded error lines; raw provider prose, paths, URLs and identifiers are discarded. The pinned
661
+ uploader sometimes emits only an error message, so missing status/code fields remain empty.
662
+ These fields do not change retry policy or the original result/diagnostic annotations.
663
+ The action exports `diagnostic-path`; the managed deploy template retains its JSON as
664
+ `play-upload-diagnostics-<run_id>-<run_attempt>` on success or failure. Adopt both action and
665
+ template to retain artifacts. Failed evidence validation/storage emits a fixed refusal and fails
666
+ the job without replaying provider work or changing its result.
667
+
655
668
  Package-owner acquisition rechecks an occupied record within one 60-second wait budget before
656
669
  provider work. Every read retains the binding and identity checks; cancellation interrupts the wait.
657
670
  Time passing never authorizes clearing an owner. A known CI owner still requires exact terminal
@@ -1227,6 +1240,71 @@ different bytes under the same number. Build numbers come from `/builds`,
1227
1240
  `/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
1228
1241
  reservation is never reused; delete such a reservation only after reading it back.
1229
1242
 
1243
+ When an iOS producer was cancelled **after** both `Build and transfer iOS binary` and
1244
+ `Retain Apple verification handoff` succeeded, version 1.0.183 provides explicit recovery:
1245
+
1246
+ ```bash
1247
+ node .github/actions/swift-app/scripts/apple_upload_recovery.cjs \
1248
+ ORIGINAL_RUN_ID ORIGINAL_ATTEMPT DEPLOYMENT_ID HANDOFF_ARTIFACT_ID \
1249
+ > "$RUNNER_TEMP/apple-upload-recovery.json"
1250
+ ```
1251
+
1252
+ Run this released helper in an authorized `workflow_dispatch` Actions job on the same
1253
+ repository's default branch, after checking out the adopted tooling. The job needs
1254
+ `actions: read`, `contents: read`, `deployments: write`, `GITHUB_TOKEN: ${{ github.token }}`
1255
+ and the existing `ASC_KEY_P8`, `ASC_KEY_ID`, `ASC_ISSUER_ID` secrets (or the prepared ASC key
1256
+ path). Serialize the recovery workflow with normal Mobile Deploy using its exact concurrency
1257
+ group and `cancel-in-progress: false`; this prevents a deploy from starting between the
1258
+ last provenance read and the receipt write. Use this workflow policy and job environment:
1259
+
1260
+ ```yaml
1261
+ on: workflow_dispatch
1262
+ permissions:
1263
+ contents: read
1264
+ actions: read
1265
+ deployments: write
1266
+ concurrency:
1267
+ group: mobile-deploy-${{ github.ref }}
1268
+ cancel-in-progress: false
1269
+ jobs:
1270
+ recover:
1271
+ if: ${{ github.ref_name == github.event.repository.default_branch }}
1272
+ runs-on: ubuntu-24.04
1273
+ timeout-minutes: 20
1274
+ env:
1275
+ GITHUB_TOKEN: ${{ github.token }}
1276
+ ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
1277
+ ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
1278
+ ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
1279
+ steps:
1280
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
1281
+ # Invoke the command above with the four verified original identifiers,
1282
+ # then retain its JSON with the pinned, guarded artifact uploader.
1283
+ ```
1284
+
1285
+ Retain `apple-upload-recovery.json` with the existing pinned, guarded artifact uploader.
1286
+ The command has no local receipt or caller-provided
1287
+ digest option: it downloads the exact original artifact, verifies GitHub's archive SHA-256,
1288
+ and accepts only its bounded `apple-verification.json` member. It authenticates the current
1289
+ dispatch separately from the original run, attempt, source, repository, branch and deployment.
1290
+
1291
+ Recovery requires a terminal cancelled original run and iOS producer with those two
1292
+ successful steps in order. A later completed attempt may exist; an active latest attempt
1293
+ refuses, including a retry observed immediately before writing the receipt. Fresh Apple
1294
+ readback must prove the exact completed upload and file, matching bytes, eligible `VALID`
1295
+ build and matching app. Only then can the original deployment become `uploaded`; repeating
1296
+ recovery verifies the same evidence without another write. No binary is rebuilt or uploaded,
1297
+ and the old handoff's metadata and release notes are never replayed. Missing, expired or
1298
+ mismatched evidence remains refused; do not relabel a receipt or impersonate the original
1299
+ `GITHUB_*` environment. Normal delivery still refuses cancelled producers.
1300
+
1301
+ Retain the recovery proof, then rerun the **whole original deployment workflow**. Its
1302
+ upload-once admission reuses the confirmed iOS receipt and skips the iOS binary. Follow that
1303
+ new attempt and any unrelated remaining jobs to completion; recovery does not change the
1304
+ original cancelled attempt's conclusion. Read current store state for remaining submission
1305
+ work without changing an already accepted submission. This exceptional command adds no
1306
+ normal delivery job, polling or latency.
1307
+
1230
1308
  Flutter SDK setup retries interrupted manifest and archive GETs, with four attempts
1231
1309
  within 60 seconds for manifests or 30 minutes for archives, with an 8 GiB archive bound. Retries resume private bytes
1232
1310
  with HTTP Range; if the origin ignores Range, a bounded full restart is permitted. The completed archive
@@ -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 read(path, digest, attempt, env = process.env) {
13
- if (fs.statSync(path).size > 2 * 1024 * 1024) fail('handoff_size');
14
- const bytes = fs.readFileSync(path);
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
- owner.source_sha !== env.GITHUB_SHA || owner.ref !== env.GITHUB_REF || !owner.ref?.startsWith('refs/heads/') ||
22
- owner.ref !== `refs/heads/${event.repository?.default_branch}` ||
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' ||
@@ -40,6 +30,22 @@ function read(path, digest, attempt, env = process.env) {
40
30
  return value;
41
31
  }
42
32
 
33
+ function read(path, digest, attempt, env = process.env) {
34
+ if (fs.statSync(path).size > 2 * 1024 * 1024) fail('handoff_size');
35
+ const bytes = fs.readFileSync(path);
36
+ if (bytes.length > 2 * 1024 * 1024 || !/^[a-f0-9]{64}$/.test(digest || '') ||
37
+ createHash('sha256').update(bytes).digest('hex') !== digest) fail('handoff_digest');
38
+ const value = validate(JSON.parse(bytes)), owner = value.owner;
39
+ const event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
40
+ if (owner.repository !== env.GITHUB_REPOSITORY || owner.source_sha !== env.GITHUB_SHA ||
41
+ owner.ref !== env.GITHUB_REF || owner.ref !== `refs/heads/${event.repository?.default_branch}` ||
42
+ owner.event !== env.GITHUB_EVENT_NAME || owner.run_id !== Number(env.GITHUB_RUN_ID) ||
43
+ !positive(attempt) || !positive(Number(env.GITHUB_RUN_ATTEMPT)) ||
44
+ owner.run_attempt !== attempt || attempt > Number(env.GITHUB_RUN_ATTEMPT) ||
45
+ owner.deployment_id !== Number(env.MOBILE_RELEASE_DEPLOYMENT_ID)) fail('handoff_owner');
46
+ return value;
47
+ }
48
+
43
49
  function transferred(job) {
44
50
  if (!Array.isArray(job.steps)) return false;
45
51
  const selected = ['Build and transfer iOS binary', 'Retain Apple verification handoff'].map(name =>
@@ -49,7 +55,7 @@ function transferred(job) {
49
55
  return selected[0][0].number < selected[1][0].number;
50
56
  }
51
57
 
52
- async function verifyProducer(value, artifactId, api = github) {
58
+ async function verifyProducer(value, artifactId, api = github, recoverCancelled = false) {
53
59
  if (!positive(artifactId)) fail('artifact_identity');
54
60
  const o = value.owner;
55
61
  const run = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}`);
@@ -57,12 +63,21 @@ async function verifyProducer(value, artifactId, api = github) {
57
63
  run.path !== '.github/workflows/deploy.yml' || run.event !== o.event ||
58
64
  `refs/heads/${run.head_branch}` !== o.ref || run.repository?.full_name !== o.repository ||
59
65
  run.head_repository?.full_name !== o.repository) fail('producer_run');
66
+ if (recoverCancelled) {
67
+ const latest = await api.request('GET', `/actions/runs/${o.run_id}`);
68
+ if (run.status !== 'completed' || run.conclusion !== 'cancelled' || latest.id !== o.run_id ||
69
+ latest.status !== 'completed' || !positive(latest.run_attempt) || latest.run_attempt < o.run_attempt ||
70
+ latest.head_sha !== o.source_sha || latest.path !== run.path || latest.event !== o.event ||
71
+ latest.head_branch !== run.head_branch || latest.repository?.full_name !== o.repository ||
72
+ latest.head_repository?.full_name !== o.repository) fail('cancelled_producer_run');
73
+ }
60
74
  const result = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}/jobs?per_page=100`);
61
75
  if (!Array.isArray(result.jobs) || result.jobs.length >= 100) fail('producer_jobs');
62
76
  const jobs = result.jobs.filter(job => job.name === 'iOS TestFlight' || job.name?.endsWith('/ iOS TestFlight'));
63
77
  // A later symbol-retention failure does not undo these exact completed steps.
64
78
  // The producer's original failure remains red; only Apple delivery is reconciled here.
65
- if (jobs.length !== 1 || jobs[0].status !== 'completed' || !['success', 'failure'].includes(jobs[0].conclusion) ||
79
+ const conclusions = recoverCancelled ? ['cancelled'] : ['success', 'failure'];
80
+ if (jobs.length !== 1 || jobs[0].status !== 'completed' || !conclusions.includes(jobs[0].conclusion) ||
66
81
  jobs[0].head_sha !== o.source_sha || !transferred(jobs[0])) fail('producer_job');
67
82
  const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
68
83
  if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== o.run_id ||
@@ -100,4 +115,4 @@ function metadata(value, env = process.env) {
100
115
  }
101
116
  }
102
117
 
103
- module.exports = {read, verifyProducer, phase, metadata, fail};
118
+ module.exports = {read, validate, verifyProducer, phase, metadata, fail};
@@ -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};
@@ -42,7 +42,7 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
42
42
  const sleep = dependencies.sleep || delay, started = now(), deadline = started + timeout * 1000;
43
43
  const key = dependencies.get ? null : credential();
44
44
  const get = dependencies.get || (path => apple.appleGet(path, key, Math.max(1, Math.min(30000, deadline - now()))));
45
- await handoff.verifyProducer(value, artifactId, api);
45
+ await handoff.verifyProducer(value, artifactId, api, dependencies.recoverCancelled === true);
46
46
  await handoff.phase(value, api);
47
47
  let proof;
48
48
  do {
@@ -52,6 +52,8 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
52
52
  await sleep(Math.min(10000, deadline - now()));
53
53
  } while (now() < deadline);
54
54
  if (!proof) handoff.fail('processing_pending');
55
+ // Explicit historical recovery rechecks its terminal producer before the only write.
56
+ if (dependencies.recoverCancelled === true) await handoff.verifyProducer(value, artifactId, api, true);
55
57
  const prior = await handoff.phase(value, api);
56
58
  if (prior === 'uploading') await api.status(value.owner.deployment_id, 'uploaded', 'success');
57
59
  if (await handoff.phase(value, api) !== 'uploaded') handoff.fail('write_unconfirmed');
@@ -26,11 +26,16 @@ inputs:
26
26
  mobileReleaseIntentScript:
27
27
  description: Committed mobile_release.cjs path, invoked after acquiring the package owner and before upload.
28
28
  default: ''
29
+ versionCode:
30
+ description: Exact compiled version code for sanitized upload diagnostics only.
31
+ default: ''
29
32
  outputs:
30
33
  review-pending:
31
34
  description: True only after a successful explicitly requested review-hold retry.
32
35
  receipt:
33
36
  description: Sanitized versioned result with attempt count and upstream identity.
37
+ diagnostic-path:
38
+ description: Runner-owned directory with closed per-attempt and terminal diagnostic JSON.
34
39
  runs:
35
40
  using: node24
36
41
  main: run.cjs
@@ -1,4 +1,5 @@
1
1
  // Observe only fixed progress/error patterns; never retain provider text in a receipt.
2
+ const provider = require('./provider_evidence.cjs');
2
3
  const phases = new Map([
3
4
  ['Creating a new Edit for this release', 'edit_create'],
4
5
  ['Committing the Edit', 'edit_commit'],
@@ -22,10 +23,11 @@ const limit = 16384;
22
23
 
23
24
  function create() {
24
25
  return {phase: 'startup', signals: new Set(), pending: {stdout: '', stderr: ''},
25
- discard: {stdout: false, stderr: false}};
26
+ discard: {stdout: false, stderr: false}, provider: provider.create()};
26
27
  }
27
28
 
28
29
  function line(state, stream, value) {
30
+ provider.observe(state.provider, value);
29
31
  if (stream === 'stdout') {
30
32
  if (phases.has(value)) state.phase = phases.get(value);
31
33
  for (const [prefix, phase] of prefixes) if (value.startsWith(prefix)) state.phase = phase;
@@ -70,4 +72,5 @@ function annotation(attempt, result) {
70
72
  + ` signals=${signals.join(',') || 'none'}`;
71
73
  }
72
74
 
73
- module.exports = {create, consume, snapshot, annotation};
75
+ module.exports = {create, consume, snapshot, annotation,
76
+ providerSnapshot: state => provider.snapshot(state.provider)};
@@ -0,0 +1,30 @@
1
+ // This separate, sanitized directory survives cleanup of the private uploader output.
2
+ const {mkdtempSync, writeFileSync} = require('node:fs');
3
+ const {isAbsolute, join} = require('node:path');
4
+ const records = require('./evidence_schema.cjs');
5
+
6
+ function begin(env) {
7
+ const binding = records.context(env);
8
+ let directory = '', failed = false, recorded = 0;
9
+ try {
10
+ if (!isAbsolute(env.RUNNER_TEMP || '') || /[\x00-\x1f\x7f]/.test(env.RUNNER_TEMP)) throw new Error();
11
+ directory = mkdtempSync(join(env.RUNNER_TEMP, 'gowalk-play-evidence-'));
12
+ } catch { failed = true; }
13
+ const save = (name, value) => {
14
+ try {
15
+ if (!directory || !records.valid(value)) throw new Error();
16
+ writeFileSync(join(directory, name), JSON.stringify(value) + '\n', {mode: 0o600, flag: 'wx'});
17
+ } catch { failed = true; }
18
+ };
19
+ return {
20
+ attempt(number, result) {
21
+ save(`attempt-${number}.json`, records.attempt(binding, number, result));
22
+ recorded += 1;
23
+ },
24
+ terminal(result) { save('terminal.json', records.terminal(binding, result, recorded)); },
25
+ get path() { return failed ? '' : directory; },
26
+ get failed() { return failed; },
27
+ };
28
+ }
29
+
30
+ module.exports = {begin};
@@ -0,0 +1,71 @@
1
+ const provider = require('./provider_evidence.cjs');
2
+ const source = require('./source.cjs');
3
+ const schema = 'gowalk-cicd/play-upload-evidence.v1';
4
+ const phases = new Set(['startup', 'edit_create', 'tracks_read', 'bundle_upload', 'track_update', 'edit_commit']);
5
+ const signals = new Set(['connection_reset', 'connection_refused', 'connection_timeout', 'dns_failure',
6
+ 'tls_verification', 'output_truncated']);
7
+ const processSignals = new Set(['none', 'SIGTERM', 'SIGINT', 'SIGKILL', 'other']);
8
+ const codes = new Set(['uploaded', 'review_pending', 'edit_expired', 'edit_deleted', 'review_hold_required',
9
+ 'unclassified_failure', 'upload_cancelled', 'upload_outcome_uncertain', 'uploader_failed',
10
+ 'runner_context_required', 'uploader_integrity_refused', 'uploader_source_unavailable',
11
+ 'mobile_release_intent_unconfirmed', ...require('./owner.cjs').codes]);
12
+ const keys = (value, names) => value && typeof value === 'object' && !Array.isArray(value)
13
+ && Object.keys(value).sort().join(',') === names.split(',').sort().join(',');
14
+ const integer = (value, minimum, maximum = Number.MAX_SAFE_INTEGER) =>
15
+ Number.isSafeInteger(value) && value >= minimum && value <= maximum;
16
+ const nullable = (value, check) => value === null || check(value);
17
+
18
+ function context(env) {
19
+ const positive = value => /^[1-9][0-9]*$/.test(value || '') && integer(Number(value), 1) ? Number(value) : null;
20
+ return {run_id: positive(env.GITHUB_RUN_ID), run_attempt: positive(env.GITHUB_RUN_ATTEMPT),
21
+ source_sha: /^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '') ? env.GITHUB_SHA : null,
22
+ version_code: positive(env.INPUT_VERSIONCODE || env.ANDROID_BUILD_NUMBER)};
23
+ }
24
+
25
+ function base(binding, kind) {
26
+ return {schema, kind, ...binding, upstream_revision: source.revision, upstream_sha256: source.digest};
27
+ }
28
+
29
+ function attempt(binding, number, result) {
30
+ const diagnostic = result.diagnostic || {};
31
+ const observed = Array.isArray(diagnostic.signals) ? diagnostic.signals.filter(value => signals.has(value)) : [];
32
+ return {...base(binding, 'attempt'), attempt: number,
33
+ phase: phases.has(diagnostic.phase) ? diagnostic.phase : 'startup',
34
+ exit_code: integer(result.status, 0, 255) ? result.status : null,
35
+ signal: processSignals.has(result.signal) ? result.signal : result.signal ? 'other' : 'none',
36
+ error_count: integer(result.errors, 0) ? result.errors : null,
37
+ cancelled: result.cancelled === true, commit_reported: result.committed === true,
38
+ transport_signals: [...new Set(observed)].sort(),
39
+ provider: provider.valid(result.provider) ? result.provider : provider.snapshot(provider.create())};
40
+ }
41
+
42
+ function terminal(binding, result, recorded) {
43
+ return {...base(binding, 'terminal'), recorded_attempts: recorded, result: {
44
+ ok: result.ok === true, code: codes.has(result.code) ? result.code : 'uploader_failed',
45
+ attempts: integer(result.attempts, 0, 2) ? result.attempts : 0,
46
+ review_pending: result.review_pending === true}};
47
+ }
48
+
49
+ function valid(value) {
50
+ const fields = 'schema,kind,run_id,run_attempt,source_sha,version_code,upstream_revision,upstream_sha256';
51
+ if (!value || value.schema !== schema || value.upstream_revision !== source.revision
52
+ || value.upstream_sha256 !== source.digest || !nullable(value.run_id, item => integer(item, 1))
53
+ || !nullable(value.run_attempt, item => integer(item, 1))
54
+ || !nullable(value.version_code, item => integer(item, 1))
55
+ || !nullable(value.source_sha, item => typeof item === 'string' && /^[0-9a-f]{40}$/.test(item))) return false;
56
+ if (value.kind === 'terminal') return keys(value, fields + ',recorded_attempts,result')
57
+ && integer(value.recorded_attempts, 0, 2) && keys(value.result, 'ok,code,attempts,review_pending')
58
+ && typeof value.result.ok === 'boolean' && codes.has(value.result.code)
59
+ && integer(value.result.attempts, 0, 2) && typeof value.result.review_pending === 'boolean';
60
+ return value.kind === 'attempt' && keys(value, fields
61
+ + ',attempt,phase,exit_code,signal,error_count,cancelled,commit_reported,transport_signals,provider')
62
+ && integer(value.attempt, 1, 2) && phases.has(value.phase) && processSignals.has(value.signal)
63
+ && nullable(value.exit_code, item => integer(item, 0, 255))
64
+ && nullable(value.error_count, item => integer(item, 0)) && typeof value.cancelled === 'boolean'
65
+ && typeof value.commit_reported === 'boolean' && Array.isArray(value.transport_signals)
66
+ && value.transport_signals.length <= signals.size
67
+ && new Set(value.transport_signals).size === value.transport_signals.length
68
+ && [...value.transport_signals].every(item => signals.has(item)) && provider.valid(value.provider);
69
+ }
70
+
71
+ module.exports = {schema, context, attempt, terminal, valid};
@@ -53,7 +53,8 @@ function execute(program, env, directory, timeout) {
53
53
  diagnostics.consume(diagnostic, 'stdout', '\n'); diagnostics.consume(diagnostic, 'stderr', '\n');
54
54
  const committed = /(?:^|\n)committedEditId(?:=|<<)/.test(readFileSync(output, 'utf8'));
55
55
  resolve({status, signal, cancelled, committed, errors: state.errors,
56
- code: state.ambiguous ? 'unclassified_failure' : state.code, diagnostic: diagnostics.snapshot(diagnostic)});
56
+ code: state.ambiguous ? 'unclassified_failure' : state.code, diagnostic: diagnostics.snapshot(diagnostic),
57
+ provider: diagnostics.providerSnapshot(diagnostic)});
57
58
  });
58
59
  });
59
60
  }
@@ -0,0 +1,51 @@
1
+ // Closed observations only. Missing provider fields are never inferred from message text.
2
+ const http = new Set([400, 401, 402, 403, 404, 405, 408, 409, 410, 412, 413, 414, 415,
3
+ 416, 417, 422, 428, 429, 500, 501, 502, 503, 504]);
4
+ const rpc = new Set(['CANCELLED', 'UNKNOWN', 'INVALID_ARGUMENT', 'DEADLINE_EXCEEDED', 'NOT_FOUND',
5
+ 'ALREADY_EXISTS', 'PERMISSION_DENIED', 'RESOURCE_EXHAUSTED', 'FAILED_PRECONDITION', 'ABORTED',
6
+ 'OUT_OF_RANGE', 'UNIMPLEMENTED', 'INTERNAL', 'UNAVAILABLE', 'DATA_LOSS', 'UNAUTHENTICATED']);
7
+ const messages = new Map([
8
+ ['This edit has expired, please create a new Edit.', 'edit_expired'],
9
+ ['This Edit has been deleted.', 'edit_deleted'],
10
+ ['Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. '
11
+ + 'Once committed, the changes in this edit can be sent for review from the Google Play Console UI.',
12
+ 'review_hold_required'],
13
+ ['Unknown error occurred.', 'uploader_unknown_error'],
14
+ ['New edit has no ID, cannot continue.', 'edit_id_missing'],
15
+ ]);
16
+ const classes = new Set(messages.values());
17
+
18
+ function create() {
19
+ return {http: new Set(), rpc: new Set(), classes: new Set()};
20
+ }
21
+
22
+ function observe(state, value) {
23
+ const match = /^::error(?: [^\r\n]+)?::(.*)$/.exec(value) || /^Error: (.*)$/.exec(value);
24
+ if (!match) return;
25
+ const text = match[1].replace(/%0D/g, '\r').replace(/%0A/g, '\n').replace(/%25/g, '%');
26
+ if (messages.has(text)) state.classes.add(messages.get(text));
27
+ const status = /^Request failed with status code ([45]\d\d)(?:\.?$|\.\s)/.exec(text);
28
+ if (status && http.has(Number(status[1]))) state.http.add(Number(status[1]));
29
+ let body;
30
+ try { body = JSON.parse(text); } catch { return; }
31
+ const error = body?.error;
32
+ if (!error || typeof error !== 'object' || Array.isArray(error) || !http.has(error.code)) return;
33
+ state.http.add(error.code);
34
+ if (rpc.has(error.status)) state.rpc.add(error.status);
35
+ if (messages.has(error.message)) state.classes.add(messages.get(error.message));
36
+ }
37
+
38
+ function snapshot(state) {
39
+ return {http_statuses: [...state.http].sort((a, b) => a - b), rpc_codes: [...state.rpc].sort(),
40
+ message_classes: [...state.classes].sort()};
41
+ }
42
+
43
+ function valid(value) {
44
+ if (!value || typeof value !== 'object' || Array.isArray(value)
45
+ || Object.keys(value).sort().join(',') !== 'http_statuses,message_classes,rpc_codes') return false;
46
+ return [['http_statuses', http], ['rpc_codes', rpc], ['message_classes', classes]].every(([key, choices]) =>
47
+ Array.isArray(value[key]) && value[key].length <= choices.size
48
+ && new Set(value[key]).size === value[key].length && [...value[key]].every(item => choices.has(item)));
49
+ }
50
+
51
+ module.exports = {create, observe, snapshot, valid};
@@ -6,6 +6,7 @@ const processRunner = require('./process.cjs');
6
6
  const owner = require('./owner.cjs');
7
7
  const diagnostics = require('./diagnostics.cjs');
8
8
  const releaseIntent = require('./release_intent.cjs');
9
+ const evidence = require('./evidence.cjs');
9
10
 
10
11
  async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000,
11
12
  cancelled = () => false, report = () => {}) {
@@ -33,9 +34,10 @@ async function upload(program, env, directory, execute = processRunner.execute,
33
34
  }
34
35
 
35
36
  async function main() {
36
- let directory, receipt;
37
+ let directory, receipt, records;
37
38
  try {
38
39
  const env = {...process.env};
40
+ records = evidence.begin(env);
39
41
  if (!env.PLAY_EDIT_BINDING) throw new Error('play_owner_binding_required');
40
42
  env.PLAY_EDIT_PRINCIPAL_SHA256 = require('./owner_principal.cjs').read(env.INPUT_SERVICEACCOUNTJSON);
41
43
  if (!env.RUNNER_TEMP || !env.GITHUB_OUTPUT) throw new Error('runner_context_required');
@@ -45,7 +47,9 @@ async function main() {
45
47
  if (cancelled()) return {ok: false, code: 'upload_cancelled', attempts: 0, review_pending: false};
46
48
  await releaseIntent.begin(env);
47
49
  return upload(program, env, directory, processRunner.execute, 3600000, cancelled,
48
- (attempt, result) => console.log(diagnostics.annotation(attempt, result)));
50
+ (attempt, result) => {
51
+ records.attempt(attempt, result); console.log(diagnostics.annotation(attempt, result));
52
+ });
49
53
  });
50
54
  } catch (error) {
51
55
  if (owner.codes.includes(error.message)) console.log(owner.annotation(error.message, undefined,
@@ -61,13 +65,19 @@ async function main() {
61
65
  } finally { if (directory) rmSync(directory, {recursive: true, force: true}); }
62
66
  receipt = {schema: 'gowalk-cicd/play-upload.v1', ...receipt, upstream_revision: source.revision,
63
67
  upstream_sha256: source.digest};
68
+ records?.terminal(receipt);
64
69
  const encoded = JSON.stringify(receipt);
65
70
  if (process.env.GITHUB_OUTPUT) {
66
71
  appendFileSync(process.env.GITHUB_OUTPUT, `review-pending=${receipt.review_pending}\nreceipt=${encoded}\n`);
72
+ appendFileSync(process.env.GITHUB_OUTPUT, `diagnostic-path=${records?.path || ''}\n`);
67
73
  }
68
74
  // The panel parses a success receipt even when a multi-line secret masks its outer braces.
69
75
  console.log(`::${receipt.ok ? 'notice' : 'error'} title=play_upload_result::${encoded}`);
70
76
  if (!receipt.ok) process.exitCode = 1;
77
+ if (records?.failed) {
78
+ console.log('::error title=play_upload_evidence_failed::Sanitized upload evidence could not be retained.');
79
+ process.exitCode = 1;
80
+ }
71
81
  }
72
82
 
73
83
  module.exports = {upload, main};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.181",
3
+ "version": "1.0.183",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
package/remedies.json CHANGED
@@ -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",
@@ -658,6 +658,17 @@ jobs:
658
658
  userFraction: >-
659
659
  ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
660
660
  whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
661
+ versionCode: ${{ steps.android.outputs.build-number || steps.reuse.outputs.build-number }}
662
+
663
+ - name: Retain sanitized Play upload diagnostics
664
+ if: ${{ always() && steps.play-upload.outputs.diagnostic-path != '' }}
665
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
666
+ env:
667
+ NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
668
+ with:
669
+ name: play-upload-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
670
+ path: ${{ steps.play-upload.outputs.diagnostic-path }}/*.json
671
+ if-no-files-found: error
661
672
 
662
673
  # The retry landed the bundle without submitting it. That is a fact for
663
674
  - name: Record confirmed Android upload
Binary file