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 +13 -0
- package/android-action/play-upload/action.yml +5 -0
- package/android-action/play-upload/diagnostics.cjs +5 -2
- package/android-action/play-upload/evidence.cjs +30 -0
- package/android-action/play-upload/evidence_schema.cjs +71 -0
- package/android-action/play-upload/process.cjs +2 -1
- package/android-action/play-upload/provider_evidence.cjs +51 -0
- package/android-action/play-upload/run.cjs +12 -2
- package/package.json +1 -1
- package/templates/deploy.yml +11 -0
- package/templates/history.json.gz +0 -0
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) =>
|
|
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
package/templates/deploy.yml
CHANGED
|
@@ -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
|