gowalk-cicd 1.0.146 → 1.0.147
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/CLAUDE.md
CHANGED
|
@@ -413,6 +413,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
413
413
|
recorded immediately in repository deployment receipts; retries reuse their prior-run evidence.
|
|
414
414
|
An uncertain upload never starts another binary automatically. Preserve its signed artifact,
|
|
415
415
|
complete exact provider readback/recovery through scoped tooling, then reconcile its receipt.
|
|
416
|
+
`action/scripts/recover_upload_intent.cjs owner/repo deployment-id plan|live` repairs only
|
|
417
|
+
an iOS intent whose exact terminal run has one failed HTTP 500 intent step and an explicitly
|
|
418
|
+
skipped uploader in the complete GitHub job log. It refuses later run attempts and changed
|
|
419
|
+
evidence, appends a read-back building receipt, and never uploads or calls Apple. Retry the
|
|
420
|
+
original failed job afterward; preserve the recovery proof separately.
|
|
416
421
|
The local Android workflow supplies `mobileReleaseIntentScript` to the upload wrapper instead
|
|
417
422
|
of marking `uploading` before acquiring Play ownership. A refused owner leaves the release
|
|
418
423
|
record at `building`; the wrapper records intent inside its acquired lease before provider
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.147
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* Repository-local GitHub deployment receipts; never a store credential or transport. */
|
|
2
2
|
'use strict';
|
|
3
3
|
const https = require('node:https');
|
|
4
|
+
const {randomUUID} = require('node:crypto');
|
|
5
|
+
const {setTimeout: delay} = require('node:timers/promises');
|
|
4
6
|
|
|
5
7
|
function request(method, path, body, timeoutMs = 30000) {
|
|
6
8
|
const repo = process.env.GITHUB_REPOSITORY;
|
|
@@ -29,10 +31,43 @@ function request(method, path, body, timeoutMs = 30000) {
|
|
|
29
31
|
});
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function uncertain(error) {
|
|
35
|
+
return /^mobile_release_(http_5\d\d|receipt_(read_unavailable|invalid|transport_unconfirmed|unconfirmed))$/
|
|
36
|
+
.test(error.message);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function confirmed(result, expected) {
|
|
40
|
+
return Number.isSafeInteger(result?.id) && result.id > 0 && result.state === expected.state &&
|
|
41
|
+
result.description === expected.description && result.log_url === expected.log_url;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readStatus(id, expected) {
|
|
45
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
46
|
+
if (attempt) await delay(attempt * 1000);
|
|
47
|
+
try {
|
|
48
|
+
const rows = await request('GET', `/deployments/${id}/statuses?per_page=1`, undefined, 10000);
|
|
49
|
+
if (Array.isArray(rows) && confirmed(rows[0], expected)) return rows[0];
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (!uncertain(error)) break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
async function status(id, phase, state = 'in_progress') {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
const run = `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
59
|
+
const expected = {state, description: phase, auto_inactive: false,
|
|
60
|
+
log_url: `${run}/attempts/${process.env.GITHUB_RUN_ATTEMPT}#mobile-receipt-${randomUUID()}`};
|
|
61
|
+
try {
|
|
62
|
+
const result = await request('POST', `/deployments/${id}/statuses`, expected);
|
|
63
|
+
if (!confirmed(result, expected)) throw Error('mobile_release_receipt_unconfirmed');
|
|
64
|
+
return result;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!uncertain(error)) throw error;
|
|
67
|
+
// Reconcile this invocation's one write. Never replay the POST or infer an upload from another receipt.
|
|
68
|
+
const result = await readStatus(id, expected);
|
|
69
|
+
if (result) return result;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
37
72
|
}
|
|
38
73
|
module.exports = {request, status};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/* Recover only a GitHub upload-intent write whose iOS uploader provably never ran. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const {execFileSync} = require('node:child_process');
|
|
4
|
+
const {createHash} = require('node:crypto');
|
|
5
|
+
const SCHEMA = 'gowalk-cicd/mobile-upload-owner.v1';
|
|
6
|
+
|
|
7
|
+
function gh(repo, path, body, raw = false) {
|
|
8
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo) || !path.startsWith('/') || path.includes('..')) {
|
|
9
|
+
throw Error('mobile_recovery_scope_invalid');
|
|
10
|
+
}
|
|
11
|
+
const args = ['api', `repos/${repo}${path}`];
|
|
12
|
+
if (raw) args.push('--allow-escape-sequences'); // Captured privately; raw logs never reach the terminal.
|
|
13
|
+
if (body) args.push('--method', 'POST', '--input', '-');
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = execFileSync('gh', args, {encoding: 'utf8', timeout: 45000, maxBuffer: 8 * 1024 * 1024,
|
|
17
|
+
input: body ? JSON.stringify(body) : undefined, stdio: ['pipe', 'pipe', 'pipe']});
|
|
18
|
+
} catch { throw Error('mobile_recovery_github_unavailable'); }
|
|
19
|
+
if (raw) return text;
|
|
20
|
+
try { return JSON.parse(text); } catch { throw Error('mobile_recovery_response_invalid'); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function verifyOwner(record, run) {
|
|
24
|
+
const p = record.payload;
|
|
25
|
+
if (p?.schema !== SCHEMA || p.platform !== 'ios' || !/^[a-f0-9]{64}$/.test(p.fingerprint) ||
|
|
26
|
+
!/^[a-f0-9]{40}$/.test(p.source_sha) || record.sha !== p.source_sha ||
|
|
27
|
+
record.environment !== `mobile-ios-${p.fingerprint}` || record.task !== 'mobile-upload' ||
|
|
28
|
+
!Number.isSafeInteger(p.run_id) || !Number.isSafeInteger(p.run_attempt) || p.run_attempt < 1 ||
|
|
29
|
+
run.id !== p.run_id || run.run_attempt !== p.run_attempt || run.head_sha !== p.source_sha ||
|
|
30
|
+
run.path !== '.github/workflows/deploy.yml' || run.head_branch !== 'main' ||
|
|
31
|
+
run.event !== 'push' || run.status !== 'completed' || run.conclusion !== 'failure') {
|
|
32
|
+
throw Error('mobile_recovery_owner_unverified');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function verifyJob(jobs, run) {
|
|
37
|
+
if (!Array.isArray(jobs.jobs) || jobs.total_count !== jobs.jobs.length || jobs.jobs.length >= 100) {
|
|
38
|
+
throw Error('mobile_recovery_jobs_incomplete');
|
|
39
|
+
}
|
|
40
|
+
const found = jobs.jobs.filter(j => j.name === 'iOS TestFlight');
|
|
41
|
+
if (found.length !== 1) throw Error('mobile_recovery_job_unverified');
|
|
42
|
+
const job = found[0];
|
|
43
|
+
const steps = job.steps?.filter(s => s.name === 'Run ./.github/actions/swift-app');
|
|
44
|
+
if (!Number.isSafeInteger(job.id) || job.run_id !== run.id || job.head_sha !== run.head_sha ||
|
|
45
|
+
job.status !== 'completed' || job.conclusion !== 'failure' || steps?.length !== 1 ||
|
|
46
|
+
steps[0].status !== 'completed' || steps[0].conclusion !== 'failure') {
|
|
47
|
+
throw Error('mobile_recovery_job_unverified');
|
|
48
|
+
}
|
|
49
|
+
return job;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function verifyLog(log) {
|
|
53
|
+
const starts = [...log.matchAll(/##\[start-action display=Record iOS upload intent;id=(ios\.\w+)\]/g)];
|
|
54
|
+
const uploads = [...log.matchAll(/##\[start-action display=Upload to TestFlight;id=ios\.upload\]/g)];
|
|
55
|
+
if (starts.length !== 1 || uploads.length !== 1 || starts[0].index >= uploads[0].index) {
|
|
56
|
+
throw Error('mobile_recovery_log_unverified');
|
|
57
|
+
}
|
|
58
|
+
const section = log.slice(starts[0].index, uploads[0].index);
|
|
59
|
+
const id = starts[0][1].replace('.', '\\.');
|
|
60
|
+
const failure = new RegExp(`##\\[end-action id=${id};outcome=failure;conclusion=failure;duration_ms=\\d+\\]`);
|
|
61
|
+
const errors = [...section.matchAll(/##\[error\]([^\r\n]+)/g)].map(m => m[1].trim());
|
|
62
|
+
const ends = [...log.matchAll(/##\[end-action id=ios\.upload;outcome=([^;]+);conclusion=([^;]+);/g)];
|
|
63
|
+
if (!failure.test(section) || errors.length !== 2 ||
|
|
64
|
+
errors[0] !== 'mobile_release_http_500' || errors[1] !== 'Process completed with exit code 1.' ||
|
|
65
|
+
ends.length !== 1 || ends[0].index <= uploads[0].index ||
|
|
66
|
+
ends[0][1] !== 'skipped' || ends[0][2] !== 'skipped') {
|
|
67
|
+
throw Error('mobile_recovery_upload_not_proven_skipped');
|
|
68
|
+
}
|
|
69
|
+
return createHash('sha256').update(log).digest('hex');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function inspect(repo, id, request = gh) {
|
|
73
|
+
const record = await request(repo, `/deployments/${id}`);
|
|
74
|
+
if (record.id !== id || !Number.isSafeInteger(record.payload?.run_id)) {
|
|
75
|
+
throw Error('mobile_recovery_owner_unverified');
|
|
76
|
+
}
|
|
77
|
+
const run = await request(repo, `/actions/runs/${record.payload.run_id}`);
|
|
78
|
+
verifyOwner(record, run);
|
|
79
|
+
const jobs = await request(repo, `/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`);
|
|
80
|
+
const job = verifyJob(jobs, run);
|
|
81
|
+
const log = await request(repo, `/actions/jobs/${job.id}/logs`, undefined, true);
|
|
82
|
+
const logSha = verifyLog(log);
|
|
83
|
+
const statuses = await request(repo, `/deployments/${id}/statuses?per_page=1`);
|
|
84
|
+
const last = statuses[0], logUrl = `https://github.com/${repo}/actions/runs/${run.id}`;
|
|
85
|
+
const marker = `${logUrl}/attempts/${run.run_attempt}#mobile-receipt-`;
|
|
86
|
+
const marked = typeof last?.log_url === 'string' && last.log_url.startsWith(marker) &&
|
|
87
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(last.log_url.slice(marker.length));
|
|
88
|
+
if (!Number.isSafeInteger(last?.id) || last.state !== 'in_progress' ||
|
|
89
|
+
last.description !== 'uploading' || (last.log_url !== logUrl && !marked)) {
|
|
90
|
+
throw Error('mobile_recovery_receipt_changed');
|
|
91
|
+
}
|
|
92
|
+
return {schema: 'gowalk-cicd/undispatched-ios-upload.v1', repo, deployment_id: id, status_id: last.id,
|
|
93
|
+
source_sha: record.sha, run_id: run.id, run_attempt: run.run_attempt, job_id: job.id,
|
|
94
|
+
job_log_sha256: logSha, uploader_skipped: true, log_url: last.log_url};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function recover(repo, id, live = false, request = gh) {
|
|
98
|
+
const proof = await inspect(repo, id, request);
|
|
99
|
+
if (!live) return {ok: true, planned: true, proof};
|
|
100
|
+
// Re-read the complete provider proof immediately before changing only this receipt.
|
|
101
|
+
const current = await inspect(repo, id, request);
|
|
102
|
+
if (JSON.stringify(current) !== JSON.stringify(proof)) throw Error('mobile_recovery_evidence_changed');
|
|
103
|
+
const body = {state: 'in_progress', description: 'building', auto_inactive: false, log_url: proof.log_url};
|
|
104
|
+
try { await request(repo, `/deployments/${id}/statuses`, body); }
|
|
105
|
+
catch { /* A lost POST response is resolved by the same exact readback below, never a second POST. */ }
|
|
106
|
+
const readback = (await request(repo, `/deployments/${id}/statuses?per_page=1`))[0];
|
|
107
|
+
if (!Number.isSafeInteger(readback?.id) || readback.id === proof.status_id ||
|
|
108
|
+
readback.state !== body.state || readback.description !== body.description ||
|
|
109
|
+
readback.log_url !== body.log_url) throw Error('mobile_recovery_write_unconfirmed');
|
|
110
|
+
return {ok: true, recovered: true, proof, readback_status_id: readback.id,
|
|
111
|
+
next: 'Retry the original failed iOS job; Apple upload remains unperformed.'};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (require.main === module) {
|
|
115
|
+
const [repo, value, mode] = process.argv.slice(2), id = Number(value);
|
|
116
|
+
if (!repo || !Number.isSafeInteger(id) || id < 1 || !['plan', 'live'].includes(mode)) {
|
|
117
|
+
process.stderr.write('Usage: node recover_upload_intent.cjs owner/repo deployment-id plan|live\n');
|
|
118
|
+
process.exitCode = 2;
|
|
119
|
+
} else recover(repo, id, mode === 'live').then(result => console.log(JSON.stringify(result))).catch(error => {
|
|
120
|
+
console.error(JSON.stringify({ok: false, code: /^mobile_recovery_\w+$/.test(error.message)
|
|
121
|
+
? error.message : 'mobile_recovery_unavailable'}));
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
module.exports = {verifyOwner, verifyJob, verifyLog, inspect, recover};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.147
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.147
|