gowalk-cicd 1.0.129 → 1.0.130

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
@@ -434,3 +434,9 @@ workflow after a protected package release/adoption; never run hand-SSH or expos
434
434
  The diagnostic reader accepts the product-independent standard WARNING logger envelope, discards its
435
435
  logger name and rejects trailing fields. Structured remote failure codes survive exit1; SSH transport,
436
436
  read bounds and scope refusals have a closed vocabulary. Unknown exceptions remain unclassified.
437
+
438
+ The same validated envelope is retained in the dedicated `backend-identity-diagnostics-<job>-<attempt>`
439
+ artifact on success and classified reader failure. Download `result.json` from that exact run;
440
+ GitHub secret masking can alter structural braces in logs. The artifact contains only schema,
441
+ ok, fixed failure and fixed stage/reason records. Raw host output, configuration and identities
442
+ 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.130
@@ -1 +1 @@
1
- 1.0.129
1
+ 1.0.130
@@ -1 +1 @@
1
- 1.0.129
1
+ 1.0.130
@@ -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.130",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {