fix(ci): rebalance native tests and stabilize flaky gates (#3719)

* fix(ci): stabilize timing gates and artifact uploads

* fix(ci): rebalance undercharged native test shards

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
Gergő Magyar 2026-10-25 09:38:21 +00:00 • committed by GitHub
parent be32540b6b
commit ee7eff97f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 252 additions and 95 deletions

View file

@ -0,0 +1,66 @@
name: Upload test artifact
description: Upload required CI evidence with bounded transport retries
inputs:
name:
description: Unique artifact name for this job
required: true
path:
description: Files to upload
required: true
retention-days:
description: Artifact retention in days
default: '5'
include-hidden-files:
description: Include explicitly selected hidden report directories
default: 'false'
runs:
using: composite
steps:
- name: Upload
id: first
if: ${{ always() }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}
retention-days: ${{ inputs.retention-days }}
include-hidden-files: ${{ inputs.include-hidden-files }}
if-no-files-found: error
- name: Wait before retry
if: ${{ !cancelled() && steps.first.outcome == 'failure' }}
shell: bash
run: sleep 10
- name: Retry upload
id: second
if: ${{ !cancelled() && steps.first.outcome == 'failure' }}
continue-on-error: true
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}
retention-days: ${{ inputs.retention-days }}
include-hidden-files: ${{ inputs.include-hidden-files }}
if-no-files-found: error
# A failed request may already have created an incomplete artifact.
overwrite: true
- name: Wait before final attempt
if: ${{ !cancelled() && steps.second.outcome == 'failure' }}
shell: bash
run: sleep 30
- name: Final upload attempt
if: ${{ !cancelled() && steps.second.outcome == 'failure' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ inputs.name }}
path: ${{ inputs.path }}
retention-days: ${{ inputs.retention-days }}
include-hidden-files: ${{ inputs.include-hidden-files }}
if-no-files-found: error
overwrite: true

View file

@ -29,13 +29,12 @@ jobs:
fi
- name: Upload release accuracy evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: release-tool-accuracy
path: |
${{ runner.temp }}/tool-accuracy/accuracy.json
${{ runner.temp }}/tool-accuracy/accuracy.md
if-no-files-found: error
retention-days: 30
# Ubuntu full-suite coverage, sharded. Each shard writes a vitest blob report
@ -108,7 +107,7 @@ jobs:
working-directory: gitnexus
- name: Upload coverage blob
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: coverage-blob-${{ matrix.shard }}
path: gitnexus/.vitest/blob/
@ -171,7 +170,7 @@ jobs:
run: node --test docker-server.test.mjs
- name: Upload test reports
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: coverage-reports
path: |
@ -195,9 +194,9 @@ jobs:
steps:
- id: gen
run: |
# Refreshed Windows timings leave eight shards too close to the
# 20-minute watchdog. Ten preserve headroom for runner variation.
TOTAL=10 # cross-platform (windows/macOS) shards per OS
# Keep the recorded native workload below 17 minutes, leaving
# headroom for runner variation under the 20-minute watchdog.
TOTAL=11 # cross-platform (windows/macOS) shards per OS
# Four coverage shards exceeded the 25-minute job limit while tests
# were still passing. Split the full suite further; keep the timeout
# and merged coverage thresholds unchanged.
@ -297,11 +296,10 @@ jobs:
working-directory: gitnexus
- name: Upload platform execution report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: execution-${{ matrix.os }}-${{ matrix.shard }}
path: gitnexus/${{ matrix.os }}-${{ matrix.shard }}.json
if-no-files-found: error
retention-days: 5
# Branch protection still requires these six names from the three-shard
@ -1103,11 +1101,10 @@ jobs:
working-directory: gitnexus
- name: Upload benchmark execution report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: execution-benchmarks
path: gitnexus/benchmarks.json
if-no-files-found: error
retention-days: 5
# Fresh-process measurements have their own runner and time budget so they
@ -1130,11 +1127,10 @@ jobs:
working-directory: gitnexus
- name: Upload scope-resolution measurements
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: scope-resolution-benchmark
path: gitnexus/scope-resolution-benchmark.json
if-no-files-found: error
retention-days: 5
# Locked eval suite. setup-uv and uv itself are immutable so CI exercises
@ -1158,11 +1154,10 @@ jobs:
working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: execution-pytest-locked
path: eval/pytest-locked.xml
if-no-files-found: error
retention-days: 5
- uses: ./.github/actions/setup-gitnexus
with:
@ -1175,11 +1170,10 @@ jobs:
working-directory: gitnexus
- name: Upload preflight execution report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: execution-preflight
path: gitnexus/preflight.json
if-no-files-found: error
retention-days: 5
test-completeness:
@ -1225,7 +1219,7 @@ jobs:
run: python3 eval/check_test_execution.py execution-reports eval/pytest-results.json
- name: Upload complete test reports
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: test-reports
path: |
@ -1234,7 +1228,6 @@ jobs:
gitnexus/test-results.json
gitnexus-web/web-test-results.json
eval/pytest-results.json
if-no-files-found: error
retention-days: 5
# Native Linux ownership and Bubblewrap boundary. The environment flag makes
@ -1336,11 +1329,10 @@ jobs:
working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: execution-pytest-ubuntu
path: eval/pytest-ubuntu.xml
if-no-files-found: error
retention-days: 5
# Native Windows Job Object canary. POSIX-only tests skip by platform, while
@ -1367,9 +1359,8 @@ jobs:
working-directory: eval
- name: Upload pytest execution report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: ./.github/actions/upload-test-artifact
with:
name: execution-pytest-windows
path: eval/pytest-windows.xml
if-no-files-found: error
retention-days: 5

