gowalk-cicd 1.0.204 → 1.0.206
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/action/scripts/local_validation.cjs +11 -7
- package/action/scripts/local_validation_core.cjs +62 -5
- package/backend-action/network_inventory.py +2 -2
- package/docs/BACKEND_SSH_GATE.md +5 -0
- package/docs/CONFIGURATION.md +4 -0
- package/docs/LOCAL_VALIDATION.md +9 -0
- package/package.json +1 -1
- package/src/config/merge.mjs +10 -3
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { spawnSync } = require('node:child_process');
|
|
4
4
|
const { mkdirSync, rmSync, writeFileSync } = require('node:fs');
|
|
5
5
|
const { dirname, relative, resolve, isAbsolute } = require('node:path');
|
|
6
|
+
const { performance } = require('node:perf_hooks');
|
|
6
7
|
const { SCHEMA, CONTEXT, command, identity, readReceipt, sameSource, github, expectedAuthor,
|
|
7
8
|
verifyStatus, digest } = require('./local_validation_core.cjs');
|
|
8
9
|
|
|
@@ -31,17 +32,18 @@ function run(args) {
|
|
|
31
32
|
console.log(JSON.stringify({ ok: true, result: 'local_validation_passed', ...receipt }));
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function publish(args) {
|
|
35
|
+
async function publish(args) {
|
|
35
36
|
localOnly();
|
|
36
37
|
if (args[0] !== '--receipt' || args.length !== 2) throw new Error('receipt_required');
|
|
37
38
|
const receipt = readReceipt(args[1]);
|
|
38
39
|
sameSource(receipt, identity());
|
|
39
|
-
const
|
|
40
|
+
const budget = {deadline: performance.now() + 60000};
|
|
41
|
+
const commit = await github(`repos/${receipt.repository}/git/commits/${receipt.head_sha}`, {}, budget);
|
|
40
42
|
if (commit.tree.sha !== receipt.tree_sha) throw new Error('remote_tree_mismatch');
|
|
41
|
-
const status = github(`repos/${receipt.repository}/statuses/${receipt.head_sha}`, {
|
|
43
|
+
const status = await github(`repos/${receipt.repository}/statuses/${receipt.head_sha}`, {
|
|
42
44
|
state: 'success', context: CONTEXT, description: `v1:${receipt.tree_sha}:${receipt.command_sha256}`,
|
|
43
|
-
});
|
|
44
|
-
verifyStatus(status, receipt.tree_sha, github('user').login);
|
|
45
|
+
}, budget);
|
|
46
|
+
verifyStatus(status, receipt.tree_sha, (await github('user', {}, budget)).login);
|
|
45
47
|
console.log(JSON.stringify({ ok: true, result: 'local_validation_published', ...receipt }));
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -60,14 +62,16 @@ async function verify(args) {
|
|
|
60
62
|
}
|
|
61
63
|
const sha = args[1], repository = process.env.GITHUB_REPOSITORY;
|
|
62
64
|
const author = expectedAuthor(sha, repository);
|
|
63
|
-
const
|
|
65
|
+
const budget = {deadline: performance.now() + 60000};
|
|
66
|
+
const tree = (await github(`repos/${repository}/git/commits/${sha}`, {}, budget)).tree.sha;
|
|
64
67
|
if (command('git', ['rev-parse', 'HEAD^{tree}']) !== tree) throw new Error('checkout_tree_mismatch');
|
|
65
68
|
let status;
|
|
66
69
|
for (let attempt = 0; attempt <= 6; attempt += 1) {
|
|
67
70
|
// The combined /status summary omits creator; full records authenticate the issuer.
|
|
68
|
-
const statuses = github(`repos/${repository}/commits/${sha}/statuses?per_page=100
|
|
71
|
+
const statuses = await github(`repos/${repository}/commits/${sha}/statuses?per_page=100`, {}, budget);
|
|
69
72
|
status = statuses.find(item => item.context === CONTEXT);
|
|
70
73
|
if (status || attempt === 6) break;
|
|
74
|
+
if (performance.now() + 5000 >= budget.deadline) throw new Error('github_statuses_deadline');
|
|
71
75
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
72
76
|
}
|
|
73
77
|
verifyStatus(status, tree, author);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const { createHash } = require('node:crypto');
|
|
2
|
-
const { execFileSync } = require('node:child_process');
|
|
2
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
3
|
+
const { performance } = require('node:perf_hooks');
|
|
4
|
+
const { setTimeout: sleep } = require('node:timers/promises');
|
|
3
5
|
const { readFileSync } = require('node:fs');
|
|
4
6
|
|
|
5
7
|
const SCHEMA = 'gowalk-cicd/local-validation.v1';
|
|
@@ -49,10 +51,65 @@ function sameSource(receipt, source) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// Preserve only fixed classifications and numeric retry hints, never provider bodies or CLI stderr.
|
|
55
|
+
function githubResponse(result, wallNow) {
|
|
56
|
+
if (result.error?.code === 'ETIMEDOUT') return {code: 'timeout', retry: true};
|
|
57
|
+
if (result.error?.code === 'ENOENT') return {code: 'cli_unavailable'};
|
|
58
|
+
if (result.error?.code === 'ENOBUFS') return {code: 'response_too_large'};
|
|
59
|
+
if (result.signal) return {code: 'interrupted'};
|
|
60
|
+
const output = String(result.stdout || '').replace(/\r\n/g, '\n');
|
|
61
|
+
const split = output.indexOf('\n\n'), header = split < 0 ? '' : output.slice(0, split);
|
|
62
|
+
const match = /^HTTP\/\S+ ([1-5][0-9]{2})(?: |$)/m.exec(header);
|
|
63
|
+
if (!match) return result.status === 0 ? {code: 'response_invalid'} : {code: 'transport', retry: true};
|
|
64
|
+
const status = Number(match[1]);
|
|
65
|
+
const headers = Object.fromEntries(header.split('\n').slice(1).map(line => {
|
|
66
|
+
const at = line.indexOf(':'); return [line.slice(0, at).toLowerCase(), line.slice(at + 1).trim()];
|
|
67
|
+
}));
|
|
68
|
+
const quota = status === 429 || status === 403 &&
|
|
69
|
+
(headers['x-ratelimit-remaining'] === '0' || headers['retry-after'] !== undefined);
|
|
70
|
+
if (quota) {
|
|
71
|
+
const retry = headers['retry-after'], reset = headers['x-ratelimit-reset'];
|
|
72
|
+
let delayMs = /^\d+$/.test(retry || '') ? Number(retry) * 1000 : Date.parse(retry) - wallNow;
|
|
73
|
+
if (headers['x-ratelimit-remaining'] === '0' && /^\d+$/.test(reset || '')) {
|
|
74
|
+
delayMs = Math.max(Number.isFinite(delayMs) ? delayMs : 0, Number(reset) * 1000 - wallNow);
|
|
75
|
+
}
|
|
76
|
+
return {code: `quota_http_${status}`, retry: true, delayMs: Number.isFinite(delayMs)
|
|
77
|
+
? Math.max(1000, delayMs) : 60000};
|
|
78
|
+
}
|
|
79
|
+
if (status < 200 || status >= 300) return {code: `http_${status}`, retry: [500, 502, 503, 504].includes(status)};
|
|
80
|
+
if (result.status !== 0) return {code: 'transport', retry: true};
|
|
81
|
+
try {
|
|
82
|
+
const value = JSON.parse(output.slice(split + 2));
|
|
83
|
+
return value && typeof value === 'object' ? {value} : {code: 'response_invalid'};
|
|
84
|
+
} catch { return {code: 'response_invalid'}; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function github(path, fields = {}, options = {}) {
|
|
88
|
+
const now = options.now || (() => performance.now()), wait = options.sleep || sleep;
|
|
89
|
+
const execute = options.execute || spawnSync, wallNow = options.wallNow || Date.now;
|
|
90
|
+
const deadline = options.deadline ?? now() + 60000;
|
|
91
|
+
const read = Object.keys(fields).length === 0;
|
|
92
|
+
const operation = path === 'user' ? 'user' : path.includes('/git/commits/') ? 'commit'
|
|
93
|
+
: path.includes('/statuses') ? 'statuses' : 'request';
|
|
94
|
+
const args = ['api', path, '--include', '--method', read ? 'GET' : 'POST'];
|
|
54
95
|
for (const [key, value] of Object.entries(fields)) args.push('-f', `${key}=${value}`);
|
|
55
|
-
|
|
96
|
+
for (let attempt = 0; attempt < (read ? 3 : 1); attempt++) {
|
|
97
|
+
const remaining = deadline - now();
|
|
98
|
+
if (remaining <= 0) throw new Error(`github_${operation}_deadline`);
|
|
99
|
+
let result;
|
|
100
|
+
try {
|
|
101
|
+
result = execute('gh', args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
102
|
+
timeout: Math.max(1, Math.min(15000, Math.floor(remaining))), killSignal: 'SIGKILL', maxBuffer: 1024 * 1024});
|
|
103
|
+
} catch { result = {status: 1}; }
|
|
104
|
+
const answer = githubResponse(result, wallNow());
|
|
105
|
+
if (now() >= deadline) throw new Error(`github_${operation}_deadline`);
|
|
106
|
+
if (Object.hasOwn(answer, 'value')) return answer.value;
|
|
107
|
+
const pause = answer.delayMs ?? (attempt + 1) * 1000;
|
|
108
|
+
if (!read || !answer.retry || attempt === 2 || pause >= deadline - now()) {
|
|
109
|
+
throw new Error(`github_${operation}_${answer.code}`);
|
|
110
|
+
}
|
|
111
|
+
await wait(pause);
|
|
112
|
+
}
|
|
56
113
|
}
|
|
57
114
|
|
|
58
115
|
function expectedAuthor(sha, repository, event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))) {
|
|
@@ -80,5 +137,5 @@ function verifyStatus(status, tree, author) {
|
|
|
80
137
|
}
|
|
81
138
|
|
|
82
139
|
module.exports = { SCHEMA, CONTEXT, command, repositoryFromRemote, identity, readReceipt, sameSource,
|
|
83
|
-
github, expectedAuthor, verifyStatus,
|
|
140
|
+
github, githubResponse, expectedAuthor, verifyStatus,
|
|
84
141
|
digest: args => createHash('sha256').update(JSON.stringify(args)).digest('hex') };
|
|
@@ -31,7 +31,7 @@ def run(arguments, *, optional=False):
|
|
|
31
31
|
def decode(raw, *, lines=False):
|
|
32
32
|
try:
|
|
33
33
|
value = [json.loads(line) for line in raw.splitlines()] if lines else json.loads(raw)
|
|
34
|
-
if not isinstance(value, list)
|
|
34
|
+
if not isinstance(value, list):
|
|
35
35
|
raise ValueError()
|
|
36
36
|
return value
|
|
37
37
|
except (ValueError, TypeError, UnicodeError):
|
|
@@ -43,7 +43,7 @@ def identifiers(raw):
|
|
|
43
43
|
values = raw.decode('ascii').splitlines()
|
|
44
44
|
except UnicodeError:
|
|
45
45
|
raise NetworkFailure('network_invalid') from None
|
|
46
|
-
if len(
|
|
46
|
+
if len(set(values)) != len(values) or any(not re.fullmatch(CID, x) for x in values):
|
|
47
47
|
raise NetworkFailure('network_invalid')
|
|
48
48
|
return sorted(values)
|
|
49
49
|
|
package/docs/BACKEND_SSH_GATE.md
CHANGED
|
@@ -129,6 +129,11 @@ it never means a VPN is provisioned, traffic is reachable, a pool is globally un
|
|
|
129
129
|
The reader binds the existing API's `BUILD_SHA`, checks its exact project/service/directory labels, and
|
|
130
130
|
rechecks application identity, selected network inventory, host addresses/routes, Docker UDP bindings and
|
|
131
131
|
socket observations before returning. A changed or ambiguous observation refuses the result.
|
|
132
|
+
Host-wide inventories are bounded only by each command's 1 MiB output limit; a larger one refuses
|
|
133
|
+
`output_limit`. `ip -j address show` reaches it first, at roughly 2,000 host interfaces (one bridge per app
|
|
134
|
+
plus a veth per attached container). Host runtimes before 1.0.206 refused a shared host with more than 1024
|
|
135
|
+
interfaces, routes, networks or running containers as `network_invalid`. `reader_version` stays `1.0.194`
|
|
136
|
+
for client compatibility, so prove adoption with the install receipt and the forced command's release path.
|
|
132
137
|
|
|
133
138
|
- `bridge` reports only the selected app bridge's actual network ID/name, ownership key, interface and IPAM.
|
|
134
139
|
- `overlap` distinguishes selected-bridge foreign/host overlaps and proposed-pool selected/foreign/host
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -141,3 +141,7 @@ not checked; there `steps.mode` renders empty.
|
|
|
141
141
|
Routine delivery targets and exact native validation proof are documented in
|
|
142
142
|
[CI_PERFORMANCE.md](CI_PERFORMANCE.md). The optional top-level
|
|
143
143
|
`performance: {target-seconds: 300}` setting accepts 60–300 seconds and controls latency reporting.
|
|
144
|
+
|
|
145
|
+
Dispatch input descriptions are app-owned help text: custom wording or explicit removal survives upstream
|
|
146
|
+
copy edits. Input types, defaults, options and functional workflow conflicts keep the existing strict
|
|
147
|
+
three-way merge. A removal of an already-absent field does not recreate a retired input or job.
|
package/docs/LOCAL_VALIDATION.md
CHANGED
|
@@ -51,6 +51,15 @@ that head's tree, including on a PR merge ref. If main advanced and changed the
|
|
|
51
51
|
the task branch and validate the new candidate locally. Missing, failed, wrong-author or mismatched
|
|
52
52
|
proof refuses. A missing status gets six five-second retries for publication immediately after push.
|
|
53
53
|
|
|
54
|
+
GitHub GET proofs recover transport/timeouts and HTTP 500/502/503/504 with at most three attempts.
|
|
55
|
+
Each command is bounded to 15 seconds; all commit/status reads and missing-status waits share one
|
|
56
|
+
60-second deadline per verification. Publishing shares the same bound across its proof calls and
|
|
57
|
+
never retries the status POST. Quota responses honor numeric/date Retry-After and primary reset
|
|
58
|
+
headers only when the delay fits the remaining budget; unknown or longer waits refuse immediately.
|
|
59
|
+
Permission, missing-source and malformed-data failures never retry. Refusals identify only the
|
|
60
|
+
operation and fixed transport/HTTP cause, such as `github_statuses_http_403`,
|
|
61
|
+
`github_commit_timeout` or `github_statuses_quota_http_429`; provider bodies and CLI stderr stay private.
|
|
62
|
+
|
|
54
63
|
The CLI also accepts `gowalk-cicd validate verify --sha ...`. The installed script and its sibling
|
|
55
64
|
`local_validation_core.cjs` avoid fetching another package in app jobs. Legacy composite inputs
|
|
56
65
|
`run-tests`, `test-command` and `test-destination` are accepted but ignored; composites execute no tests.
|
package/package.json
CHANGED
package/src/config/merge.mjs
CHANGED
|
@@ -51,8 +51,13 @@ export function selectBaseline(file, source, local) {
|
|
|
51
51
|
return selected;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function appSetting(
|
|
55
|
-
const key = path.at(-1);
|
|
54
|
+
function appSetting(change, before, after) {
|
|
55
|
+
const path = change.path, key = path.at(-1);
|
|
56
|
+
// A dispatch input's help text does not change its behavior. Preserve app wording or removal
|
|
57
|
+
// across upstream copy edits, but never recreate an input removed by the new template.
|
|
58
|
+
if (path.length === 5 && path[0] === 'on' && path[1] === 'workflow_dispatch'
|
|
59
|
+
&& path[2] === 'inputs' && key === 'description' && typeof before === 'string'
|
|
60
|
+
&& typeof after === 'string' && (change.remove || typeof change.value === 'string')) return true;
|
|
56
61
|
if (path.includes('env') || path.includes('with')) {
|
|
57
62
|
return !['candidate-binary', 'upload', 'auto-update', 'manage-app-store-version'].includes(key);
|
|
58
63
|
}
|
|
@@ -134,9 +139,11 @@ export function mergeChanges(base, incoming, changes, file) {
|
|
|
134
139
|
for (const raw of changes) {
|
|
135
140
|
const change = modeChange(file, raw, ownMode);
|
|
136
141
|
const before = at(base, change.path), after = at(incoming, change.path);
|
|
142
|
+
// An already-absent leaf needs no write; assign would otherwise recreate its retired parents.
|
|
143
|
+
if (change.remove && after === undefined) continue;
|
|
137
144
|
if (change.path.at(-1) === 'order' && Array.isArray(before) && Array.isArray(after)) {
|
|
138
145
|
assign(result, { path: change.path, value: mergeOrder(before, change.value, after, file, change.path) });
|
|
139
|
-
} else if (equal(before, after) || equal(change.value, after) || appSetting(change
|
|
146
|
+
} else if (equal(before, after) || equal(change.value, after) || appSetting(change, before, after)) {
|
|
140
147
|
assign(result, iosChange(file, before, after, change));
|
|
141
148
|
} else {
|
|
142
149
|
throw new MigrationRepair('custom_workflow_conflict', file, change.path);
|