gowalk-cicd 1.0.132 → 1.0.134
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/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/android-action/play-upload/run.cjs +32 -1
- package/android-action/scripts/android_build_diagnostics.py +58 -2
- package/android-action/scripts/test_android_build_diagnostics.py +40 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.134
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.134
|
|
@@ -32,6 +32,21 @@ async function upload(program, env, directory, execute = processRunner.execute,
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
//: A code reaches the annotation only as a bare token: it is interpolated into
|
|
36
|
+
//: workflow-command syntax, so anything outside this shape is reported as such
|
|
37
|
+
//: rather than trusted.
|
|
38
|
+
function state(code) {
|
|
39
|
+
return typeof code === 'string' && /^[a-z0-9_]{1,64}$/.test(code) ? code : 'unknown';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stateAnnotation(receipt) {
|
|
43
|
+
return `::${receipt.ok === true ? 'notice' : 'error'} title=play_upload_state::`
|
|
44
|
+
+ `gowalk-cicd/play-upload-state.v1 ok=${receipt.ok === true}`
|
|
45
|
+
+ ` code=${state(receipt.code)} review_pending=${receipt.review_pending === true}`
|
|
46
|
+
+ ` attempts=${Number.isSafeInteger(receipt.attempts) ? receipt.attempts : 'unknown'}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
35
50
|
async function main() {
|
|
36
51
|
let directory, receipt;
|
|
37
52
|
try {
|
|
@@ -66,10 +81,26 @@ async function main() {
|
|
|
66
81
|
appendFileSync(process.env.GITHUB_OUTPUT, `review-pending=${receipt.review_pending}\nreceipt=${encoded}\n`);
|
|
67
82
|
}
|
|
68
83
|
console.log(`::${receipt.ok ? 'notice' : 'error'} title=play_upload_result::${encoded}`);
|
|
84
|
+
// The JSON receipt above does not survive as a READABLE annotation on this job.
|
|
85
|
+
// `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` is a multi-line JSON secret, so Actions
|
|
86
|
+
// registers each of its lines as maskable — and its first and last lines are a
|
|
87
|
+
// bare `{` and `}`. Every brace in this job's output is therefore replaced by
|
|
88
|
+
// `***`, leaving the payload legible to a person but not parseable by the
|
|
89
|
+
// panel, which reads typed check-run annotations and is forbidden the log text.
|
|
90
|
+
// The iOS job emits the same JSON shape unmasked, which is what isolates the
|
|
91
|
+
// cause to this job's secret rather than to redaction policy in general.
|
|
92
|
+
//
|
|
93
|
+
// So the decision-relevant fields also go out brace-free, in the same
|
|
94
|
+
// key=value shape as `play_upload_diagnostic` — which is emitted from this
|
|
95
|
+
// very action and arrives unmasked. `review_pending` is the field that
|
|
96
|
+
// distinguishes an upload from a submission, and losing it is how a green
|
|
97
|
+
// Android job reads as "Play is done" when the AAB sits unreviewed on a
|
|
98
|
+
// test track.
|
|
99
|
+
console.log(stateAnnotation(receipt));
|
|
69
100
|
if (!receipt.ok) process.exitCode = 1;
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
module.exports = {upload, main};
|
|
103
|
+
module.exports = {upload, main, state, stateAnnotation};
|
|
73
104
|
if (require.main === module) void main().catch(() => {
|
|
74
105
|
process.stderr.write('play_upload_receipt_failed\n'); process.exitCode = 1;
|
|
75
106
|
});
|
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
"""Run one Android compiler with bounded private output and fixed failure signals.
|
|
1
|
+
"""Run one Android compiler with bounded private output and fixed failure signals.
|
|
2
|
+
|
|
3
|
+
A signal alone does not locate a failure. ``signals":["dependency_resolution",
|
|
4
|
+
"tls_verification"]`` was the WHOLE record of a 15-minute Android build on task
|
|
5
|
+
1183 (run 34362765661): the session could not tell which artifact would not
|
|
6
|
+
resolve or whose certificate was rejected, so its only move was to run the same
|
|
7
|
+
build again. Child output stays unpublished — it carries the store proxy's
|
|
8
|
+
credentials, and GitHub masks only the exact registered secret — so the detail
|
|
9
|
+
is EXTRACTED and SANITIZED instead: bare hostnames and artifact coordinates,
|
|
10
|
+
never a URL, never userinfo, never a free-text line.
|
|
11
|
+
"""
|
|
2
12
|
import argparse
|
|
3
13
|
import json
|
|
14
|
+
import re
|
|
4
15
|
import subprocess
|
|
5
16
|
import sys
|
|
6
17
|
import threading
|
|
7
18
|
import time
|
|
8
19
|
|
|
9
20
|
LIMIT = 65536
|
|
21
|
+
#: At most this many extracted facts per kind — a bounded record, not a log.
|
|
22
|
+
MAX_DETAILS = 8
|
|
23
|
+
#: A host in a URL, with any ``user:password@`` userinfo dropped by the pattern
|
|
24
|
+
#: itself so a credential can never reach the capture group.
|
|
25
|
+
_URL_HOST = re.compile(r"\bhttps?://(?:[^/@\s]*@)?([A-Za-z0-9.-]+\.[A-Za-z]{2,})")
|
|
26
|
+
#: Gradle/Maven coordinates: group:artifact:version, which carry no secrets.
|
|
27
|
+
_COORDINATE = re.compile(r"\b([A-Za-z][\w.-]*(?::[\w.+-]+){2})\b")
|
|
28
|
+
#: Hosts a certificate complaint names, e.g. "No subject alternative DNS name
|
|
29
|
+
#: matching pub.dev found" or "unable to find valid certification path to <host>".
|
|
30
|
+
_CERT_HOST = re.compile(r"(?:matching|for|to)\s+([a-z0-9-]+(?:\.[a-z0-9-]+)+)", re.I)
|
|
10
31
|
SIGNALS = {
|
|
11
32
|
"tls_verification": ("PKIX path building failed", "CERTIFICATE_VERIFY_FAILED", "certificate verify failed"),
|
|
12
33
|
"dependency_resolution": ("Could not resolve all files", "Could not resolve all artifacts", "Could not GET '"),
|
|
@@ -29,6 +50,39 @@ def signals(output: bytes) -> list[str]:
|
|
|
29
50
|
return sorted(name for name, patterns in SIGNALS.items() if any(pattern in text for pattern in patterns))
|
|
30
51
|
|
|
31
52
|
|
|
53
|
+
def _bounded(values) -> list[str]:
|
|
54
|
+
"""Deduplicate, order and cap — a record the session can act on."""
|
|
55
|
+
seen = []
|
|
56
|
+
for value in values:
|
|
57
|
+
if value not in seen:
|
|
58
|
+
seen.append(value)
|
|
59
|
+
if len(seen) >= MAX_DETAILS:
|
|
60
|
+
break
|
|
61
|
+
return sorted(seen)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def details(output: bytes, found: list[str]) -> dict:
|
|
65
|
+
"""Sanitized facts that LOCATE the signals, extracted from the private tail.
|
|
66
|
+
|
|
67
|
+
Only pattern captures leave this function: hostnames and dependency
|
|
68
|
+
coordinates. The raw text, any URL, and anything shaped like userinfo stay
|
|
69
|
+
in the buffer that is discarded with the process.
|
|
70
|
+
"""
|
|
71
|
+
text = output.decode("utf-8", errors="replace")
|
|
72
|
+
out = {}
|
|
73
|
+
if "dependency_resolution" in found or "tls_verification" in found:
|
|
74
|
+
hosts = _bounded(_URL_HOST.findall(text))
|
|
75
|
+
if "tls_verification" in found:
|
|
76
|
+
hosts = _bounded(hosts + _CERT_HOST.findall(text))
|
|
77
|
+
if hosts:
|
|
78
|
+
out["hosts"] = hosts
|
|
79
|
+
if "dependency_resolution" in found:
|
|
80
|
+
coordinates = _bounded(_COORDINATE.findall(text))
|
|
81
|
+
if coordinates:
|
|
82
|
+
out["artifacts"] = coordinates
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
32
86
|
def emit(title: str, level: str, **fields) -> None:
|
|
33
87
|
print(f"::{level} title={title}::" + json.dumps(fields, separators=(",", ":")), flush=True)
|
|
34
88
|
|
|
@@ -60,8 +114,10 @@ def run(phase: str, command: list[str], *, interval: float = 60) -> int:
|
|
|
60
114
|
if not reader.is_alive():
|
|
61
115
|
child.stdout.close()
|
|
62
116
|
if code:
|
|
117
|
+
retained = bytes(tail)
|
|
118
|
+
found = signals(retained)
|
|
63
119
|
emit("android_build_failed", "error", schema="gowalk-cicd/android-build-failed.v1",
|
|
64
|
-
phase=phase, exit_code=code, signals=
|
|
120
|
+
phase=phase, exit_code=code, signals=found, **details(retained, found))
|
|
65
121
|
return code if code >= 0 else 128 - code
|
|
66
122
|
|
|
67
123
|
|
|
@@ -103,6 +103,46 @@ class BuildDiagnosticsTests(unittest.TestCase):
|
|
|
103
103
|
self.assertIn('"signals":["r8_missing_classes"]', result.stdout)
|
|
104
104
|
self.assertNotIn("private-class", result.stdout + result.stderr)
|
|
105
105
|
|
|
106
|
+
def test_dependency_failure_names_hosts_and_artifacts_without_credentials(self):
|
|
107
|
+
"""A signal alone cannot be acted on; the record must LOCATE the failure.
|
|
108
|
+
|
|
109
|
+
Task 1183's run 34362765661 recorded only the two signal names for a
|
|
110
|
+
15-minute build, so the session could only run it again.
|
|
111
|
+
"""
|
|
112
|
+
code, rows, output = self.invoke(
|
|
113
|
+
"import sys; print(\"Could not resolve all files for configuration ':app:releaseRuntimeClasspath'\");"
|
|
114
|
+
"print('Could not resolve com.google.firebase:firebase-analytics:21.5.0');"
|
|
115
|
+
"print(\"Could not GET 'https://proxyuser:sup3rSecret@residential.exit.test:8080/maven2/x.pom'\");"
|
|
116
|
+
"print('PKIX path building failed: unable to find valid certification path to dl.google.com');"
|
|
117
|
+
"sys.exit(1)"
|
|
118
|
+
)
|
|
119
|
+
self.assertEqual(code, 1)
|
|
120
|
+
row = rows[0]
|
|
121
|
+
self.assertEqual(row["signals"], ["dependency_resolution", "tls_verification"])
|
|
122
|
+
self.assertIn("dl.google.com", row["hosts"])
|
|
123
|
+
self.assertIn("residential.exit.test", row["hosts"])
|
|
124
|
+
self.assertIn("com.google.firebase:firebase-analytics:21.5.0", row["artifacts"])
|
|
125
|
+
for secret in ("sup3rSecret", "proxyuser", "maven2", "x.pom"):
|
|
126
|
+
self.assertNotIn(secret, output)
|
|
127
|
+
|
|
128
|
+
def test_extracted_detail_is_bounded_and_ordered(self):
|
|
129
|
+
text = "".join(f"Could not GET 'https://host{n}.test/a'\n" for n in range(40)).encode()
|
|
130
|
+
found = ["dependency_resolution"]
|
|
131
|
+
hosts = diagnostics.details(text, found)["hosts"]
|
|
132
|
+
self.assertEqual(len(hosts), diagnostics.MAX_DETAILS)
|
|
133
|
+
self.assertEqual(hosts, sorted(hosts))
|
|
134
|
+
|
|
135
|
+
def test_a_signal_that_needs_no_locating_adds_no_detail(self):
|
|
136
|
+
code, rows, _ = self.invoke(
|
|
137
|
+
"import sys; print('Missing classes detected while running R8'); sys.exit(3)")
|
|
138
|
+
self.assertEqual(code, 3)
|
|
139
|
+
self.assertEqual(set(rows[0]), {"schema", "phase", "exit_code", "signals"})
|
|
140
|
+
|
|
141
|
+
def test_userinfo_never_reaches_the_record_even_without_a_signal_match(self):
|
|
142
|
+
detail = diagnostics.details(b"Could not GET 'https://u:p@only.test/x'", ["dependency_resolution"])
|
|
143
|
+
self.assertEqual(detail["hosts"], ["only.test"])
|
|
144
|
+
self.assertNotIn("p@", json.dumps(detail))
|
|
145
|
+
|
|
106
146
|
|
|
107
147
|
if __name__ == "__main__":
|
|
108
148
|
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.134
|