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 +12 -0
- package/action/scripts/apple_upload_handoff.cjs +4 -11
- package/action/scripts/apple_upload_producer.cjs +47 -0
- package/action/scripts/apple_upload_verify.cjs +3 -2
- package/action/scripts/mobile_release_api.cjs +3 -2
- package/backend-action/action.yml +5 -2
- package/backend-action/network_diagnostics.py +17 -4
- package/docs/BACKEND_SSH_GATE.md +8 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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],
|
|
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=(',', ':')))
|
package/docs/BACKEND_SSH_GATE.md
CHANGED
|
@@ -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.
|