gowalk-cicd 1.0.202 → 1.0.204
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/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 +6 -1
- package/package.json +1 -1
- package/src/config/settings.mjs +8 -1
- package/src/config/workflows.mjs +1 -1
- 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
|
|
@@ -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
|
@@ -38,7 +38,6 @@ backend:
|
|
|
38
38
|
runtime-env: ${{ secrets.BACKEND_RUNTIME_ENV }}
|
|
39
39
|
public-base-url: ${{ vars.BACKEND_PUBLIC_BASE_URL }}
|
|
40
40
|
public-health-url: ${{ vars.BACKEND_PUBLIC_HEALTH_URL }}
|
|
41
|
-
expected-build-sha: ${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}
|
|
42
41
|
ingress-required: ${{ vars.BACKEND_INGRESS_REQUIRED || 'false' }}
|
|
43
42
|
ingress-token: ${{ secrets.BACKEND_INGRESS_TOKEN }}
|
|
44
43
|
workflows:
|
|
@@ -52,6 +51,12 @@ until the app explicitly replaces their references in this file. Keep passwords,
|
|
|
52
51
|
signing material, tokens and private keys in GitHub Secrets. Reference their names;
|
|
53
52
|
never put their values in this configuration.
|
|
54
53
|
|
|
54
|
+
Leave `backend.expected-build-sha` omitted to inherit the managed operation-aware default:
|
|
55
|
+
network diagnostics uses the explicit `diagnostic-build-sha`, while deployment uses its source SHA.
|
|
56
|
+
Upgrades remove only the former generated setting
|
|
57
|
+
`${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}`, which otherwise overrides the diagnostic
|
|
58
|
+
input. Custom build expressions remain unchanged; callers must supply the observed deployed SHA.
|
|
59
|
+
|
|
55
60
|
Backend discovery accepts `compose.yaml`, `compose.yml`, `docker-compose.yaml` and
|
|
56
61
|
`docker-compose.yml` inside `backend/` or `server/`, or at the repository root with a Dockerfile in
|
|
57
62
|
exactly one of those source directories. Multiple matches require an explicit selection; no file is
|
package/package.json
CHANGED
package/src/config/settings.mjs
CHANGED
|
@@ -31,7 +31,14 @@ export function extractSettings(config, file, workflow) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
export function applySettings(config, file, workflow) {
|
|
34
|
+
export function applySettings(config, file, workflow, defaults) {
|
|
35
|
+
if (file === 'deploy-backend.yml'
|
|
36
|
+
&& config.backend?.['expected-build-sha'] === "${{ vars.BACKEND_PUBLIC_HEALTH_URL && github.sha || '' }}") {
|
|
37
|
+
// Former generated default masks the diagnostic build input after a configured upgrade.
|
|
38
|
+
delete config.backend['expected-build-sha'];
|
|
39
|
+
const path = [...backend, 'expected-build-sha'];
|
|
40
|
+
assign(workflow, { path, value: at(defaults, path) });
|
|
41
|
+
}
|
|
35
42
|
for (const { name, path } of settingsPaths(file)) {
|
|
36
43
|
const value = at(config, name);
|
|
37
44
|
if (value !== undefined) assign(workflow, { path, value });
|
package/src/config/workflows.mjs
CHANGED
|
@@ -67,7 +67,7 @@ function upgradeWorkflow(root, file, templateDir, state) {
|
|
|
67
67
|
result = mergeChanges(base.value, current, state.config.workflows[file]?.overrides || [], file);
|
|
68
68
|
}
|
|
69
69
|
if (state.migrating) extractSettings(state.config, file, result);
|
|
70
|
-
result = applySettings(state.config, file, result);
|
|
70
|
+
result = applySettings(state.config, file, result, current);
|
|
71
71
|
const options = settingsPaths(file);
|
|
72
72
|
const overrides = differences(current, result)
|
|
73
73
|
.filter(change => !options.some(({ path }) => equal(path, change.path)));
|
package/templates/deploy.yml
CHANGED