gowalk-cicd 1.0.181 → 1.0.182

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
@@ -652,6 +652,19 @@ bounded to 16 KiB. No provider message, path, URL, track or credential is republ
652
652
  These observations never authorize a retry. Missing commit output does not prove no mutation,
653
653
  and a reported commit does not prove review submission. Unknown failures remain unclassified.
654
654
 
655
+ The wrapper also writes validated `gowalk-cicd/play-upload-evidence.v1` JSON outside its
656
+ private upload directory: `attempt-1.json`, optional `attempt-2.json`, and `terminal.json`.
657
+ Each record binds the GitHub run/attempt, source SHA, compiled version code (the optional
658
+ `versionCode` input), and immutable uploader identity. Missing bindings are null, never inferred.
659
+ Attempt records add observed HTTP status/RPC codes and exact known message classes from complete,
660
+ bounded error lines; raw provider prose, paths, URLs and identifiers are discarded. The pinned
661
+ uploader sometimes emits only an error message, so missing status/code fields remain empty.
662
+ These fields do not change retry policy or the original result/diagnostic annotations.
663
+ The action exports `diagnostic-path`; the managed deploy template retains its JSON as
664
+ `play-upload-diagnostics-<run_id>-<run_attempt>` on success or failure. Adopt both action and
665
+ template to retain artifacts. Failed evidence validation/storage emits a fixed refusal and fails
666
+ the job without replaying provider work or changing its result.
667
+
655
668
  Package-owner acquisition rechecks an occupied record within one 60-second wait budget before
656
669
  provider work. Every read retains the binding and identity checks; cancellation interrupts the wait.
657
670
  Time passing never authorizes clearing an owner. A known CI owner still requires exact terminal
@@ -26,11 +26,16 @@ inputs:
26
26
  mobileReleaseIntentScript:
27
27
  description: Committed mobile_release.cjs path, invoked after acquiring the package owner and before upload.
28
28
  default: ''
29
+ versionCode:
30
+ description: Exact compiled version code for sanitized upload diagnostics only.
31
+ default: ''
29
32
  outputs:
30
33
  review-pending:
31
34
  description: True only after a successful explicitly requested review-hold retry.
32
35
  receipt:
33
36
  description: Sanitized versioned result with attempt count and upstream identity.
37
+ diagnostic-path:
38
+ description: Runner-owned directory with closed per-attempt and terminal diagnostic JSON.
34
39
  runs:
35
40
  using: node24
36
41
  main: run.cjs
@@ -1,4 +1,5 @@
1
1
  // Observe only fixed progress/error patterns; never retain provider text in a receipt.
