gowalk-cicd 1.0.129 → 1.0.131

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/CLAUDE.md CHANGED
@@ -417,6 +417,11 @@ node /path/to/gowalk-cicd/bin/cli.mjs
417
417
  recorded immediately in repository deployment receipts; retries reuse their prior-run evidence.
418
418
  An uncertain upload never starts another binary automatically. Preserve its signed artifact,
419
419
  complete exact provider readback/recovery through scoped tooling, then reconcile its receipt.
420
+ The local Android workflow supplies `mobileReleaseIntentScript` to the upload wrapper instead
421
+ of marking `uploading` before acquiring Play ownership. A refused owner leaves the release
422
+ record at `building`; the wrapper records intent inside its acquired lease before provider
423
+ dispatch. Adopt both the action and workflow wiring; action-only updates preserve older
424
+ workflows' intent handling. Never clear an existing `uploading` marker on an owner refusal.
420
425
  Actions without `MOBILE_RELEASE_DEPLOYMENT_ID` preserve legacy consumer behavior. All new
421
426
  receipt helper reads are fixed-origin GitHub requests; Apple/Google still use their assigned exits.
422
427
 
@@ -434,3 +439,9 @@ workflow after a protected package release/adoption; never run hand-SSH or expos
434
439
  The diagnostic reader accepts the product-independent standard WARNING logger envelope, discards its
435
440
  logger name and rejects trailing fields. Structured remote failure codes survive exit1; SSH transport,
436
441
  read bounds and scope refusals have a closed vocabulary. Unknown exceptions remain unclassified.
442
+
443
+ The same validated envelope is retained in the dedicated `backend-identity-diagnostics-<job>-<attempt>`
444
+ artifact on success and classified reader failure. Download `result.json` from that exact run;
445
+ GitHub secret masking can alter structural braces in logs. The artifact contains only schema,
446
+ ok, fixed failure and fixed stage/reason records. Raw host output, configuration and identities
447
+ never enter it. Its pinned upload uses the same process-scoped artifact transport as mobile actions.
package/README.md CHANGED
@@ -1229,6 +1229,12 @@ it does not prove authentication succeeded. Match the bounded observation window
1229
1229
 
1230
1230
  Reader failures retain only: `invalid_scope`, `invalid_window`, `invalid_destination`,
1231
1231
  `container_ambiguous_or_absent`, `container_scope_mismatch`, `command_timeout`, `output_limit`,
1232
- `command_failed`, `transport_failure`, `remote_invalid`, or `unclassified`. Structured remote refusals
1232
+ `command_failed`, `transport_failure`, `remote_invalid`, `artifact_write_failed`, or `unclassified`. Structured remote refusals
1233
1233
  survive a remote exit1. SSH transport failures remain distinct. Unknown exceptions never contribute text.
1234
1234
  The optional standard `WARNING:<logger>:` prefix is discarded; logger names and trailing fields are never returned.
1235
+
1236
+ Download the dedicated `backend-identity-diagnostics-<job>-<attempt>` artifact from that exact run.
1237
+ Its `result.json` contains the validated envelope on success and classified failure, without raw logs,
1238
+ headers, environment values, provider exceptions or identities. Normal GitHub artifact integrity checks
1239
+ apply. The console copy is supplementary: secret masking can replace JSON punctuation in logs.
1240
+ Each invocation uses a fresh local directory so a failed reader cannot publish a prior result.
@@ -1 +1 @@
1
- 1.0.129
1
+ 1.0.131
@@ -1 +1 @@
1
- 1.0.129
1
+ 1.0.131
@@ -23,6 +23,9 @@ inputs:
23
23
  inAppUpdatePriority:
24
24
  description: In-app update priority.
25
25
  default: '0'
26
+ mobileReleaseIntentScript:
27
+ description: Committed mobile_release.cjs path, invoked after acquiring the package owner and before upload.
28
+ default: ''
26
29
  outputs:
27
30
  review-pending:
28
31
  description: True only after a successful explicitly requested review-hold retry.
