gowalk-cicd 1.0.203 → 1.0.205

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
@@ -682,6 +682,13 @@ The action exports `diagnostic-path`; the managed deploy template retains its JS
682
682
  template to retain artifacts. Failed evidence validation/storage emits a fixed refusal and fails
683
683
  the job without replaying provider work or changing its result.
684
684
 
685
+ Explicit panel-provisioned `panel-v2` bindings coordinate through the panel database without Git commits.
686
+ The binding supplies an HTTPS `coordinator_url` ending in `/pub/play-edit`; GitHub OIDC tokens use that exact
687
+ URL as audience. Both mobile workflows require `id-token: write`. Repository tokens never reach the panel.
688
+ Acquisition and release replay one immutable owner identity after a lost reply; release seals delayed
689
+ acquisitions. An unreachable panel refuses new provider work and never falls back to Git. Existing v1
690
+ bindings retain the old protocol until the panel retires their Git owner record and activates v2.
691
+
685
692
  Package-owner acquisition rechecks an occupied record within one 60-second wait budget before
686
693
  provider work. Every read retains the binding and identity checks; cancellation interrupts the wait.
687
694
  Time passing never authorizes clearing an owner. A known CI owner still requires exact terminal
@@ -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') };
@@ -89,6 +89,9 @@ async function acquire(client, selected, attempted = () => {}, waiting = {}) {
89
89
 
90
90
  async function withOwner(env, packageName, operation, transport, timing = {}) {
91
91
  const selected = record.binding(env, packageName);
92
+ if (selected.value.protocol === 'panel-v2') {
93
+ return require('./owner_panel.cjs').withOwner(env, selected, operation, transport, timing);
94
+ }
92
95
  const client = http.client(selected.value.repo, env.GITHUB_TOKEN, transport);
93
96
  const controller = new AbortController();
94
97
  let cancelled = false, dirty = false, result, failure;
@@ -0,0 +1,63 @@
1
+ // A durable panel owner replaces Git writes only for explicitly migrated bindings.
2
+ const {performance} = require('node:perf_hooks');
3
+ const {setTimeout: delay} = require('node:timers/promises');
4
+ const {isDeepStrictEqual: same} = require('node:util');
5
+ const http = require('./owner_panel_http.cjs');
6
+
7
+ async function acquire(send, selected, attempted, waiting) {
8
+ const now = waiting.now || (() => performance.now());
9
+ const sleep = waiting.sleep || ((ms, signal) => delay(ms, undefined, {signal}));
10
+ const deadline = now() + 60000;
11
+ for (let errors = 0; errors < 3;) {
12
+ if (waiting.signal.aborted) throw new Error('play_owner_cancelled');
13
+ attempted();
14
+ let result;
15
+ try { result = await send('acquire'); }
16
+ catch { errors++; continue; } // Replay the same owner id, never a second acquisition.
17
+ if (result.status === 200 && result.body?.ok === true
18
+ && result.body.generation === selected.value.generation && same(result.body.owner, selected.owner)) return;
19
+ if (result.status !== 409 || result.body?.code !== 'play_edit_busy') throw new Error('play_owner_unverified');
20
+ const remaining = deadline - now();
21
+ if (remaining <= 0) throw new Error('play_owner_busy');
22
+ await sleep(Math.min(5000, remaining), waiting.signal);
23
+ }
24
+ throw new Error('play_owner_unverified');
25
+ }
26
+
27
+ async function release(send, selected) {
28
+ for (let attempt = 0; attempt < 3; attempt++) {
29
+ let result;
30
+ try { result = await send('release'); } catch { continue; }
31
+ if (result.status === 200 && result.body?.ok === true && result.body.released === true
32
+ && result.body.owner_id === selected.owner.id && result.body.generation === selected.value.generation) return;
33
+ }
34
+ throw new Error('play_owner_release_unverified');
35
+ }
36
+
37
+ async function withOwner(env, selected, operation, transport, timing = {}) {
38
+ const send = http.client(env, selected, transport);
39
+ const controller = new AbortController();
40
+ let cancelled = false, dirty = false, result, failure;
41
+ const stop = () => { cancelled = true; controller.abort(); };
42
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
43
+ try {
44
+ await acquire(send, selected, () => { dirty = true; }, {...timing, signal: controller.signal});
45
+ if (cancelled) throw new Error('play_owner_cancelled');
46
+ result = await operation(() => cancelled);
47
+ } catch (error) { failure = error; }
48
+ finally {
49
+ // Cleanup follows the drained provider, including cancellation. A durable release tombstone
50
+ // fences a delayed acquire even when none of its replies reached this process.
51
+ try { if (dirty) await release(send, selected); }
52
+ catch (error) { failure ||= error; }
53
+ process.off('SIGTERM', stop); process.off('SIGINT', stop);
54
+ }
55
+ if (cancelled && (!failure || failure.name === 'AbortError')) failure = new Error('play_owner_cancelled');
56
+ if (failure) {
57
+ if (result !== undefined) failure.operationResult = result;
58
+ throw failure;
59
+ }
60
+ return result;
61
+ }
62
+
63
+ module.exports = {acquire, release, withOwner};
@@ -0,0 +1,71 @@
1
+ // Only audience-bound OIDC reaches the panel. Repository credentials never leave GitHub.
2
+ const https = require('node:https');
3
+
4
+ function endpoint(value) {
5
+ let url;
6
+ try { url = new URL(value); } catch { throw new Error('play_owner_binding_required'); }
7
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
8
+ || url.pathname !== '/pub/play-edit' || url.href !== value || url.port) {
9
+ throw new Error('play_owner_binding_required');
10
+ }
11
+ return url;
12
+ }
13
+
14
+ function request(url, token, body) {
15
+ return new Promise((resolve, reject) => {
16
+ const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
17
+ const agent = new https.Agent({keepAlive: false, proxyEnv: {}});
18
+ let timer, settled = false;
19
+ const finish = (error, result) => {
20
+ if (settled) return;
21
+ settled = true; clearTimeout(timer); agent.destroy();
22
+ if (error) reject(new Error('play_owner_unverified')); else resolve(result);
23
+ };
24
+ const call = https.request(url, {method: data ? 'POST' : 'GET', agent,
25
+ headers: {Authorization: `Bearer ${token}`, Accept: 'application/json',
26
+ ...(data ? {'Content-Type': 'application/json', 'Content-Length': data.length} : {})}}, response => {
27
+ const chunks = []; let size = 0;
28
+ response.on('data', chunk => {
29
+ size += chunk.length;
30
+ if (size > 131072) { finish(true); call.destroy(); }
31
+ else chunks.push(chunk);
32
+ });
33
+ response.on('end', () => {
34
+ try { finish(false, {status: response.statusCode, body: JSON.parse(Buffer.concat(chunks))}); }
35
+ catch { finish(true); }
36
+ });
37
+ response.on('error', () => finish(true));
38
+ response.on('aborted', () => finish(true));
39
+ response.on('close', () => { if (!response.complete) finish(true); });
40
+ });
41
+ timer = setTimeout(() => { finish(true); call.destroy(); }, 10000);
42
+ call.on('error', () => finish(true));
43
+ call.end(data);
44
+ });
45
+ }
46
+
47
+ async function identity(env, audience, transport) {
48
+ let url;
49
+ try { url = new URL(env.ACTIONS_ID_TOKEN_REQUEST_URL); } catch { throw new Error('play_owner_unverified'); }
50
+ const token = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
51
+ if (url.protocol !== 'https:' || !url.hostname.endsWith('.actions.githubusercontent.com')
52
+ || url.username || url.password || url.hash || url.port
53
+ || typeof token !== 'string' || !token || /[\r\n]/.test(token)) throw new Error('play_owner_unverified');
54
+ url.searchParams.set('audience', audience);
55
+ const response = await transport(url, token);
56
+ if (response.status !== 200 || typeof response.body?.value !== 'string'
57
+ || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(response.body.value)) {
58
+ throw new Error('play_owner_unverified');
59
+ }
60
+ return response.body.value;
61
+ }
62
+
63
+ function client(env, selected, transport = request) {
64
+ const url = endpoint(selected.value.coordinator_url);
65
+ return async action => {
66
+ const token = await identity(env, url.href, transport);
67
+ return transport(url, token, {action, binding: selected.value, owner: selected.owner});
68
+ };
69
+ }
70
+
71
+ module.exports = {endpoint, identity, request, client};
@@ -9,6 +9,10 @@ const positive = value => Number.isSafeInteger(value) && value > 0;
9
9
  function binding(env, packageName) {
10
10
  let value;
11
11
  try { value = JSON.parse(env.PLAY_EDIT_BINDING); } catch { throw new Error('play_owner_binding_required'); }
12
+ if (value?.protocol !== undefined && value.protocol !== 'panel-v2') {
13
+ throw new Error('play_owner_binding_required');
14
+ }
15
+ if (value?.protocol === 'panel-v2') require('./owner_panel_http.cjs').endpoint(value.coordinator_url);
12
16
  const prefix = `${env.GITHUB_REPOSITORY}/`;
13
17
  const workflow = String(env.GITHUB_WORKFLOW_REF || '');
14
18
  const path = workflow.startsWith(prefix) ? workflow.slice(prefix.length).split('@')[0] : '';
@@ -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.203",
3
+ "version": "1.0.205",
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);
@@ -15,6 +15,7 @@ permissions:
15
15
  contents: write
16
16
  actions: read
17
17
  deployments: write
18
+ id-token: write
18
19
 
19
20
  concurrency:
20
21
  group: mobile-deploy-${{ github.ref }}
@@ -9,6 +9,7 @@ permissions:
9
9
  contents: write
10
10
  actions: read
11
11
  deployments: write
12
+ id-token: write
12
13
 
13
14
  concurrency:
14
15
  group: mobile-candidate-${{ github.event.pull_request.number }}-${{ github.event.pull_request.draft }}