gowalk-cicd 1.0.211 → 1.0.213

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
@@ -1335,6 +1335,16 @@ and the old handoff's metadata and release notes are never replayed. Missing, ex
1335
1335
  mismatched evidence remains refused; do not relabel a receipt or impersonate the original
1336
1336
  `GITHUB_*` environment. Normal delivery still refuses cancelled producers.
1337
1337
 
1338
+ From 1.0.212, incomplete GitHub job-step metadata is reconciled by reading the exact producer job
1339
+ within a 60-second budget per producer observation, also bounded by the verification deadline.
1340
+ From 1.0.213, historical recovery's final producer recheck gets a fresh budget after Apple processing.
1341
+ Complete observations add no wait. Producer identity, successful ordered transfer/handoff steps and artifact checks remain
1342
+ required; contradictory identity, duplicate steps or explicit transfer failure refuse immediately.
1343
+ `apple_upload_verify_producer_job_metadata_unavailable` means the required observations remained
1344
+ unavailable, not that the upload failed. Preserve its handoff and build; after GitHub supplies the
1345
+ complete steps, rerun only the failed **Verify Apple processing** job or use the managed recovery
1346
+ workflow. Never upload another binary to resolve this observation failure.
1347
+
1338
1348
  The managed workflow retains `apple-recovered-<recovery-run>-<recovery-attempt>` with the
1339
1349
  unchanged `apple-verification.json`, its `.verified.json` proof and derived `.upload-receipt.json`.
1340
1350
  The proof identifies the original producer and the current recovery caller separately.
@@ -5,6 +5,7 @@ const {createHash} = require('node:crypto');
5
5
  const {join} = require('node:path');
6
6
  const github = require('./mobile_release_api.cjs');
7
7
  const {historicalProducer, uniqueArtifact} = require('./apple_upload_handoff_history.cjs');
8
+ const {verifySteps} = require('./apple_upload_producer.cjs');
8
9
  const SCHEMA = 'gowalk-cicd/apple-upload-handoff.v1';
9
10
  const positive = value => Number.isSafeInteger(value) && value > 0;
10
11
  const id = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(value);
@@ -48,16 +49,7 @@ function read(path, digest, attempt, env = process.env) {
48
49
  return value;
49
50
  }
50
51
 