@@ -0,0 +1,21 @@
1
+ // The workflow supplies its committed release helper; Play ownership precedes the write marker.
2
+ const {spawn} = require('node:child_process');
3
+
4
+ function begin(env) {
5
+ const script = env.INPUT_MOBILERELEASEINTENTSCRIPT;
6
+ // Older workflows record their own intent. Unwired consumers retain that behavior.
7
+ if (!script || !env.MOBILE_RELEASE_DEPLOYMENT_ID) return;
8
+ return new Promise((resolve, reject) => {
9
+ const child = spawn(process.execPath, [script, 'begin-upload', 'android'], {env, stdio: 'ignore'});
10
+ // Keep the owner cancellation handler responsive while its bounded marker write settles.
11
+ const timer = setTimeout(() => child.kill('SIGKILL'), 120000);
12
+ child.once('error', () => { clearTimeout(timer); reject(Error('mobile_release_intent_unconfirmed')); });
13
+ child.once('close', (code, signal) => {
14
+ clearTimeout(timer);
15
+ if (code !== 0 || signal) reject(Error('mobile_release_intent_unconfirmed'));
16
+ else resolve();
17
+ });
18
+ });
19
+ }
20
+
21
+ module.exports = {begin};
@@ -5,6 +5,7 @@ const source = require('./source.cjs');
5
5
  const processRunner = require('./process.cjs');
6
6
  const owner = require('./owner.cjs');
7
7
  const diagnostics = require('./diagnostics.cjs');
8
+ const releaseIntent = require('./release_intent.cjs');
8
9
 
9
10
  async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000,
