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 +7 -0
- package/action/scripts/local_validation.cjs +11 -7
- package/action/scripts/local_validation_core.cjs +62 -5
- package/android-action/play-upload/owner.cjs +3 -0
- package/android-action/play-upload/owner_panel.cjs +63 -0
- package/android-action/play-upload/owner_panel_http.cjs +71 -0
- package/android-action/play-upload/owner_record.cjs +4 -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
- package/templates/deploy.yml +1 -0
- package/templates/mobile-candidate.yml +1 -0
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
|
|
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') };
|
|
@@ -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] : '';
|
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);
|
package/templates/deploy.yml
CHANGED