2
+ const provider = require('./provider_evidence.cjs');
2
3
  const phases = new Map([
3
4
  ['Creating a new Edit for this release', 'edit_create'],
4
5
  ['Committing the Edit', 'edit_commit'],
@@ -22,10 +23,11 @@ const limit = 16384;
22
23
 
23
24
  function create() {
24
25
  return {phase: 'startup', signals: new Set(), pending: {stdout: '', stderr: ''},
25
- discard: {stdout: false, stderr: false}};
26
+ discard: {stdout: false, stderr: false}, provider: provider.create()};
26
27
  }
27
28
 
28
29
  function line(state, stream, value) {
30
+ provider.observe(state.provider, value);
29
31
  if (stream === 'stdout') {
30
32
  if (phases.has(value)) state.phase = phases.get(value);
31
33
  for (const [prefix, phase] of prefixes) if (value.startsWith(prefix)) state.phase = phase;
@@ -70,4 +72,5 @@ function annotation(attempt, result) {
70
72
  + ` signals=${signals.join(',') || 'none'}`;
71
73
  }
72
74
 
73
- module.exports = {create, consume, snapshot, annotation};
75
+ module.exports = {create, consume, snapshot, annotation,
76
+ providerSnapshot: state => provider.snapshot(state.provider)};
@@ -0,0 +1,30 @@
1
+ // This separate, sanitized directory survives cleanup of the private uploader output.
2
+ const {mkdtempSync, writeFileSync} = require('node:fs');
3
+ const {isAbsolute, join} = require('node:path');
4
+ const records = require('./evidence_schema.cjs');
5
+
6
+ function begin(env) {
7
+ const binding = records.context(env);
8
+ let directory = '', failed = false, recorded = 0;
9
+ try {
10
+ if (!isAbsolute(env.RUNNER_TEMP || '') || /[\x00-\x1f\x7f]/.test(env.RUNNER_TEMP)) throw new Error();
11
+ directory = mkdtempSync(join(env.RUNNER_TEMP, 'gowalk-play-evidence-'));
12
+ } catch { failed = true; }
13
+ const save = (name, value) => {
14
+ try {
15
+ if (!directory || !records.valid(value)) throw new Error();
16
+ writeFileSync(join(directory, name), JSON.stringify(value) + '\n', {mode: 0o600, flag: 'wx'});
17
+ } catch { failed = true; }
18
+ };
19
+ return {
20
+ attempt(number, result) {
21
+ save(`attempt-${number}.json`, records.attempt(binding, number, result));
22
+ recorded += 1;
23
+ },
24
+ terminal(result) { save('terminal.json', records.terminal(binding, result, recorded)); },
25
+ get path() { return failed ? '' : directory; },
26
+ get failed() { return failed; },
27
+ };
28
+ }
29
+
30
+ module.exports = {begin};
@@ -0,0 +1,71 @@
1
+ const provider = require('./provider_evidence.cjs');
2
+ const source = require('./source.cjs');
3
+ const schema = 'gowalk-cicd/play-upload-evidence.v1';
4
+ const phases = new Set(['startup', 'edit_create', 'tracks_read', 'bundle_upload', 'track_update', 'edit_commit']);
5
+ const signals = new Set(['connection_reset', 'connection_refused', 'connection_timeout', 'dns_failure',
6
+ 'tls_verification', 'output_truncated']);
7
+ const processSignals = new Set(['none', 'SIGTERM', 'SIGINT', 'SIGKILL', 'other']);
8
+ const codes = new Set(['uploaded', 'review_pending', 'edit_expired', 'edit_deleted', 'review_hold_required',
9
+ 'unclassified_failure', 'upload_cancelled', 'upload_outcome_uncertain', 'uploader_failed',
10
+ 'runner_context_required', 'uploader_integrity_refused', 'uploader_source_unavailable',
11
+ 'mobile_release_intent_unconfirmed', ...require('./owner.cjs').codes]);
12
+ const keys = (value, names) => value && typeof value === 'object' && !Array.isArray(value)
13
+ && Object.keys(value).sort().join(',') === names.split(',').sort().join(',');
14
+ const integer = (value, minimum, maximum = Number.MAX_SAFE_INTEGER) =>
15
+ Number.isSafeInteger(value) && value >= minimum && value <= maximum;
16
+ const nullable = (value, check) => value === null || check(value);
17
+
18
+ function context(env) {
19
+ const positive = value => /^[1-9][0-9]*$/.test(value || '') && integer(Number(value), 1) ? Number(value) : null;
20
+ return {run_id: positive(env.GITHUB_RUN_ID), run_attempt: positive(env.GITHUB_RUN_ATTEMPT),
21
+ source_sha: /^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '') ? env.GITHUB_SHA : null,
22
+ version_code: positive(env.INPUT_VERSIONCODE || env.ANDROID_BUILD_NUMBER)};
23
+ }
24
+
25
+ function base(binding, kind) {
26
+ return {schema, kind, ...binding, upstream_revision: source.revision, upstream_sha256: source.digest};
27
+ }
28
+
29
+ function attempt(binding, number, result) {
30
+ const diagnostic = result.diagnostic || {};
31
+ const observed = Array.isArray(diagnostic.signals) ? diagnostic.signals.filter(value => signals.has(value)) : [];
32
+ return {...base(binding, 'attempt'), attempt: number,
33
+ phase: phases.has(diagnostic.phase) ? diagnostic.phase : 'startup',
34
+ exit_code: integer(result.status, 0, 255) ? result.status : null,
35
+ signal: processSignals.has(result.signal) ? result.signal : result.signal ? 'other' : 'none',
36
+ error_count: integer(result.errors, 0) ? result.errors : null,
37
+ cancelled: result.cancelled === true, commit_reported: result.committed === true,
38
+ transport_signals: [...new Set(observed)].sort(),
39
+ provider: provider.valid(result.provider) ? result.provider : provider.snapshot(provider.create())};
40
+ }
41
+
42
+ function terminal(binding, result, recorded) {
43
+ return {...base(binding, 'terminal'), recorded_attempts: recorded, result: {
44
+ ok: result.ok === true, code: codes.has(result.code) ? result.code : 'uploader_failed',
45
+ attempts: integer(result.attempts, 0, 2) ? result.attempts : 0,
46
+ review_pending: result.review_pending === true}};
47
+ }
48
+
49
+ function valid(value) {
50
+ const fields = 'schema,kind,run_id,run_attempt,source_sha,version_code,upstream_revision,upstream_sha256';
51
+ if (!value || value.schema !== schema || value.upstream_revision !== source.revision
52
+ || value.upstream_sha256 !== source.digest || !nullable(value.run_id, item => integer(item, 1))
53
+ || !nullable(value.run_attempt, item => integer(item, 1))
54
+ || !nullable(value.version_code, item => integer(item, 1))
55
+ || !nullable(value.source_sha, item => typeof item === 'string' && /^[0-9a-f]{40}$/.test(item))) return false;
56
+ if (value.kind === 'terminal') return keys(value, fields + ',recorded_attempts,result')
57
+ && integer(value.recorded_attempts, 0, 2) && keys(value.result, 'ok,code,attempts,review_pending')
58
+ && typeof value.result.ok === 'boolean' && codes.has(value.result.code)
59
+ && integer(value.result.attempts, 0, 2) && typeof value.result.review_pending === 'boolean';
60
+ return value.kind === 'attempt' && keys(value, fields
61
+ + ',attempt,phase,exit_code,signal,error_count,cancelled,commit_reported,transport_signals,provider')
62
+ && integer(value.attempt, 1, 2) && phases.has(value.phase) && processSignals.has(value.signal)
63
+ && nullable(value.exit_code, item => integer(item, 0, 255))
64
+ && nullable(value.error_count, item => integer(item, 0)) && typeof value.cancelled === 'boolean'
65
+ && typeof value.commit_reported === 'boolean' && Array.isArray(value.transport_signals)
66
+ && value.transport_signals.length <= signals.size
67
+ && new Set(value.transport_signals).size === value.transport_signals.length
68
+ && [...value.transport_signals].every(item => signals.has(item)) && provider.valid(value.provider);
69
+ }
70
+
71
+ module.exports = {schema, context, attempt, terminal, valid};
@@ -53,7 +53,8 @@ function execute(program, env, directory, timeout) {
53
53
  diagnostics.consume(diagnostic, 'stdout', '\n'); diagnostics.consume(diagnostic, 'stderr', '\n');
54
54
  const committed = /(?:^|\n)committedEditId(?:=|<<)/.test(readFileSync(output, 'utf8'));
55
55
  resolve({status, signal, cancelled, committed, errors: state.errors,
56
- code: state.ambiguous ? 'unclassified_failure' : state.code, diagnostic: diagnostics.snapshot(diagnostic)});
56
+ code: state.ambiguous ? 'unclassified_failure' : state.code, diagnostic: diagnostics.snapshot(diagnostic),
57
+ provider: diagnostics.providerSnapshot(diagnostic)});
57
58
  });