10
11
  cancelled = () => false, report = () => {}) {
@@ -40,14 +41,17 @@ async function main() {
40
41
  if (!env.RUNNER_TEMP || !env.GITHUB_OUTPUT) throw new Error('runner_context_required');
41
42
  directory = mkdtempSync(join(env.RUNNER_TEMP, 'gowalk-play-upload-'));
42
43
  const program = await source.prepare(directory);
43
- receipt = await owner.withOwner(env, env.INPUT_PACKAGENAME, cancelled =>
44
- upload(program, env, directory, processRunner.execute, 3600000, cancelled,
45
- (attempt, result) => console.log(diagnostics.annotation(attempt, result))));
44
+ receipt = await owner.withOwner(env, env.INPUT_PACKAGENAME, async cancelled => {
45
+ if (cancelled()) return {ok: false, code: 'upload_cancelled', attempts: 0, review_pending: false};
46
+ await releaseIntent.begin(env);
47
+ return upload(program, env, directory, processRunner.execute, 3600000, cancelled,
48
+ (attempt, result) => console.log(diagnostics.annotation(attempt, result)));
49
+ });
46
50
  } catch (error) {
47
51
  if (owner.codes.includes(error.message)) console.log(owner.annotation(error.message, undefined,
48
52
  error.detail || (typeof error.releaseUnverified === 'string' ? error.releaseUnverified : undefined)));
49
53
  const allowed = ['assigned_proxy_required', 'runner_context_required', 'uploader_integrity_refused',
50
- 'uploader_source_unavailable', ...owner.codes];
54
+ 'uploader_source_unavailable', 'mobile_release_intent_unconfirmed', ...owner.codes];
51
55
  receipt = {ok: false, code: allowed.includes(error.message) ? error.message : 'uploader_failed',
52
56
  attempts: 0, review_pending: false};
53
57
  if (owner.codes.includes(error.message) && error.operationResult) {
@@ -1 +1 @@
1
- 1.0.129
1
+ 1.0.131
@@ -182,6 +182,7 @@ runs:
182
182
  '${{ inputs.health-path }}' '${{ inputs.cert-email }}'"
183
183
 
184
184
  - name: Read fixed backend identity diagnostics
185
+ id: identity_diagnostics
185
186
  if: inputs.operation == 'identity-diagnostics'
186
187
  shell: bash
187
188
  env:
@@ -191,9 +192,22 @@ runs:
191
192
  DIAGNOSTIC_HOST: ${{ inputs.host }}
192
193
  DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
193
194
  run: |
195
+ RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-identity-diagnostics.XXXXXX")"
196
+ echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
194
197
  python3 "${{ github.action_path }}/identity_diagnostics.py" \
195
198
  --app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --since "$DIAGNOSTIC_SINCE" \
196
- --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER"
199
+ --host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
200
+ --output "$RESULT_DIR/result.json"
201
+
202
+ - name: Retain fixed backend identity result
203
+ if: always() && inputs.operation == 'identity-diagnostics'
204
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
205
+ env:
206
+ NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
207
+ with:
208
+ name: backend-identity-diagnostics-${{ github.job }}-${{ github.run_attempt }}
209
+ path: ${{ steps.identity_diagnostics.outputs.result }}
210
+ if-no-files-found: error
197
211
 
198
212
  - name: Cleanup key
199
213
  if: always()
@@ -30,6 +30,7 @@ FAILURES = frozenset({
30
30
  'unclassified', 'invalid_scope', 'invalid_window', 'invalid_destination',
31
31
  'container_ambiguous_or_absent', 'container_scope_mismatch', 'command_timeout',
32
32
  'output_limit', 'command_failed', 'transport_failure', 'remote_invalid',
33
+ 'artifact_write_failed',
33
34
  })
34
35
 
35
36
 
@@ -178,6 +179,17 @@ def decode_remote(status, raw):
178
179
  return result
179
180
 
180
181
 
182
+ def retain_result(path, result):
183
+ """Retain only the already validated public envelope, never captured host output."""
184
+ try:
185
+ target = Path(path)
186
+ target.parent.mkdir(parents=True, exist_ok=True)
187
+ with target.open('x', encoding='utf-8') as output:
188
+ output.write(json.dumps(result, separators=(',', ':')) + '\n')
189
+ except OSError:
190
+ raise ReaderFailure('artifact_write_failed') from None
191
+
192
+
181
193
  def main():
182
194
  parser = argparse.ArgumentParser(description=__doc__)
183
195
  parser.add_argument('--remote', action='store_true')
@@ -186,16 +198,27 @@ def main():
186
198
  parser.add_argument('--since', required=True)
187
199
  parser.add_argument('--host')
188
200
  parser.add_argument('--user', default='root')
201
+ parser.add_argument('--output', help='Exclusive local file for the closed diagnostic envelope')
189
202
  args = parser.parse_args()
203
+ status = 0
190
204
  try:
205
+ if args.remote and args.output:
206
+ raise ReaderFailure('invalid_destination')
191
207
  result = remote(args.app, args.service, args.since) if args.remote else local(args)
208
+ result = decode_remote(0, json.dumps(result).encode())
192
209
  except Exception as error:
193
210
  # Only our closed vocabulary survives; provider and parser exceptions remain unclassified.
194
211
  code = error.code if isinstance(error, ReaderFailure) else 'unclassified'
195
- print(json.dumps({'schema': SCHEMA, 'ok': False, 'failure': code, 'records': []}))
196
- return 1
212
+ result = {'schema': SCHEMA, 'ok': False, 'failure': code, 'records': []}
213
+ status = 1
214
+ if args.output and not args.remote:
215
+ try:
216
+ retain_result(args.output, result)
217
+ except ReaderFailure as error:
218
+ result = {'schema': SCHEMA, 'ok': False, 'failure': error.code, 'records': []}
219
+ status = 1
197
220
  print(json.dumps(result, separators=(',', ':')))
198
- return 0
221
+ return status
199
222
 
200
223
 
201
224
  if __name__ == '__main__':
@@ -0,0 +1,10 @@
1
+ // Only the exact pinned upload-artifact process receives this GitHub-specific transport.
2
+ try {
3
+ const scope = require('./cache_scope.cjs');
4
+ scope.authorize(process.argv[1], process.env, 'artifact');
5
+ scope.guardRequests();
6
+ for (const key of scope.proxyKeys) delete process.env[key];
7
+ } catch {
8
+ process.stderr.write('github_artifact_transport_scope_refused\n');
9
+ process.exit(1);
10
+ }
@@ -0,0 +1,83 @@
1
+ // Direct routing belongs only to pinned first-party Actions programs and approved HTTPS origins.
2
+ const {readFileSync, copyFileSync, mkdtempSync, appendFileSync} = require('node:fs');
3
+ const {createHash} = require('node:crypto');
4
+ const {basename, dirname, join} = require('node:path');
5
+ const hashes = new Map([
6
+ ['restore', 'a49301428b4c5477522c00f6464566457ed327d64ae5f80f8c12e762fb2ea9a2'],
7
+ ['save', 'b969065795aaf5c6d20ba158e910be5d353eefb02eb2785263eb32e13090c066'],
8
+ ['restore-only', '6255afaa3956351b8cfefc1e82f026b4db418a10678a8eaddf3d6c55f81744de'],
9
+ ['save-only', '7fb63f90f06ce6a10f39d40a113f99791bffdedad5394cdad5b4dfaa644559cb'],
10
+ ]);
11
+ const artifactHash = '168c44946cba03564808c19b83a72601e6d1d6f57081dbd2d6e2ea1cedc522f5';
12
+ const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
13
+ const refusal = () => new Error('github_cache_transport_scope_refused');
14
+
15
+ function allowedHost(host) {
16
+ return host === 'api.github.com'
17
+ || /^[a-z0-9.-]+\.actions\.githubusercontent\.com$/.test(host)
18
+ || /^[a-z0-9]{3,24}\.blob\.core\.windows\.net$/.test(host);
19
+ }
20
+
21
+ function requireOrigin(value, service = false) {
22
+ let url;
23
+ try { url = new URL(value); } catch { throw refusal(); }
24
+ if (url.protocol !== 'https:' || url.port || url.username || url.password || !allowedHost(url.hostname)
25
+ || (service && !url.hostname.endsWith('.actions.githubusercontent.com'))) throw refusal();
26
+ return url;
27
+ }
28
+
29
+ function authorize(entrypoint, env, kind = 'cache') {
30
+ if (!entrypoint || basename(entrypoint) !== 'index.js' || basename(dirname(dirname(entrypoint))) !== 'dist') {
31
+ throw refusal();
32
+ }
33
+ if (!['cache', 'artifact'].includes(kind)) throw refusal();
34
+ const program = basename(dirname(entrypoint));
35
+ const expected = kind === 'artifact' ? (program === 'upload' && artifactHash) : hashes.get(program);
36
+ if (!expected || createHash('sha256').update(readFileSync(entrypoint)).digest('hex') !== expected) throw refusal();
37
+ const origins = (kind === 'artifact' ? [env.ACTIONS_RESULTS_URL]
38
+ : [env.ACTIONS_RESULTS_URL, env.ACTIONS_CACHE_URL]).filter(Boolean);
39
+ if (!origins.length) throw refusal();
40
+ for (const origin of origins) requireOrigin(origin, true);
41
+ }
42
+
43
+ function requireRequest(args, protocol) {
44
+ const [input, extra] = args;
45
+ const options = typeof input === 'string' || input instanceof URL
46
+ ? {protocol, ...require('node:url').urlToHttpOptions(requireOrigin(input)),
47
+ ...(typeof extra === 'object' ? extra : {})}
48
+ : {...input};
49
+ if (!options || options.socketPath || (options.protocol || protocol) !== 'https:') throw refusal();
50
+ for (const host of [options.hostname, options.host].filter(Boolean)) {
51
+ requireOrigin(`https://${host}${options.port ? ':' + options.port : ''}`);
52
+ }
53
+ if (!options.hostname && !options.host) throw refusal();
54
+ }
55
+
56
+ function guardRequests() {
57
+ for (const [name, protocol] of [['node:http', 'http:'], ['node:https', 'https:']]) {
58
+ const client = require(name);
59
+ for (const verb of ['request', 'get']) {
60
+ const original = client[verb];
61
+ client[verb] = function (...args) { requireRequest(args, protocol); return original.apply(this, args); };
62
+ }
63
+ }
64
+ if (globalThis.fetch) {
65
+ const original = globalThis.fetch;
66
+ globalThis.fetch = function (input, init) {
67
+ requireOrigin(typeof input === 'string' || input instanceof URL ? input : input.url);
68
+ return original.call(this, input, {...init, redirect: 'error'});
69
+ };
70
+ }
71
+ }
72
+
73
+ function snapshot() {
74
+ const target = mkdtempSync(join(process.env.RUNNER_TEMP, 'gowalk-cache-scope-'));
75
+ for (const file of ['cache_env.cjs', 'cache_scope.cjs']) copyFileSync(join(__dirname, file), join(target, file));
76
+ appendFileSync(process.env.GITHUB_OUTPUT, `preloader=${join(target, 'cache_env.cjs')}\n`);
77
+ }
78
+
79
+ module.exports = {authorize, guardRequests, proxyKeys, requireOrigin};
80
+ if (require.main === module) {
81
+ try { if (process.argv[2] !== '--snapshot') throw refusal(); snapshot(); }
82
+ catch { process.stderr.write('github_cache_scope_snapshot_failed\n'); process.exitCode = 1; }
83
+ }
@@ -0,0 +1,77 @@
1
+ """The dedicated artifact carries the complete closed result independently of log masking."""
2
+ from contextlib import redirect_stdout
3
+ import hashlib
4
+ import io
5
+ import json
6
+ from pathlib import Path
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from unittest.mock import patch
11
+ from test_identity_diagnostics import READER, SINCE
12
+
13
+
14
+ class IdentityArtifact(unittest.TestCase):
15
+ def invoke(self, path, *, result=None, error=None, remote=False):
16
+ argv = ['reader', '--app', 'fixture', '--service', 'api', '--since', SINCE, '--output', str(path)]
17
+ if remote:
18
+ argv.append('--remote')
19
+ output = io.StringIO()
20
+ with patch.object(sys, 'argv', argv), patch.object(READER, 'local', return_value=result,
21
+ side_effect=error), redirect_stdout(output):
22
+ status = READER.main()
23
+ return status, output.getvalue()
24
+
25
+ def test_success_file_survives_structural_log_redaction_byte_for_byte(self):
26
+ result = {'schema': READER.SCHEMA, 'ok': True, 'failure': '',
27
+ 'records': [{'stage': 'auth_signature', 'reason': 'token_invalid'}]}
28
+ with tempfile.TemporaryDirectory() as folder:
29
+ path = Path(folder) / 'result.json'
30
+ status, log = self.invoke(path, result=result)
31
+ data = path.read_bytes()
32
+ self.assertEqual(status, 0)
33
+ self.assertEqual(json.loads(data), result)
34
+ self.assertNotEqual(log.replace('{', '***').replace('}', '***'), data.decode())
35
+ self.assertEqual(hashlib.sha256(data).digest(), hashlib.sha256(log.encode()).digest())
36
+
37
+ def test_classified_and_unknown_failures_are_retained_without_exception_data(self):
38
+ for failure, expected in [(READER.ReaderFailure('container_scope_mismatch'), 'container_scope_mismatch'),
39
+ (RuntimeError('PRIVATE_SENTINEL'), 'unclassified')]:
40
+ with self.subTest(expected=expected), tempfile.TemporaryDirectory() as folder:
41
+ path = Path(folder) / 'result.json'
42
+ status, log = self.invoke(path, error=failure)
43
+ self.assertEqual(status, 1)
44
+ self.assertEqual(json.loads(path.read_bytes()), {
45
+ 'schema': READER.SCHEMA, 'ok': False, 'failure': expected, 'records': []})
46
+ self.assertNotIn('PRIVATE_SENTINEL', log + path.read_text())
47
+
48
+ def test_unvalidated_fields_never_reach_artifact(self):
49
+ invalid = {'schema': READER.SCHEMA, 'ok': True, 'failure': '', 'records': [], 'raw': 'PRIVATE_SENTINEL'}
50
+ with tempfile.TemporaryDirectory() as folder:
51
+ path = Path(folder) / 'result.json'
52
+ status, log = self.invoke(path, result=invalid)
53
+ self.assertEqual(status, 1)
54
+ self.assertEqual(json.loads(path.read_text())['failure'], 'remote_invalid')
55
+ self.assertNotIn('PRIVATE_SENTINEL', log + path.read_text())
56
+
57
+ def test_existing_file_is_preserved_and_write_failure_is_closed(self):
58
+ with tempfile.TemporaryDirectory() as folder:
59
+ path = Path(folder) / 'result.json'; path.write_text('existing')
60
+ status, log = self.invoke(path, error=READER.ReaderFailure('invalid_scope'))
61
+ self.assertEqual(status, 1)
62
+ self.assertEqual(json.loads(log)['failure'], 'artifact_write_failed')
63
+ self.assertEqual(path.read_text(), 'existing')
64
+
65
+ def test_remote_mode_cannot_create_output_files(self):
66
+ with tempfile.TemporaryDirectory() as folder:
67
+ path = Path(folder) / 'result.json'
68
+ with patch.object(READER, 'remote') as remote:
69
+ status, log = self.invoke(path, remote=True)
70
+ self.assertEqual(status, 1)
71
+ self.assertEqual(json.loads(log)['failure'], 'invalid_destination')
72
+ self.assertFalse(path.exists())
73
+ remote.assert_not_called()
74
+
75
+
76
+ if __name__ == '__main__':
77
+ unittest.main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.129",
3
+ "version": "1.0.131",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -550,9 +550,6 @@ jobs:
550
550
  # The wrapper verifies the unchanged pinned official uploader and classifies
551
551
  # its immediate result. Only explicit expired/deleted edits retry normally;
552
552
  # only Play's explicit review-hold refusal changes the review flag.
553
- - name: Record Android upload intent
554
- if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
555
- run: node .github/actions/swift-app/scripts/mobile_release.cjs begin-upload android
556
553
  - name: Upload Android release to Google Play
557
554
  id: play-upload
558
555
  if: ${{ steps.mode.outputs.mode == 'local' && steps.play.outputs.ready == 'true' }}
@@ -561,6 +558,7 @@ jobs:
561
558
  GOOGLE_STORE_PROXY_URL: ${{ secrets.GOOGLE_STORE_PROXY_URL }}
562
559
  GITHUB_TOKEN: ${{ github.token }}
563
560
  with:
561
+ mobileReleaseIntentScript: ${{ github.workspace }}/.github/actions/swift-app/scripts/mobile_release.cjs
564
562
  serviceAccountJson: ${{ steps.android.outputs.play-service-account }}
565
563
  packageName: ${{ steps.android.outputs.package-name }}
566
564
  releaseFiles: ${{ steps.android.outputs.bundle-path }}