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.
@@ -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 commit = github(`repos/${receipt.repository}/git/commits/${receipt.head_sha}`);
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 tree = github(`repos/${repository}/git/commits/${sha}`).tree.sha;
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
- function github(path, fields = {}) {
53
- const args = ['api', path];
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
- return JSON.parse(command('gh', args));
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) or len(value) > 1024:
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(values) > 1024 or len(set(values)) != len(values) or any(not re.fullmatch(CID, x) for x in values):
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
 
@@ -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
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.204",
3
+ "version": "1.0.206",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,8 +51,13 @@ export function selectBaseline(file, source, local) {
51
51
  return selected;
52
52
  }
53
53
 
54
- function appSetting(path) {
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.path)) {
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);