58
59
  });
59
60
  }
@@ -0,0 +1,51 @@
1
+ // Closed observations only. Missing provider fields are never inferred from message text.
2
+ const http = new Set([400, 401, 402, 403, 404, 405, 408, 409, 410, 412, 413, 414, 415,
3
+ 416, 417, 422, 428, 429, 500, 501, 502, 503, 504]);
4
+ const rpc = new Set(['CANCELLED', 'UNKNOWN', 'INVALID_ARGUMENT', 'DEADLINE_EXCEEDED', 'NOT_FOUND',
5
+ 'ALREADY_EXISTS', 'PERMISSION_DENIED', 'RESOURCE_EXHAUSTED', 'FAILED_PRECONDITION', 'ABORTED',
6
+ 'OUT_OF_RANGE', 'UNIMPLEMENTED', 'INTERNAL', 'UNAVAILABLE', 'DATA_LOSS', 'UNAUTHENTICATED']);
7
+ const messages = new Map([
8
+ ['This edit has expired, please create a new Edit.', 'edit_expired'],
9
+ ['This Edit has been deleted.', 'edit_deleted'],
10
+ ['Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. '
11
+ + 'Once committed, the changes in this edit can be sent for review from the Google Play Console UI.',
12
+ 'review_hold_required'],
13
+ ['Unknown error occurred.', 'uploader_unknown_error'],
14
+ ['New edit has no ID, cannot continue.', 'edit_id_missing'],
15
+ ]);
16
+ const classes = new Set(messages.values());
17
+
18
+ function create() {
19
+ return {http: new Set(), rpc: new Set(), classes: new Set()};
20
+ }
21
+
22
+ function observe(state, value) {
23
+ const match = /^::error(?: [^\r\n]+)?::(.*)$/.exec(value) || /^Error: (.*)$/.exec(value);
24
+ if (!match) return;
25
+ const text = match[1].replace(/%0D/g, '\r').replace(/%0A/g, '\n').replace(/%25/g, '%');
26
+ if (messages.has(text)) state.classes.add(messages.get(text));
27
+ const status = /^Request failed with status code ([45]\d\d)(?:\.?$|\.\s)/.exec(text);
28
+ if (status && http.has(Number(status[1]))) state.http.add(Number(status[1]));
29
+ let body;
30
+ try { body = JSON.parse(text); } catch { return; }
31
+ const error = body?.error;
32
+ if (!error || typeof error !== 'object' || Array.isArray(error) || !http.has(error.code)) return;
33
+ state.http.add(error.code);
34
+ if (rpc.has(error.status)) state.rpc.add(error.status);
35
+ if (messages.has(error.message)) state.classes.add(messages.get(error.message));
36
+ }
37
+
38
+ function snapshot(state) {
39
+ return {http_statuses: [...state.http].sort((a, b) => a - b), rpc_codes: [...state.rpc].sort(),
40
+ message_classes: [...state.classes].sort()};
41
+ }
42
+
43
+ function valid(value) {
44
+ if (!value || typeof value !== 'object' || Array.isArray(value)
45
+ || Object.keys(value).sort().join(',') !== 'http_statuses,message_classes,rpc_codes') return false;
46
+ return [['http_statuses', http], ['rpc_codes', rpc], ['message_classes', classes]].every(([key, choices]) =>
47
+ Array.isArray(value[key]) && value[key].length <= choices.size
48
+ && new Set(value[key]).size === value[key].length && [...value[key]].every(item => choices.has(item)));
49
+ }
50
+
51
+ module.exports = {create, observe, snapshot, valid};
@@ -6,6 +6,7 @@ const processRunner = require('./process.cjs');
6
6
  const owner = require('./owner.cjs');
