gowalk-cicd 1.0.210 → 1.0.212

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,15 @@ 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 one 60-second budget, also bounded by the verification deadline. Complete observations add
1340
+ no wait. Producer identity, successful ordered transfer/handoff steps and artifact checks remain
1341
+ required; contradictory identity, duplicate steps or explicit transfer failure refuse immediately.
1342
+ `apple_upload_verify_producer_job_metadata_unavailable` means the required observations remained
1343
+ unavailable, not that the upload failed. Preserve its handoff and build; after GitHub supplies the
1344
+ complete steps, rerun only the failed **Verify Apple processing** job or use the managed recovery
1345
+ workflow. Never upload another binary to resolve this observation failure.
1346
+
1338
1347
  The managed workflow retains `apple-recovered-<recovery-run>-<recovery-attempt>` with the
1339
1348
  unchanged `apple-verification.json`, its `.verified.json` proof and derived `.upload-receipt.json`.
1340
1349
  The proof identifies the original producer and the current recovery caller separately.
@@ -1395,6 +1404,9 @@ Backend Deploy also offers `operation: network-diagnostics`, with an exact `diag
1395
1404
  It reports the owned bridge/IPAM, observed pool conflicts, UDP 500/4500 ownership, bound host identity and
1396
1405
  kernel/firewall facts in a closed v3 artifact for IPv4 and v4 for IPv6. The IPv6 result also distinguishes
1397
1406
  host and owned-gateway default-route and forwarding observations; actual external connectivity stays unverified.
1407
+ Its artifact also retains a separate `transport.json` with the SSH exit status and fixed failure signals,
1408
+ including an observed gate refusal, without private transport output. These observations do not establish the
1409
+ installed host reader or the reason its gate refused; the network result and refusal retain their existing meaning.
1398
1410
  The allocator stays retired; a completed read is never
1399
1411
  deployment, port reservation, globally unused address space or tunnel proof. Effective firewall ingress and
1400
1412
  unobserved capabilities remain unverified. The [trusted-host procedure](docs/BACKEND_SSH_GATE.md#owned-bridge-and-vpn-prerequisite-readback)
@@ -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,8 @@ 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
+ const observation = {now, sleep, deadline: Math.min(deadline, now() + 60000)};
29
+ await handoff.verifyProducer(value, artifactId, api, dependencies.recoverCancelled === true, observation);
29
30
  await handoff.phase(value, api);
30
31
  let proof;
31
32
  do {
@@ -36,7 +37,7 @@ async function verify(value, artifactId, timeout, dependencies = {}) {
36
37
  } while (now() < deadline);
37
38
  if (!proof) handoff.fail('processing_pending');
38
39
  // Explicit historical recovery rechecks its terminal producer before the only write.
39
- if (dependencies.recoverCancelled === true) await handoff.verifyProducer(value, artifactId, api, true);
40
+ if (dependencies.recoverCancelled === true) await handoff.verifyProducer(value, artifactId, api, true, observation);
40
41
  const prior = await handoff.phase(value, api);
41
42
  if (prior === 'uploading') await api.status(value.owner.deployment_id, 'uploaded', 'success');
42
43
  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) {
@@ -341,11 +341,12 @@ runs:
341
341
  run: |
342
342
  RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-network-diagnostics.XXXXXX")"
343
343
  echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
344
+ echo "transport=$RESULT_DIR/transport.json" >> "$GITHUB_OUTPUT"
344
345
  python3 "${{ github.action_path }}/network_diagnostics.py" \
345
346
  --app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --expected-build-sha "$DIAGNOSTIC_BUILD" \
346
347
  --network "$DIAGNOSTIC_NETWORK" --pool "$DIAGNOSTIC_POOL" --vpn-service "$DIAGNOSTIC_VPN_SERVICE" \
347
348
  --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" --provider "$DIAGNOSTIC_PROVIDER" \
348
- --output "$RESULT_DIR/result.json"
349
+ --output "$RESULT_DIR/result.json" --transport-output "$RESULT_DIR/transport.json"
349
350
 
350
351
  - name: Retain scoped backend network result
351
352
  if: always() && inputs.operation == 'network-diagnostics'
@@ -354,7 +355,9 @@ runs:
354
355
  NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
355
356
  with:
356
357
  name: backend-network-diagnostics-${{ github.job }}-${{ github.run_attempt }}
357
- path: ${{ steps.network_diagnostics.outputs.result }}
358
+ path: |
359
+ ${{ steps.network_diagnostics.outputs.result }}
360
+ ${{ steps.network_diagnostics.outputs.transport }}
358
361
  if-no-files-found: error
359
362
 
360
363
  - name: Cleanup key
@@ -3,13 +3,15 @@ import argparse
3
3
  import json
4
4
  import re
5
5
  import shlex
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  from identity_diagnostics import ReaderFailure, bounded, retain_result
9
10
  from network_projection import NetworkFailure, envelope, scope, validate
11
+ from runtime_diagnostics import transport_signals
10
12
 
11
13
 
12
- def local(args):
14
+ def local(args, transport=None):
13
15
  fields = (args.app, args.service, args.expected_build_sha, args.network, args.pool,
14
16
  args.vpn_service, args.host, args.provider)
15
17
  scope(*fields)
@@ -17,14 +19,17 @@ def local(args):
17
19
  raise NetworkFailure('invalid_scope')
18
20
  command = shlex.join(['gowalk-backend', 'network-diagnostics', *fields])
19
21
  try:
20
- status, raw = bounded(['ssh', '-T', '-i', str(Path.home() / '.ssh/backend_deploy'),
22
+ status, raw, errors = bounded(['ssh', '-T', '-i', str(Path.home() / '.ssh/backend_deploy'),
21
23
  '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
22
24
  '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
23
- '-o', 'ClearAllForwardings=yes', args.user + '@' + args.host, command], seconds=120, with_status=True)
25
+ '-o', 'ClearAllForwardings=yes', args.user + '@' + args.host, command],
26
+ seconds=120, with_status=True, with_stderr=True)
24
27
  except ReaderFailure as error:
25
28
  raise NetworkFailure(error.code) from None
26
29
  except OSError:
27
30
  raise NetworkFailure('transport_failure') from None
31
+ if transport is not None:
32
+ transport.update(exit_status=status, signals=transport_signals(status, errors))
28
33
  if status not in (0, 1):
29
34
  raise NetworkFailure('transport_failure')
30
35
  if status == 1 and not raw.strip():
@@ -45,16 +50,24 @@ def main():
45
50
  for name, default in [('service', 'api'), ('network', 'default'), ('vpn-service', 'vpn'),
46
51
  ('provider', 'none'), ('user', 'root')]:
47
52
  parser.add_argument('--' + name, default=default)
53
+ parser.add_argument('--transport-output', help='Separate closed SSH observation receipt')
48
54
  args = parser.parse_args()
49
55
  fields = (args.app, args.service, args.expected_build_sha, args.network, args.pool,
50
56
  args.vpn_service, args.host, args.provider)
57
+ transport = {'schema': 'gowalk-cicd/backend-network-transport.v1', 'exit_status': None, 'signals': []}
51
58
  try:
52
- result = local(args)
59
+ result = local(args, transport)
53
60
  except Exception as error:
54
61
  result = envelope(*fields, failure=error.code if isinstance(error, NetworkFailure) else 'unclassified',
55
62
  reader_version=None)
56
63
  try:
57
64
  retain_result(args.output, result)
65
+ if args.transport_output:
66
+ retain_result(args.transport_output, transport)
67
+ status = 'unknown' if transport['exit_status'] is None else str(transport['exit_status'])
68
+ print('::notice title=backend_network_transport::schema=' + transport['schema']
69
+ + ' exit_status=' + status + ' signals=' + (','.join(transport['signals']) or 'none'),
70
+ file=sys.stderr)
58
71
  except ReaderFailure:
59
72
  result = envelope(*fields, failure='artifact_write_failed', reader_version=None)
60
73
  print(json.dumps(result, separators=(',', ':')))
@@ -123,6 +123,14 @@ released `network_readback.py`. It accepts no uploaded Python or app-mirror help
123
123
  alone cannot install this host operation: install the integrity-verified published runtime first. A missing
124
124
  host reader returns `network_reader_unavailable` with no host reader version; it is not a network verdict.
125
125
 
126
+ The same artifact retains `transport.json`, schema `gowalk-cicd/backend-network-transport.v1`, and emits the
127
+ brace-free `backend_network_transport` notice. It uses the runtime diagnostic's fixed `exit_status` and
128
+ `signals` vocabulary described above; no private stderr is retained. In particular, SSH exit 1 with empty
129
+ stdout still yields `network_reader_unavailable`, while the separate `gate_refused` signal records an observed
130
+ gate refusal without guessing its policy, ownership or installed-reader cause. An incomplete SSH exit remains
131
+ null, and unknown text supplies no signal. Neither this sidecar nor a workflow update proves host adoption or
132
+ authorizes a retry, permission change or transport bypass; the existing result schema and refusal remain unchanged.
133
+
126
134
  The closed `gowalk-cicd/backend-network-diagnostics.v3` IPv4 result, or `v4` for IPv6, is retained as
127
135
  `backend-network-diagnostics-<job>-<attempt>/result.json`. `ok: true` means the bounded read completed;
128
136
  it never means a VPN is provisioned, traffic is reachable, a pool is globally unused or a tunnel works.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.210",
3
+ "version": "1.0.212",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {