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 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] : '';
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.202",
3
+ "version": "1.0.204",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 });
@@ -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)));
@@ -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 }}