7
7
  const diagnostics = require('./diagnostics.cjs');
8
8
  const releaseIntent = require('./release_intent.cjs');
9
+ const evidence = require('./evidence.cjs');
9
10
 
10
11
  async function upload(program, env, directory, execute = processRunner.execute, timeout = 3600000,
11
12
  cancelled = () => false, report = () => {}) {
@@ -33,9 +34,10 @@ async function upload(program, env, directory, execute = processRunner.execute,
33
34
  }
34
35
 
35
36
  async function main() {
36
- let directory, receipt;
37
+ let directory, receipt, records;
37
38
  try {
38
39
  const env = {...process.env};
40
+ records = evidence.begin(env);
39
41
  if (!env.PLAY_EDIT_BINDING) throw new Error('play_owner_binding_required');
40
42
  env.PLAY_EDIT_PRINCIPAL_SHA256 = require('./owner_principal.cjs').read(env.INPUT_SERVICEACCOUNTJSON);
41
43
  if (!env.RUNNER_TEMP || !env.GITHUB_OUTPUT) throw new Error('runner_context_required');
@@ -45,7 +47,9 @@ async function main() {
45
47
  if (cancelled()) return {ok: false, code: 'upload_cancelled', attempts: 0, review_pending: false};
46
48
  await releaseIntent.begin(env);
47
49
  return upload(program, env, directory, processRunner.execute, 3600000, cancelled,
48
- (attempt, result) => console.log(diagnostics.annotation(attempt, result)));
50
+ (attempt, result) => {
51
+ records.attempt(attempt, result); console.log(diagnostics.annotation(attempt, result));
52
+ });
49
53
  });
50
54
  } catch (error) {
51
55
  if (owner.codes.includes(error.message)) console.log(owner.annotation(error.message, undefined,
@@ -61,13 +65,19 @@ async function main() {
61
65
  } finally { if (directory) rmSync(directory, {recursive: true, force: true}); }
62
66
  receipt = {schema: 'gowalk-cicd/play-upload.v1', ...receipt, upstream_revision: source.revision,
63
67
  upstream_sha256: source.digest};
68
+ records?.terminal(receipt);
64
69
  const encoded = JSON.stringify(receipt);
65
70
  if (process.env.GITHUB_OUTPUT) {
66
71
  appendFileSync(process.env.GITHUB_OUTPUT, `review-pending=${receipt.review_pending}\nreceipt=${encoded}\n`);
72
+ appendFileSync(process.env.GITHUB_OUTPUT, `diagnostic-path=${records?.path || ''}\n`);
67
73
  }
68
74
  // The panel parses a success receipt even when a multi-line secret masks its outer braces.
69
75
  console.log(`::${receipt.ok ? 'notice' : 'error'} title=play_upload_result::${encoded}`);
70
76
  if (!receipt.ok) process.exitCode = 1;
77
+ if (records?.failed) {
78
+ console.log('::error title=play_upload_evidence_failed::Sanitized upload evidence could not be retained.');
79
+ process.exitCode = 1;
80
+ }
71
81
  }
72
82
 
73
83
  module.exports = {upload, main};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.181",
3
+ "version": "1.0.182",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -658,6 +658,17 @@ jobs:
658
658
  userFraction: >-
659
659
  ${{ vars.GOOGLE_PLAY_STATUS == 'inProgress' && (vars.GOOGLE_PLAY_USER_FRACTION || '0.2') || '' }}
660
660
  whatsNewDirectory: ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
661
+ versionCode: ${{ steps.android.outputs.build-number || steps.reuse.outputs.build-number }}
662
+
663
+ - name: Retain sanitized Play upload diagnostics
664
+ if: ${{ always() && steps.play-upload.outputs.diagnostic-path != '' }}
665
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
666
+ env:
667
+ NODE_OPTIONS: --require "${{ github.workspace }}/.github/actions/android-app/scripts/artifact_env.cjs"
668
+ with:
669
+ name: play-upload-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
670
+ path: ${{ steps.play-upload.outputs.diagnostic-path }}/*.json
671
+ if-no-files-found: error
661
672
 
662
673
  # The retry landed the bundle without submitting it. That is a fact for
663
674
  - name: Record confirmed Android upload
Binary file