Skip to content

Commit d17af3d

Browse files
committed
ci: a failure is suspect until a second independent run confirms it
Failures record their attempt number and the last error line. A single failure keeps the combination eligible for one more run on another runner; a second failure confirms it and renders as unsupported. Any success outranks failures for the cell.
1 parent 7707865 commit d17af3d

10 files changed

Lines changed: 148 additions & 63 deletions

File tree

.github/workflows/compat-check.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ jobs:
112112
cd compat
113113
npm install @nativescript/ios@${{ matrix.runtime }} --save-dev
114114
# One retry before a failure is recorded: a runner hiccup must not become a permanent verdict.
115-
ns build ios --disableAnalytics || ns build ios --disableAnalytics
115+
set -o pipefail
116+
(ns build ios --disableAnalytics || ns build ios --disableAnalytics) 2>&1 | tee ../build.log
116117
- name: Record result
117118
# A pinned combination that failed is as final as one that passed: record it so it is never rebuilt.
118119
if: always() && steps.build.outcome != 'skipped'
@@ -121,7 +122,7 @@ jobs:
121122
--toolchain xcode=${{ matrix.xcode }} --toolchain cocoapods="$(pod --version)" \
122123
--with nativescript=${{ matrix.cli }} --with node=${{ matrix.node }} \
123124
--resolved node="$(node --version | tr -d v)" --resolved xcode="$(xcodebuild -version | head -1 | cut -d' ' -f2)" \
124-
--outcome ${{ steps.build.outcome }} \
125+
--outcome ${{ steps.build.outcome }} --signature "$(grep -aE 'error|FAILURE|failed' build.log | grep -viE 'warning|deprecat' | tail -1)" \
125126
--evidence "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
126127
- uses: actions/upload-artifact@v4
127128
if: always() && steps.build.outcome != 'skipped'
@@ -160,15 +161,16 @@ jobs:
160161
ns create compat --tsc --disableAnalytics
161162
cd compat
162163
npm install @nativescript/android@${{ matrix.runtime }} --save-dev
163-
ns build android --compileSdk ${{ matrix.compileSdk }} --disableAnalytics || ns build android --compileSdk ${{ matrix.compileSdk }} --disableAnalytics
164+
set -o pipefail
165+
(ns build android --compileSdk ${{ matrix.compileSdk }} --disableAnalytics || ns build android --compileSdk ${{ matrix.compileSdk }} --disableAnalytics) 2>&1 | tee ../build.log
164166
- name: Record result
165167
if: always() && steps.build.outcome != 'skipped'
166168
run: |
167169
node scripts/report-verification.mjs --package @nativescript/android --version ${{ matrix.runtime }} \
168170
--toolchain compileSdk=${{ matrix.compileSdk }} --toolchain buildTools=${{ matrix.buildTools }} --toolchain jdk=${{ matrix.jdk }} \
169171
--with nativescript=${{ matrix.cli }} --with node=${{ matrix.node }} \
170172
--resolved node="$(node --version | tr -d v)" --resolved jdk="$(java -version 2>&1 | head -1 | cut -d'"' -f2)" \
171-
--outcome ${{ steps.build.outcome }} \
173+
--outcome ${{ steps.build.outcome }} --signature "$(grep -aE 'error|FAILURE|failed' build.log | grep -viE 'warning|deprecat' | tail -1)" \
172174
--evidence "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
173175
- uses: actions/upload-artifact@v4
174176
if: always() && steps.build.outcome != 'skipped'

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ as a browser-support-style matrix (Vue + Vite) for people.
4040
2. **Build jobs**: one job per combination installs the pinned CLI and
4141
Node.js, creates a fresh app, builds it with the pinned runtime and
4242
toolchain, and writes its result file under `data/verified/` whether it
43-
passed or failed. A success proves the runtime's toolchain cells and the
44-
CLI's Node.js cell; a failure marks the toolchain cells unsupported. Either
45-
way the combination is never rebuilt.
43+
passed or failed, with the last error line as a signature. A success is
44+
final. A failure is suspect until a later run fails the same combination
45+
again on another runner, which confirms it; a success in between clears it.
4646
3. **Collect**: the artifacts are dropped onto the checkout and committed to
4747
the default branch. Every file names the run that produced it, so the
4848
commit is the audit trail; nothing needs a manual merge.
@@ -59,7 +59,9 @@ For a release and a toolchain version, in this order:
5959
1. **Unsupported / Advisory**: a maintainer advisory matches the release and
6060
the toolchain version.
6161
2. **Verified**: a recorded CI build succeeded with that toolchain version.
62-
A recorded CI failure marks the cell unsupported instead.
62+
Any success outranks failures. A failure only marks the cell unsupported
63+
once a second, independent run has failed the same combination; a single
64+
failure shows as unverified with a pending second attempt.
6365
3. **Declared**: inside the range the package published under
6466
`nativescript.requirements`, or inside a maintainer override for releases
6567
that predate the block.