51
- function transferred(job) {
52
- if (!Array.isArray(job.steps)) return false;
53
- const selected = ['Build and transfer iOS binary', 'Retain Apple verification handoff'].map(name =>
54
- job.steps.filter(step => step.name === name));
55
- if (selected.some(steps => steps.length !== 1 || steps[0].status !== 'completed' ||
56
- steps[0].conclusion !== 'success' || !positive(steps[0].number))) return false;
57
- return selected[0][0].number < selected[1][0].number;
58
- }
59
-
60
- async function verifyProducer(value, artifactId, api = github, recoverCancelled = false) {
52
+ async function verifyProducer(value, artifactId, api = github, recoverCancelled = false, options = {}) {
61
53
  if (!positive(artifactId)) fail('artifact_identity');
62
54
  const o = value.owner;
63
55
  const run = await api.request('GET', `/actions/runs/${o.run_id}/attempts/${o.run_attempt}`);
@@ -73,7 +65,8 @@ async function verifyProducer(value, artifactId, api = github, recoverCancelled
73
65
  // The producer's original failure remains red; only Apple delivery is reconciled here.
74
66
  const conclusions = recoverCancelled && run.conclusion === 'cancelled' ? ['cancelled'] : ['success', 'failure'];
75
67
  if (jobs.length !== 1 || jobs[0].status !== 'completed' || !conclusions.includes(jobs[0].conclusion) ||
76
- jobs[0].head_sha !== o.source_sha || !transferred(jobs[0])) fail('producer_job');
68
+ jobs[0].head_sha !== o.source_sha) fail('producer_job');
69
+ await verifySteps(jobs[0], o, api, fail, options);
77
70
  const artifact = await api.request('GET', `/actions/artifacts/${artifactId}`);
78
71
  if (artifact.id !== artifactId || artifact.expired !== false || artifact.workflow_run?.id !== o.run_id ||
79
72
  artifact.workflow_run?.head_sha !== o.source_sha ||
@@ -0,0 +1,47 @@
1
+ /* Reconcile incomplete GitHub step observations without relaxing producer identity or transfer proof. */
2
+ 'use strict';
3
+ const {setTimeout: delay} = require('node:timers/promises');
4
+ const positive = value => Number.isSafeInteger(value) && value > 0;
5
+ const names = ['Build and transfer iOS binary', 'Retain Apple verification handoff'];
6
+
7
+ function stepsReady(job, fail) {
8
+ if (!Array.isArray(job.steps)) fail('producer_job');
9
+ const selected = names.map(name => job.steps.filter(step => step.name === name));
10
+ for (const steps of selected) {
11
+ if (steps.length > 1) fail('producer_job');
12
+ if (!steps.length) continue;
13
+ const step = steps[0];
14
+ if (!positive(step.number) || step.conclusion && step.conclusion !== 'success') fail('producer_job');
15
+ }
16
+ if (selected.some(steps => !steps.length || steps[0].status !== 'completed' ||
17
+ steps[0].conclusion !== 'success')) return false;
18
+ if (selected[0][0].number >= selected[1][0].number) fail('producer_job');
19
+ return true;
20
+ }
21
+
22
+ function identity(job, original, owner, fail) {
23
+ if (!positive(job.id) || job.id !== original.id || job.name !== original.name ||
24
+ job.run_id !== owner.run_id || job.run_attempt !== owner.run_attempt ||
25
+ job.head_sha !== owner.source_sha || job.status !== original.status ||
26
+ job.conclusion !== original.conclusion) fail('producer_job');
27
+ }
28
+
29
+ async function verifySteps(original, owner, api, fail, options = {}) {
30
+ const now = options.now || Date.now, sleep = options.sleep || delay;
31
+ const deadline = Math.min(options.deadline ?? Infinity, now() + 60000);
32
+ identity(original, original, owner, fail);
33
+ if (now() >= deadline) fail('producer_job_metadata_unavailable');
34
+ if (stepsReady(original, fail)) return;
35
+ while (now() < deadline) {
36
+ const job = await api.request('GET', `/actions/jobs/${original.id}`, undefined,
37
+ Math.max(1, Math.min(10000, deadline - now())));
38
+ identity(job, original, owner, fail);
39
+ if (now() >= deadline) fail('producer_job_metadata_unavailable');
40
+ if (stepsReady(job, fail)) return;
41
+ if (now() < deadline) await sleep(Math.min(5000, deadline - now()));
42
+ }
43
+ // Missing metadata is not failed transfer proof and must never authorize another upload.
44
+ fail('producer_job_metadata_unavailable');
45
+ }
46
+
47
+ module.exports = {verifySteps};
@@ -25,7 +25,9 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
25
25
  const sleep = dependencies.sleep || delay, started = now(), deadline = started + timeout * 1000;
26
26
  const key = dependencies.get ? null : credential();
27
27
  const get = dependencies.get || (path => apple.appleGet(path, key, Math.max(1, Math.min(30000, deadline - now()))));
28
- await handoff.verifyProducer(value, artifactId, api, dependencies.recoverCancelled === true);
28
+ // Each producer observation has its own bounded metadata readback within this verification deadline.
29
+ const observation = {now, sleep, deadline};
30
+ await handoff.verifyProducer(value, artifactId, api, dependencies.recoverCancelled === true, observation);
29
31
  await handoff.phase(value, api);
30
32
  let proof;
31
33
  do {
@@ -36,7 +38,7 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
36
38
  } while (now() < deadline);
37
39
  if (!proof) handoff.fail('processing_pending');
38
40
  // Explicit historical recovery rechecks its terminal producer before the only write.
39
- if (dependencies.recoverCancelled === true) await handoff.verifyProducer(value, artifactId, api, true);
41
+ if (dependencies.recoverCancelled === true) await handoff.verifyProducer(value, artifactId, api, true, observation);
40
42
  const prior = await handoff.phase(value, api);
41
43
  if (prior === 'uploading') await api.status(value.owner.deployment_id, 'uploaded', 'success');
42
44
  if (await handoff.phase(value, api) !== 'uploaded') handoff.fail('write_unconfirmed');
@@ -10,6 +10,7 @@ function request(method, path, body, timeoutMs = 30000) {
10
10
  throw Error('mobile_release_repository_invalid');
11
11
  }
12
12
  const data = body === undefined ? null : JSON.stringify(body);
13
+ let timer;
13
14
  return new Promise((resolve, reject) => {
14
15
  const req = https.request({hostname: 'api.github.com', path: `/repos/${repo}${path}`, method,
15
16
  headers: {'User-Agent': 'gowalk-cicd', Accept: 'application/vnd.github+json',
@@ -25,10 +26,10 @@ function request(method, path, body, timeoutMs = 30000) {
25
26
  try { resolve(JSON.parse(text)); } catch { reject(Error('mobile_release_receipt_invalid')); }
26
27
  });
27
28
  });
28
- req.setTimeout(timeoutMs, () => req.destroy(Error('timeout')));
29
+ timer = setTimeout(() => req.destroy(Error('timeout')), timeoutMs);
29
30
  req.on('error', () => reject(Error('mobile_release_receipt_transport_unconfirmed')));
30
31
  req.end(data);
31
- });
32
+ }).finally(() => clearTimeout(timer));
32
33
  }
33
34
 
34
35
  function uncertain(error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.211",
3
+ "version": "1.0.213",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {