mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[CI] (P13 W1): push-without-MG_Remote control and zero-MG_Remote check, monolith means no transport, skip census
This commit is contained in:
+156
-12
@@ -198,7 +198,7 @@ jobs:
|
||||
# The pull-library negative control must not consume the primary artifact:
|
||||
# on disaggregation branches that artifact now contains the real transport.
|
||||
build-linux-monolith-control:
|
||||
name: build explicit pull runtime control
|
||||
name: build monolith controls (pull; push without MG_Remote)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUILD_DIR: build-monolith-control
|
||||
@@ -262,10 +262,40 @@ jobs:
|
||||
- name: Prepare packaged test metadata
|
||||
run: python3 scripts/ci/prepare_test_runtime.py "${BUILD_DIR}"
|
||||
|
||||
- name: Package pull control
|
||||
# P13 W1: THE PUSH MONOLITH WITHOUT MG_Remote - the shape FCL embeds once the pull build is
|
||||
# gone (PLAN-P13 §4.1, §4.3; ID-P13-1). Two jobs in one: it is the transport-negative control
|
||||
# that replaces the pull library in retrace-split (run side by side with it until W2 drops the
|
||||
# pull build), and its runtime_mode_proof is the zero-MG_Remote-symbol check, one of G1's
|
||||
# successors (ID-P13-4): a push library that links any MG_Remote symbol is red here.
|
||||
- name: Configure push control without MG_Remote
|
||||
run: |
|
||||
cmake -S . -B build-push-control -G Ninja \
|
||||
-DCMAKE_C_COMPILER="${MOBILEGL_CI_CC}" -DCMAKE_CXX_COMPILER="${MOBILEGL_CI_CXX}" \
|
||||
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
|
||||
-DCMAKE_BUILD_TYPE=Release -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \
|
||||
-DMOBILEGL_BUILD_TEST=OFF -DMOBILEGL_BUILD_BENCHMARK=OFF \
|
||||
-DMOBILEGL_BUILD_INTEGRATION_TEST=OFF -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \
|
||||
-DMOBILEGL_BUILD_DISAGGREGATED=OFF -DMOBILEGL_BUILD_DISAGGREGATED_INPROC=OFF \
|
||||
-DMOBILEGL_PIPE_PUSH=ON -DMOBILEGL_PIPE_VERIFY=OFF \
|
||||
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5
|
||||
- name: Build push control and prove it has zero MG_Remote symbols
|
||||
run: |
|
||||
cmake --build build-push-control --target MobileGL --parallel "$(nproc)"
|
||||
python3 scripts/ci/runtime_mode_proof.py build --build-dir build-push-control \
|
||||
--mode monolith --output build-push-control/runtime-mode.json
|
||||
python3 - build-push-control/runtime-mode.json <<'PY'
|
||||
import json, sys
|
||||
rows = json.load(open(sys.argv[1]))['production_compilation']
|
||||
if not rows or any(row['definitions'].get('MOBILEGL_PIPE_PUSH') != '1' for row in rows):
|
||||
sys.exit('::error::the push control was not compiled with MOBILEGL_PIPE_PUSH=1 everywhere')
|
||||
print(f'push control: {len(rows)} production sources compiled with MOBILEGL_PIPE_PUSH=1, zero MG_Remote symbols')
|
||||
PY
|
||||
|
||||
- name: Package monolith controls
|
||||
run: |
|
||||
tar -czf mobilegl-linux-monolith-control.tgz \
|
||||
"${BUILD_DIR}/.ci-source-root" "${BUILD_DIR}/libMobileGL.so" "${BUILD_DIR}/runtime-mode.json"
|
||||
"${BUILD_DIR}/.ci-source-root" "${BUILD_DIR}/libMobileGL.so" "${BUILD_DIR}/runtime-mode.json" \
|
||||
build-push-control/libMobileGL.so build-push-control/runtime-mode.json
|
||||
- name: Upload pull control
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -316,11 +346,20 @@ jobs:
|
||||
ulimit -c unlimited
|
||||
if [ "$MOBILEGL_CI_NATIVE_GPU" != 1 ] && [ ! -f /.dockerenv ]; then sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'; fi
|
||||
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
|
||||
ctest -V -L unit --no-tests=error
|
||||
ctest -V -L unit --no-tests=error --output-junit "${RUNNER_TEMP}/unit.xml"
|
||||
else
|
||||
ctest --output-on-failure -L unit --no-tests=error
|
||||
ctest --output-on-failure -L unit --no-tests=error --output-junit "${RUNNER_TEMP}/unit.xml"
|
||||
fi
|
||||
|
||||
# P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py).
|
||||
- name: Upload JUnit for the skip census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-test
|
||||
path: ${{ runner.temp }}/*.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload core dumps
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -451,6 +490,15 @@ jobs:
|
||||
build-linux/MobileGL/MG_IntegrationTest/p5f-rsp-*.log
|
||||
if-no-files-found: warn
|
||||
|
||||
# P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py).
|
||||
- name: Upload JUnit for the skip census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-integration-${{ matrix.lane }}-${{ matrix.transport }}
|
||||
path: ${{ runner.temp }}/*.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload core dumps
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -708,9 +756,9 @@ jobs:
|
||||
ulimit -c unlimited
|
||||
if [ "$MOBILEGL_CI_NATIVE_GPU" != 1 ] && [ ! -f /.dockerenv ]; then sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'; fi
|
||||
if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then
|
||||
ctest -V -L integration-verify --no-tests=error
|
||||
ctest -V -L integration-verify --no-tests=error --output-junit "${RUNNER_TEMP}/verify.xml"
|
||||
else
|
||||
ctest --output-on-failure -L integration-verify --no-tests=error
|
||||
ctest --output-on-failure -L integration-verify --no-tests=error --output-junit "${RUNNER_TEMP}/verify.xml"
|
||||
fi
|
||||
|
||||
# The push-only unit tests, on the verify runtime.
|
||||
@@ -731,7 +779,7 @@ jobs:
|
||||
# unit binaries would otherwise report a green run of nothing.
|
||||
- name: Unit tests on the verify runtime (G6, G10)
|
||||
working-directory: build-verify
|
||||
run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)"
|
||||
run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" --output-junit "${RUNNER_TEMP}/unit-verify.xml"
|
||||
|
||||
# The always-on P2 and P3a negative controls (G8, G10, G12), which are labelled
|
||||
# integration-gpu and not integration-verify - they are about the handle key, the subsystem
|
||||
@@ -893,6 +941,15 @@ jobs:
|
||||
path: build-verify/MobileGL/MG_IntegrationTest/pipe-*.log*
|
||||
if-no-files-found: warn
|
||||
|
||||
# P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py).
|
||||
- name: Upload JUnit for the skip census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-integration-verify
|
||||
path: ${{ runner.temp }}/*.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload core dumps
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1138,6 +1195,15 @@ jobs:
|
||||
build-verify-split/MobileGL/MG_IntegrationTest/pipe-*split*.log*
|
||||
if-no-files-found: warn
|
||||
|
||||
# P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py).
|
||||
- name: Upload JUnit for the skip census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-integration-verify-split
|
||||
path: ${{ runner.temp }}/*.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload core dumps
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1625,6 +1691,15 @@ jobs:
|
||||
${{ runner.temp }}/arm-monolith.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
# P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py).
|
||||
- name: Upload JUnit for the skip census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-integration-split
|
||||
path: ${{ runner.temp }}/*.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload core dumps
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1742,7 +1817,8 @@ jobs:
|
||||
PY
|
||||
|
||||
rc=0
|
||||
ctest -L integration-split --no-tests=error -j 4 > "${RUNNER_TEMP}/strict-split.txt" 2>&1 || rc=$?
|
||||
ctest -L integration-split --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/strict-split.xml" \
|
||||
> "${RUNNER_TEMP}/strict-split.txt" 2>&1 || rc=$?
|
||||
tail -5 "${RUNNER_TEMP}/strict-split.txt"
|
||||
echo "strict run: rc=${rc}"
|
||||
|
||||
@@ -2216,6 +2292,15 @@ jobs:
|
||||
if: failure()
|
||||
run: python3 scripts/ci/junit_annotate.py "${RUNNER_TEMP}"
|
||||
|
||||
# P13 W1: every JUnit this job wrote, for the skip census (scripts/ci/skip_census.py).
|
||||
- name: Upload JUnit for the skip census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: junit-integration-split-strict
|
||||
path: ${{ runner.temp }}/*.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload strict lane logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -3060,9 +3145,12 @@ jobs:
|
||||
- build-retrace
|
||||
- trace-cases
|
||||
- trace-fixtures
|
||||
# The separate OFF/pull artifact is for the negative control ONLY: its library is what the control
|
||||
# swaps in to prove the transport-resolution assertion is load-bearing.
|
||||
if: ${{ always() && needs.build-linux-monolith-control.result == 'success' && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }}
|
||||
# The monolith-control artifact is for the negative controls ONLY: its libraries are what the
|
||||
# controls swap in to prove the transport-resolution assertion is load-bearing. P13 W1: the
|
||||
# legs no longer require that build to have SUCCEEDED - gating on it skipped the whole split
|
||||
# matrix (grey, not red) whenever the control build failed (PLAN-P13 §0-7). The control steps
|
||||
# below download its artifact, and a missing one is a red step on every leg instead.
|
||||
if: ${{ always() && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
@@ -3130,6 +3218,7 @@ jobs:
|
||||
run: |
|
||||
tar -xzf pull-runtime/mobilegl-linux-monolith-control.tgz -C pull-runtime
|
||||
test -f pull-runtime/build-monolith-control/libMobileGL.so
|
||||
test -f pull-runtime/build-push-control/libMobileGL.so
|
||||
|
||||
- name: Prepare downloaded test metadata
|
||||
run: |
|
||||
@@ -3276,6 +3365,22 @@ jobs:
|
||||
bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_pull_library_control.sh"
|
||||
'${{ matrix.case }}' '${{ matrix.backend }}'
|
||||
|
||||
# P13 W1: THE SAME CONTROL WITH THE PUSH MONOLITH WITHOUT MG_Remote - the library FCL embeds
|
||||
# after W2, and the successor of the pull library above once W2 drops the pull build. Same
|
||||
# script and the same evidence sentence: what makes either library red here is that it has no
|
||||
# transport, not that it pulls.
|
||||
- name: Negative control (push library without MG_Remote)
|
||||
working-directory: build-retrace/tools/trace_replay
|
||||
env:
|
||||
CONTROL_TMPDIR: ${{ runner.temp }}
|
||||
PULL_LIBRARY: ${{ github.workspace }}/pull-runtime/build-push-control/libMobileGL.so
|
||||
FROZEN_LIBRARY: ${{ github.workspace }}/build-linux/libMobileGL.so
|
||||
SPLIT_TRANSPORT: ${{ matrix.transport }}
|
||||
MOBILEGL_IPC_SERVER_PATH: ${{ github.workspace }}/build-linux/libMobileGLServer.so
|
||||
run: >-
|
||||
bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_pull_library_control.sh"
|
||||
'${{ matrix.case }}' '${{ matrix.backend }}'
|
||||
|
||||
# EXIT GATE E2's OTHER HALF, and the one BRIEF-P5 §7 E2 actually names: "drop an emission
|
||||
# and the SSIM must fall below the threshold". The control above proves the lane runs a
|
||||
# SPLIT library; it cannot prove the PICTURE came through the wire, because OpenRA scores
|
||||
@@ -3729,3 +3834,42 @@ jobs:
|
||||
name: mobilegl-linux-runtime-verify-split
|
||||
path: ci-artifacts/mobilegl-linux-runtime-verify-split.tgz
|
||||
if-no-files-found: error
|
||||
|
||||
# P13 W1 (PLAN-P13 §4.2): THE SKIP CENSUS. Deleting a compile-time macro before the GTEST_SKIP
|
||||
# or pull stub that guards on it turns tests "green by skipping"; a per-lane pass/fail count
|
||||
# cannot see that. Every gtest lane uploads its JUnit as junit-*; this job keys each case by
|
||||
# lane and name and holds the run against scripts/data/skip_census_baseline.json: a new skip
|
||||
# (pass -> skip included) or a vanished name is red, a skip that turned into a pass is a note.
|
||||
# A deliberate retirement edits the baseline in the same commit and names the cases.
|
||||
skip-census:
|
||||
name: skip census
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
- integration
|
||||
- integration-verify
|
||||
- integration-verify-split
|
||||
- integration-split
|
||||
- integration-split-strict
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v6
|
||||
- name: Skip census self-test
|
||||
run: python3 scripts/ci/skip_census.py --self-test
|
||||
- name: Download lane JUnit
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: junit-*
|
||||
path: junit
|
||||
- name: Skip census
|
||||
run: |
|
||||
python3 scripts/ci/skip_census.py check junit \
|
||||
--baseline scripts/data/skip_census_baseline.json --report skip-census.json
|
||||
- name: Upload census
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skip-census
|
||||
path: skip-census.json
|
||||
if-no-files-found: warn
|
||||
|
||||
@@ -29,9 +29,10 @@ def build_proof(build, mode):
|
||||
argv = row.get('arguments') or shlex.split(row['command'])
|
||||
flags = {arg[2:].split('=', 1)[0]: arg[2:].split('=', 1)[1] if '=' in arg else '1'
|
||||
for arg in argv if arg.startswith('-D')}
|
||||
# P13: "monolith" means the transport is absent (no MG_Remote), not that the pipe is
|
||||
# pulled - the monolith build pushes too, so MGPipeApply symbols are expected in both.
|
||||
expected = mode == 'disaggregated'
|
||||
if any((flags.get(name) == '1') != expected
|
||||
for name in ('MOBILEGL_BUILD_DISAGGREGATED', 'MOBILEGL_PIPE_PUSH')):
|
||||
if (flags.get('MOBILEGL_BUILD_DISAGGREGATED') == '1') != expected:
|
||||
raise RuntimeError(f'production macros disagree with {mode}: {flags}')
|
||||
definitions.append({'source': row['file'], 'definitions': flags})
|
||||
symbols = subprocess.check_output(['nm', '--defined-only', str(library)], text=True)
|
||||
@@ -39,7 +40,7 @@ def build_proof(build, mode):
|
||||
raise RuntimeError('library appears stripped; symbol absence would prove nothing')
|
||||
remote = sum('MG_Remote' in line for line in symbols.splitlines())
|
||||
applier = sum('MGPipeApply' in line for line in symbols.splitlines())
|
||||
if (mode == 'disaggregated' and (not remote or not applier)) or (mode == 'monolith' and (remote or applier)):
|
||||
if (mode == 'disaggregated' and (not remote or not applier)) or (mode == 'monolith' and remote):
|
||||
raise RuntimeError(f'{mode} symbol mismatch: MG_Remote={remote}, MGPipeApply={applier}')
|
||||
return {'mode': mode, 'source_sha': subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip(),
|
||||
'library': str(library), 'sha256': hashlib.sha256(library.read_bytes()).hexdigest(),
|
||||
@@ -122,13 +123,18 @@ def self_test():
|
||||
raise AssertionError('missing server log passed the runtime log proof')
|
||||
(build / 'libMobileGL.so').write_bytes(b'checker fixture, not a real runtime')
|
||||
normal = ''.join(f'00000000 T fixture{i}\n' for i in range(1000))
|
||||
remote = normal + '00000000 T MG_Remote_fixture\n00000000 T MGPipeApply_fixture\n'
|
||||
pushed = normal + '00000000 T MGPipeApply_fixture\n'
|
||||
remote = pushed + '00000000 T MG_Remote_fixture\n'
|
||||
for mode, flags, symbols, succeeds in (
|
||||
('monolith', '', normal, True),
|
||||
('monolith', '-DMOBILEGL_PIPE_PUSH=1', pushed, True),
|
||||
('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1 -DMOBILEGL_PIPE_PUSH=1', remote, True),
|
||||
('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1', remote, False),
|
||||
('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1', remote, True),
|
||||
('disaggregated', '-DMOBILEGL_PIPE_PUSH=1', remote, False),
|
||||
('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1 -DMOBILEGL_PIPE_PUSH=1', normal, False),
|
||||
('disaggregated', '-DMOBILEGL_BUILD_DISAGGREGATED=1', normal + '00000000 T MG_Remote_fixture\n', False),
|
||||
('monolith', '', remote, False),
|
||||
('monolith', '-DMOBILEGL_BUILD_DISAGGREGATED=1', pushed, False),
|
||||
('monolith', '', '00000000 T stripped\n', False),
|
||||
):
|
||||
(build / 'compile_commands.json').write_text(json.dumps([
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The skip census: across every gtest lane of the Test workflow, skips may only shrink.
|
||||
|
||||
P13 deletes compile-time macros (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS) that dozens of
|
||||
tests guard with GTEST_SKIP or a pull stub. Deleting a macro before its guard turns those tests
|
||||
"green by skipping", and nothing in a per-lane pass/fail count notices. This gate reads every
|
||||
lane's ctest JUnit, keys each case by lane and name, and compares the run with a baseline checked
|
||||
into the tree (scripts/data/skip_census_baseline.json):
|
||||
|
||||
* a case skipped now that the baseline does not list as skipped is RED - including the
|
||||
pass -> skip flip, which is the trap the gate exists for;
|
||||
* a case the baseline knows that is gone from its lane is RED (names only grow, G14), unless
|
||||
the lane is missing entirely, which is reported on its own line;
|
||||
* a skip that turned into a pass is reported, never red - rewrite the baseline in the commit
|
||||
that earns it so the improvement is locked in;
|
||||
* new lanes and new names are reported, never red.
|
||||
|
||||
A deliberate retirement edits the baseline in the same commit and names the cases in the commit
|
||||
message (PLAN-P13 §4.2).
|
||||
|
||||
Usage:
|
||||
skip_census.py check <junit-dir> --baseline <json> [--report <json>]
|
||||
skip_census.py write <junit-dir> --baseline <json>
|
||||
skip_census.py --self-test
|
||||
<junit-dir> holds one sub-directory per lane (actions/download-artifact with a pattern lays
|
||||
them out like that); every *.xml under a lane directory counts toward that lane, keyed
|
||||
<lane>/<xml file stem>.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def case_status(case):
|
||||
if case.find('failure') is not None or case.find('error') is not None:
|
||||
return 'fail'
|
||||
if case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'):
|
||||
return 'skip'
|
||||
return 'pass'
|
||||
|
||||
|
||||
def collect(root):
|
||||
"""{lane: {name: status}} from <root>/<lane>/**/*.xml."""
|
||||
root = pathlib.Path(root)
|
||||
lanes = {}
|
||||
for xml in sorted(root.rglob('*.xml')):
|
||||
rel = xml.relative_to(root)
|
||||
lane = f'{rel.parts[0]}/{xml.stem}' if len(rel.parts) > 1 else xml.stem
|
||||
try:
|
||||
cases = ET.parse(xml).getroot().iter('testcase')
|
||||
except ET.ParseError as error:
|
||||
raise SystemExit(f'skip census: {xml} is not valid JUnit ({error})')
|
||||
entries = lanes.setdefault(lane, {})
|
||||
for case in cases:
|
||||
name = case.get('name')
|
||||
if not name:
|
||||
continue
|
||||
status = case_status(case)
|
||||
# A name reported twice in one lane (a rerun) keeps its worst outcome.
|
||||
order = {'pass': 0, 'skip': 1, 'fail': 2}
|
||||
if name not in entries or order[status] > order[entries[name]]:
|
||||
entries[name] = status
|
||||
return lanes
|
||||
|
||||
|
||||
def to_baseline(lanes):
|
||||
return {lane: {'names': sorted(entries), 'skipped': sorted(n for n, s in entries.items() if s == 'skip')}
|
||||
for lane, entries in sorted(lanes.items())}
|
||||
|
||||
|
||||
def compare(lanes, baseline):
|
||||
red, info = [], []
|
||||
for lane, known in sorted(baseline.items()):
|
||||
current = lanes.get(lane)
|
||||
if current is None:
|
||||
red.append(f'lane {lane}: no JUnit at all in this run (the job did not run or did not upload)')
|
||||
continue
|
||||
allowed = set(known['skipped'])
|
||||
for name in sorted(n for n, s in current.items() if s == 'skip' and n not in allowed):
|
||||
was = 'new' if name not in set(known['names']) else 'pass'
|
||||
red.append(f'lane {lane}: {name} SKIPPED (baseline: {was})')
|
||||
missing = sorted(set(known['names']) - set(current))
|
||||
for name in missing:
|
||||
red.append(f'lane {lane}: {name} disappeared (names only grow; retire it in the baseline by name)')
|
||||
for name in sorted(n for n in allowed if current.get(n) == 'pass'):
|
||||
info.append(f'lane {lane}: {name} now PASSES (was skipped) - tighten the baseline')
|
||||
added = sorted(set(current) - set(known['names']))
|
||||
if added:
|
||||
info.append(f'lane {lane}: {len(added)} new case(s), e.g. {added[0]}')
|
||||
for lane in sorted(set(lanes) - set(baseline)):
|
||||
info.append(f'lane {lane}: new lane ({len(lanes[lane])} cases) - add it to the baseline')
|
||||
return red, info
|
||||
|
||||
|
||||
def summary(lanes):
|
||||
total = sum(len(e) for e in lanes.values())
|
||||
skipped = sum(1 for e in lanes.values() for s in e.values() if s == 'skip')
|
||||
return f'{len(lanes)} lane(s), {total} case(s), {skipped} skipped'
|
||||
|
||||
|
||||
def run(argv):
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('command', choices=('check', 'write'))
|
||||
parser.add_argument('junit_dir')
|
||||
parser.add_argument('--baseline', required=True, type=pathlib.Path)
|
||||
parser.add_argument('--report', type=pathlib.Path)
|
||||
args = parser.parse_args(argv)
|
||||
lanes = collect(args.junit_dir)
|
||||
if not lanes:
|
||||
print(f'::error::skip census: no JUnit under {args.junit_dir}')
|
||||
return 1
|
||||
print(f'skip census: {summary(lanes)}')
|
||||
if args.report:
|
||||
args.report.write_text(json.dumps(to_baseline(lanes), indent=1) + '\n')
|
||||
if args.command == 'write':
|
||||
args.baseline.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.baseline.write_text(json.dumps(to_baseline(lanes), indent=1) + '\n')
|
||||
print(f'wrote {args.baseline}')
|
||||
return 0
|
||||
if not args.baseline.exists():
|
||||
print(f'::error::skip census: no baseline at {args.baseline}; write one from a full run')
|
||||
return 1
|
||||
red, info = compare(lanes, json.loads(args.baseline.read_text()))
|
||||
for line in info:
|
||||
print(f'note: {line}')
|
||||
for line in red:
|
||||
print(f'::error::skip census: {line}')
|
||||
print(f'skip census: {"RED" if red else "green"} ({len(red)} finding(s))')
|
||||
return 1 if red else 0
|
||||
|
||||
|
||||
def self_test():
|
||||
def junit(path, cases):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = ''.join(
|
||||
f'<testcase name="{n}">' + ('<skipped/>' if s == 'skip' else '<failure message="x"/>' if s == 'fail' else '')
|
||||
+ '</testcase>' for n, s in cases)
|
||||
path.write_text(f'<testsuite>{body}</testsuite>')
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp = pathlib.Path(tmp)
|
||||
base = tmp / 'base.json'
|
||||
good = tmp / 'good'
|
||||
junit(good / 'junit-unit' / 'unit.xml', [('A', 'pass'), ('B', 'skip'), ('C', 'pass')])
|
||||
assert run(['write', str(good), '--baseline', str(base)]) == 0
|
||||
assert run(['check', str(good), '--baseline', str(base)]) == 0, 'identical run must be green'
|
||||
cases = {
|
||||
'pass -> skip': ([('A', 'skip'), ('B', 'skip'), ('C', 'pass')], 1),
|
||||
'new skipped case': ([('A', 'pass'), ('B', 'skip'), ('C', 'pass'), ('D', 'skip')], 1),
|
||||
'case disappeared': ([('A', 'pass'), ('B', 'skip')], 1),
|
||||
'skip -> pass': ([('A', 'pass'), ('B', 'pass'), ('C', 'pass')], 0),
|
||||
'new passing case': ([('A', 'pass'), ('B', 'skip'), ('C', 'pass'), ('D', 'pass')], 0),
|
||||
'failure is not a skip': ([('A', 'fail'), ('B', 'skip'), ('C', 'pass')], 0),
|
||||
}
|
||||
for label, (run_cases, expected) in cases.items():
|
||||
d = tmp / label.replace(' ', '_').replace('>', '')
|
||||
junit(d / 'junit-unit' / 'unit.xml', run_cases)
|
||||
got = run(['check', str(d), '--baseline', str(base)])
|
||||
assert got == expected, f'{label}: exit {got}, expected {expected}'
|
||||
lost = tmp / 'lost'
|
||||
junit(lost / 'junit-other' / 'other.xml', [('A', 'pass')])
|
||||
assert run(['check', str(lost), '--baseline', str(base)]) == 1, 'a lane that vanished must be red'
|
||||
assert run(['check', str(tmp / 'empty'), '--baseline', str(base)]) == 1, 'no JUnit must be red'
|
||||
assert run(['check', str(good), '--baseline', str(tmp / 'none.json')]) == 1, 'no baseline must be red'
|
||||
print('skip_census self-test: ok')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1:] == ['--self-test']:
|
||||
sys.exit(self_test())
|
||||
sys.exit(run(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user