schemas/compatibility.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,17 @@
101101
},
102102
"failed": {
103103
"type": "object",
104-
"description": "Toolchain versions a CI build failed with, per key.",
104+
"description": "Toolchain versions whose builds failed in two independent CI runs, per key.",
105+
"additionalProperties": {
106+
"type": "array",
107+
"items": {
108+
"type": "string"
109+
}
110+
}
111+
},
112+
"suspect": {
113+
"type": "object",
114+
"description": "Toolchain versions with a single CI failure, awaiting a second attempt, per key.",
105115
"additionalProperties": {
106116
"type": "array",
107117
"items": {

schemas/verified.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "http://json-schema.org/draft-07/schema#",
33
"$id": "https://compatibility.nativescript.org/v1/schemas/verified.json",
44
"title": "CI build result",
5-
"description": "One pinned build of a runtime version with its toolchain versions, CLI version and Node.js major, and whether it succeeded. Stored under data/verified/<package>/ as <timestamp>_<version>_<pinned versions>.json; CI only ever adds files, and a combination recorded once, whatever the outcome, is never rebuilt.",
5+
"description": "One pinned build of a runtime version with its toolchain versions, CLI version and Node.js major, and its outcome. Stored under data/verified/<package>/ as <timestamp>_<version>_<pinned versions>.json. A success is final. A failure is suspect until a second, independent run fails the same combination (attempts >= 2); a later success outranks any failure.",
66
"type": "object",
77
"required": [
88
"package",
@@ -79,6 +79,15 @@
7979
],
8080
"default": "success",
8181
"description": "Omitted means success."
82+
},
83+
"attempts": {
84+
"type": "integer",
85+
"minimum": 1,
86+
"description": "For failures: how many independent runs have failed this combination, this one included."
87+
},
88+
"signature": {
89+
"type": "string",
90+
"description": "The last recognisable error line of the failed build."
8291
}
8392
}
8493
}

scripts/build-matrix.mjs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// MANUAL_XCODE or MANUAL_COMPILE_SDK + MANUAL_JDK. MANUAL_FORCE=true rebuilds
1414
// a combination that is already recorded.
1515
import { appendFileSync } from "node:fs";
16-
import { readResults } from "./lib/results.mjs";
16+
import { readResults, status } from "./lib/results.mjs";
1717

