diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18897431..fed8adc2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,8 +40,10 @@ jobs: actions: write contents: read outputs: - split-identical: ${{ steps.split-guard.outputs.identical }} link-ratchet: ${{ steps.ratchet.outcome }} + image: ${{ steps.toolchain.outputs.image }} + compiler: ${{ steps.toolchain.outputs.compiler }} + build-type: ${{ steps.configure.outputs.build-type }} env: BUILD_DIR: build-linux MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci' || github.ref_name == 'cleanup') && 'ON' || 'OFF' }} @@ -110,38 +112,28 @@ jobs: sudo apt-get update sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + # The image and compiler are job outputs: integration-split prints them beside build-guard's. - name: Show installed toolchain + id: toolchain run: | ccache --version "${MOBILEGL_CI_CC}" --version "${MOBILEGL_CI_CXX}" --version "${MOBILEGL_CI_LLD}" --version dpkg -l 'libc++*' 'libegl*' 'libgles*' 'mesa*' 'vulkan*' || true + echo "image=${ImageOS:-} ${ImageVersion:-}" >> "${GITHUB_OUTPUT}" + echo "compiler=$("${MOBILEGL_CI_CXX}" --version | head -n 1)" >> "${GITHUB_OUTPUT}" - # The configure line lives in scripts/ci/configure_build.sh, shared with build-linux-split. + # The configure line lives in scripts/ci/configure_build.sh, shared with build-linux-split + # and build-guard. The build type is a job output: the split consumers reuse this build only + # when it is Release, the only type build-guard can find identical to the split line. - name: Configure CMake + id: configure env: CI_BUILD_TYPE: ${{ secrets.ACTIONS_STEP_DEBUG == 'true' && 'Debug' || 'Release' }} - run: bash scripts/ci/configure_build.sh primary "${BUILD_DIR}" - - # On the disaggregation branches build-linux-split configures the same sources with only the - # benchmarks left out. This configures its line here as a probe, on this runner, and compares - # the two trees (scripts/ci/build_fingerprint.py). Equal means the split consumers take this - # job's artifacts; a difference, or a guard that cannot decide, means false, and - # build-linux-split builds its own as before. It never reds this job. - - name: Split configuration guard - id: split-guard - if: env.MOBILEGL_CI_DISAGGREGATED == 'ON' run: | - if python3 scripts/ci/build_fingerprint.py --self-test > "${RUNNER_TEMP}/fingerprint-self-test.txt"; then - python3 scripts/ci/build_fingerprint.py guard --built "${BUILD_DIR}" --probe split \ - --probe-dir build-guard-split --github-output identical || echo "identical=false" >> "${GITHUB_OUTPUT}" - else - cat "${RUNNER_TEMP}/fingerprint-self-test.txt" - echo "::warning::build_fingerprint.py's self-test failed, so the split build is not reused" - echo "identical=false" >> "${GITHUB_OUTPUT}" - fi - rm -rf build-guard-split + echo "build-type=${CI_BUILD_TYPE}" >> "${GITHUB_OUTPUT}" + bash scripts/ci/configure_build.sh primary "${BUILD_DIR}" - name: Build run: cmake --build "${BUILD_DIR}" --parallel "$(nproc)" @@ -154,12 +146,14 @@ jobs: python3 scripts/ci/runtime_mode_proof.py build --build-dir "${BUILD_DIR}" \ --mode "${mode}" --output "${BUILD_DIR}/runtime-mode.json" - # build-linux-split's server link-closure ratchet, run on this build when that job reuses it. - # continue-on-error keeps a ratchet red from skipping this job's consumers; integration-split - # turns it into a red of its own instead (Split runtime agreement). + # build-linux-split's server link-closure ratchet, on this build, which the split consumers + # take when build-guard proves the two configurations identical. This job does not wait for + # that verdict, so it runs on every disaggregation build. continue-on-error keeps a ratchet + # red from skipping this job's consumers; integration-split reds on it when it takes this + # build. The artifact has its own name, so it never replaces build-linux-split's. - name: Server link-closure ratchet id: ratchet - if: steps.split-guard.outputs.identical == 'true' + if: env.MOBILEGL_CI_DISAGGREGATED == 'ON' continue-on-error: true run: | mkdir -p ci-artifacts @@ -169,16 +163,15 @@ jobs: --baseline scripts/data/link_ratchet_baseline.txt \ --assert-monotone \ --bucket \ - --json ci-artifacts/link-ratchet.json + --json ci-artifacts/link-ratchet-primary.json - name: Upload link ratchet - if: ${{ always() && steps.split-guard.outputs.identical == 'true' }} + if: ${{ always() && env.MOBILEGL_CI_DISAGGREGATED == 'ON' }} uses: actions/upload-artifact@v7 with: - name: link-ratchet - path: ci-artifacts/link-ratchet.json + name: link-ratchet-primary + path: ci-artifacts/link-ratchet-primary.json if-no-files-found: warn - overwrite: true - name: Show ccache stats if: ${{ always() }} @@ -1192,55 +1185,54 @@ jobs: path: /tmp/core.* if-no-files-found: ignore - # The split build: the same sources with the transport and its in-process arm always on, which the - # split integration and retrace lanes consume. On the disaggregation branches build-linux has the - # same configuration apart from the benchmarks; the guard below proves that on this runner, and - # when it holds this job skips its compile and the consumers take build-linux's artifacts - # (build-linux runs the same guard, and the consumers require both). On dev it builds as before. - # - # The `nm` step is the point of a build of its own. In a build without the option, MG_Remote is - # not compiled and MOBILEGL_TRANSPORT's parser does not exist, so the variable is accepted and - # ignored and every downstream lane would run monolith under a name that says split. CMake does - # not complain about a typo'd -D, so the build-level assertion is the guard; in reuse mode - # build-linux's runtime_mode_proof (--mode disaggregated) is the same assertion and more. - build-linux-split: + # Whether build-linux-split may skip its compile. On the disaggregation branches its configure + # line and build-linux's differ only in the benchmarks; this job configures both on one runner + # (configure only) and compares the trees (scripts/ci/build_fingerprint.py). Its verdict is + # identical, different or unknown, and its failure never fails the run: only 'identical' makes + # build-linux-split skip its compile and the split consumers take build-linux's artifacts, + # while 'different' and 'unknown' keep both builds. The builds run on the same runner image as + # this job, which records the image and compiler it saw; integration-split prints both sides. + # MOBILEGL_CI_DISAGGREGATED is build-linux's own expression, so a branch list that drifts + # between the two jobs configures an OFF primary here and gives 'different'. + build-guard: + name: build guard (split reuse) runs-on: ubuntu-26.04 - timeout-minutes: 120 - permissions: - actions: write - contents: read + timeout-minutes: 150 + if: ${{ github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci' || github.ref_name == 'cleanup' }} + # Consumers read only its outputs, and any verdict but 'identical' (none at all included) + # keeps both builds. + continue-on-error: true outputs: - reuse-primary: ${{ steps.guard.outputs.identical == 'true' && 'true' || 'false' }} + verdict: ${{ steps.guard.outputs.verdict || 'unknown' }} + image: ${{ steps.guard.outputs.image }} + compiler: ${{ steps.guard.outputs.compiler }} env: - BUILD_DIR: build-split MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci' || github.ref_name == 'cleanup') && 'ON' || 'OFF' }} - CCACHE_BASEDIR: ${{ github.workspace }} - CCACHE_COMPRESS: "true" - CCACHE_DIR: ${{ github.workspace }}/.ccache - CCACHE_MAXSIZE: 4G - CCACHE_NOHASHDIR: "true" + # Every step may fail without failing the job: a setup step that failed leaves a configure + # that fails, which the guard reports as 'unknown'. steps: - name: Checkout repo + continue-on-error: true + timeout-minutes: 15 uses: actions/checkout@v6 with: - # recursive, and load-bearing here beyond the usual: MOBILEGL_BUILD_DISAGGREGATED - # shadows itself back to OFF when 3rdparty/flatbuffers/include is missing (a normal - # variable rather than a cache force, deliberately). A shallow checkout would therefore - # configure cleanly, build a monolith library, and be caught only by the nm step below. submodules: recursive - name: Configure runner environment + continue-on-error: true + timeout-minutes: 15 uses: ./.github/actions/setup-test-runner - name: Get CMake + continue-on-error: true timeout-minutes: 15 uses: lukka/get-cmake@v4.3.3 with: cmakeVersion: 3.31.10 - name: Prepare Vulkan SDK - id: netsetup1274 + id: vulkan continue-on-error: true timeout-minutes: 15 if: env.MOBILEGL_CI_NATIVE_GPU != '1' @@ -1251,8 +1243,9 @@ jobs: vulkan-use-cache: true - name: Prepare Vulkan SDK (retry) + continue-on-error: true timeout-minutes: 15 - if: ${{ (env.MOBILEGL_CI_NATIVE_GPU != '1') && steps.netsetup1274.outcome == 'failure' }} + if: ${{ (env.MOBILEGL_CI_NATIVE_GPU != '1') && steps.vulkan.outcome == 'failure' }} uses: humbletim/setup-vulkan-sdk@v1.2.1 with: vulkan-query-version: 1.4.304.1 @@ -1260,17 +1253,143 @@ jobs: vulkan-use-cache: true - name: Update glslang external sources + continue-on-error: true + timeout-minutes: 15 working-directory: 3rdparty/glslang - run: python3 update_glslang_sources.py + run: python3 update_glslang_sources.py || python3 update_glslang_sources.py - name: Install build dependencies + continue-on-error: true timeout-minutes: 15 if: env.MOBILEGL_CI_NATIVE_GPU != '1' run: | sudo apt-get update sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + # The self-test first: a guard whose canned cases fail decides nothing. The two configures + # share one FetchContent directory, so only the first fetches; each attempt is killed after + # 10 minutes and retried once, which this step's timeout leaves room for. + - name: Configure both lines and compare + id: guard + continue-on-error: true + timeout-minutes: 45 + env: + CI_BUILD_TYPE: ${{ secrets.ACTIONS_STEP_DEBUG == 'true' && 'Debug' || 'Release' }} + run: | + if python3 scripts/ci/build_fingerprint.py --self-test > "${RUNNER_TEMP}/fingerprint-self-test.txt"; then + python3 scripts/ci/build_fingerprint.py guard --primary-dir build-guard-primary \ + --split-dir build-guard-split --deps-dir build-guard-deps \ + --github-output verdict --report "${RUNNER_TEMP}/build-guard.json" + else + cat "${RUNNER_TEMP}/fingerprint-self-test.txt" + echo "::warning::build_fingerprint.py's self-test failed, so the guard decides nothing" + echo "verdict=unknown" >> "${GITHUB_OUTPUT}" + fi + + - name: Upload guard report + if: ${{ always() }} + continue-on-error: true + uses: actions/upload-artifact@v7 + with: + name: build-guard + path: ${{ runner.temp }}/build-guard.json + if-no-files-found: warn + + # The split build: the same sources with the transport and its in-process arm always on, which the + # split integration and retrace lanes consume. When build-guard proves its configuration + # identical to build-linux's (the disaggregation branches, benchmarks apart), this job builds + # nothing and the consumers take build-linux's artifacts; on any other verdict, and where + # build-guard does not run, it builds. + # + # The `nm` step is the point of a build of its own. In a build without the option, MG_Remote is + # not compiled and MOBILEGL_TRANSPORT's parser does not exist, so the variable is accepted and + # ignored and every downstream lane would run monolith under a name that says split. CMake does + # not complain about a typo'd -D, so the build-level assertion is the guard; in reuse mode + # build-linux's runtime_mode_proof (--mode disaggregated) is the same assertion and more. + build-linux-split: + runs-on: ubuntu-26.04 + timeout-minutes: 120 + needs: [build-guard] + # A build-guard that was skipped, failed or undecided means this job builds. + if: ${{ !cancelled() }} + permissions: + actions: write + contents: read + env: + BUILD_DIR: build-split + SPLIT_REUSE: ${{ needs.build-guard.outputs.verdict == 'identical' && 'true' || 'false' }} + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Reuse decision + env: + GUARD_VERDICT: ${{ needs.build-guard.outputs.verdict }} + run: | + if [ "${SPLIT_REUSE}" = true ]; then + echo "build-guard found the two configurations identical: this job builds nothing and the split consumers take build-linux's artifacts" + else + echo "build-guard verdict '${GUARD_VERDICT:-none}': this job builds the split library" + fi + + - name: Checkout repo + if: env.SPLIT_REUSE != 'true' + uses: actions/checkout@v6 + with: + # recursive, and load-bearing here beyond the usual: MOBILEGL_BUILD_DISAGGREGATED + # shadows itself back to OFF when 3rdparty/flatbuffers/include is missing (a normal + # variable rather than a cache force, deliberately). A shallow checkout would therefore + # configure cleanly, build a monolith library, and be caught only by the nm step below. + submodules: recursive + + - name: Configure runner environment + if: env.SPLIT_REUSE != 'true' + uses: ./.github/actions/setup-test-runner + + - name: Get CMake + if: env.SPLIT_REUSE != 'true' + timeout-minutes: 15 + uses: lukka/get-cmake@v4.3.3 + with: + cmakeVersion: 3.31.10 + + - name: Prepare Vulkan SDK + id: netsetup1274 + continue-on-error: true + timeout-minutes: 15 + if: ${{ env.SPLIT_REUSE != 'true' && (env.MOBILEGL_CI_NATIVE_GPU != '1') }} + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Prepare Vulkan SDK (retry) + timeout-minutes: 15 + if: ${{ env.SPLIT_REUSE != 'true' && ((env.MOBILEGL_CI_NATIVE_GPU != '1') && steps.netsetup1274.outcome == 'failure') }} + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Update glslang external sources + if: env.SPLIT_REUSE != 'true' + working-directory: 3rdparty/glslang + run: python3 update_glslang_sources.py + + - name: Install build dependencies + timeout-minutes: 15 + if: ${{ env.SPLIT_REUSE != 'true' && (env.MOBILEGL_CI_NATIVE_GPU != '1') }} + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + - name: Show installed toolchain + if: env.SPLIT_REUSE != 'true' run: | ccache --version "${MOBILEGL_CI_CC}" --version @@ -1280,35 +1399,17 @@ jobs: # Release/INFO like the shipped build; the line and the reasons are in # scripts/ci/configure_build.sh, shared with build-linux. - name: Configure CMake + if: env.SPLIT_REUSE != 'true' run: bash scripts/ci/configure_build.sh split "${BUILD_DIR}" - # build-linux's configuration, probed on this runner and compared with this tree. Equal - # means this job builds nothing and ends green; the consumers then take build-linux's - # artifacts, and red if build-linux's own guard disagrees. It never reds this job. - - name: Primary configuration guard - id: guard - if: env.MOBILEGL_CI_DISAGGREGATED == 'ON' - env: - CI_BUILD_TYPE: ${{ secrets.ACTIONS_STEP_DEBUG == 'true' && 'Debug' || 'Release' }} - run: | - if python3 scripts/ci/build_fingerprint.py --self-test > "${RUNNER_TEMP}/fingerprint-self-test.txt"; then - python3 scripts/ci/build_fingerprint.py guard --built "${BUILD_DIR}" --probe primary \ - --probe-dir build-guard-primary --github-output identical || echo "identical=false" >> "${GITHUB_OUTPUT}" - else - cat "${RUNNER_TEMP}/fingerprint-self-test.txt" - echo "::warning::build_fingerprint.py's self-test failed, so this job builds its own library" - echo "identical=false" >> "${GITHUB_OUTPUT}" - fi - rm -rf build-guard-primary - - name: Set swap space - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' uses: pierotofy/set-swap-space@v1.0 with: swap-size-gb: 32 - name: Restore ccache - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' uses: actions/cache/restore@v5 with: path: .ccache @@ -1317,7 +1418,7 @@ jobs: ${{ runner.os }}-ubuntu26-cpu-test-${{ github.job }}-ccache- - name: Build - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' run: cmake --build "${BUILD_DIR}" --parallel "$(nproc)" # `nm` and NOT `nm -D`, for the reason build-linux-verify spells out: everything under @@ -1326,7 +1427,7 @@ jobs: # stripped library would make the grep fail for a third, silent reason. # - name: MG_Remote symbols in split library - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' run: | test -f "${BUILD_DIR}/libMobileGL.so" defined=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | wc -l) @@ -1354,7 +1455,7 @@ jobs: # script re-checks CMakeCache.txt) and before anything deletes the *.o files it reads: here, # or in build-linux when this job reuses that build. - name: Server link-closure ratchet - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' run: | mkdir -p ci-artifacts python3 scripts/link_ratchet.py --self-test @@ -1368,28 +1469,27 @@ jobs: # Uploaded even when the gate fired: a red ratchet is exactly the moment someone needs the # per-symbol definer/referrer attribution the JSON carries. - name: Upload link ratchet - if: ${{ always() && steps.guard.outputs.identical != 'true' }} + if: ${{ always() && env.SPLIT_REUSE != 'true' }} uses: actions/upload-artifact@v7 with: name: link-ratchet path: ci-artifacts/link-ratchet.json if-no-files-found: warn - overwrite: true - name: Show ccache stats - if: ${{ always() && steps.guard.outputs.identical != 'true' }} + if: ${{ always() && env.SPLIT_REUSE != 'true' }} run: ccache --show-stats # Only after a build: with nothing built, deleting would drop this branch's entry for good. - name: Release superseded ccache entry - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' env: GH_TOKEN: ${{ github.token }} CACHE_KEY: ${{ runner.os }}-ubuntu26-cpu-test-${{ github.job }}-ccache-v1 run: gh cache delete "${CACHE_KEY}" --ref "${GITHUB_REF}" || true - name: Save ccache - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' continue-on-error: true uses: actions/cache/save@v5 with: @@ -1397,11 +1497,11 @@ jobs: key: ${{ runner.os }}-ubuntu26-cpu-test-${{ github.job }}-ccache-v1 - name: Prepare packaged test metadata - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' run: python3 scripts/ci/prepare_test_runtime.py "${BUILD_DIR}" - name: Package Linux split runtime - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' run: | mkdir -p ci-artifacts mapfile -t SHARED_LIBS < <(find "${BUILD_DIR}" -type f \( -name '*.so' -o -name '*.so.*' \) -print | sort) @@ -1419,7 +1519,7 @@ jobs: "${SHARED_LIBS[@]}" - name: Upload Linux split runtime - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' uses: actions/upload-artifact@v7 with: name: mobilegl-linux-runtime-split @@ -1429,14 +1529,14 @@ jobs: # The retrace legs load only the library, and the server image on the spawn arm, so they # take this instead of the whole test runtime. - name: Package retrace library - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' run: | files=("${BUILD_DIR}/.ci-source-root" "${BUILD_DIR}/libMobileGL.so") if [ -f "${BUILD_DIR}/libMobileGLServer.so" ]; then files+=("${BUILD_DIR}/libMobileGLServer.so"); fi tar -czf ci-artifacts/mobilegl-linux-lib-split.tgz "${files[@]}" - name: Upload retrace library - if: steps.guard.outputs.identical != 'true' + if: env.SPLIT_REUSE != 'true' uses: actions/upload-artifact@v7 with: name: mobilegl-linux-lib-split @@ -1448,38 +1548,41 @@ jobs: integration-split: runs-on: ubuntu-26.04 timeout-minutes: 120 - needs: [build-linux-split, build-linux] - if: ${{ !cancelled() && needs.build-linux-split.result == 'success' && (needs.build-linux-split.outputs.reuse-primary != 'true' || needs.build-linux.result == 'success') }} + needs: [build-guard, build-linux-split, build-linux] + if: ${{ !cancelled() && needs.build-linux-split.result == 'success' && (needs.build-guard.outputs.verdict != 'identical' || needs.build-linux.result == 'success') }} # Short-lived test processes skip the glslang prewarm, as in the test job. env: MOBILEGL_DISABLE_SHADER_PREWARM: "1" - SPLIT_REUSE: ${{ needs.build-linux-split.outputs.reuse-primary }} - SPLIT_BUILD_DIR: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'build-linux' || 'build-split' }} - SPLIT_RUNTIME: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'mobilegl-linux-runtime' || 'mobilegl-linux-runtime-split' }} + SPLIT_REUSE: ${{ needs.build-guard.outputs.verdict == 'identical' && 'true' || 'false' }} + SPLIT_BUILD_DIR: ${{ needs.build-guard.outputs.verdict == 'identical' && 'build-linux' || 'build-split' }} + SPLIT_RUNTIME: ${{ needs.build-guard.outputs.verdict == 'identical' && 'mobilegl-linux-runtime' || 'mobilegl-linux-runtime-split' }} MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci' || github.ref_name == 'cleanup') && 'ON' || 'OFF' }} steps: - name: Checkout repo uses: actions/checkout@v6 - # The two build guards must agree. build-linux-split skips its compile on its own guard's - # word; the artifacts these lanes then take are build-linux's, so build-linux's guard must - # have proved the same equality on the runner that built them. - - name: Split runtime agreement + # Which build these lanes take: build-linux's when build-guard proved the two configurations + # identical, build-linux-split's otherwise. build-guard compared them on its own runner, so + # the image and compiler it saw are printed beside build-linux's. It reds only when it would + # reuse a build-linux that is not Release (see scripts/ci/split_reuse_build_type.sh). + - name: Split runtime env: - PRIMARY_IDENTICAL: ${{ needs.build-linux.outputs.split-identical }} - PRIMARY_RATCHET: ${{ needs.build-linux.outputs.link-ratchet }} + GUARD_VERDICT: ${{ needs.build-guard.outputs.verdict }} + GUARD_IMAGE: ${{ needs.build-guard.outputs.image }} + GUARD_COMPILER: ${{ needs.build-guard.outputs.compiler }} + PRIMARY_IMAGE: ${{ needs.build-linux.outputs.image }} + PRIMARY_COMPILER: ${{ needs.build-linux.outputs.compiler }} + PRIMARY_BUILD_TYPE: ${{ needs.build-linux.outputs.build-type }} run: | - if [ "${SPLIT_REUSE}" = "true" ] && [ "${PRIMARY_IDENTICAL}" != "true" ]; then - echo "::error::build-linux-split skipped its build, but build-linux's guard did not prove the two configurations equal (split-identical='${PRIMARY_IDENTICAL}'): the runners disagree, so these lanes have no split runtime they can trust" - exit 1 + echo "split runtime: ${SPLIT_BUILD_DIR} (build-guard verdict '${GUARD_VERDICT:-none}')" + bash scripts/ci/split_reuse_build_type.sh + echo "build-guard ran on '${GUARD_IMAGE}' with '${GUARD_COMPILER}'" + echo "build-linux ran on '${PRIMARY_IMAGE}' with '${PRIMARY_COMPILER}'" + if [ "${SPLIT_REUSE}" = true ] && { [ "${GUARD_IMAGE}" != "${PRIMARY_IMAGE}" ] || [ "${GUARD_COMPILER}" != "${PRIMARY_COMPILER}" ]; }; then + echo "::notice::build-guard and build-linux saw different runner images or compilers; the reused build is the one build-linux configured" fi - if [ "${SPLIT_REUSE}" = "true" ] && [ "${PRIMARY_RATCHET}" != "success" ]; then - echo "::error::the server link-closure ratchet ran in build-linux for this reused build and did not pass (outcome '${PRIMARY_RATCHET}'); its link-ratchet artifact names the symbols" - exit 1 - fi - echo "split runtime: ${SPLIT_BUILD_DIR} (reuse of build-linux: ${SPLIT_REUSE})" - name: Configure runner environment uses: ./.github/actions/setup-test-runner @@ -1684,6 +1787,19 @@ jobs: SPLIT_BASELINE_JUNIT: ${{ runner.temp }}/split-baseline.xml run: bash "${GITHUB_WORKSPACE}/scripts/ci/split_negative_controls.sh" + # build-linux ran the server link-closure ratchet on the build these lanes took, with + # continue-on-error so that a red there does not skip build-linux's own consumers. + - name: Server link-closure ratchet of the reused build + if: ${{ !cancelled() && env.SPLIT_REUSE == 'true' }} + env: + PRIMARY_RATCHET: ${{ needs.build-linux.outputs.link-ratchet }} + run: | + if [ "${PRIMARY_RATCHET}" != success ]; then + echo "::error::the server link-closure ratchet of the build-linux build these lanes took did not pass (outcome '${PRIMARY_RATCHET}'); build-linux's link-ratchet-primary artifact names the symbols" + exit 1 + fi + echo "the server link-closure ratchet passed on the build-linux build these lanes took" + # The run log needs a signed-in reader; annotations do not. Name every failed ctest entry, # with the lines of its output that say why, where anyone reading the run can see them. - name: Annotate failed entries @@ -1727,34 +1843,31 @@ jobs: integration-split-strict: runs-on: ubuntu-26.04 timeout-minutes: 60 - needs: [build-linux-split, build-linux] - if: ${{ !cancelled() && needs.build-linux-split.result == 'success' && (needs.build-linux-split.outputs.reuse-primary != 'true' || needs.build-linux.result == 'success') }} + needs: [build-guard, build-linux-split, build-linux] + if: ${{ !cancelled() && needs.build-linux-split.result == 'success' && (needs.build-guard.outputs.verdict != 'identical' || needs.build-linux.result == 'success') }} # Short-lived test processes skip the glslang prewarm, as in the test job. env: MOBILEGL_DISABLE_SHADER_PREWARM: "1" - SPLIT_REUSE: ${{ needs.build-linux-split.outputs.reuse-primary }} - SPLIT_BUILD_DIR: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'build-linux' || 'build-split' }} - SPLIT_RUNTIME: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'mobilegl-linux-runtime' || 'mobilegl-linux-runtime-split' }} + SPLIT_REUSE: ${{ needs.build-guard.outputs.verdict == 'identical' && 'true' || 'false' }} + SPLIT_BUILD_DIR: ${{ needs.build-guard.outputs.verdict == 'identical' && 'build-linux' || 'build-split' }} + SPLIT_RUNTIME: ${{ needs.build-guard.outputs.verdict == 'identical' && 'mobilegl-linux-runtime' || 'mobilegl-linux-runtime-split' }} MOBILEGL_CI_DISAGGREGATED: ${{ (github.ref_name == 'feat/disaggregated' || github.ref_name == 'codex/fix-github-ci' || github.ref_name == 'cleanup') && 'ON' || 'OFF' }} steps: - name: Checkout repo uses: actions/checkout@v6 - # The two build guards must agree. build-linux-split skips its compile on its own guard's - # word; the artifacts these lanes then take are build-linux's, so build-linux's guard must - # have proved the same equality on the runner that built them. - - name: Split runtime agreement + # build-linux's build when build-guard proved the two configurations identical, + # build-linux-split's otherwise; integration-split prints the guard's evidence. A build-linux + # that is not Release is never reused (scripts/ci/split_reuse_build_type.sh). + - name: Split runtime env: - PRIMARY_IDENTICAL: ${{ needs.build-linux.outputs.split-identical }} - PRIMARY_RATCHET: ${{ needs.build-linux.outputs.link-ratchet }} + GUARD_VERDICT: ${{ needs.build-guard.outputs.verdict }} + PRIMARY_BUILD_TYPE: ${{ needs.build-linux.outputs.build-type }} run: | - if [ "${SPLIT_REUSE}" = "true" ] && [ "${PRIMARY_IDENTICAL}" != "true" ]; then - echo "::error::build-linux-split skipped its build, but build-linux's guard did not prove the two configurations equal (split-identical='${PRIMARY_IDENTICAL}'): the runners disagree, so these lanes have no split runtime they can trust" - exit 1 - fi - echo "split runtime: ${SPLIT_BUILD_DIR} (reuse of build-linux: ${SPLIT_REUSE})" + echo "split runtime: ${SPLIT_BUILD_DIR} (build-guard verdict '${GUARD_VERDICT:-none}')" + bash scripts/ci/split_reuse_build_type.sh - name: Configure runner environment uses: ./.github/actions/setup-test-runner @@ -3204,24 +3317,25 @@ jobs: timeout-minutes: 240 # The setup action selects lavapipe on GitHub-hosted runners. needs: + - build-guard - build-linux-split - build-linux - build-retrace - trace-cases - trace-fixtures - if: ${{ !cancelled() && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' && (needs.build-linux-split.outputs.reuse-primary != 'true' || needs.build-linux.result == 'success') }} + if: ${{ !cancelled() && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' && (needs.build-guard.outputs.verdict != 'identical' || needs.build-linux.result == 'success') }} env: - SPLIT_REUSE: ${{ needs.build-linux-split.outputs.reuse-primary }} - SPLIT_LIB: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'mobilegl-linux-lib' || 'mobilegl-linux-lib-split' }} - SPLIT_LIB_DIR: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'build-linux' || 'build-split' }} - SPLIT_BUILD_DIR: ${{ needs.build-linux-split.outputs.reuse-primary == 'true' && 'build-linux' || 'build-split' }} + SPLIT_REUSE: ${{ needs.build-guard.outputs.verdict == 'identical' && 'true' || 'false' }} + SPLIT_LIB: ${{ needs.build-guard.outputs.verdict == 'identical' && 'mobilegl-linux-lib' || 'mobilegl-linux-lib-split' }} + SPLIT_LIB_DIR: ${{ needs.build-guard.outputs.verdict == 'identical' && 'build-linux' || 'build-split' }} + SPLIT_BUILD_DIR: ${{ needs.build-guard.outputs.verdict == 'identical' && 'build-linux' || 'build-split' }} strategy: fail-fast: false max-parallel: 8 # With build-linux reused, the inproc rows (except OpenRA's) are replayed by the retrace job's # inproc-acceptance rows on the same library, strict and with the dual block and run-ahead # on; the spawn rows below prove per leg that those are also the defaults they run with. - matrix: ${{ fromJSON(needs.build-linux-split.outputs.reuse-primary == 'true' && needs.trace-cases.outputs.split-spawn-matrix || needs.trace-cases.outputs.split-matrix) }} + matrix: ${{ fromJSON(needs.build-guard.outputs.verdict == 'identical' && needs.trace-cases.outputs.split-spawn-matrix || needs.trace-cases.outputs.split-matrix) }} steps: - name: Set swap space @@ -3233,19 +3347,16 @@ jobs: - name: Checkout repo uses: actions/checkout@v6 - # The two build guards must agree. build-linux-split skips its compile on its own guard's - # word; the artifacts these lanes then take are build-linux's, so build-linux's guard must - # have proved the same equality on the runner that built them. - - name: Split runtime agreement + # build-linux's build when build-guard proved the two configurations identical, + # build-linux-split's otherwise; integration-split prints the guard's evidence. A build-linux + # that is not Release is never reused (scripts/ci/split_reuse_build_type.sh). + - name: Split runtime env: - PRIMARY_IDENTICAL: ${{ needs.build-linux.outputs.split-identical }} - PRIMARY_RATCHET: ${{ needs.build-linux.outputs.link-ratchet }} + GUARD_VERDICT: ${{ needs.build-guard.outputs.verdict }} + PRIMARY_BUILD_TYPE: ${{ needs.build-linux.outputs.build-type }} run: | - if [ "${SPLIT_REUSE}" = "true" ] && [ "${PRIMARY_IDENTICAL}" != "true" ]; then - echo "::error::build-linux-split skipped its build, but build-linux's guard did not prove the two configurations equal (split-identical='${PRIMARY_IDENTICAL}'): the runners disagree, so these lanes have no split runtime they can trust" - exit 1 - fi - echo "split runtime: ${SPLIT_BUILD_DIR} (reuse of build-linux: ${SPLIT_REUSE})" + echo "split runtime: ${SPLIT_BUILD_DIR} (build-guard verdict '${GUARD_VERDICT:-none}')" + bash scripts/ci/split_reuse_build_type.sh - name: Configure runner environment uses: ./.github/actions/setup-test-runner @@ -3766,6 +3877,7 @@ jobs: python3 tools/trace_replay/test_compare_actuals.py python3 tools/trace_replay/test_run_trace_case.py python3 scripts/ci/build_fingerprint.py --self-test + bash scripts/ci/split_reuse_build_type.sh --self-test python3 MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py python3 scripts/ci/transport_pinned.py --self-test diff --git a/docs/ci/ci-cleanup-log.md b/docs/ci/ci-cleanup-log.md index 5c751d50..40b33106 100644 --- a/docs/ci/ci-cleanup-log.md +++ b/docs/ci/ci-cleanup-log.md @@ -46,7 +46,7 @@ them wrong. | 2026-10-17 | `Negative control A (server-side read)` and `Negative control B (omitted stamp)` | Test / `integration-verify-split` | the first re-ran the two VerifySplitReadCorrupted entries the lane had just run and grepped what they already assert in-test (at least two where=read reports in the server half); the second re-ran with the pair the in-lane VerifySplitPoisonOmitted entries already pin, and accepted any red | `Verify-split control entries`: `--require-entry-passed` on VerifySplitArming, VerifySplitCorrupted, VerifySplitReadCorrupted and VerifySplitPoisonOmitted of both backends | | 2026-10-17 | the pre-flip arm of `Strict split lane` (red-by-design while `kMGPipeP5eRunAheadReady` was false) and the "GREEN with rows outstanding" rule of `Dual-block census lane` | Test / `integration-split-strict` | dead: the constant is true, and `dualblock-expected-fatals.txt` has no rows, so neither branch could run | the hard-green arm and its two-sided marker census, and the empty-census rule (rows == 0 requires ctest success), both kept | | 2026-10-17 | `scripts/ci/tcp_log_forwarding_control.py` | none | no caller outside the archived planning notes | none needed | -| 2026-10-17 | the compile of `build-linux-split` on the disaggregation branches (when its guard proves the configurations equal) | Test / `build-linux-split` | it built the library `build-linux` builds there, from a configuration that differs only by the benchmarks (211 paired runs never disagreed); it delayed every split consumer by up to 9 minutes | `build_fingerprint.py` guards in both build jobs (cache, compile commands, link statements, CTest files, toolchain; benchmarks excluded); the consumers take `build-linux`'s artifacts and red if `build-linux`'s own guard disagrees; the link ratchet runs in `build-linux`; on any difference `build-linux-split` builds as before, and dev is unchanged | +| 2026-10-17 | the compile of `build-linux-split` on the disaggregation branches (when `build-guard` proves the configurations identical) | Test / `build-linux-split` | it built the library `build-linux` builds there, from a configuration that differs only by the benchmarks (211 paired runs never disagreed); it delayed every split consumer by up to 9 minutes | the `build-guard` job configures both lines on one runner and compares them with `build_fingerprint.py` (cache, compile commands, link statements, CTest files; benchmarks excluded; one shared FetchContent directory, a failed configure or one killed after 10 minutes retried once) and records its image and compiler; only the verdict `identical` lets the consumers take `build-linux`'s artifacts, and only a Release one, while `different` (annotated as a warning) and `unknown` (a configure that failed twice, any error, too little evidence; a notice) keep both builds and the full split lanes; the guard job's own failure never fails the run; `build-linux` runs the link ratchet on every disaggregation build (artifact `link-ratchet-primary`) and integration-split reds on it when it takes that build; dev runs no guard and always builds | | 2026-10-17 | `Transport A/B (monolith vs inproc)` (skip-census lanes `arm-monolith`, `arm-inproc` now optional) | Test / `integration-split` | with `build-linux` reused it repeats the integration job's two legs on the same library (79 paired runs, no one-sided red) | the integration legs; `Default-knob resolution check` (disaggregation branches) reads 'Split lanes' own run and proves, from every passed entry's client log, strict=0, role-split-state=1 and run-ahead=1 unless pinned (`split_log_paths.py ipc-config`); A/B still runs when a default differs or the build is not reused | | 2026-10-17 | `Unit tests on the split runtime` (skip-census lane `unit-split` now optional) | Test / `integration-split` | with `build-linux` reused the test job runs the same unit lane on the same build (182 paired runs; the one recurring one-sided red was a fixed test race) | the test job, which now carries `MOBILEGL_ITEST_REQUIRE_GPU=1` and `libegl-mesa0` on the three branches; the strict unit step is unchanged | | 2026-10-17 | the integration-gpu entries that pin their own `MOBILEGL_TRANSPORT` (804 inproc, 1446 spawn, 131 monolith) and the 12 `TcpServer` fixture entries only they need, in the inproc-acceptance leg (both passes) | Test / `integration` (inproc-acceptance) | their pin overrides the only variable the two legs differ in, so they ran identically in both (173 paired runs; the one one-sided red was a port bind fixed by cde6512b) | the monolith-control leg, which runs them on every branch; `transport_pinned.py` proves per run that the trimmed selection lost exactly that population (else the leg runs everything) and that the monolith-control leg ran every one; skip census: the monolith-control lanes list their own names and the inproc-acceptance lanes drop the pinned ones | diff --git a/scripts/ci/build_fingerprint.py b/scripts/ci/build_fingerprint.py index 7afa029c..2e6298e9 100644 --- a/scripts/ci/build_fingerprint.py +++ b/scripts/ci/build_fingerprint.py @@ -7,51 +7,63 @@ # SPDX-License-Identifier: LGPL-3.0-only # End of Source File Header -"""Prove that two CMake build trees were configured identically, apart from the benchmarks. +"""Decide whether the two Linux library configurations are identical apart from the benchmarks. build-linux and build-linux-split configure the same sources twice. On the disaggregation branches the only intended difference is MOBILEGL_BUILD_BENCHMARK, which adds MG_Benchmark and the -google benchmark dependency and touches nothing else. When the two configurations are equal, the -split consumers can take build-linux's artifacts and build-linux-split need not compile again. +google benchmark dependency and touches nothing else. When the two configurations are identical, +the split consumers can take build-linux's artifacts and build-linux-split need not compile again. The fingerprint is read from what configure writes, so it can be taken before the build: cache MOBILEGL_* entries (except MOBILEGL_BUILD_BENCHMARK), the build type, compilers, launchers, flags and the link tools from CMakeCache.txt; compile every compile_commands.json entry: file, output and arguments; link every link statement of build.ninja: outputs, rule, inputs and variables; - tests the text of every CTestTestfile.cmake and gtest *_include.cmake; - toolchain compiler, linker, cmake, ninja and package versions on this runner; - image the runner image, recorded and never compared. -The build directory's own path is replaced by @BUILD@, and everything under MobileGL/MG_Benchmark -or the benchmark dependency is left out. A fingerprint with fewer than 200 compile entries, 3 link -statements (libMobileGL.so among them) or 2 CTest files proves nothing and never counts as equal. + tests the text of every CTestTestfile.cmake and gtest *_include.cmake. +The build directory's path becomes @BUILD@ and a shared FetchContent directory @DEPS@; everything +under MobileGL/MG_Benchmark or the benchmark dependency is left out. Both fingerprints come from +one runner, so its toolchain and image are recorded beside the verdict and never compared. - build_fingerprint.py fingerprint --build-dir D [--out F] + build_fingerprint.py fingerprint --build-dir D [--deps-dir F] [--out F] build_fingerprint.py compare A.json B.json - build_fingerprint.py guard --built D --probe primary|split --probe-dir P [--github-output KEY] + build_fingerprint.py guard --primary-dir P --split-dir S --deps-dir F [--github-output KEY] [--report R] build_fingerprint.py --self-test -guard configures the probe with scripts/ci/configure_build.sh (configure only), compares it with -the built tree and writes KEY=true|false to $GITHUB_OUTPUT. Any error makes it false, and it always -exits 0: a difference sends the workflow down the path that builds both, it is not a red. +guard configures both lines with scripts/ci/configure_build.sh (configure only) into P and S, with +F as their one FetchContent directory, so only the first configure fetches anything; a configure +that fails, or runs past 10 minutes and is killed, is retried once. It writes +KEY=identical|different|unknown, image= and compiler= to $GITHUB_OUTPUT and always exits 0. +'different' is a proven difference. A configure that failed twice, any error, or a tree with fewer +than 200 compile entries, 3 link statements (libMobileGL.so among them) or 2 CTest files is +'unknown': only 'identical' lets the workflow reuse a build. Any other verdict is annotated with +its first reasons, a warning for 'different' and a notice for 'unknown'. """ import argparse +import contextlib +import io import json import os from pathlib import Path import re import shlex import shutil +import signal import subprocess import sys import tempfile +import time ROOT = Path(__file__).resolve().parents[2] CONFIGURE = ROOT / 'scripts' / 'ci' / 'configure_build.sh' +# Each configure attempt is killed after CONFIGURE_TIMEOUT seconds. GUARD_STEP_MINUTES must equal the +# guard step's timeout-minutes in test.yml, and leave room for four killed attempts. +CONFIGURE_TIMEOUT = 600 +GUARD_STEP_MINUTES = 45 MIN_COMPILE = 200 MIN_LINK = 3 MIN_TESTS = 2 +VERDICTS = ('identical', 'different', 'unknown') CACHE_KEY = re.compile( r'^(MOBILEGL_\w+' @@ -64,18 +76,22 @@ CACHE_KEY = re.compile( CACHE_SKIP = {'MOBILEGL_BUILD_BENCHMARK'} CACHE_LINE = re.compile(r'^([A-Za-z0-9_.+-]+):([A-Z]+)=(.*)$') LINK_RULE = re.compile(r'^(C|CXX)_(SHARED_LIBRARY|EXECUTABLE|MODULE|STATIC_LIBRARY)_LINKER__') -BENCHMARK_PATH = re.compile(r'(^|/)(MobileGL/MG_Benchmark|_deps/benchmark-[a-z]+)(/|$)') -BENCHMARK_SUBDIRS = re.compile(r'^\s*subdirs\("[^"]*(MobileGL/MG_Benchmark|_deps/benchmark-build)"\)\s*$') +BENCHMARK_PATH = re.compile(r'(^|/)(MobileGL/MG_Benchmark|(_deps|@DEPS@)/benchmark-[a-z]+)(/|$)') +BENCHMARK_SUBDIRS = re.compile( + r'^\s*subdirs\("[^"]*(MobileGL/MG_Benchmark|(_deps|@DEPS@)/benchmark-build)"\)\s*$') PACKAGES = ('clang-20', 'lld-20', 'libc++-20-dev', 'libc++abi-20-dev', 'libvulkan-dev', 'libegl1-mesa-dev', 'libgles2-mesa-dev') -def normaliser(build_dir): - roots = sorted({str(Path(build_dir)), str(Path(build_dir).resolve())}, key=len, reverse=True) +def normaliser(build_dir, deps_dir=None): + roots = [(str(Path(build_dir)), '@BUILD@'), (str(Path(build_dir).resolve()), '@BUILD@')] + if deps_dir: + roots += [(str(Path(deps_dir)), '@DEPS@'), (str(Path(deps_dir).resolve()), '@DEPS@')] + roots = sorted(set(roots), key=lambda pair: len(pair[0]), reverse=True) def norm(text): - for root in roots: - text = text.replace(root, '@BUILD@') + for root, token in roots: + text = text.replace(root, token) return text return norm @@ -197,9 +213,8 @@ def read_tests(build, norm): continue if is_benchmark(rel) or '/CMakeFiles/' in '/' + rel: continue - text = path.read_text(errors='replace') - kept = [norm(line) for line in text.splitlines() - if not BENCHMARK_SUBDIRS.match(line) and not line.startswith('# ')] + lines = [norm(line) for line in path.read_text(errors='replace').splitlines()] + kept = [line for line in lines if not BENCHMARK_SUBDIRS.match(line) and not line.startswith('# ')] tests[rel] = '\n'.join(kept) return tests @@ -223,22 +238,21 @@ def read_toolchain(): return tools -def fingerprint(build_dir, toolchain=None): +def read_image(): + return {key: os.environ.get(key, '') for key in ('ImageOS', 'ImageVersion', 'RUNNER_NAME')} + + +def fingerprint(build_dir, deps_dir=None): build = Path(build_dir) - norm = normaliser(build) - print_counts = {} + norm = normaliser(build, deps_dir) result = { 'cache': read_cache(build, norm), 'compile': read_compile(build, norm), 'link': read_links(build, norm), 'tests': read_tests(build, norm), - 'toolchain': read_toolchain() if toolchain is None else toolchain, - 'image': {key: os.environ.get(key, '') for key in ('ImageOS', 'ImageVersion', 'RUNNER_NAME')}, } - print_counts['compile'] = len(result['compile']) - print_counts['link'] = len(result['link']) - print_counts['tests'] = len(result['tests']) - result['counts'] = print_counts + result['counts'] = {'compile': len(result['compile']), 'link': len(result['link']), + 'tests': len(result['tests'])} return result @@ -293,62 +307,119 @@ def compare(a, b, limit=8): if a['tests'].get(name) != b['tests'].get(name)] if tests: diffs['tests'] = tests - toolchain = [k for k in sorted(set(a['toolchain']) | set(b['toolchain'])) - if a['toolchain'].get(k) != b['toolchain'].get(k)] - if toolchain: - diffs['toolchain'] = [f'{k} differs' for k in toolchain] return {field: rows[:limit] + ([f'... {len(rows) - limit} more'] if len(rows) > limit else []) for field, rows in diffs.items()} def verdict(a, b): - """(identical, reasons). Unproven evidence on either side is never 'identical'.""" - reasons = [f'first: {p}' for p in unproven(a)] + [f'second: {p}' for p in unproven(b)] - diffs = compare(a, b) - for field, rows in diffs.items(): - reasons += [f'{field}: {row}' for row in rows] - return not reasons, reasons + """(verdict, reasons). Too little evidence on either side is 'unknown', never 'identical'.""" + thin = [f'first: {p}' for p in unproven(a)] + [f'second: {p}' for p in unproven(b)] + if thin: + return 'unknown', thin + reasons = [f'{field}: {row}' for field, rows in compare(a, b).items() for row in rows] + return ('different' if reasons else 'identical'), reasons -def write_output(key, identical): +def run_attempt(argv, label, timeout=CONFIGURE_TIMEOUT): + """One configure attempt; True on success. One that runs past the timeout (a stalled fetch) is + killed with its whole process group, cmake and its git clones included, and counts as failed.""" + proc = subprocess.Popen(argv, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + start_new_session=True) + try: + output, _ = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + try: + output, _ = proc.communicate(timeout=60) + except subprocess.TimeoutExpired: + output = '' + print(output[-6000:]) + print(f'build_fingerprint: {label} ran past {timeout} s and was killed') + return False + if proc.returncode != 0: + print(output[-6000:]) + print(f'build_fingerprint: {label} failed with exit {proc.returncode}') + return proc.returncode == 0 + + +def run_configure(variant, build_dir, deps_dir, timeout=None): + """configure_build.sh, configure only, with the shared FetchContent directory; True on success.""" + return run_attempt(['bash', str(CONFIGURE), variant, str(build_dir), f'-DFETCHCONTENT_BASE_DIR={deps_dir}'], + f'configure_build.sh {variant}', CONFIGURE_TIMEOUT if timeout is None else timeout) + + +def configure_twice(configure, variant, build_dir, deps_dir): + """One retry: a transient fetch failure must not decide anything. Returns the attempts used.""" + for attempt in (1, 2): + if build_dir.exists(): + shutil.rmtree(build_dir) + if configure(variant, build_dir, deps_dir): + return attempt + return 0 + + +def decide(primary_dir, split_dir, deps_dir, configure=run_configure): + """(verdict, reasons, counts). The primary line runs first: it alone fetches the benchmark.""" + primary_dir, split_dir, deps_dir = Path(primary_dir), Path(split_dir), Path(deps_dir) + counts = {} + for variant, build_dir in (('primary', primary_dir), ('split', split_dir)): + attempts = configure_twice(configure, variant, build_dir, deps_dir) + if not attempts: + return 'unknown', [f'the {variant} configure failed twice'], counts + counts[f'{variant}_attempts'] = attempts + primary = fingerprint(primary_dir, deps_dir) + split = fingerprint(split_dir, deps_dir) + counts.update(primary=primary['counts'], split=split['counts']) + result, reasons = verdict(primary, split) + return result, reasons, counts + + +def one_line(text, limit=200): + return ' '.join(str(text).split())[:limit] + + +def annotation(result, reasons): + """The workflow annotation for a verdict other than 'identical': a 'different' that persists is + configuration drift that brings the second build back on every run, so it is a warning.""" + if result == 'identical': + return None + level = 'warning' if result == 'different' else 'notice' + text = one_line('; '.join(reasons[:3]) or 'no reason recorded', 600).replace('%', '%25') + return f'::{level}::build-guard verdict {result}, so build-linux-split builds: {text}' + + +def write_outputs(key, result, image, compiler): + lines = [f'{key}={result}', f'image={one_line(image)}', f'compiler={one_line(compiler)}'] + for line in lines: + print(line) path = os.environ.get('GITHUB_OUTPUT') - line = f"{key}={'true' if identical else 'false'}" - print(line) if path: with open(path, 'a') as out: - out.write(line + '\n') + out.write(''.join(line + '\n' for line in lines)) -def guard(args): - identical = False +def guard(args, configure=run_configure, toolchain_reader=read_toolchain): + result, reasons, counts = 'unknown', [], {} + image = read_image() + toolchain = {} try: - probe = Path(args.probe_dir) - if probe.exists(): - shutil.rmtree(probe) - configured = subprocess.run(['bash', str(CONFIGURE), args.probe, str(probe)], cwd=ROOT, - capture_output=True, text=True) - if configured.returncode != 0: - print(configured.stdout[-4000:] + configured.stderr[-4000:]) - raise RuntimeError(f'configure_build.sh {args.probe} failed with exit {configured.returncode}') - toolchain = read_toolchain() - built = fingerprint(args.built, toolchain) - probed = fingerprint(probe, toolchain) - print(f"build_fingerprint: built {args.built}: {built['counts']}; probe {args.probe} at {probe}: " - f"{probed['counts']}") - print(f"build_fingerprint: compiler {toolchain.get('MOBILEGL_CI_CXX', '').splitlines()[:1]}, " - f"image {built['image']}") - identical, reasons = verdict(built, probed) - for reason in reasons: - print(f'build_fingerprint: {reason}') - if args.report: - Path(args.report).write_text(json.dumps({'identical': identical, 'reasons': reasons, - 'built': built['counts'], 'probe': probed['counts'], - 'toolchain': toolchain, 'image': built['image']}, - indent=1) + '\n') - except Exception as error: # a guard that cannot decide must not reuse anything - print(f'build_fingerprint: guard could not compare the configurations: {error}') - identical = False - write_output(args.github_output, identical) + toolchain = toolchain_reader() + result, reasons, counts = decide(args.primary_dir, args.split_dir, args.deps_dir, configure) + except Exception as error: # a guard that cannot decide says so, and nothing is reused + result, reasons = 'unknown', [f'the guard could not compare the configurations: {error!r}'] + compiler = (toolchain.get('MOBILEGL_CI_CXX') or 'unset').splitlines()[0] if toolchain else 'unread' + image_text = f"{image['ImageOS']} {image['ImageVersion']}".strip() or 'unknown image' + print(f'build_fingerprint: {counts}') + print(f'build_fingerprint: this runner: {image_text}, {compiler}') + for reason in reasons: + print(f'build_fingerprint: {reason}') + note = annotation(result, reasons) + if note: + print(note) + if args.report: + Path(args.report).write_text(json.dumps({'verdict': result, 'reasons': reasons, 'counts': counts, + 'toolchain': toolchain, 'image': image}, indent=1) + '\n') + write_outputs(args.github_output, result, image_text, compiler) return 0 @@ -356,12 +427,13 @@ def guard(args): def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release', define='-DMG=1', link_flag='-Wl,--as-needed', test_env='VK_ICD_FILENAMES=/icd.json', benchmark=False, - empty=False): + empty=False, compile_count=MIN_COMPILE + 5, deps=None, library='libMobileGL.so'): build = root / name build.mkdir(parents=True) if empty: return build abs_build = str(build) + deps = str(deps) if deps else abs_build + '/_deps' (build / 'CMakeCache.txt').write_text( f'CMAKE_BUILD_TYPE:STRING={build_type}\n' 'CMAKE_CXX_COMPILER:FILEPATH=/usr/bin/clang++-20\n' @@ -369,24 +441,33 @@ def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release', 'CMAKE_CXX_FLAGS:STRING=\n' f'MOBILEGL_BUILD_DISAGGREGATED:BOOL={mobilegl_flag}\n' f"MOBILEGL_BUILD_BENCHMARK:BOOL={'ON' if benchmark else 'OFF'}\n" + f'FETCHCONTENT_BASE_DIR:PATH={deps}\n' f'CMAKE_CACHEFILE_DIR:INTERNAL={abs_build}\n') entries = [{'directory': abs_build, 'file': f'/src/MobileGL/f{i}.cpp', 'output': f'CMakeFiles/MobileGL.dir/f{i}.cpp.o', 'command': f'ccache clang++ {define if i == 7 else "-DMG=1"} -I{abs_build}/gen ' + f'-I{deps}/googletest-src/googletest/include ' f'-o CMakeFiles/MobileGL.dir/f{i}.cpp.o -c /src/MobileGL/f{i}.cpp'} - for i in range(MIN_COMPILE + 5)] + for i in range(compile_count)] + entries.append({'directory': deps + '/googletest-build/googletest', + 'file': deps + '/googletest-src/googletest/src/gtest-all.cc', + 'output': 'CMakeFiles/gtest.dir/src/gtest-all.cc.o', 'command': 'clang++ -c gtest-all.cc'}) if benchmark: entries.append({'directory': abs_build + '/MobileGL/MG_Benchmark', 'file': '/src/MobileGL/MG_Benchmark/b.cpp', 'output': 'CMakeFiles/SanityBench.dir/b.cpp.o', 'command': 'clang++ -c b.cpp'}) + entries.append({'directory': deps + '/benchmark-build/src', 'file': deps + '/benchmark-src/src/benchmark.cc', + 'output': 'CMakeFiles/benchmark.dir/benchmark.cc.o', 'command': 'clang++ -c benchmark.cc'}) (build / 'compile_commands.json').write_text(json.dumps(entries)) links = [ - ('libMobileGL.so', 'CXX_SHARED_LIBRARY_LINKER__MobileGL_Release', 'CMakeFiles/MobileGL.dir/f1.cpp.o'), + (library, 'CXX_SHARED_LIBRARY_LINKER__MobileGL_Release', 'CMakeFiles/MobileGL.dir/f1.cpp.o'), ('libMobileGL_s.a', 'CXX_STATIC_LIBRARY_LINKER__MobileGL_s_Release', 'CMakeFiles/MobileGL.dir/f2.cpp.o'), ('MobileGL/MG_Test/SanityTest', 'CXX_EXECUTABLE_LINKER__SanityTest_Release', 'libMobileGL_s.a'), ] if benchmark: links.append(('MobileGL/MG_Benchmark/SanityBench', 'CXX_EXECUTABLE_LINKER__SanityBench_Release', 'libMobileGL_s.a')) + links.append((deps + '/benchmark-build/src/libbenchmark.a', 'CXX_STATIC_LIBRARY_LINKER__benchmark_Release', + deps + '/benchmark-build/src/CMakeFiles/benchmark.dir/benchmark.cc.o')) ninja = ['ninja_required_version = 1.5', ''] for out, rule, inp in links: ninja += [f'build {out}: {rule} {inp} | /usr/lib/libc.so || cmake_object_order_depends_target_x', @@ -394,7 +475,8 @@ def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release', f' POST_BUILD = cd {abs_build}/MobileGL && :', ''] ninja.append('build CMakeFiles/x.o: CXX_COMPILER__MobileGL_Release /src/x.cpp') (build / 'build.ninja').write_text('\n'.join(ninja) + '\n') - subdirs = 'subdirs("MobileGL/MG_Test")\n' + ('subdirs("MobileGL/MG_Benchmark")\n' if benchmark else '') + subdirs = (f'subdirs("MobileGL/MG_Test")\nsubdirs("{deps}/googletest-build")\n' + + (f'subdirs("MobileGL/MG_Benchmark")\nsubdirs("{deps}/benchmark-build")\n' if benchmark else '')) (build / 'CTestTestfile.cmake').write_text(f'# CMake generated Testfile for {abs_build}\n{subdirs}') (build / 'MobileGL' / 'MG_Test').mkdir(parents=True) (build / 'MobileGL' / 'MG_Test' / 'CTestTestfile.cmake').write_text( @@ -408,51 +490,144 @@ def canned_tree(root, *, name='build', mobilegl_flag='ON', build_type='Release', def self_test(): - toolchain = {'MOBILEGL_CI_CXX': 'clang version 20.1.8', 'cmake': 'cmake version 3.31.10'} - other_compiler = dict(toolchain, MOBILEGL_CI_CXX='clang version 20.1.2') cases = [ - ('none', {}, toolchain, True), - ('a MOBILEGL_* cache line', {'mobilegl_flag': 'OFF'}, toolchain, False), - ("one TU's -D", {'define': '-DMG=2'}, toolchain, False), - ('the build type', {'build_type': 'Debug'}, toolchain, False), - ("the compiler's --version text", {}, other_compiler, False), - ('a link flag', {'link_flag': '-Wl,--no-as-needed'}, toolchain, False), - ('a test ENVIRONMENT', {'test_env': 'VK_ICD_FILENAMES=/other.json'}, toolchain, False), - ('only the benchmark TUs, link lines and subdirs line', {'benchmark': True}, toolchain, True), - ('only the build directory name', {'name': 'build-other'}, toolchain, True), - ('an empty directory', {'empty': True}, toolchain, False), + ('none', {}, 'identical'), + ('a MOBILEGL_* cache line', {'mobilegl_flag': 'OFF'}, 'different'), + ("one TU's -D", {'define': '-DMG=2'}, 'different'), + ('the build type', {'build_type': 'Debug'}, 'different'), + ('a link flag', {'link_flag': '-Wl,--no-as-needed'}, 'different'), + ('a test ENVIRONMENT', {'test_env': 'VK_ICD_FILENAMES=/other.json'}, 'different'), + ('only the benchmark TUs, link lines, subdirs lines and dependency', {'benchmark': True}, 'identical'), + ('only the build directory name', {'name': 'build-other'}, 'identical'), + ('only where the shared FetchContent directory is', {'deps_name': 'deps-elsewhere'}, 'identical'), + ('an empty directory', {'empty': True}, 'unknown'), ] failures = 0 - with tempfile.TemporaryDirectory() as tmp: - base = fingerprint(canned_tree(Path(tmp) / 'base'), toolchain) - assert not unproven(base), unproven(base) - for label, change, chain, expected in cases: - other = fingerprint(canned_tree(Path(tmp) / label.replace(' ', '_').replace("'", ''), **change), - chain) - identical, reasons = verdict(base, other) - ok = identical == expected - failures += not ok - print(f"{'ok' if ok else 'NOT OK':7} {label}: identical={identical}" - + ('' if ok or identical else f' ({reasons[:2]})')) - # The guard itself: a probe that cannot be configured is never 'identical'. - output = Path(tmp) / 'github_output' - old = os.environ.get('GITHUB_OUTPUT') - os.environ['GITHUB_OUTPUT'] = str(output) - try: - args = argparse.Namespace(built=str(Path(tmp) / 'base' / 'build'), probe='no-such-variant', - probe_dir=str(Path(tmp) / 'probe'), github_output='identical', report=None) - import contextlib - import io - with contextlib.redirect_stdout(io.StringIO()): - guard(args) - finally: - if old is None: - os.environ.pop('GITHUB_OUTPUT', None) - else: - os.environ['GITHUB_OUTPUT'] = old - ok = output.read_text().strip() == 'identical=false' + + def report(ok, label, got): + nonlocal failures failures += not ok - print(f"{'ok' if ok else 'NOT OK':7} a probe that fails to configure: {output.read_text().strip()}") + print(f"{'ok' if ok else 'NOT OK':7} {label}: {got}") + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + base_deps = tmp / 'deps' + base = fingerprint(canned_tree(tmp / 'base', deps=base_deps), base_deps) + assert not unproven(base), unproven(base) + for label, change, expected in cases: + change = dict(change) + deps = tmp / change.pop('deps_name', 'deps') + root = tmp / re.sub(r'\W+', '_', label) + other = fingerprint(canned_tree(root, deps=deps, **change), deps) + got, reasons = verdict(base, other) + report(got == expected, label, got + ('' if got == expected or not reasons else f' ({reasons[:2]})')) + # Two trees that agree on everything but are too thin to prove anything are never identical. + thin_a = fingerprint(canned_tree(tmp / 'thin-a', compile_count=10, deps=base_deps), base_deps) + thin_b = fingerprint(canned_tree(tmp / 'thin-b', compile_count=10, deps=base_deps), base_deps) + report(verdict(thin_a, thin_b)[0] == 'unknown', 'both sides thin', verdict(thin_a, thin_b)[0]) + empty = verdict(fingerprint(canned_tree(tmp / 'empty-a', empty=True)), + fingerprint(canned_tree(tmp / 'empty-b', empty=True)))[0] + report(empty == 'unknown', 'both sides empty', empty) + # Full-sized trees that agree but link no libMobileGL.so prove nothing about the library. + no_lib = [fingerprint(canned_tree(tmp / f'no-lib-{side}', deps=base_deps, library='libMobileGLServer.so'), + base_deps) for side in 'ab'] + report(verdict(*no_lib)[0] == 'unknown', 'both sides without a libMobileGL.so link', verdict(*no_lib)[0]) + + # A configure attempt that hangs is killed with its children, so it cannot hold the step. + started = time.monotonic() + with contextlib.redirect_stdout(io.StringIO()): + hung = run_attempt(['bash', '-c', 'sleep 60 & wait'], 'a hung configure', timeout=1) + elapsed = time.monotonic() - started + report(not hung and elapsed < 30, 'a hung configure is killed and fails', f'{hung} after {elapsed:.1f} s') + # The production path carries the timeout too, and four killed attempts fit the step. + stub = tmp / 'hanging-configure.sh' + stub.write_text('sleep 60 & wait\n') + real_configure = CONFIGURE + globals()['CONFIGURE'] = stub + try: + started = time.monotonic() + with contextlib.redirect_stdout(io.StringIO()): + hung = run_configure('primary', tmp / 'hung-build', tmp / 'hung-deps', timeout=1) + elapsed = time.monotonic() - started + finally: + globals()['CONFIGURE'] = real_configure + report(not hung and elapsed < 30, 'run_configure passes its timeout on', f'{hung} after {elapsed:.1f} s') + report(isinstance(CONFIGURE_TIMEOUT, int) and CONFIGURE_TIMEOUT > 0 + and 4 * (CONFIGURE_TIMEOUT + 60) <= GUARD_STEP_MINUTES * 60, + 'four killed configure attempts fit the guard step', CONFIGURE_TIMEOUT) + escaped = annotation('different', ['100% of the flags']) + report(escaped is not None and escaped.endswith('100%25 of the flags'), "an annotation escapes '%'", escaped) + + # The guard itself, with a stand-in for configure_build.sh. + def run_guard(configure, label): + output = tmp / f'github_output-{label}' + old = os.environ.get('GITHUB_OUTPUT') + os.environ['GITHUB_OUTPUT'] = str(output) + try: + args = argparse.Namespace(primary_dir=str(tmp / f'{label}-primary'), + split_dir=str(tmp / f'{label}-split'), + deps_dir=str(tmp / f'{label}-deps'), github_output='verdict', report=None) + printed = io.StringIO() + with contextlib.redirect_stdout(printed): + guard(args, configure, lambda: {'MOBILEGL_CI_CXX': 'clang version 20.1.8'}) + finally: + if old is None: + os.environ.pop('GITHUB_OUTPUT', None) + else: + os.environ['GITHUB_OUTPUT'] = old + outputs = dict(line.split('=', 1) for line in output.read_text().splitlines()) + outputs['annotations'] = [line for line in printed.getvalue().splitlines() if line.startswith('::')] + return outputs + + calls = [] + + def flaky(variant, build_dir, deps_dir): + calls.append(variant) + if variant == 'primary' and calls.count('primary') == 1: + return False + canned_tree(build_dir.parent, name=build_dir.name, benchmark=variant == 'primary', deps=deps_dir) + return True + out = run_guard(flaky, 'flaky') + report(out.get('verdict') == 'identical' and calls == ['primary', 'primary', 'split'], + 'a configure that fails once is retried', f"{out.get('verdict')} after {calls}") + report(set(out) == {'verdict', 'image', 'compiler', 'annotations'}, + 'the guard writes verdict, image and compiler', sorted(out)) + report(out['annotations'] == [], "'identical' is not annotated", out['annotations']) + + hangs = [] + + def hangs_once(variant, build_dir, deps_dir): + hangs.append(variant) + if len(hangs) == 1: + return run_attempt(['bash', '-c', 'sleep 60 & wait'], 'a hung configure', timeout=1) + canned_tree(build_dir.parent, name=build_dir.name, benchmark=variant == 'primary', deps=deps_dir) + return True + out = run_guard(hangs_once, 'hangs') + report(out.get('verdict') == 'identical' and hangs == ['primary', 'primary', 'split'], + 'a configure killed on its timeout is retried', f"{out.get('verdict')} after {hangs}") + + def broken(variant, build_dir, deps_dir): + return variant != 'split' + out = run_guard(broken, 'broken') + report(out.get('verdict') == 'unknown', 'a configure that fails twice', out.get('verdict')) + report(len(out['annotations']) == 1 and out['annotations'][0].startswith('::notice::') + and 'failed twice' in out['annotations'][0], "'unknown' is a notice with its reason", + out['annotations']) + + def debug_split(variant, build_dir, deps_dir): + canned_tree(build_dir.parent, name=build_dir.name, deps=deps_dir, + build_type='Debug' if variant == 'split' else 'Release') + return True + out = run_guard(debug_split, 'differ') + report(out.get('verdict') == 'different', 'a proven difference', out.get('verdict')) + report(len(out['annotations']) == 1 and out['annotations'][0].startswith('::warning::') + and 'CMAKE_BUILD_TYPE' in out['annotations'][0], "'different' is a warning with its reason", + out['annotations']) + + def raising(variant, build_dir, deps_dir): + raise OSError('no bash') + out = run_guard(raising, 'raising') + report(out.get('verdict') == 'unknown', 'an error inside the guard', out.get('verdict')) print(f'build_fingerprint self-test: {"FAILED" if failures else "ok"}') return 1 if failures else 0 @@ -466,19 +641,20 @@ def main(argv=None): sub = parser.add_subparsers(dest='command', required=True) p = sub.add_parser('fingerprint') p.add_argument('--build-dir', required=True) + p.add_argument('--deps-dir') p.add_argument('--out') p = sub.add_parser('compare') p.add_argument('first') p.add_argument('second') p = sub.add_parser('guard') - p.add_argument('--built', required=True) - p.add_argument('--probe', required=True, choices=('primary', 'split')) - p.add_argument('--probe-dir', required=True) - p.add_argument('--github-output', default='identical') + p.add_argument('--primary-dir', required=True) + p.add_argument('--split-dir', required=True) + p.add_argument('--deps-dir', required=True) + p.add_argument('--github-output', default='verdict') p.add_argument('--report') args = parser.parse_args(argv) if args.command == 'fingerprint': - fp = fingerprint(args.build_dir) + fp = fingerprint(args.build_dir, args.deps_dir) text = json.dumps(fp, indent=1, sort_keys=True) + '\n' if args.out: Path(args.out).write_text(text) @@ -487,11 +663,11 @@ def main(argv=None): if args.command == 'compare': a = json.loads(Path(args.first).read_text()) b = json.loads(Path(args.second).read_text()) - identical, reasons = verdict(a, b) + result, reasons = verdict(a, b) for reason in reasons: print(reason) - print(f"identical={'true' if identical else 'false'}") - return 0 if identical else 1 + print(f'verdict={result}') + return 0 if result == 'identical' else 1 return guard(args) diff --git a/scripts/ci/configure_build.sh b/scripts/ci/configure_build.sh index be934620..dc1b10f5 100644 --- a/scripts/ci/configure_build.sh +++ b/scripts/ci/configure_build.sh @@ -16,12 +16,14 @@ # library logs the resolved transport at INFO, and run_trace_case.cmake and the split lanes read # that line back as proof the transport resolved in the process under test. # -# usage: configure_build.sh -# with MOBILEGL_CI_CC, MOBILEGL_CI_CXX and VK_ICD_FILENAMES from setup-test-runner. +# usage: configure_build.sh [extra cmake arguments...] +# with MOBILEGL_CI_CC, MOBILEGL_CI_CXX and VK_ICD_FILENAMES from setup-test-runner. The builds pass +# no extra arguments; build_fingerprint.py's guard passes its shared FetchContent directory. set -euo pipefail variant="${1:?usage: configure_build.sh }" build_dir="${2:?usage: configure_build.sh }" +shift 2 common=( -S . -B "${build_dir}" -G Ninja @@ -72,4 +74,4 @@ case "${variant}" in ;; esac -cmake "${common[@]}" "${flags[@]}" +cmake "${common[@]}" "${flags[@]}" "$@" diff --git a/scripts/ci/split_reuse_build_type.sh b/scripts/ci/split_reuse_build_type.sh new file mode 100644 index 00000000..1a18e157 --- /dev/null +++ b/scripts/ci/split_reuse_build_type.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# MobileGL - scripts/ci/split_reuse_build_type.sh +# Copyright (c) 2025-2026 MobileGL-Dev +# Licensed under the GNU Lesser General Public License v3.0: +# https://www.gnu.org/licenses/gpl-3.0.txt +# https://www.gnu.org/licenses/lgpl-3.0.txt +# SPDX-License-Identifier: LGPL-3.0-only +# End of Source File Header + +# The split consumers' build-type check. A consumer that reuses build-linux's build (SPLIT_REUSE is +# true: build-guard found the two configurations identical) takes it only when build-linux built +# Release, the only type build-guard can find identical to the split line. A re-run of failed jobs +# with debug logging rebuilds build-linux as Debug but keeps build-guard's verdict, while +# build-linux-split built nothing, so the consumer stops here and says so. +# +# usage: split_reuse_build_type.sh (SPLIT_REUSE and PRIMARY_BUILD_TYPE from the job) +# split_reuse_build_type.sh --self-test +set -euo pipefail + +if [ "${1:-}" = --self-test ]; then + failures=0 + expect() { + local want="$1" reuse="$2" type="$3" got=0 + SPLIT_REUSE="${reuse}" PRIMARY_BUILD_TYPE="${type}" bash "$0" > /dev/null || got=$? + if [ "${got}" -ne "${want}" ]; then + echo "NOT OK reuse=${reuse} build type '${type}': exit ${got}, expected ${want}" + failures=$((failures + 1)) + fi + } + expect 0 true Release + expect 1 true Debug + expect 1 true '' + expect 0 false Debug + expect 0 '' '' + if [ "${failures}" -ne 0 ]; then + echo "split_reuse_build_type self-test: FAILED" + exit 1 + fi + echo "split_reuse_build_type self-test: ok" + exit 0 +fi + +if [ "${SPLIT_REUSE:-false}" = true ] && [ "${PRIMARY_BUILD_TYPE:-}" != Release ]; then + echo "::error::build-guard found the two configurations identical as Release builds, but build-linux built '${PRIMARY_BUILD_TYPE:-an unknown type}' (a re-run of failed jobs with debug logging keeps the guard's verdict) and build-linux-split built nothing; re-run all jobs" + exit 1 +fi