View file

@ -352,6 +352,62 @@ def test_platform_ci_preserves_required_check_names_with_complete_execution_gate
}
def test_test_artifact_retries_cannot_hide_missing_evidence(ci_test_jobs):
repo_root = Path(__file__).resolve().parents[2]
action_path = ".github/actions/upload-test-artifact/action.yml"
action = yaml.safe_load((repo_root / action_path).read_text())
uploads = [step for step in action["runs"]["steps"] if "uses" in step]
assert len(uploads) == 3
first, second, final = uploads
assert first["if"] == "${{ always() }}"
assert second["if"] == "${{ !cancelled() && steps.first.outcome == 'failure' }}"
assert final["if"] == "${{ !cancelled() && steps.second.outcome == 'failure' }}"
assert first["id"] == "first" and second["id"] == "second"
assert first["continue-on-error"] is True
assert second["continue-on-error"] is True
assert not final.get("continue-on-error", False)
assert not first["with"].get("overwrite", False)
assert second["with"]["overwrite"] is True
assert final["with"]["overwrite"] is True
for step in uploads:
assert re.fullmatch(r"actions/upload-artifact@[a-f0-9]{40}", step["uses"])
assert step["with"]["if-no-files-found"] == "error"
for key in ("name", "path", "retention-days", "include-hidden-files"):
assert step["with"][key] == "${{ inputs." + key + " }}"
evidence_jobs = {
"tests", "coverage-merge", "cross-platform", "benchmarks",
"scope-resolution-benchmark", "eval-tests", "test-completeness",
"eval-containment-windows", "eval-containment-linux", "tool-accuracy",
}
for name in evidence_jobs:
job = ci_test_jobs[name]
reports = [
step for step in job["steps"]
if step.get("uses") == "./.github/actions/upload-test-artifact"
]
assert reports, f"{name} must publish execution evidence"
assert not job.get("continue-on-error", False)
for step in reports:
assert not step.get("continue-on-error", False)
assert step["with"]["name"] and step["with"]["path"]
artifact_names = set()
for job in ci_test_jobs.values():
for step in job.get("steps", []):
assert not step.get("uses", "").startswith("actions/upload-artifact@")
if step.get("uses") != "./.github/actions/upload-test-artifact":
continue
name = step["with"]["name"]
assert name not in artifact_names, f"Retry could overwrite another job: {name}"
artifact_names.add(name)
for axis in job.get("strategy", {}).get("matrix", {}):
if axis not in {"include", "exclude"}:
assert "${{ matrix." + axis + " }}" in name, (
f"Artifact {name} must distinguish matrix axis {axis}"
)
@pytest.mark.parametrize("native_result", ["success", "failure", "cancelled", "skipped", ""])
@pytest.mark.parametrize("execution_result", ["success", "failure", "cancelled", "skipped", ""])
@pytest.mark.parametrize("scope_bench_result", ["success", "failure", "cancelled", "skipped", ""])