1818
const REGISTRY = "https://registry.npmjs.org/";
1919
const XCODE_FEED = "https://xcodereleases.com/data.json";
@@ -190,19 +190,12 @@ async function jdkReleases() {
190190

191191
const recorded = readResults();
192192

193-
// A result counts when its pins match and it covers at least the toolchains
194-
// the matrix controls; jobs also record incidental versions such as CocoaPods.
193+
// A success is final and a failure confirmed by two independent runs is
194+
// final; a single failure stays eligible so a later run can retry it on a
195+
// different runner.
195196
function isRecorded(candidate) {
196-
return recorded.some(
197-
(result) =>
198-
result.package === candidate.package &&
199-
result.version === candidate.version &&
200-
result.with.nativescript === candidate.with.nativescript &&
201-
result.with.node === candidate.with.node &&
202-
Object.entries(candidate.toolchains).every(
203-
([key, version]) => result.toolchains[key] !== undefined && sameLine(result.toolchains[key], version),
204-
),
205-
);
197+
const current = status(recorded, candidate);
198+
return current === "success" || current === "confirmed";
206199
}
207200

208201
function emit(matrix) {

scripts/lib/results.mjs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,44 @@ export function resultPath(result, date = new Date()) {
2121
return new URL(`${result.package}/${timestamp(date)}_${result.version}_${resultKey(result.toolchains, result.with)}.json`, ROOT);
2222
}
2323

24-
export function findRecorded(results, candidate) {
25-
return results.find(
26-
(result) =>
27-
result.package === candidate.package &&
28-
result.version === candidate.version &&
29-
result.with.nativescript === candidate.with.nativescript &&
30-
result.with.node === candidate.with.node &&
31-
resultKey(result.toolchains, {}) === resultKey(candidate.toolchains, {}),
24+
/** Toolchains recorded for the record but chosen by the job, not by the matrix. */
25+
const INCIDENTAL = new Set(["cocoapods", "buildTools"]);
26+
27+
function controlled(toolchains) {
28+
return Object.fromEntries(Object.entries(toolchains).filter(([key]) => !INCIDENTAL.has(key)));
29+
}
30+
31+
/** Records of the same pinned combination, ignoring incidental toolchains. */
32+
export function sameCombination(a, b) {
33+
return (
34+
a.package === b.package &&
35+
a.version === b.version &&
36+
a.with.nativescript === b.with.nativescript &&
37+
a.with.node === b.with.node &&
38+
resultKey(controlled(a.toolchains), {}) === resultKey(controlled(b.toolchains), {})
3239
);
3340
}
3441

42+
/**
43+
* How a combination stands: "success" if any run built it, "confirmed" after
44+
* two independent failures, "suspect" after one, or null if never recorded.
45+
*/
46+
export function status(results, candidate) {
47+
const matching = results.filter((result) => sameCombination(result, candidate));
48+
if (!matching.length) {
49+
return null;
50+
}
51+
if (matching.some((result) => (result.outcome ?? "success") === "success")) {
52+
return "success";
53+
}
54+
const attempts = Math.max(...matching.map((result) => result.attempts ?? 1));
55+
return attempts >= 2 ? "confirmed" : "suspect";
56+
}
57+
58+
export function failedAttempts(results, candidate) {
59+
return results.filter((result) => sameCombination(result, candidate) && result.outcome === "failure").length;
60+
}
61+
3562
export function readResults() {
3663
const results = [];
3764
const walk = (dir) => {

scripts/report-verification.mjs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
#!/usr/bin/env node
2-
// Records a successful pinned build under data/verified/. CI jobs and humans
3-
// use the same command; an existing file is left untouched.
2+
// Records a pinned build under data/verified/. CI jobs and humans use the
3+
// same command. A success is recorded once. A failure is recorded per
4+
// independent run with its attempt number, so the second one confirms the
5+
// combination as failing; a later success outranks any failure.
46
//
57
// node scripts/report-verification.mjs --package @nativescript/ios --version 9.1.0 \
68
// --toolchain xcode=26.3 --toolchain cocoapods=1.16.2 \
7-
// --with nativescript=9.1.1 --with node=22 --resolved node=22.23.2 --evidence <url> [--outcome failure]
9+
// --with nativescript=9.1.1 --with node=22 --resolved node=22.23.2 --evidence <url> \
10+
// [--outcome failure --signature "<last error line>"]
811
import { mkdirSync, writeFileSync } from "node:fs";
912
import { dirname } from "node:path";
10-
import { findRecorded, readResults, resultPath } from "./lib/results.mjs";
13+
import { failedAttempts, readResults, resultPath, status } from "./lib/results.mjs";
1114

1215
const args = process.argv.slice(2);
1316
const options = { toolchains: {}, with: {}, resolved: {} };
1417
for (let i = 0; i < args.length; i += 2) {
1518
const flag = args[i].replace(/^--/, "");
1619
const value = args[i + 1];
17-
if (["toolchain", "with", "resolved"].includes(flag)) {
20+
if (flag === "signature") {
21+
options.signature = value;
22+
} else if (["toolchain", "with", "resolved"].includes(flag)) {
1823
const [key, version] = value.split("=");
1924
options[flag === "toolchain" ? "toolchains" : flag][key] = version;
2025
} else {
@@ -27,21 +32,24 @@ if (!options.package || !options.version || !Object.keys(options.toolchains).len
2732
process.exit(1);
2833
}
2934

35+
const recorded = readResults();
36+
const failure = options.outcome === "failure";
37+
const candidate = { package: options.package, version: options.version, toolchains: options.toolchains, with: options.with };
3038
const result = {
3139
$schema: "https://compatibility.nativescript.org/v1/schemas/verified.json",
32-
package: options.package,
33-
version: options.version,
34-
toolchains: options.toolchains,
35-
with: options.with,
40+
...candidate,
3641
...(Object.keys(options.resolved).length ? { resolved: options.resolved } : {}),
37-
...(options.outcome === "failure" ? { outcome: "failure" } : {}),
42+
...(failure ? { outcome: "failure", attempts: failedAttempts(recorded, candidate) + 1 } : {}),
43+
...(failure && options.signature ? { signature: options.signature.slice(0, 300) } : {}),
3844
...(options.evidence ? { evidence: options.evidence } : {}),
3945
recordedAt: new Date().toISOString(),
4046
};
4147

42-
const existing = findRecorded(readResults(), result);
43-
if (existing) {
44-
console.log(`already recorded at ${existing.recordedAt}`);
48+
const current = status(recorded, candidate);
49+
if (current === "success" && !failure) {
50+
console.log("already recorded as a success");
51+
} else if (current === "confirmed" && failure) {
52+
console.log("already confirmed as failing");
4553
} else {
4654
const file = resultPath(result);
4755
mkdirSync(dirname(file.pathname), { recursive: true });

shared/compute.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,15 @@ export function buildDocument(input: {
6363
manifest.requirements,
6464
input.overrides,
6565
);
66-
const failed = verifiedFor(spec.name, manifest.version, input.verified, "failure");
66+
const verified = verifiedFor(spec.name, manifest.version, input.verified, "success");
67+
const failed = verifiedFor(spec.name, manifest.version, input.verified, "confirmed");
68+
const suspect = verifiedFor(spec.name, manifest.version, input.verified, "suspect");
6769
versions[manifest.version] = {
6870
requirements: requirements.ranges,
6971
source: requirements.source,
70-
verified: verifiedFor(spec.name, manifest.version, input.verified, "success"),
72+
verified,
7173
...(Object.keys(failed).length ? { failed } : {}),
74+
...(Object.keys(suspect).length ? { suspect } : {}),
7275
publishedAt: manifest.publishedAt,
7376
};
7477
}
@@ -116,16 +119,27 @@ export function effectiveRequirements(
116119
return { ranges, source };
117120
}
118121

122+
export type ResultClass = "success" | "confirmed" | "suspect";
123+
124+
/** A failure counts once a second independent run has failed the same combination. */
125+
export function classify(entry: VerificationResult): ResultClass {
126+
if ((entry.outcome ?? "success") === "success") {
127+
return "success";
128+
}
129+
return (entry.attempts ?? 1) >= 2 ? "confirmed" : "suspect";
130+
}
131+
119132
/**
120-
* A result proves every toolchain it pinned for the runtime it built, and the
121-
* Node.js major for the CLI it built with.
133+
* A successful result proves every toolchain it pinned for the runtime it
134+
* built, and the Node.js major for the CLI it built with. A failure only
135+
* speaks about the runtime's toolchains.
122136
*/
123137
export function verifiedPairs(
124138
verified: VerificationResult[],
125-
outcome: "success" | "failure" = "success",
139+
outcome: ResultClass = "success",
126140
): Array<{ package: string; version: string; toolchain: ToolchainKey; toolchainVersion: string }> {
127141
return verified
128-
.filter((entry) => (entry.outcome ?? "success") === outcome)
142+
.filter((entry) => classify(entry) === outcome)
129143
.flatMap((entry) => [
130144
...Object.entries(entry.toolchains).map(([toolchain, toolchainVersion]) => ({
131145
package: entry.package,
@@ -151,7 +165,7 @@ function verifiedFor(
151165
packageName: string,
152166
version: string,
153167
verified: VerificationResult[],
154-
outcome: "success" | "failure",
168+
outcome: ResultClass,
155169
): VersionCompatibility["verified"] {
156170
const result: VersionCompatibility["verified"] = {};
157171
for (const pair of verifiedPairs(verified, outcome)) {
@@ -221,11 +235,16 @@ export function cellFor(
221235
};
222236
}
223237

238+
// Any success outranks failures: a combination that built once builds.
224239
if (entry.verified[key]?.some((verified) => sameLine(verified, toolchainVersion))) {
225-
return { state: "verified", reason: "verified by CI" };
240+
const alsoFailed = entry.suspect?.[key]?.some((failed) => sameLine(failed, toolchainVersion));
241+
return { state: "verified", reason: alsoFailed ? "verified by CI (one other attempt failed)" : "verified by CI" };
226242
}
227243
if (entry.failed?.[key]?.some((failed) => sameLine(failed, toolchainVersion))) {
228-
return { state: "unsupported", reason: "build failed in CI" };
244+
return { state: "unsupported", reason: "build failed in two independent CI runs" };
245+
}
246+
if (entry.suspect?.[key]?.some((failed) => sameLine(failed, toolchainVersion))) {
247+
return { state: "unverified", reason: "one CI build failed; a second attempt is pending" };
229248
}
230249

231250
const range = entry.requirements[key];

shared/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ export interface VersionCompatibility {
3737
source: RequirementSource;
3838
/** Toolchain versions a CI build proved to work, per key. */
3939
verified: Partial<Record<ToolchainKey, string[]>>;
40-
/** Toolchain versions a CI build failed with, per key. */
40+
/** Toolchain versions whose builds failed in two independent CI runs, per key. */
4141
failed?: Partial<Record<ToolchainKey, string[]>>;
42+
/** Toolchain versions with a single CI failure, awaiting a second attempt, per key. */
43+
suspect?: Partial<Record<ToolchainKey, string[]>>;
4244
publishedAt?: string;
4345
}
4446

@@ -105,6 +107,10 @@ export interface VerificationResult {
105107
with: { nativescript: string; node: string };
106108
/** Omitted means success. */
107109
outcome?: "success" | "failure";
110+
/** For failures: independent runs that failed this combination, this one included. */
111+
attempts?: number;
112+
/** The last recognisable error line of a failed build. */
113+
signature?: string;
108114
resolved?: Record<string, string>;
109115
/** Workflow run or job URL that proved it. */
110116
evidence?: string;

0 commit comments

Comments
 (0)