View file

@ -15,7 +15,7 @@
* same-named callable in the file.
*
* For a synthetic corpus at two scales it reports:
* - elapsed_ms_small / elapsed_ms_large (fastest of REPS, see `fastest`) + a scaling ratio
* - elapsed_ms_small / elapsed_ms_large (median of window minima) + a scaling ratio
* `(t_large/t_small)/(LARGE/SMALL)`: ~1.0 linear, ~3.x quadratic;
* - `callable_only_ms_large`, the same corpus with the PRE-#2693 def
* population, so the cost the widening actually added stays visible as
@ -48,8 +48,9 @@ const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
const REPS = 7;
const WARMUP = 2;
const WINDOW_MS = 1000;
/**
* Deterministic synthetic corpus — no randomness, so the fingerprint is stable.
@ -144,34 +145,23 @@ function callableOnlyScopes(scopes) {
return { defs: { byId } };
}
/**
* MIN, not median. Both scales are timed in one process, and every source of
* error here is additive — scheduler preemption, GC, a noisy neighbour on a
* shared CI runner. The fastest observed run is the closest estimate of the
* uncontended cost, so the derived ratios stay comparable across machines
* instead of tracking whatever else the box was doing. (Measured directly: the
* same build reported an overhead of 1.65 idle and 2.03 while a test shard was
* running — a median-based gate would have to be loosened until it could no
* longer detect the regression it exists to catch.)
*/
function fastest(values) {
return Math.min(...values);
function median(values) {
return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
}
function timeIndex(scopes, nodeLookup, graph) {
// Warm up before timing: the first calls carry JIT compilation of the whole
// resolve chain, and the widened and callable-only runs would otherwise be
// measured at different optimisation tiers — which alone moved the reported
// overhead by ~30%.
for (let w = 0; w < WARMUP; w++) buildGraphTargetIndex(scopes, nodeLookup, undefined, graph);
const samples = [];
// A short handful of calls can all overlap JIT, GC or scheduler pauses.
// Keep the uncontended-cost estimator, but give each variant a full window
// to reach it. Retaining only the minimum also bounds sampling memory.
const start = performance.now();
let best = Infinity;
let last;
for (let r = 0; r < REPS; r++) {
do {
const t0 = performance.now();
last = buildGraphTargetIndex(scopes, nodeLookup, undefined, graph);
samples.push(performance.now() - t0);
}
return { ms: fastest(samples), result: last };
best = Math.min(best, performance.now() - t0);
} while (performance.now() - start < WINDOW_MS);
return { ms: best, result: last };
}
function fingerprint(targets) {
@ -179,29 +169,46 @@ function fingerprint(targets) {
return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
}
const scales = {};
for (const [name, fileCount] of [
const cases = [
['small', SMALL],
['large', LARGE],
]) {
const { graph, scopes, nodeLookup } = buildCorpus(fileCount);
const widened = timeIndex(scopes, nodeLookup, graph);
const callableOnly = timeIndex(callableOnlyScopes(scopes), nodeLookup, graph);
scales[name] = {
files: fileCount,
defs: scopes.defs.byId.size,
ms: widened.ms,
callable_only_ms: callableOnly.ms,
targets: widened.result.size,
callable_only_targets: callableOnly.result.size,
fingerprint: fingerprint(widened.result),
].map(([name, files]) => ({ name, files, ...buildCorpus(files) }));
const variants = cases.flatMap((corpus) => [
corpus,
{ ...corpus, scopes: callableOnlyScopes(corpus.scopes) },
]);
const samples = variants.map(() => []);
const results = [];
// Measure all four variants in each round, reversing their order to avoid
// consistently favouring one tier or scale. Discard complete warmup rounds.
// Pair ratios within rounds so a change in runner load does not compare a
// quiet control period with a busy widened period (or small with large).
for (let round = 0; round < WARMUP + REPS; round++) {
for (const i of round % 2 === 0 ? [0, 1, 2, 3] : [3, 2, 1, 0]) {
const { scopes, nodeLookup, graph } = variants[i];
const timed = timeIndex(scopes, nodeLookup, graph);
results[i] = timed.result;
if (round >= WARMUP) samples[i].push(timed.ms);
}
}
const scales = {};
for (const [i, corpus] of cases.entries()) {
scales[corpus.name] = {
files: corpus.files,
defs: corpus.scopes.defs.byId.size,
ms: median(samples[i * 2]),
callable_only_ms: median(samples[i * 2 + 1]),
targets: results[i * 2].size,
callable_only_targets: results[i * 2 + 1].size,
fingerprint: fingerprint(results[i * 2]),
};
}
const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL);
const scalingRatio = median(samples[2].map((ms, i) => ms / samples[0][i] / (LARGE / SMALL)));
// How much slower the widened gate is than the pre-#2693 one on the same
// corpus. 1.0 = free; 2.0 = the widening doubled the index build.
const wideningOverhead = scales.large.ms / scales.large.callable_only_ms;
const wideningOverhead = median(samples[2].map((ms, i) => ms / samples[3][i]));
const report = {
small: scales.small,

View file

@ -59,13 +59,13 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
'test/integration/rename-provider-c.test.ts': 202,
'test/integration/rename-provider-cobol.test.ts': 75,
'test/integration/rename-provider-cpp.test.ts': 250,
'test/integration/rename-provider-csharp.test.ts': 200,
'test/integration/rename-provider-csharp.test.ts': 266,
'test/integration/rename-provider-dart.test.ts': 425,
'test/integration/rename-provider-go.test.ts': 221,
'test/integration/rename-provider-java.test.ts': 300,
'test/integration/rename-provider-javascript.test.ts': 225,
'test/integration/rename-provider-kotlin.test.ts': 300,
'test/integration/rename-provider-objective-c.test.ts': 285,
'test/integration/rename-provider-objective-c.test.ts': 386,
'test/integration/rename-provider-php.test.ts': 100,
'test/integration/rename-provider-python.test.ts': 375,
'test/integration/rename-provider-ruby.test.ts': 100,
@ -78,7 +78,12 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
// Windows persisted-fixture profile above. Replace with observed CI timing.
'test/integration/rename-preview-plan.test.ts': 175,
'test/integration/mcp-index-generation.test.ts': 201,
'test/unit/incremental-dirty-recovery.test.ts': 76,
// Windows run 39934695864 timed out after undercharged native suites;
// 40172490240 supplies the query-symbol and dirty-recovery measurements.
'test/integration/incremental-method-integrity.test.ts': 145,
'test/integration/fts-repair-warm-session.test.ts': 62,
'test/integration/query-symbol-integrity.test.ts': 34,
'test/unit/incremental-dirty-recovery.test.ts': 128,
// Re-measured after the analyze --watch e2e landed in #3072. The previous
// 361 s entry undercharged this suite and left shard 1 close to the watchdog.
'test/integration/cli-e2e.test.ts': 621,

View file

@ -74,38 +74,50 @@ describe('segmentCjkSpans', () => {
expect(outputBytes).toBeLessThan(expectedBytes * 1.05);
});
it('scales linearly on realistic interleaved CJK/non-CJK content, not quadratically', () => {
// Regression guard: an earlier implementation indexed into the growing
// accumulated output string once per run boundary, which forces V8 to
// flatten its internal rope representation on every access — O(n^2) on
// content that alternates CJK and non-CJK runs (ordinary source code
// with inline CJK comments, the feature's actual target). A single-run
// input (like the growth-factor test above) never exercises this path,
// since there is only one run boundary regardless of size.
const unit = '采购订单自动审批流程 // approve the request after manual review\n';
const build = (totalBytes: number) => unit.repeat(Math.ceil(totalBytes / unit.length));
it.skipIf(!process.env.GITNEXUS_BENCH)(
'scales linearly on realistic interleaved CJK/non-CJK content, not quadratically',
() => {
// Regression guard: an earlier implementation indexed into the growing
// accumulated output string once per run boundary, which forces V8 to
// flatten its internal rope representation on every access — O(n^2) on
// content that alternates CJK and non-CJK runs (ordinary source code
// with inline CJK comments, the feature's actual target). A single-run
// input (like the growth-factor test above) never exercises this path,
// since there is only one run boundary regardless of size.
const unit = '采购订单自动审批流程 // approve the request after manual review\n';
const build = (totalBytes: number) => unit.repeat(Math.ceil(totalBytes / unit.length));
const small = build(64 * 1024);
const large = build(512 * 1024); // 8x the input size
const small = build(64 * 1024);
const large = build(512 * 1024); // 8x the input size
const timeOf = (input: string) => {
const start = performance.now();
segmentCjkSpans(input);
return performance.now() - start;
};
const timeOf = (input: string) => {
const start = performance.now();
segmentCjkSpans(input);
return performance.now() - start;
};
// Warm up the JIT before measuring either size.
timeOf(small);
timeOf(large);
// run-benchmarks.ts discovers this opt-in test and runs it serially.
// Sample both sizes after warmup: one scheduler pause must not look like
// the quadratic regression this guard is intended to catch.
const smallSamples: number[] = [];
const largeSamples: number[] = [];
for (let sample = 0; sample < 10; sample++) {
const smallMs = timeOf(small);
const largeMs = timeOf(large);
if (sample >= 3) {
smallSamples.push(smallMs);
largeSamples.push(largeMs);
}
}
const smallMs = Math.min(...smallSamples);
const largeMs = Math.min(...largeSamples);
const smallMs = timeOf(small);
const largeMs = timeOf(large);
// Linear scaling means ~8x input takes roughly ~8x time, with headroom
// for noise; quadratic scaling would mean ~64x time. 20x catches the
// regression while tolerating CI timing variance.
expect(largeMs).toBeLessThan(Math.max(smallMs, 1) * 20);
});
// Linear scaling means ~8x input takes roughly ~8x time, with headroom
// for noise; quadratic scaling would mean ~64x time. 20x catches the
// regression while tolerating CI timing variance.
expect(largeMs).toBeLessThan(Math.max(smallMs, 1) * 20);
},
);
});
describe('containsCjkIdeograph', () => {

View file

@ -134,6 +134,26 @@ describe('cross-platform shard partition', () => {
expect(Math.max(...loads)).toBeLessThanOrEqual(17 * 60);
});
it('leaves watchdog headroom for the October 23 native-suite timeout', () => {
// Windows run 39934695864: shard 10 timed out with Kotlin still pending.
// Use its completed suite times, plus the October 25 green run 40172490240
// for query-symbol-integrity and incremental-dirty-recovery. These measured
// costs must remain independent of the scheduling hints being tested.
const observed: Readonly<Record<string, number>> = {
'test/integration/rename-provider-objective-c.test.ts': 385.174,
'test/integration/rename-provider-csharp.test.ts': 265.99,
'test/integration/incremental-method-integrity.test.ts': 144.974,
'test/integration/fts-repair-warm-session.test.ts': 61.015,
'test/integration/query-symbol-integrity.test.ts': 33.892,
'test/unit/incremental-dirty-recovery.test.ts': 127.8,
};
const floor = weightOf('test/unmeasured.test.ts');
const loads = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL).map((files) =>
files.reduce((sum, file) => sum + Math.max(weightOf(file), (observed[file] ?? 0) + floor), 0),
);
expect(Math.max(...loads)).toBeLessThanOrEqual(17 * 60);
});
it('keeps each shard within a shard of the ideal weight', () => {
const shards = allShards(ALL_CROSS_PLATFORM, SHARD_TOTAL);
const weights = shards.map(shardWeight);