# yamllint disable rule:line-length rule:truthy --- name: Batch syntax/run check on: workflow_dispatch: null push: branches: - '**' permissions: contents: read pages: write id-token: write actions: read pull-requests: write issues: write concurrency: group: batch-check-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: selftest: name: Batch syntax/run check (${{ matrix.mode }}) permissions: actions: read contents: read strategy: fail-fast: false matrix: include: - mode: cache - mode: real # conda-full: third lane - no fallbacks, proves real Miniconda path works - mode: conda-full # justme-test: simulates non-elevated process via HP_TEST_NOT_ELEVATED=1 to cover fsutil branch (informational) - mode: justme-test # uv: explicit lane for uv env+dep installer path (no HP_FORCE_CONDA_ONLY) - mode: uv # contract-uv: enforces uv contract on the happy path (informational while shaking out) - mode: contract-uv # contract-uv-fail: HP_TEST_UV_FAIL=1 forces uv venv to fail; asserts explicit conda fallback - mode: contract-uv-fail # uv-dl-fallback: HP_TEST_UV_DL_FALLBACK=1 forces primary uv URL to fail; exercises fallback URL (non-gating) - mode: uv-dl-fallback continue-on-error: ${{ matrix.mode == 'cache' || matrix.mode == 'justme-test' || matrix.mode == 'uv' || matrix.mode == 'contract-uv' || matrix.mode == 'contract-uv-fail' || matrix.mode == 'uv-dl-fallback' }} runs-on: windows-latest outputs: has_failures: ${{ steps.verdict.outputs.has_failures }} env: GH_TOKEN: ${{ github.token }} # HP_CI_LANE: tags every NDJSON row so diagnostics can filter by lane HP_CI_LANE: ${{ matrix.mode }} steps: - uses: actions/checkout@v5 - name: Extract pipreqs version from bootstrapper if: ${{ matrix.mode == 'cache' }} id: extract_version continue-on-error: true shell: pwsh run: | $content = Get-Content run_setup.bat -Raw $match = $content | Select-String 'set "HP_PIPREQS_VERSION=([0-9.]+)"' if ($match.Matches.Count -gt 0) { $version = $match.Matches[0].Groups[1].Value "pipreqs_version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append Write-Host "Extracted pipreqs version: $version" } else { Write-Host "::warning::Could not extract HP_PIPREQS_VERSION; using fallback 0.4.13" "pipreqs_version=0.4.13" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append } - name: Restore Miniconda cache if: ${{ matrix.mode == 'cache' }} id: conda_cache_restore continue-on-error: true uses: actions/cache/restore@v5 with: path: C:\Users\Public\Documents\Miniconda3 key: win-${{ runner.os }}-py311b-conda-${{ hashFiles('run_setup.bat') }}-${{ steps.extract_version.outputs.pipreqs_version }} restore-keys: | win-${{ runner.os }}-py311b-conda- - name: Validate restored conda binary if: ${{ matrix.mode == 'cache' }} id: cache_health continue-on-error: true shell: pwsh env: HP_CACHE_EXACT_HIT: ${{ steps.conda_cache_restore.outputs.cache-hit }} run: | # derived requirement: the actual health-check-and-heal logic now lives in # tools/ci_cache_selfheal.ps1 so tests/test_ci_cache_selfheal.ps1 can exercise it # deterministically on every CI run (a GATING lane), independent of whether GitHub's # own cache happens to be organically corrupted this run -- see that file and # docs/agent-closed-backlog.md's Item 19 entry for why the ambient `cache` lane alone # (informational, job-level continue-on-error) was not enough signal on its own. $exactArgs = @() if ($env:HP_CACHE_EXACT_HIT -eq 'true') { $exactArgs = @('-ExactHit') } & .\tools\ci_cache_selfheal.ps1 -CondaDir 'C:\Users\Public\Documents\Miniconda3' @exactArgs $rc = $LASTEXITCODE if ($rc -eq 1 -or $rc -eq 3) { 'HP_CACHE_CORRUPTED=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append } if ($rc -eq 3) { 'HP_CACHE_SELFHEAL_FAILED=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append } if ($rc -eq 2 -or $rc -eq 3) { 'HP_CACHE_SELFHEAL_ATTEMPTED=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append } exit 0 # health check is informational; never fail this step - name: Emit cache-corruption skip artifacts if: ${{ env.HP_CACHE_CORRUPTED == '1' }} shell: pwsh run: | Write-Host "::warning:: Cache corrupted, skipping fast-path tests (HP_CACHE_CORRUPTED=1)" '' | Set-Content '.ci_bootstrap_marker' -Encoding Ascii '{"state":"cache_corrupted","exitCode":0,"pyFiles":0}' | Set-Content '~bootstrap.status.json' -Encoding Ascii if (-not (Test-Path 'tests')) { New-Item -ItemType Directory 'tests' | Out-Null } $row = '{"id":"self.cache.corrupted","pass":true,"desc":"Cache corrupted; bootstrap skipped (infrastructure, not product failure)","lane":"cache"}' $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii - name: "Record cache self-heal outcome (visibility row; never fails)" # derived requirement: this is the gap the owner flagged directly -- when the self-heal # SUCCEEDS (the ordinary case), HP_CACHE_CORRUPTED is never set, so no NDJSON row was # ever emitted saying "this run actually had to self-heal a corrupted cache" -- the run # just looked like an ordinary fresh install, with the only trace being a ::warning:: # buried in raw job logs. Emitting this unconditionally whenever the self-heal branch is # entered (success OR failure) makes both outcomes queryable on the diagnostics site over # time, instead of requiring a manual raw-log dig to notice either one. if: ${{ !cancelled() && env.HP_CACHE_SELFHEAL_ATTEMPTED == '1' }} shell: pwsh run: | if (-not (Test-Path 'tests')) { New-Item -ItemType Directory 'tests' | Out-Null } $healed = ($env:HP_CACHE_SELFHEAL_FAILED -ne '1') $row = [ordered]@{ id = 'self.cache.selfheal.fired' pass = $true lane = 'cache' desc = 'Restored cache failed its health check on a restore-keys prefix match; self-heal (delete stale dir, fall through to fresh install) was attempted' details = [ordered]@{ healed = $healed } } | ConvertTo-Json -Compress $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii - name: "Enforce cache self-heal success (Item 19 follow-on)" # derived requirement: an ordinary corrupted-and-healed cache is routine infra noise and # stays informational -- run_setup.bat hashes into the cache key on nearly every PR, so # this fires often and is expected. But the self-heal ITSELF failing to clear the stale # directory is not routine noise: it means this repo's own Item 19 fix has regressed back # into the exact pre-fix "always corrupted, never self-heals" trap that item existed to # close. This step's own failure is still absorbed by the `cache` lane's job-level # continue-on-error (see CLAUDE.md's CI lane gating maturity notes -- this lane stays # intentionally non-gating for ordinary organic flakiness), so on its own this does not # block a PR; it exists so the failure is a loud, explicit ::error:: annotation and a red # step marker instead of a buried ::warning::. tests/test_ci_cache_selfheal.ps1 (a GATING # regression test wired into the `real` lane) is what actually catches a regression in # this logic on every single CI run, independent of whether GitHub's own cache happens to # be organically corrupted this run. if: ${{ !cancelled() && env.HP_CACHE_SELFHEAL_FAILED == '1' }} shell: pwsh run: | Write-Host "::error::Cache self-heal failed to clear the stale Miniconda3 directory -- see 'Validate restored conda binary' step output above." exit 1 # probe fires in real, conda-full, uv, and contract-uv* lanes; cache lane skips it intentionally - name: Enable Miniconda probe (real/conda-full/uv/contract-uv mode) if: ${{ matrix.mode == 'real' || matrix.mode == 'conda-full' || matrix.mode == 'uv' || matrix.mode == 'contract-uv' || matrix.mode == 'contract-uv-fail' }} shell: pwsh run: | 'HP_CI_TEST_CONDA_DL=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append # contract-uv-fail only: HP_TEST_UV_FAIL=1 lets uv venv creation succeed, then forces # uv dep install to fail (injected bad package). The contract assertion verifies # UV_FALLBACK reason=dep_install_failed and that uv venv creation succeeded first. - name: Force uv failure (contract-uv-fail only) if: ${{ matrix.mode == 'contract-uv-fail' }} shell: pwsh run: | 'HP_TEST_UV_FAIL=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append # conda-full only: clears venv/system fallbacks so a conda failure can't hide as a venv pass - name: Force conda-only bootstrap if: ${{ matrix.mode == 'conda-full' }} shell: pwsh run: | 'HP_FORCE_CONDA_ONLY=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append # justme-test only: simulates non-elevated process so JustMe fallback path runs with CI coverage - name: Force JustMe install path (simulate non-elevated) if: ${{ matrix.mode == 'justme-test' }} shell: pwsh run: | 'HP_TEST_NOT_ELEVATED=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append # justme-test only: forces Miniconda DL fallback and completely prevents uv acquisition # so the JustMe and Miniconda DL fallback paths are exercised (uv-first would bypass them). - name: Force download fallback path (justme-test only) if: ${{ matrix.mode == 'justme-test' }} shell: pwsh run: | 'HP_TEST_CONDA_DL_FALLBACK=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append 'HP_TEST_FORCE_UV_FAIL=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append # uv-dl-fallback only: forces the primary uv download URL to fail so the fallback URL is tried. # Does not set HP_FORCE_CONDA_ONLY or HP_TEST_FORCE_UV_FAIL so uv is still the primary provider. - name: Force uv download fallback (uv-dl-fallback only) if: ${{ matrix.mode == 'uv-dl-fallback' }} shell: pwsh run: | 'HP_TEST_UV_DL_FALLBACK=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append - name: Ensure CI fallbacks are off shell: pwsh run: | echo "HP_ALLOW_VENV_FALLBACK=" | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append echo "HP_ALLOW_SYSTEM_FALLBACK=" | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append - name: Show environment shell: cmd run: | ver echo Mode: ${{ matrix.mode }} echo %COMSPEC% echo Workspace: %CD% git rev-parse HEAD dir /b /s - name: Debug to show harness with line numbers if: ${{ !cancelled() }} shell: pwsh run: | Write-Host "HEAD: $env:GITHUB_SHA Branch: $env:GITHUB_REF" if (Test-Path .\tests\harness.ps1) { $i = 0 Get-Content .\tests\harness.ps1 | ForEach-Object { $i++; '{0,5}: {1}' -f $i, $_ } $bytes = [System.IO.File]::ReadAllBytes('.\tests\harness.ps1') $hi = $bytes | Where-Object { $_ -gt 127 } | Select-Object -First 10 if ($hi) { Write-Host "Non-ASCII bytes present: $($hi -join ', ')" } else { Write-Host "ASCII-only file." } } else { Write-Error "tests\harness.ps1 not found in workspace" } - name: Static-parse harness (AST) if: ${{ !cancelled() }} shell: pwsh run: | if (Test-Path .\tests\harness.ps1) { $tokens = $null; $errors = $null [void][System.Management.Automation.Language.Parser]::ParseFile('.\tests\harness.ps1',[ref]$tokens,[ref]$errors) if ($errors) { Write-Host "::group::AST parse errors" foreach ($e in $errors) { "{0} at line {1}, col {2}" -f $e.Message, $e.Extent.StartLineNumber, $e.Extent.StartColumnNumber $ctx = Get-Content .\tests\harness.ps1 $start = [Math]::Max($e.Extent.StartLineNumber-2,1) $end = [Math]::Min($e.Extent.StartLineNumber+2,$ctx.Count) for ($n=$start; $n -le $end; $n++) { '{0,5}: {1}' -f $n, $ctx[$n-1] } "" } Write-Host "::endgroup::" } else { Write-Host "No AST parse errors in tests\harness.ps1" } } # derived requirement: wired into `real` specifically (a GATING lane, not in the # job-level continue-on-error list at the top of this file) so a regression in # tools/ci_cache_selfheal.ps1 actually fails CI -- see that file and # docs/agent-closed-backlog.md's Item 19 entry for why the ambient `cache` lane alone # (informational, only fires on organic corruption) was not sufficient signal on its own. - name: "Self-test: cache-lane self-heal logic (real lane only, GATING)" if: ${{ matrix.mode == 'real' }} shell: pwsh run: | & tests\test_ci_cache_selfheal.ps1 # derived requirement: wired into `real` specifically (a GATING lane) for the same reason # as the cache-selfheal self-test immediately above -- so a regression in # tools/aggregate_selftest_verdicts.ps1 (the selftest-gate "Aggregate verdicts" logic, # CLAUDE.md Active Backlog Item 35's "Precondition" caveat) actually fails CI on every run, # independent of whether a real artifact ever goes missing/duplicated/unexpected in this # particular run. - name: "Self-test: selftest-gate verdict aggregation logic (real lane only, GATING)" if: ${{ matrix.mode == 'real' }} shell: pwsh run: | & tests\test_aggregate_selftest_verdicts.ps1 - name: "Pre-bootstrap: ensure Python file exists" shell: pwsh run: | New-Item -ItemType Directory -Force -Path tests\~bootstrap | Out-Null Set-Content -Path tests\~bootstrap\bootstrap_stub.py -Value "print('bootstrap trigger')" - name: Bootstrap environment (run_setup.bat) if: ${{ env.HP_CACHE_CORRUPTED != '1' }} id: bootstrap_env continue-on-error: ${{ matrix.mode == 'cache' }} shell: cmd run: | setlocal EnableExtensions EnableDelayedExpansion echo === BEGIN %CD%\run_setup.bat === echo on call run_setup.bat > bootstrap.log 2>&1 set RC=%errorlevel% @echo off if NOT "%RC%"=="0" ( echo Bootstrapper failed with exit code %RC% type bootstrap.log exit /b %RC% ) echo bootstrapped> .ci_bootstrap_marker - name: Catch cache lane bootstrap failure if: ${{ matrix.mode == 'cache' && steps.bootstrap_env.outcome == 'failure' }} shell: pwsh run: | '' | Set-Content '.ci_bootstrap_marker' -Encoding Ascii '{"state":"cache_bootstrap_failed","exitCode":1,"pyFiles":0}' | Set-Content '~bootstrap.status.json' -Encoding Ascii if (-not (Test-Path 'tests')) { New-Item -ItemType Directory 'tests' | Out-Null } $row = '{"id":"self.cache.bootstrap.failed","pass":true,"desc":"Cache lane bootstrap failed; treated as infrastructure issue (non-blocking)","lane":"cache"}' $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii 'HP_CACHE_CORRUPTED=1' | Out-File -FilePath $env:GITHUB_ENV -Encoding ascii -Append - name: Append pipreqs summary if: ${{ !cancelled() }} shell: pwsh run: | $summary = Join-Path $PWD '~pipreqs.summary.txt' if (Test-Path $summary) { Get-Content -LiteralPath $summary | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } else { 'pipreqs summary not generated.' | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } - name: "Self-test: empty repo behavior" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & .\tests\selftests.ps1 Get-Content -LiteralPath tests\~selftest_empty\~empty_bootstrap.log -Tail 60 -ErrorAction SilentlyContinue - name: "Self-test: single .py entry (CI-only)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} env: HP_CI_SKIP_ENV: '1' shell: pwsh run: | & tests\selfapps_single.ps1 - name: "Self-test: entry selection (CI-only)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} env: HP_CI_SKIP_ENV: '1' shell: pwsh run: | & tests\selfapps_entry.ps1 - name: "Self-test: isolation and directory integrity (CI-only)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} env: HP_CI_SKIP_ENV: '1' shell: pwsh run: | & tests\selfapps_isolation.ps1 # CLAUDE.md Active Backlog Item 46 Bucket A Batch 5: :ci_skip_entry's own # ~find_entry.py staging failure used to fall through call :die into a redundant second # call :die ("find_entry helper syntax error") for the same root cause. Cheap # (HP_CI_SKIP_ENV path, no conda/uv needed) so runs in every lane, matching the # HP_CI_SKIP_ENV-based steps directly above. # derived requirement: per-step continue-on-error, not just the job-level one -- see the # identical note above the PEP 723 write-back steps (this same file, "REQ-005.11: PEP 723 # header write-back") for why: without it, a genuine failure here silently skips every # later step in the SAME job on a non-gating lane. Scoped to non-gating lanes only # (matches matrix.mode != 'real' && matrix.mode != 'conda-full', the complement of the # job-level continue-on-error list on the job definition itself) -- a real failure on the # two gating lanes must still fail the job. - name: "Self-test: die-emit-fallthrough ci_skip_entry (CI-only)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} continue-on-error: ${{ matrix.mode != 'real' && matrix.mode != 'conda-full' }} env: DIE_EMIT_SCENARIO: ci_skip_entry shell: pwsh run: | & tests\selfapps_die_emit_fallthrough.ps1 - name: "Self-test: env-name sanitization (leading hyphen)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_envname.ps1 - name: "Self-test: env-name sanitization (ampersand readability, CLAUDE.md Item 26)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} env: ENVNAME_SCENARIO: 'ampersand' shell: pwsh run: | & tests\selfapps_envname.ps1 - name: "Self-test: env-name sanitization (64-char truncation bound, CodeRabbit PR #417)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} env: ENVNAME_SCENARIO: 'longname' shell: pwsh run: | & tests\selfapps_envname.ps1 - name: "Self-test: bootstrapper size tripwire (REQ-017)" # derived requirement (item 7 scoping pass): this step never executes run_setup.bat # (a static byte-size check only), so !cancelled() carries zero duration-inflation risk -- # it is safe to surface even when an earlier step (e.g. Bootstrap environment) failed. if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_size.ps1 - name: "Self-test: real env smoke (CI-only)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_envsmoke.ps1 - name: "Check Miniconda availability (diagnostic; gates 27 conda-full self-tests + a loud tripwire)" # derived requirement (moved here 2026-07-27, correcting item 7's original placement): this # check previously ran right after "Bootstrap environment (run_setup.bat)" -- but that step # runs against THIS repo's own root (no loose .py files, the empty-repo/no_python_files # graceful-exit path), so it never installs Miniconda. Every downstream selfapps step # capable of performing the FIRST real install was ALSO gated on that same premature check, # producing a circular self-skip: confirmed via the GitHub Actions API against the CI runs # for two real commits on PR #390 (efd7a5c, fd7a046) that ~27 real/conda-full-only self- # tests silently "skipped" every run while the job still reported overall SUCCESS. See # docs/agent-closed-backlog.md's closed Item 7 entry for the full incident writeup and # PR #391 for the revert that restored those steps to unconditional (matrix.mode == # 'conda-full') form. # # This step is now positioned right after "Self-test: real env smoke (CI-only)" # (selfapps_envsmoke.ps1) instead -- traced 2026-07-27 as the genuine first selfapps step # that performs a REAL, unconditional run_setup.bat bootstrap under HP_FORCE_CONDA_ONLY=1 # (its own script comment: "FULL bootstrap here: do NOT set HP_CI_SKIP_ENV"; every earlier # candidate -- selfapps_single.ps1/selfapps_entry.ps1/selfapps_isolation.ps1/ # selfapps_envname.ps1 -- sets HP_CI_SKIP_ENV=1 and never touches conda at all; selftests.ps1 # only replays a captured log; selfapps_size.ps1 is a static byte-size check). Under # conda-full specifically, HP_FORCE_CONDA_ONLY=1 blocks every venv/system fallback envsmoke's # own script would otherwise allow, so a nonzero exit there can only mean the conda install # itself failed -- making this the correct point to sample "is conda now really available." # # Re-wired 2026-07-27 (owner sign-off, full risk/benefit assessment in chat -- see # docs/agent-closed-backlog.md's closed Item 7 entry for the summary) after the # corrected POSITION # above was empirically confirmed working across two real conda-full runs (PR #395, #396: # `available` correctly read `true` both times). The 27 downstream conda-full-only self-test # steps below now gate on `steps.conda_avail.outputs.available == 'true'` -- BUT this alone # would reintroduce exactly the PR #390 risk (a wrong condition silently skips real tests # while the job stays green), so it never ships without its paired tripwire: the very next # step unconditionally FAILS THE JOB (not skip-silently) whenever this reads anything other # than 'true' in the conda-full lane, regardless of whether the root cause is a genuine # Miniconda install failure or a bug in this gating mechanism itself -- owner's explicit # direction was to default to a loud failure in either case, not a graceful/silent one. if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} id: conda_avail shell: pwsh run: | $condaMain = 'C:\Users\Public\Documents\Miniconda3\condabin\conda.bat' $condaAlt = 'C:\Users\Public\Documents\Miniconda3\Scripts\conda.bat' $avail = (Test-Path -LiteralPath $condaMain) -or (Test-Path -LiteralPath $condaAlt) "available=$($avail.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append Write-Host "Miniconda available at shared path: $avail" # derived requirement: this diagnostic's own output was previously observable # (Write-Host + step output) had no NDJSON row -- CodeRabbit flagged the gap on # PR #394. This step itself never fails the job (that judgment now lives in the # paired tripwire step immediately below) -- it only ever reports the observed fact. $row = [ordered]@{ id = 'diag.conda.available' lane = $env:HP_CI_LANE pass = $true desc = 'Miniconda availability diagnostic (raw fact only; see diag.conda.available.gate for the enforced judgment)' details = [ordered]@{ available = $avail } } | ConvertTo-Json -Compress -Depth 8 $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii - name: "Enforce Miniconda availability (conda-full only; fails loud, never skips silently)" # derived requirement: this is the tripwire that makes the conda_avail gate above safe to # use. It intentionally does NOT try to distinguish "Miniconda genuinely failed to install" # (which already independently fails the "Self-test: real env smoke (CI-only)" step's own # self.env.smoke.conda NDJSON row today, via $bootstrapPass requiring a clean interpreter + # entry-run + no [ERROR] lines, not just a zero exit code -- see selfapps_envsmoke.ps1) from # "a bug in this gating mechanism itself" (the PR #390 failure class: a wrong lane/step/ # output reference that silently skips real tests while the job stays green). Per the # owner's explicit direction, BOTH cases fail this step loudly and unconditionally, even # though the first case is technically "not our fault" (a transient external Miniconda/ # network issue) -- a loud, attributable failure on a rare true negative is preferred over # any risk of a silent false negative recurring. Runs in every non-cache-corrupted lane # (matching conda_avail's own if:) so its own NDJSON row is always present in every such # artifact, uniformly skip=true outside conda-full per this repo's established skip-pattern # convention (see docs/agent-interconnect.md "Skip pattern template"). Both this step and # conda_avail above are themselves skipped, like everything else in this job, on the rare # HP_CACHE_CORRUPTED=1 path (cache-lane restore failure -- see this file's own "cache lane # Miniconda-corruption handling" lessons-learned entry). if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} id: conda_avail_gate env: HP_CONDA_AVAIL: ${{ steps.conda_avail.outputs.available }} shell: pwsh run: | $avail = $env:HP_CONDA_AVAIL if ($env:HP_CI_LANE -ne 'conda-full') { $row = [ordered]@{ id = 'diag.conda.available.gate' lane = $env:HP_CI_LANE pass = $true desc = 'Miniconda availability gate (enforced judgment; conda-full only)' details = [ordered]@{ skip = $true; reason = 'not-conda-full' } } | ConvertTo-Json -Compress -Depth 8 $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii exit 0 } if ($avail -eq 'true') { $row = [ordered]@{ id = 'diag.conda.available.gate' lane = $env:HP_CI_LANE pass = $true desc = 'Miniconda availability gate (enforced judgment; conda-full only)' details = [ordered]@{ available = $avail } } | ConvertTo-Json -Compress -Depth 8 $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii exit 0 } $row = [ordered]@{ id = 'diag.conda.available.gate' lane = $env:HP_CI_LANE pass = $false desc = 'Miniconda availability gate (enforced judgment; conda-full only)' details = [ordered]@{ available = $avail } } | ConvertTo-Json -Compress -Depth 8 $row | Add-Content 'tests\~test-results.ndjson' -Encoding Ascii $row | Add-Content 'ci_test_results.ndjson' -Encoding Ascii Write-Host '::error::Miniconda was NOT detected at the shared install path after "Self-test: real env smoke (CI-only)" completed. 27 downstream conda-full self-tests are gated on this and will have been skipped.' Write-Host '::error::Two possible root causes, both must be checked: (1) a genuine Miniconda install failure during real env smoke -- this already independently fails that step''s own self.env.smoke.conda NDJSON row, so if that row also failed, this is likely just secondary confirmation; check tests\~envsmoke\~envsmoke_bootstrap.log and tests\~envsmoke\~setup.log below and in the job artifacts. (2) a bug in this gating mechanism itself (wrong lane/step/output reference) -- if the env-smoke step passed cleanly, treat this as case (2) and investigate the conda_avail / conda_avail_gate steps and the 27 if: conditions before assuming this is safe to ignore. See docs/agent-closed-backlog.md''s closed Item 7 entry for the documented history of exactly this failure class (PR #390).' $envsmokeLog = 'tests\~envsmoke\~envsmoke_bootstrap.log' if (Test-Path -LiteralPath $envsmokeLog) { Write-Host '--- tail of tests\~envsmoke\~envsmoke_bootstrap.log ---' Get-Content -LiteralPath $envsmokeLog -Tail 40 -ErrorAction SilentlyContinue } else { Write-Host '(tests\~envsmoke\~envsmoke_bootstrap.log not found -- real env smoke may not have run at all)' } exit 1 - name: "Self-test: uv contract assertions (contract-uv* only)" if: ${{ matrix.mode == 'contract-uv' || matrix.mode == 'contract-uv-fail' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_contract_uv.ps1 - name: "Self-test: JustMe install path (justme-test only)" if: ${{ matrix.mode == 'justme-test' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_justme.ps1 - name: "Self-test: download fallback paths (justme-test/uv-dl-fallback)" if: ${{ matrix.mode == 'justme-test' || matrix.mode == 'uv-dl-fallback' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_dl_fallback.ps1 - name: "Self-test: requirements specifier coverage" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_reqspec.ps1 - name: "Self-test: dep-check skip scenario (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_depcheck.ps1 - name: "Self-test: warnfix PASS scenario (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} env: WARNFIX_SCENARIO: pass shell: pwsh run: | & tests\selfapps_warnfix.ps1 - name: "Self-test: warnfix XFAIL scenario (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} env: WARNFIX_SCENARIO: xfail shell: pwsh run: | & tests\selfapps_warnfix.ps1 - name: "Self-test: warnfix REAL scenario - heuristic prevents warnfix (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} env: WARNFIX_SCENARIO: real shell: pwsh run: | & tests\selfapps_warnfix.ps1 - name: "Self-test: warnfix REAL_WARNFIX scenario - warnfix fixes non-heuristic dep (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} env: WARNFIX_SCENARIO: real_warnfix shell: pwsh run: | & tests\selfapps_warnfix.ps1 - name: "Self-test: warnfix REAL_WARNFIX_DELAYED scenario - warnfix processes delayed imports (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} env: WARNFIX_SCENARIO: real_warnfix_delayed shell: pwsh run: | & tests\selfapps_warnfix.ps1 # CLAUDE.md Active Backlog Item 36: the warnfix repair-install dispatch previously had # ONLY uv/conda branches, so it silently no-opped under venv/embed/system while still # logging as if the repair succeeded. Now that venv/embed branches exist, this proves # the venv branch genuinely installs the missing module, not just that the EXE builds. # uv lane only (non-gating for first landing, matches this repo's established graduation # pattern for a new PowerShell scenario not yet proven stable across several real runs) -- # forces venv mode via HP_OFFLINE_MODE=1 + HP_TEST_FORCE_CONDA_FAIL=1 in a fresh scratch # dir (see selfapps_warnfix_venv_repair.ps1's own header comment for the mechanism). - name: "Self-test: warnfix repair-install under venv mode (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_warnfix_venv_repair.ps1 # CLAUDE.md Active Backlog Item 39: the EXE fast path's freshness check switched from # mtime-only to a content-hash comparison, so a genuinely changed source file whose # mtime is backdated (the ZIP/xcopy/robocopy delivery scenario) is no longer silently # treated as fresh. uv lane only (non-gating for first landing -- the isolated # tools/fast_check.ps1 script is already verified locally via real pwsh, but this is # the first time the full :try_fast_exe -> :run_entry_smoke -> :success -> # :write_fast_hash cycle runs end-to-end on real Windows CI). See # selfapps_fastpath_hash.ps1's own header comment for the full mechanism. - name: "Self-test: EXE fast path detects a backdated-mtime content change (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_fastpath_hash.ps1 # REQ-005.11: PEP 723 header write-back (uv add --script). v1-scoped to HP_ENV_MODE=uv, # so wired to the dedicated uv lane only (non-gating) for this first CI pass rather than # the gating real lane -- lets these brand-new scenarios prove out in real Windows CI # without risking a merge block if an assumption needs adjusting. Promote to real/conda- # full once stable, mirroring how self.cascade.exec graduated from uv-lane-only. # derived requirement: each of these 8 steps has its own continue-on-error: true, not # just the job-level one -- confirmed directly via a real CI run that GitHub Actions' # job-level continue-on-error does NOT make sibling steps in the same job resilient to # an earlier step's failure; without a per-step flag, one failing scenario here silently # skipped every step after it in the SAME job, including pre-existing, unrelated tests # (selfapps_warnfix.ps1, selfapps_collect.ps1, selfapps_hidden_import.ps1, ...). - name: "Self-test: PEP 723 write-back FRESH scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: fresh shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back IDEMPOTENT scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: idempotent shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back SKIPFLAG scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: skipflag shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back MALFORMED scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: malformed shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back TRAILING_WS_MALFORMED scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: trailing_ws_malformed shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back EXISTING_LOCKFILE scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: existing_lockfile shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back NON_UTF8 scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: non_utf8 shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 - name: "Self-test: PEP 723 write-back WARNFIX scenario (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: PEP723_SCENARIO: warnfix shell: pwsh run: | & tests\selfapps_pep723_writeback.ps1 # derived requirement: dry-run test for the README "PVW QuickStart" copy-paste # commands (uv/autopep723, no run_setup.bat involved) -- an isolated proof that the # underlying tool mechanics work before docs/plan-autopep723-two-tier.md's Tier 1/2 # reuse them inside the bootstrapper. Own per-step continue-on-error, matching the # sibling PEP 723 write-back steps above (see that block's own comment for why). - name: "Self-test: PVW QuickStart check-only command (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: QUICKSTART_SCENARIO: check shell: pwsh run: | & tests\selfapps_pvw_quickstart.ps1 - name: "Self-test: PVW QuickStart just-run-it command (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true env: QUICKSTART_SCENARIO: run shell: pwsh run: | & tests\selfapps_pvw_quickstart.ps1 # derived requirement: REQ-005.12 (Tier 1, docs/plan-autopep723-two-tier.md) -- proves # the bootstrapper-integrated autopep723-check-and-merge block actually populates # requirements.txt and the app builds/runs from it alone (HP_SKIP_PIPREQS=1 isolates # Tier 1's own contribution from pipreqs's overlapping discovery). - name: "Self-test: autopep723 discovery merge (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_autopep_discovery.ps1 # derived requirement: REQ-005.13 (Tier 2, docs/plan-autopep723-two-tier.md) -- proves # HP_PVW_KNOWN_IDEMPOTENT actually runs the entry live (stdout inherited, not captured) # and persists what it needed, and the app builds/runs from it alone. - name: "Self-test: HP_PVW_KNOWN_IDEMPOTENT execute-mode discovery (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_pvw_idempotent.ps1 # CLAUDE.md former Active Backlog item 10: PVW_PYTHON_EXE and PVW_WORKSPACE had ZERO test # coverage of any kind, unlike the other three PVW_* super-user overrides. Non-gating for # its first landing, matching this repo's established graduation pattern for a new # PowerShell scenario not yet proven stable across several real runs. - name: "Self-test: PVW_PYTHON_EXE / PVW_WORKSPACE super-user overrides (uv lane only)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_pvw_overrides.ps1 - name: "Self-test: pre-build collect-submodules double-gate (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_collect.ps1 - name: "Self-test: strict --hidden-import auto-recovery (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_hidden_import.ps1 # CLAUDE.md Active Backlog item 11: no test previously drove the --hidden-import # auto-recovery loop to its 3-attempt cap without ever succeeding (its sibling above only # covers the one-shot-recoverable success path). - name: "Self-test: strict --hidden-import auto-recovery exhaustion (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_hidden_import_exhaust.ps1 # CLAUDE.md Active Backlog item 10: :tci_both_failed (both AllUsers and JustMe Miniconda # installs fail) had zero CI coverage. Miniconda installs to the SHARED, machine-wide # %PUBLIC%\Documents\Miniconda3 path -- this step MUST run before any earlier step that # would install Miniconda for real (e.g. the provider-cascade-exec step directly below, # which cascades uv -> conda), or :try_conda_install's own top-level gate would find # conda.bat already present and skip the install block this test needs to exercise. The # uv lane's own main bootstrap step succeeds via uv alone (no Miniconda installed), so by # the time this step runs conda.bat genuinely does not exist yet on the runner. - name: "Self-test: Miniconda both-install-types-fail (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_conda_bothfail.ps1 # REQ-009/REQ-005.10 slice 3: provider-cascade EXECUTION. Runs only in the uv lane # (uv-first, non-gating). The cascade walks uv -> conda (Miniconda download mid-run) -> # venv -> stop. Heavy and provider-dependent, hence non-gating (uv lane continue-on-error). - name: "Self-test: provider cascade EXECUTION uv -> conda (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_cascade.ps1 # CLAUDE.md Active Backlog Item 23: a genuine conda-create failure during a REQ-009 cascade # re-entry previously fell through :die instead of gracefully keeping the previous working # uv build. Must run AFTER the step directly above (selfapps_cascade.ps1) so Miniconda is # already installed/cached from that step -- otherwise :cascade_acquire_conda would need a # real Miniconda download of its own, same CI-ordering reasoning as # selfapps_conda_bothfail.ps1's own placement note, just the opposite direction. - name: "Self-test: cascade-reentry conda-create failure keeps previous build (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_cascade_conda_create_fail.ps1 # PR #413 CodeRabbit review finding: the scenario above only exercises the fix's # :conda_create_failed call site (create itself fails); this exercises the OTHER call site # inside :conda_create_done (create genuinely succeeds but python.exe is missing # afterward). Same placement requirement as the step above (Miniconda already cached). - name: "Self-test: cascade-reentry conda-create missing python.exe keeps previous build (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: CASCADE_CCF_SCENARIO: missing_python run: | & tests\selfapps_cascade_conda_create_fail.ps1 # CLAUDE.md Active Backlog Item 42 (lever 1): proves the :log console-tiering mechanism # actually behaves at runtime -- [DEBUG]/[TRACE]/[INSTALL] suppressed from the live console # by default, always still written to ~setup.log, restorable via HP_VERBOSE_CONSOLE=1. Two # scenarios (see the script's own header). Must run after the cascade steps above so # Miniconda is already installed/cached, avoiding a second fresh download for this test's # own HP_FORCE_CONDA_ONLY=1 conda-create. Non-gating for its first landing (uv lane is # continue-on-error at the job level) -- promote once proven stable across several real runs. - name: "Self-test: console output tiering, default suppression (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: CONSOLE_TIER_SCENARIO: default run: | & tests\selfapps_console_tiering.ps1 - name: "Self-test: console output tiering, HP_VERBOSE_CONSOLE=1 opt-in (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: CONSOLE_TIER_SCENARIO: verbose run: | & tests\selfapps_console_tiering.ps1 # docs/agent-closed-backlog.md's Item 22: a real, non-simulated end-to-end test proving the # uv-to-conda cascade, warnfix repair (both success and failure in the same round), and # --hidden-import auto-recovery all fire for real in ONE run -- no HP_TEST_FORCE_*/ # HP_SKIP_*/HP_DISABLE_* flags beyond the unavoidable cascade-consent answer. Runs only in # the cache lane (uv-first, and the only lane that already caches Miniconda across runs to # amortize the one-time download this test's own cascade triggers). Non-gating for its # first landing (cache lane is continue-on-error at the job level) -- promote once proven # stable across several real runs. env.HP_CACHE_CORRUPTED != '1' matches every other # post-bootstrap step in this lane -- this test invokes run_setup.bat and relies on the # shared Miniconda cache the cascade downloads into, so a corrupted cache would fail it # for an infrastructure reason unrelated to the mechanisms it actually tests. - name: "Self-test: layered dependency chain -- cascade + warnfix + hidden-import, real (cache lane, non-gating)" if: ${{ !cancelled() && matrix.mode == 'cache' && env.HP_CACHE_CORRUPTED != '1' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_layered_e2e.ps1 # AV-Safe Build Path (docs/prd-av-safe-build-path.md) requirements 2-4, Tier A: proves a # real Nuitka fallback build succeeds and is used when PyInstaller's own build fails. # Non-gating for its first landing -- unlike self.exe.build.xfail (real/conda-full, # gating), this exercises a genuine Nuitka build whose CLI flags/compiler availability # could not be verified locally. Promote once proven stable across several real runs. - name: "Self-test: AV-Safe Build Path Tier A -- real Nuitka fallback build (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_nuitka_tiera.ps1 # Regression test for a real bug found via a refinement pass on the shipped Tier A code: # the --hidden-import auto-recovery loop unconditionally rebuilt via PyInstaller on a # recoverable missing-import failure, with no check for whether the current EXE was # actually built by Nuitka (Tier A). Same non-gating reasoning as the sibling step above # (depends on a real Nuitka build succeeding). - name: "Self-test: Tier A / hidden-import-recovery skip guard (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_nuitka_tiera_hidden_skip.ps1 # AV-Safe Build Path requirement 9 (P1): the elective "want an optimized build too?" # upsell after a normal successful PyInstaller build. Four scenarios in one file/lane # (uv, non-gating) -- 'accept'/'swapfail' depend on a real Nuitka build succeeding (same # reasoning as the Tier A steps above); 'forcefail'/'decline' are deterministic but kept in # the same lane for consistency with the shared NDJSON row id. - name: "Self-test: optimized-build offer, accept (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: OPTBUILD_SCENARIO: accept run: | & tests\selfapps_optimized_build.ps1 - name: "Self-test: optimized-build offer, forced failure leaves original untouched (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: OPTBUILD_SCENARIO: forcefail run: | & tests\selfapps_optimized_build.ps1 - name: "Self-test: optimized-build offer, swap failure leaves original untouched (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: OPTBUILD_SCENARIO: swapfail run: | & tests\selfapps_optimized_build.ps1 - name: "Self-test: optimized-build offer, decline (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh env: OPTBUILD_SCENARIO: decline run: | & tests\selfapps_optimized_build.ps1 # docs/plan-cli-interactive-verification.md requirement 2: proves piped stdin flows # unbroken through the real cmd.exe -> :run_exe_smokerun -> ~exe_smokerun.ps1 -> EXE chain # for a genuine multi-round input()-driven program, closing the gap the unit tests # (tests/test_failfast_probe.py, tests/test_exe_smokerun.py) can't reach on their own # (those invoke the .ps1 helpers directly via pwsh, not through the full production # nesting). Provider-agnostic by construction -- see the test file's own header comment # for why one uv-lane pass is representative of every lane. Non-gating for its first # landing, matching this repo's established graduation pattern. - name: "Self-test: interactive stdin round-trip through the full EXE verification chain (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_interactive_stdin.ps1 # CLAUDE.md Active Backlog Item 41: a real, silent, force-killed EXE gets the new # GUI-app-aware caveat hint instead of the generic caveat text alone. Non-gating for its # first landing -- first time HP_SMOKERUN_KILL_MS is exercised from a full-bootstrap # selfapps test against a real PyInstaller-frozen EXE's own cold-start behavior, so it # could not be verified against real Windows locally. - name: "Self-test: GUI-timeout caveat hint (uv lane, non-gating)" if: ${{ matrix.mode == 'uv' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_gui_timeout_hint.ps1 - name: "Self-test: EXE smokerun XFAIL bad import (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_exefail.ps1 - name: "Self-test: PyInstaller build failure correctly reported, not masked (execfail, real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh env: PYI_FAIL_SCENARIO: execfail run: | & tests\selfapps_pyinstaller_fail.ps1 - name: "Self-test: PyInstaller build failure correctly reported, not masked (output_vanish, real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh env: PYI_FAIL_SCENARIO: output_vanish run: | & tests\selfapps_pyinstaller_fail.ps1 - name: "Self-test: no-EXE briefing is honest when the interpreter fallback also fails (execfail_runtimefail, real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh env: PYI_FAIL_SCENARIO: execfail_runtimefail run: | & tests\selfapps_pyinstaller_fail.ps1 - name: "Self-test: REQ-021 py_compile pre-flight syntax error (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_preflight.ps1 - name: "Self-test: early preflight branches -- line-ending + writable-CWD (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_lineending_check.ps1 - name: "Self-test: EXE CWD consistency across fresh-build and fast-path runs (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_exe_cwd_consistency.ps1 - name: "Self-test: EXE smokerun XFAIL missing data file, name coincidentally contains _MEI+digits (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh env: EXEDATA_SCENARIO: mei_substring run: | & tests\selfapps_exedata_fail.ps1 - name: "Self-test: EXE smokerun XFAIL missing data file under a genuine _MEIxxxxxx extraction path (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh env: EXEDATA_SCENARIO: mei_genuine run: | & tests\selfapps_exedata_fail.ps1 - name: "Self-test: EXE smokerun XFAIL dynamic import not bundled (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_exedyn_fail.ps1 - name: "Self-test: fast-path broken-EXE graceful fallback (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_exefastpath.ps1 - name: "Self-test: fail-fast probe -- fast-failure discard and alive-past-probe no-discard (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_failfast_probe.ps1 - name: "Self-test: post-execution checkpoint -- accept and decline (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_postexec_checkpoint.ps1 - name: "Self-test: super-user skip hooks (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} shell: pwsh run: | & tests\selfapps_skiphooks.ps1 - name: "Self-test: REQ-002 timed entry picker (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} shell: pwsh run: | & tests\selfapps_entry_picker.ps1 - name: "Self-test: REQ-009 cascade consent timed prompt (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} shell: pwsh run: | & tests\selfapps_cascade_timed.ps1 # CLAUDE.md Active Backlog Item 45: a genuine first-attempt conda-create failure (all # fallback tiers exhausted via the test's own HP_FORCE_CONDA_ONLY=1) must never reach the # PyInstaller build/warnfix/repair block against a nonexistent HP_PY. Self-contained # (own HP_FORCE_CONDA_ONLY=1 override, matching selfapps_pipgap.ps1's pattern) and # deterministic (HP_TEST_FORCE_CONDA_CREATE_BOTH_FAIL=1, no real network dependency for # the failure itself) -- gating from first landing. - name: "Self-test: entry-smoke no-interpreter guard (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} shell: pwsh run: | & tests\selfapps_entrysmoke_no_interpreter.ps1 # CLAUDE.md Active Backlog Item 46 Bucket A Batch 2: :conda_create_done's own # "python.exe missing" check (the NON-cascade path, distinct from selfapps_cascade_ # conda_create_fail.ps1's own missing_python scenario) used to fall through call :die into # the rest of its body, logging a misleading "[BOOT] ... Selected Python provider: Conda # (Portable)." success line right after the error. Self-contained (own # HP_FORCE_CONDA_ONLY=1 + HP_TEST_FORCE_CONDA_MISSING_PYTHON=1), gating from first landing # (same reasoning as the step directly above). - name: "Self-test: die-emit-fallthrough missing_python (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} env: DIE_EMIT_SCENARIO: missing_python shell: pwsh run: | & tests\selfapps_die_emit_fallthrough.ps1 # CLAUDE.md Active Backlog Item 46 Bucket A Batch 4: :conda_create_done's own .condarc # staging failure used to fall through call :die into a doomed copy attempt, triggering a # SECOND, redundant call :die plus the same misleading success line as the scenario above. # New hook: HP_TEST_FORCE_EMIT_FAIL= (run_setup.bat, :emit_from_base64) fails one # specific embedded-helper write deterministically. - name: "Self-test: die-emit-fallthrough condarc (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} env: DIE_EMIT_SCENARIO: condarc shell: pwsh run: | & tests\selfapps_die_emit_fallthrough.ps1 # CLAUDE.md Active Backlog Item 46 Bucket A Batch 3: :determine_entry runs twice per normal # bootstrap; a genuine failure on the FIRST call used to fall through call :die into the # entire dependency-install/pipreqs/warnfix block (real work, not just a redundant message) # before reproducing the identical failure at the second call site. - name: "Self-test: die-emit-fallthrough determine_entry (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} env: DIE_EMIT_SCENARIO: determine_entry shell: pwsh run: | & tests\selfapps_die_emit_fallthrough.ps1 - name: "Test pandas/openpyxl heuristic (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} shell: pwsh run: | & tests\selfapps_pandas_excel.ps1 - name: "Test pip gap-fill safety net (conda-full only)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} shell: pwsh run: | & tests\selfapps_pipgap.ps1 # CLAUDE.md Active Backlog Item 24 / docs/prd-conda-native-dll-bundling.md Requirement 1: # empirical, standalone experiment (does NOT invoke run_setup.bat) testing whether forcing # --hidden-import=gribapi on a pygrib PyInstaller build makes the existing # pyinstaller-hooks-contrib hook-gribapi.py bundle eccodes.dll for free. Non-gating: # exploratory by design, and the conda-forge solve for pygrib+eccodes+python-eccodes+ # pyinstaller together in one env is unproven. - name: "Probe: does --hidden-import=gribapi bundle eccodes.dll for pygrib (conda-full only, non-gating)" if: ${{ !cancelled() && matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true' }} continue-on-error: true shell: pwsh run: | & tests\selfapps_gribapi_hook_probe.ps1 - name: "Self-test: parse_warn TRANSLATIONS table coverage" # derived requirement (item 7 scoping pass): this step only decodes the embedded # HP_PARSE_WARN base64 payload out of run_setup.bat as static text -- it never executes # run_setup.bat -- so !cancelled() carries zero duration-inflation risk. if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_parse_warn_table.ps1 - name: "Self-test: parse_warn pytest (test_parse_warn.py)" # derived requirement (item 7 scoping pass): pure `python -m unittest` against this # repo's own tools/parse_warn.py -- no dependency on run_setup.bat/conda/Miniconda at # all, so !cancelled() carries zero duration-inflation risk. if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | $nd = "tests\~test-results.ndjson" $ciNd = "ci_test_results.ndjson" if (-not (Test-Path $nd)) { New-Item -ItemType File -Path $nd -Force | Out-Null } if (-not (Test-Path $ciNd)) { New-Item -ItemType File -Path $ciNd -Force | Out-Null } function Write-NdjsonRow { param([hashtable]$Row) $lane = [Environment]::GetEnvironmentVariable('HP_CI_LANE') if ($lane -and -not $Row.ContainsKey('lane')) { $Row['lane'] = $lane } $json = $Row | ConvertTo-Json -Compress -Depth 8 Add-Content -LiteralPath $nd -Value $json -Encoding Ascii Add-Content -LiteralPath $ciNd -Value $json -Encoding Ascii } $rawOut = & python -m unittest tests.test_parse_warn -v 2>&1 $exitCode = $LASTEXITCODE $outStr = ($rawOut -join "`n") $pass = ($exitCode -eq 0) $summary = ($rawOut | Select-String -Pattern '(OK|FAILED|ERROR|Ran \d+)' | Select-Object -Last 1) if ($summary) { $summaryStr = $summary.Line.Trim() } else { $summaryStr = "exit=$exitCode" } Write-NdjsonRow ([ordered]@{ id = 'self.parse_warn.pytest' pass = $pass desc = 'parse_warn pytest: test_parse_warn.py all tests pass' details = [ordered]@{ summary = $summaryStr; exit = $exitCode } }) if (-not $pass) { Write-Host $outStr; exit 1 } - name: "Self-test: heuristics unittest (test_heuristics.py)" # derived requirement (item 7 scoping pass): pure `python -m unittest` against this # repo's own tools/prep_requirements.py -- no dependency on run_setup.bat/conda/Miniconda # at all, so !cancelled() carries zero duration-inflation risk. if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | $nd = "tests\~test-results.ndjson" $ciNd = "ci_test_results.ndjson" if (-not (Test-Path $nd)) { New-Item -ItemType File -Path $nd -Force | Out-Null } if (-not (Test-Path $ciNd)) { New-Item -ItemType File -Path $ciNd -Force | Out-Null } function Write-NdjsonRow { param([hashtable]$Row) $lane = [Environment]::GetEnvironmentVariable('HP_CI_LANE') if ($lane -and -not $Row.ContainsKey('lane')) { $Row['lane'] = $lane } $json = $Row | ConvertTo-Json -Compress -Depth 8 Add-Content -LiteralPath $nd -Value $json -Encoding Ascii Add-Content -LiteralPath $ciNd -Value $json -Encoding Ascii } $rawOut = & python -m unittest tests.test_heuristics -v 2>&1 $exitCode = $LASTEXITCODE $outStr = ($rawOut -join "`n") $pass = ($exitCode -eq 0) $summary = ($rawOut | Select-String -Pattern '(OK|FAILED|ERROR|Ran \d+)' | Select-Object -Last 1) if ($summary) { $summaryStr = $summary.Line.Trim() } else { $summaryStr = "exit=$exitCode" } Write-NdjsonRow ([ordered]@{ id = 'self.heuristics.pytest' pass = $pass desc = 'heuristics unittest: test_heuristics.py all tests pass' details = [ordered]@{ summary = $summaryStr; exit = $exitCode } }) if (-not $pass) { Write-Host $outStr; exit 1 } - name: "Self-test: Python unit tests (pytest cross-platform)" # derived requirement (item 7 scoping pass): pure `pytest` against this repo's own # tests/test_*.py suite -- no dependency on run_setup.bat/conda/Miniconda at all, so # !cancelled() carries zero duration-inflation risk. if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | $nd = "tests\~test-results.ndjson" $ciNd = "ci_test_results.ndjson" if (-not (Test-Path $nd)) { New-Item -ItemType File -Path $nd -Force | Out-Null } if (-not (Test-Path $ciNd)) { New-Item -ItemType File -Path $ciNd -Force | Out-Null } function Write-NdjsonRow { param([hashtable]$Row) $lane = [Environment]::GetEnvironmentVariable('HP_CI_LANE') if ($lane -and -not $Row.ContainsKey('lane')) { $Row['lane'] = $lane } $json = $Row | ConvertTo-Json -Compress -Depth 8 Add-Content -LiteralPath $nd -Value $json -Encoding Ascii Add-Content -LiteralPath $ciNd -Value $json -Encoding Ascii } python -m pip install pytest pydantic requests --quiet # derived requirement: collect only the flat tests/test_*.py files (this repo's actual # test-file layout -- confirmed no legitimate test_*.py exists in any subdirectory), # not a recursive "tests/" scan. A recursive scan picks up ANY nested Python # installation's own bundled test suite that happens to land under tests/ during this # same CI run -- e.g. the REQ-009 embed tier's real python.org download (in # tests/~selftest_cascade_exec/~embed_python/ or tests/~selftest_embed_real/) installs # pip/setuptools/PyInstaller, and setuptools bundles its OWN test_*.py files under # site-packages/setuptools/_distutils/tests/ -- pytest then tries to import those as if # they were this repo's tests and fails to collect them (missing distutils/jaraco.path, # relative-import errors), even though they were never meant to run standalone. Root # cause was always latent (any scratch dir containing a nested Python install could # trigger it); confirmed via a real CI failure once the provider-chain reorder made the # uv-lane cascade test reach a real embed build for the first time. PowerShell does not # glob-expand a "tests/test_*.py" string argument to an external command the way a bash # shell would, so the file list is enumerated explicitly via Get-ChildItem instead of # relying on a wildcard string reaching python/pytest unexpanded. $excludeUnitFiles = @('test_entry_selection.py', 'test_entry_single.py', 'test_poll_public_diag_logging.py') $unitTestFiles = Get-ChildItem -Path 'tests' -Filter 'test_*.py' -File | Where-Object { $excludeUnitFiles -notcontains $_.Name } | ForEach-Object { $_.FullName } $rawOut = & python -m pytest $unitTestFiles --tb=line -v 2>&1 $exitCode = $LASTEXITCODE $outStr = ($rawOut -join "`n") $pass = ($exitCode -eq 0) $summaryLine = ($rawOut | Select-String -Pattern '(passed|failed|error)' | Select-Object -Last 1) if ($summaryLine) { $summaryStr = $summaryLine.Line.Trim() } else { $summaryStr = "exit=$exitCode" } $failedLines = @($rawOut | Select-String -Pattern '^\s*FAILED\s+' | ForEach-Object { $_.Line.Trim() } | Select-Object -First 10) if ($failedLines.Count -gt 0) { $summaryStr = ($failedLines -join ' | ') + ' | ' + $summaryStr } Write-NdjsonRow ([ordered]@{ id = 'self.pytest.unit' pass = $pass desc = 'Python unit tests: pytest cross-platform suite' details = [ordered]@{ summary = $summaryStr; exit = $exitCode } }) if (-not $pass) { Write-Host $outStr; exit 1 } - name: "Self-test: runtime.txt write-back (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_runtime_writeback.ps1 - name: "Self-test: pyvisa NI-VISA detection (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_pyvisa.ps1 - name: "Self-test: pyproject.toml Python version precedence (real/conda-full only)" if: ${{ !cancelled() && (matrix.mode == 'real' || (matrix.mode == 'conda-full' && steps.conda_avail.outputs.available == 'true')) }} shell: pwsh run: | & tests\selfapps_pyproject_precedence.ps1 - name: "Self-test: UX hardening (git config merge)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_ux_hardening.ps1 - name: "Self-test: REQ-007 system-Python build consent gate (non-conda-full)" if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | & tests\selfapps_sysbuild.ps1 - name: Upload CI summary (on failure) if: ${{ failure() }} uses: actions/upload-artifact@v6 with: name: ci_test_summary-${{ github.job }}-${{ matrix.mode }}-${{ github.run_id }}-${{ github.run_attempt }} path: tests/~test-summary.txt if-no-files-found: ignore retention-days: 7 - name: Upload CI NDJSON if: ${{ !cancelled() }} uses: actions/upload-artifact@v6 with: name: ci_test_results-${{ github.job }}-${{ matrix.mode }}-${{ github.run_id }}-${{ github.run_attempt }} path: tests/~test-results.ndjson if-no-files-found: ignore retention-days: 7 # ORDERING CONSTRAINT - do not move these five steps (Verdict through Upload iterate # gate verdict) below "Run tests (map empty repo to success)". # # Reason: run_tests.bat calls harness.ps1, which DELETES and REWRITES # tests/~test-results.ndjson with static analysis rows. If Verdict runs # after that, it reads harness rows instead of selftest rows, causing false # gate triggers on lanes where envsmoke actually passed. # # "Upload CI NDJSON" (above) captures the correct selftest state. # Verdict must read that same state before harness overwrites the file. - name: Verdict from NDJSON id: verdict if: ${{ !cancelled() }} shell: pwsh run: | function Read-Ndjson([string]$p) { if (Test-Path $p) { Get-Content $p | Where-Object { $_ -match '^\{.*\}$' } | ForEach-Object { try { $_ | ConvertFrom-Json } catch {} } } } $rows = @() $rows += Read-Ndjson 'tests\~test-results.ndjson' if (-not $rows) { $rows += Read-Ndjson 'ci_test_results.ndjson' } $has = (-not $rows) $allowedStates = @('ok','no_python_files','venv_env','degraded_env') foreach ($r in $rows) { $hasPass = $false if ($r -is [psobject]) { $hasPass = $r.PSObject.Properties.Name -contains 'pass' } if ($hasPass) { if ($r.pass -eq $false) { $has = $true } continue } if ($r.PSObject.Properties.Name -contains 'status') { $statusText = [string]$r.status if ($statusText -eq 'failed') { # derived requirement: some harness rows only expose status=failed; mirror the iterate gate logic so model assist kicks in. $has = $true } } if ($r.id -eq 'bootstrap.state' -and $r.details.state -and $r.details.state -notin $allowedStates) { $has = $true } if ($r.id -eq 'self.bootstrap.state' -and $r.details.state -and $r.details.state -notin $allowedStates) { $has = $true } } $verdict = @{ has_failures = $has; sources = @('tests~test-results.ndjson','ci_test_results.ndjson') } if (-not $rows) { $verdict.missing = @('tests~test-results.ndjson','ci_test_results.ndjson') } $value = $has.ToString().ToLowerInvariant() "has_failures=$value" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append $verdict | ConvertTo-Json -Compress -Depth 8 | Out-File -Encoding UTF8 'iterate_gate.json' # closes the false-pass gap: has_failures=true was computed but never actually failed the job - name: Enforce NDJSON failures for gated lanes if: ${{ !cancelled() && (matrix.mode == 'real' || matrix.mode == 'conda-full') && steps.verdict.outputs.has_failures == 'true' }} shell: bash run: | echo "NDJSON indicates failures; failing gated lane." exit 1 - name: Append iterate gate to Summary if: ${{ !cancelled() }} shell: pwsh run: | $flag = '${{ steps.verdict.outputs.has_failures }}' "### Iterate gate (pre-flight snapshot)" | Out-File -Append $env:GITHUB_STEP_SUMMARY "- has_failures: $flag" | Out-File -Append $env:GITHUB_STEP_SUMMARY "This snapshot ensures missing tests~test-results.ndjson / ci_test_results.ndjson are treated as failures so empty streams never pass; later gates reflect the real NDJSON rows." | Out-File -Append $env:GITHUB_STEP_SUMMARY if (Test-Path 'iterate_gate.json') { '```json' | Out-File -Append $env:GITHUB_STEP_SUMMARY Get-Content -Raw 'iterate_gate.json' | Out-File -Append $env:GITHUB_STEP_SUMMARY '```' | Out-File -Append $env:GITHUB_STEP_SUMMARY } - name: Persist iterate gate verdict if: ${{ !cancelled() }} shell: pwsh run: | $flag = '${{ steps.verdict.outputs.has_failures }}' if (-not $flag) { $flag = 'false' } $has = $false if ($flag -eq 'true') { $has = $true } $record = [ordered]@{ lane = '${{ matrix.mode }}' has_failures = $has raw = $flag run_id = $env:GITHUB_RUN_ID run_attempt = $env:GITHUB_RUN_ATTEMPT sources = @('tests~test-results.ndjson','ci_test_results.ndjson') } $record | ConvertTo-Json -Compress -Depth 8 | Out-File -FilePath lane_verdict.json -Encoding ascii - name: Upload iterate gate verdict if: ${{ !cancelled() }} uses: actions/upload-artifact@v6 with: name: selftest-verdict-${{ matrix.mode }} path: lane_verdict.json if-no-files-found: ignore retention-days: 7 - name: Run dynamic tests (if present) if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh run: | $ErrorActionPreference = 'Stop' $pwd = Get-Location Write-Host "PWD:" $pwd Write-Host "DIR (tests):" if (Test-Path .\tests) { Get-ChildItem .\tests -Force -Name } else { Write-Host "tests folder not found" } $statusPath = Join-Path $pwd.Path '~bootstrap.status.json' $testsDir = Join-Path $pwd.Path 'tests' $extractDir = Join-Path $testsDir 'extracted' $logPath = Join-Path $pwd.Path 'dynamic_tests.log' $mirrorPath = Join-Path $testsDir '~dynamic-run.log' $notePath = Join-Path $pwd.Path 'dynamic_summary_note.txt' $resultsNdjson = Join-Path $testsDir '~dynamic-results.ndjson' if (Test-Path $logPath) { Remove-Item -Force $logPath } if (Test-Path $mirrorPath) { Remove-Item -Force $mirrorPath } if (Test-Path $notePath) { Remove-Item -Force $notePath } if (-not (Test-Path $testsDir)) { New-Item -ItemType Directory -Force -Path $testsDir | Out-Null } function Show-LogTail { param([string]$Path,[string]$Title) if (Test-Path $Path) { Write-Host "::group::$Title" Get-Content -Path $Path -Tail 120 Write-Host "::endgroup::" } } if (-not (Test-Path $statusPath)) { Show-LogTail '.\bootstrap.log' 'bootstrap.log (tail)' Show-LogTail '.\~setup.log' '~setup.log (tail)' # derived requirement: a plain Write-Host+exit here (not `throw`) keeps this # step's failure signal a clean, informative one instead of an uncaught-exception # stack trace -- this step runs under !cancelled(), so it is now reached even when # the earlier "Bootstrap environment" step itself failed, and a missing status file # in that case is a real, expected consequence, not a scripting bug. Write-Host "::error::~bootstrap.status.json not found; bootstrapper did not record status." exit 1 } try { $status = Get-Content $statusPath -Raw -Encoding Ascii | ConvertFrom-Json } catch { Show-LogTail '.\bootstrap.log' 'bootstrap.log (tail)' Show-LogTail '.\~setup.log' '~setup.log (tail)' Write-Host ("::error::Failed to parse ~bootstrap.status.json: {0}" -f $_.Exception.Message) exit 1 } $state = if ($null -ne $status.state) { [string]$status.state } else { '' } if ($state -eq 'no_python_files') { $msg = 'Dynamic tests skipped: bootstrap reported no Python files.' Set-Content -Path $notePath -Value $msg -Encoding Ascii 'SKIPPED: no_python_files' | Set-Content -Path $logPath -Encoding Ascii 'SKIPPED: no_python_files' | Set-Content -Path $mirrorPath -Encoding Ascii if (Test-Path $resultsNdjson) { Remove-Item -Force $resultsNdjson } return } if ($state -ne 'ok') { Show-LogTail '.\bootstrap.log' 'bootstrap.log (tail)' Show-LogTail '.\~setup.log' '~setup.log (tail)' Write-Host ("::error::Bootstrap state '{0}' blocks dynamic tests execution." -f $state) exit 1 } if (-not (Test-Path $extractDir)) { Write-Host "Creating tests\\extracted at $extractDir" New-Item -ItemType Directory -Force -Path $extractDir | Out-Null } $helpers = @('~detect_python.py','~prep_requirements.py') foreach ($helper in $helpers) { $src = Join-Path $pwd.Path $helper $dest = Join-Path $extractDir $helper if (Test-Path $src) { Copy-Item -Path $src -Destination $dest -Force Write-Host "Copied helper $helper to tests\\extracted" } else { Write-Host "Helper $helper not found at $src" } } $ran = $false if (Test-Path .\tests\dynamic_tests.bat) { Write-Host "Running tests\dynamic_tests.bat" cmd /c tests\dynamic_tests.bat *>&1 | Tee-Object -FilePath $logPath if (Test-Path $logPath) { Copy-Item -Path $logPath -Destination $mirrorPath -Force } if ($LASTEXITCODE -ne 0) { throw "dynamic_tests.bat failed with exit code $LASTEXITCODE" } $ran = $true } elseif (Test-Path .\tests\dynamic_tests.py) { $pyExe = $null $pyArgs = @() if (Get-Command python -ErrorAction SilentlyContinue) { $pyExe = 'python' } elseif (Get-Command py -ErrorAction SilentlyContinue) { $pyExe = 'py' $pyArgs += '-3' } if (-not $pyExe) { Write-Host "Python not found on PATH; cannot run dynamic_tests.py" } else { $cmdArgs = $pyArgs + @('-u','tests\dynamic_tests.py') Write-Host ("Running tests\dynamic_tests.py via: {0} {1}" -f $pyExe, ($cmdArgs -join ' ')) & $pyExe @cmdArgs *>&1 | Tee-Object -FilePath $logPath if (Test-Path $logPath) { Copy-Item -Path $logPath -Destination $mirrorPath -Force } if ($LASTEXITCODE -ne 0) { throw "dynamic_tests.py failed with exit code $LASTEXITCODE" } $ran = $true } } if (-not $ran) { $msg = 'Dynamic tests not found; skipping (ok).' Set-Content -Path $notePath -Value $msg -Encoding Ascii 'SKIPPED: no_dynamic_tests' | Set-Content -Path $logPath -Encoding Ascii 'SKIPPED: no_dynamic_tests' | Set-Content -Path $mirrorPath -Encoding Ascii if (Test-Path $resultsNdjson) { Remove-Item -Force $resultsNdjson } } - name: Run tests (map empty repo to success) if: ${{ !cancelled() && env.HP_CACHE_CORRUPTED != '1' }} shell: pwsh env: SCRIPT: run_tests.bat run: | $ErrorActionPreference = 'Continue' $outDir = Join-Path $PWD '_out' New-Item -ItemType Directory -Force -Path $outDir | Out-Null $transcriptPath = Join-Path $outDir 'bootstrapper-transcript.txt' $stdoutPath = Join-Path $outDir 'bootstrapper-stdout.txt' $transcriptStarted = $false try { Start-Transcript -Path $transcriptPath -IncludeInvocationHeader | Out-Null $transcriptStarted = $true } catch { Write-Warning ("Start-Transcript failed: {0}" -f $_.Exception.Message) } Write-Host "=== BEGIN $pwd\$env:SCRIPT ===" & cmd /c $env:SCRIPT *>&1 | Tee-Object -FilePath $stdoutPath $rc = $LASTEXITCODE if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch { Write-Warning ("Stop-Transcript failed: {0}" -f $_.Exception.Message) } } elseif (-not (Test-Path $transcriptPath)) { # Professional note: keep a placeholder so diagnostics always find a transcript payload. New-Item -ItemType File -Path $transcriptPath -Force | Out-Null } if (-not (Test-Path $stdoutPath)) { Set-Content -Path $stdoutPath -Value 'bootstrapper stdout missing' -Encoding Ascii } if (Test-Path '~bootstrap.status.json') { try { $status = Get-Content '~bootstrap.status.json' -Raw | ConvertFrom-Json if ($status.state -eq 'no_python_files') { Write-Host "Runner rc=$rc on empty repo; mapping to SUCCESS by CI contract." exit 0 } } catch { Write-Warning "Could not parse ~bootstrap.status.json; keeping rc=$rc" } } else { Write-Warning "~bootstrap.status.json missing; keeping rc=$rc" } exit $rc - name: Upload bootstrapper tests artifact if: ${{ !cancelled() && matrix.mode == 'real' }} uses: actions/upload-artifact@v6 with: name: bootstrapper-tests path: _out/* if-no-files-found: ignore retention-days: 7 - name: Evaluate iterate gate if: ${{ !cancelled() }} id: iterate_gate shell: pwsh run: | function Read-Ndjson([string]$path) { if (-not (Test-Path -LiteralPath $path)) { return @() } try { return Get-Content -LiteralPath $path -ErrorAction Stop | Where-Object { $_ -match '^\{.*\}$' } | ForEach-Object { try { $_ | ConvertFrom-Json } catch { $null } } } catch { return @() } } $rows = @() $rows += Read-Ndjson 'tests\~test-results.ndjson' if (-not $rows) { $rows += Read-Ndjson 'ci_test_results.ndjson' } $shouldRun = $false $hasRows = ($rows -and $rows.Count -gt 0) if (-not $hasRows) { $shouldRun = $true } else { $allowedStates = @('ok','no_python_files','venv_env','degraded_env') foreach ($row in $rows) { if ($null -eq $row) { continue } if ($row.PSObject.Properties.Name -contains 'pass' -and $row.pass -eq $false) { $shouldRun = $true break } if ($row.PSObject.Properties.Name -contains 'status' -and [string]::IsNullOrWhiteSpace($row.status) -eq $false) { if ([string]$row.status -eq 'failed') { $shouldRun = $true break } } if ($row.PSObject.Properties.Name -contains 'details' -and $row.details) { $state = $null if ($row.details -is [psobject] -and $row.details.PSObject.Properties.Name -contains 'state') { $state = [string]$row.details.state } if ($state -and $state -notin $allowedStates -and ( $row.id -eq 'bootstrap.state' -or $row.id -eq 'self.bootstrap.state')) { $shouldRun = $true break } } } } $value = if ($shouldRun) { 'true' } else { 'false' } "should_run=$value" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append if (-not $hasRows) { # Professional note: missing NDJSON keeps the iterate helper active so diagnostics explain unknown outcomes. "gate_reason=ndjson_missing" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append } # derived requirement: expose the iterate payload contents inline so field triage # can confirm breadcrumbs exist before upload. - name: List _ctx prior to upload (debug) if: ${{ !cancelled() }} shell: bash working-directory: ${{ github.workspace }} run: | echo "PWD=$(pwd)" if [ -d _ctx ]; then find _ctx -maxdepth 2 -type f -printf "%p (%s bytes)\n" | sort else echo "_ctx MISSING" fi - name: Upload iterate logs (fail-only) # derived requirement: only upload when _ctx exists so cache/real self-tests # do not emit warn-only artifacts. if: ${{ steps.iterate_gate.outputs.should_run == 'true' && hashFiles('_ctx/**') != '' }} # derived requirement: breadcrumbs now exist for empty-model runs, but keep # the workflow tolerant so diagnostics jobs do not fail when the helper # intentionally skips packaging. continue-on-error: true uses: actions/upload-artifact@v6 with: name: iterate-logs-${{ github.job }}-${{ matrix.mode }}-${{ github.run_id }}-${{ github.run_attempt }} path: _ctx/** if-no-files-found: warn overwrite: false retention-days: 7 - name: Find and print test logs (tail) if: ${{ !cancelled() }} shell: pwsh run: | $focus = @( 'tests\~entry1\~entry1_bootstrap.log', 'tests\~entryA\~entryA_bootstrap.log', 'tests\~entryB\~entryB_bootstrap.log', 'tests\~setup.log' ) $focusItems = @() foreach ($path in $focus) { if (Test-Path -LiteralPath $path) { $focusItems += Get-Item -LiteralPath $path } } $patterns = @('*.log','*install_log*.txt','~*.txt','*.out','*.ndjson') $otherItems = @() foreach($p in $patterns){ $otherItems += Get-ChildItem -Path . -Recurse -Include $p -File -ErrorAction SilentlyContinue } $otherItems += Get-ChildItem -Path .\tests -Recurse -Include *.txt,*.log,*.ndjson -File -ErrorAction SilentlyContinue $otherItems = $otherItems | Sort-Object LastWriteTime -Descending $otherItems = $otherItems | Where-Object { $_.Name -ne 'README_TESTS.txt' } $files = @() $seen = @{} foreach ($item in $focusItems) { if (-not $seen.ContainsKey($item.FullName)) { $files += $item $seen[$item.FullName] = $true } } foreach ($item in $otherItems) { if (-not $seen.ContainsKey($item.FullName)) { $files += $item $seen[$item.FullName] = $true } } if(-not $files){ Write-Host "No log files found."; exit 0 } foreach($f in $files){ Write-Host "::group::LOG $($f.FullName)" Get-Content -Path $f.FullName -Tail 400 Write-Host "::endgroup::" } - name: Upload test logs if: ${{ !cancelled() }} uses: actions/upload-artifact@v6 with: name: test-logs-${{ github.job }}-${{ matrix.mode }}-${{ github.run_id }}-${{ github.run_attempt }} path: | bootstrap.log ~setup.log runtime.txt dependency_source.txt ~dependency_resolved.txt ~dependency_installed.txt ~pipreqs.diff.txt tests/~dynamic-run.log tests/~entry1/~entry1_bootstrap.log tests\~entry1\~entry1_bootstrap.log tests/~entry1/~setup.log tests\~entry1\~setup.log tests/~entry1/~pipreqs.diff.txt tests\~entry1\~pipreqs.diff.txt tests/~entryA/~entryA_bootstrap.log tests\~entryA\~entryA_bootstrap.log tests/~entryA/~setup.log tests\~entryA\~setup.log tests/~entryA/~pipreqs.diff.txt tests\~entryA\~pipreqs.diff.txt tests/~entryB/~entryB_bootstrap.log tests\~entryB\~entryB_bootstrap.log tests/~entryB/~setup.log tests\~entryB\~setup.log tests/~entryB/~pipreqs.diff.txt tests\~entryB\~pipreqs.diff.txt tests/~envsmoke/~envsmoke_bootstrap.log tests\~envsmoke\~envsmoke_bootstrap.log tests/~envsmoke/~envsmoke_fastpath.log tests\~envsmoke\~envsmoke_fastpath.log # ~setup.log captures probe errors and bootstrap path (conda vs venv) tests/~envsmoke/~setup.log tests\~envsmoke\~setup.log tests/~envsmoke/~run.out.txt tests\~envsmoke\~run.out.txt tests/~envsmoke/~environment.lock.txt tests\~envsmoke\~environment.lock.txt tests/~envsmoke/~pipreqs.diff.txt tests\~envsmoke\~pipreqs.diff.txt tests/~envsmoke/~pipreqs_direct.log tests\~envsmoke\~pipreqs_direct.log tests/~reqspec/~reqspec_run.log tests\~reqspec\~reqspec_run.log tests/~selftest_stub/~stub_bootstrap.log tests\~selftest_stub\~stub_bootstrap.log tests/~selftest_stub/~stub_fastpath.log tests\~selftest_stub\~stub_fastpath.log tests/~selftest_stub/~stub_rebuild.log tests\~selftest_stub\~stub_rebuild.log tests/~selftest_stub/~stub_run.log tests\~selftest_stub\~stub_run.log tests/~selftest_stub/~setup.log tests\~selftest_stub\~setup.log tests/~selftest_stub/~pipreqs.diff.txt tests\~selftest_stub\~pipreqs.diff.txt tests/~selftest_stub/~pipreqs_direct.log tests\~selftest_stub\~pipreqs_direct.log tests/~selftest_stub/~bootstrap.status.json tests\~selftest_stub\~bootstrap.status.json tests/~selftest_stub/~env.state.json tests\~selftest_stub\~env.state.json tests/~envsmoke/~env.state.json tests\~envsmoke\~env.state.json tests/~setup.log tests\~setup.log tests/~selftest_depcheck/~depcheck_bootstrap.log tests\~selftest_depcheck\~depcheck_bootstrap.log tests/~selftest_depcheck/~depcheck_rebuild.log tests\~selftest_depcheck\~depcheck_rebuild.log tests/~selftest_depcheck/~dep_check.txt tests\~selftest_depcheck\~dep_check.txt tests/~selftest_depcheck/~environment.lock.txt tests\~selftest_depcheck\~environment.lock.txt tests/~selftest_depcheck/~env.state.json tests\~selftest_depcheck\~env.state.json tests/~selftest_depcheck/~setup.log tests\~selftest_depcheck\~setup.log tests/~selftest_depcheck/~pipreqs.diff.txt tests\~selftest_depcheck\~pipreqs.diff.txt tests/~selftest_depcheck/~pipreqs_direct.log tests\~selftest_depcheck\~pipreqs_direct.log tests/~selftest_warnfix/~warnfix_bootstrap.log tests\~selftest_warnfix\~warnfix_bootstrap.log tests/~selftest_warnfix/~setup.log tests\~selftest_warnfix\~setup.log tests/~selftest_warnfix/~pipreqs.diff.txt tests\~selftest_warnfix\~pipreqs.diff.txt tests/~selftest_warnfix/dist/~warnfix_token.txt tests\~selftest_warnfix\dist\~warnfix_token.txt tests/~selftest_warnfix/dist/~warnfix_exe.log tests\~selftest_warnfix\dist\~warnfix_exe.log tests/~selftest_warnfix/~warnfile.txt tests\~selftest_warnfix\~warnfile.txt tests/~selftest_warnfix_xfail/~warnfix_xfail_bootstrap.log tests\~selftest_warnfix_xfail\~warnfix_xfail_bootstrap.log tests/~selftest_warnfix_xfail/~setup.log tests\~selftest_warnfix_xfail\~setup.log tests/~selftest_warnfix_xfail/~pipreqs.diff.txt tests\~selftest_warnfix_xfail\~pipreqs.diff.txt tests/~selftest_warnfix_xfail/dist/~warnfix_exe.log tests\~selftest_warnfix_xfail\dist\~warnfix_exe.log tests/~selftest_warnfix_real/~warnfix_real_bootstrap.log tests\~selftest_warnfix_real\~warnfix_real_bootstrap.log tests/~selftest_warnfix_real/~setup.log tests\~selftest_warnfix_real\~setup.log tests/~selftest_warnfix_real/~pipreqs.diff.txt tests\~selftest_warnfix_real\~pipreqs.diff.txt tests/~selftest_warnfix_real/dist/~warnfix_token.txt tests\~selftest_warnfix_real\dist\~warnfix_token.txt tests/~selftest_warnfix_real/dist/~warnfix_exe.log tests\~selftest_warnfix_real\dist\~warnfix_exe.log tests/~selftest_warnfix_real_warnfix/~warnfix_real_warnfix_bootstrap.log tests\~selftest_warnfix_real_warnfix\~warnfix_real_warnfix_bootstrap.log tests/~selftest_warnfix_real_warnfix/~setup.log tests\~selftest_warnfix_real_warnfix\~setup.log tests/~selftest_warnfix_real_warnfix/~pipreqs.diff.txt tests\~selftest_warnfix_real_warnfix\~pipreqs.diff.txt tests/~selftest_warnfix_real_warnfix/dist/~warnfix_token.txt tests\~selftest_warnfix_real_warnfix\dist\~warnfix_token.txt tests/~selftest_warnfix_real_warnfix/dist/~warnfix_exe.log tests\~selftest_warnfix_real_warnfix\dist\~warnfix_exe.log tests/~selftest_warnfix_real_warnfix/~warnfile.txt tests\~selftest_warnfix_real_warnfix\~warnfile.txt tests/~selftest_warnfix_real_warnfix_delayed/~warnfix_real_warnfix_delayed_bootstrap.log tests\~selftest_warnfix_real_warnfix_delayed\~warnfix_real_warnfix_delayed_bootstrap.log tests/~selftest_warnfix_real_warnfix_delayed/~setup.log tests\~selftest_warnfix_real_warnfix_delayed\~setup.log tests/~selftest_warnfix_real_warnfix_delayed/~pipreqs.diff.txt tests\~selftest_warnfix_real_warnfix_delayed\~pipreqs.diff.txt tests/~selftest_warnfix_real_warnfix_delayed/dist/~warnfix_token.txt tests\~selftest_warnfix_real_warnfix_delayed\dist\~warnfix_token.txt tests/~selftest_warnfix_real_warnfix_delayed/dist/~warnfix_exe.log tests\~selftest_warnfix_real_warnfix_delayed\dist\~warnfix_exe.log tests/~selftest_warnfix_real_warnfix_delayed/~warnfile.txt tests\~selftest_warnfix_real_warnfix_delayed\~warnfile.txt tests/~selftest_warnfix_venv_repair/~warnfix_venv_repair_bootstrap.log tests\~selftest_warnfix_venv_repair\~warnfix_venv_repair_bootstrap.log tests/~selftest_warnfix_venv_repair/~setup.log tests\~selftest_warnfix_venv_repair\~setup.log tests/~selftest_warnfix_venv_repair/dist/~warnfix_venv_repair_exe.log tests\~selftest_warnfix_venv_repair\dist\~warnfix_venv_repair_exe.log tests/~selftest_warnfix_venv_repair/dist/~warnfix_venv_token.txt tests\~selftest_warnfix_venv_repair\dist\~warnfix_venv_token.txt tests/~selftest_collect/~collect_bootstrap.log tests\~selftest_collect\~collect_bootstrap.log tests/~selftest_collect/~setup.log tests\~selftest_collect\~setup.log tests/~selftest_collect/dist/~collect_token.txt tests\~selftest_collect\dist\~collect_token.txt tests/~selftest_collect/dist/~collect_exe.log tests\~selftest_collect\dist\~collect_exe.log tests/~selftest_hidden_import/~hidden_bootstrap.log tests\~selftest_hidden_import\~hidden_bootstrap.log tests/~selftest_hidden_import/~setup.log tests\~selftest_hidden_import\~setup.log tests/~selftest_hidden_import/dist/~hidden_token.txt tests\~selftest_hidden_import\dist\~hidden_token.txt tests/~selftest_hidden_import/dist/~hidden_exe.log tests\~selftest_hidden_import\dist\~hidden_exe.log tests/~selftest_exefail/~exefail_bootstrap.log tests\~selftest_exefail\~exefail_bootstrap.log tests/~selftest_exefail/~setup.log tests\~selftest_exefail\~setup.log tests/~selftest_exefail/~pipreqs.diff.txt tests\~selftest_exefail\~pipreqs.diff.txt tests/~selftest_exedata_fail/~exedata_bootstrap.log tests\~selftest_exedata_fail\~exedata_bootstrap.log tests/~selftest_exedata_fail/~setup.log tests\~selftest_exedata_fail\~setup.log tests/~selftest_exedata_fail/~pipreqs.diff.txt tests\~selftest_exedata_fail\~pipreqs.diff.txt tests/~selftest_exedyn_fail/~exedyn_bootstrap.log tests\~selftest_exedyn_fail\~exedyn_bootstrap.log tests/~selftest_exedyn_fail/~setup.log tests\~selftest_exedyn_fail\~setup.log tests/~selftest_exedyn_fail/~pipreqs.diff.txt tests\~selftest_exedyn_fail\~pipreqs.diff.txt tests/~selftest_exefastpath/~exefastpath_run1.log tests\~selftest_exefastpath\~exefastpath_run1.log tests/~selftest_exefastpath/~exefastpath_run2.log tests\~selftest_exefastpath\~exefastpath_run2.log tests/~selftest_exefastpath/~setup.log tests\~selftest_exefastpath\~setup.log tests/~selftest_fastpath_hash/~fastpath_hash_run1.log tests\~selftest_fastpath_hash\~fastpath_hash_run1.log tests/~selftest_fastpath_hash/~fastpath_hash_run2.log tests\~selftest_fastpath_hash\~fastpath_hash_run2.log tests/~selftest_fastpath_hash/~setup.log tests\~selftest_fastpath_hash\~setup.log tests/~selftest_fastpath_hash/~run.out.txt tests\~selftest_fastpath_hash\~run.out.txt tests/~selftest_fastpath_hash/~fast_check.hash.txt tests\~selftest_fastpath_hash\~fast_check.hash.txt tests/~selftest_console_tier_default/~console_tier_default_bootstrap.log tests\~selftest_console_tier_default\~console_tier_default_bootstrap.log tests/~selftest_console_tier_default/~setup.log tests\~selftest_console_tier_default\~setup.log tests/~selftest_console_tier_verbose/~console_tier_verbose_bootstrap.log tests\~selftest_console_tier_verbose\~console_tier_verbose_bootstrap.log tests/~selftest_console_tier_verbose/~setup.log tests\~selftest_console_tier_verbose\~setup.log tests/~pandas_excel/~pandas_excel.log tests\~pandas_excel\~pandas_excel.log tests/~pipgap/~pipgap.log tests\~pipgap\~pipgap.log tests/~pipgap/~pipgap_bootstrap.log tests\~pipgap\~pipgap_bootstrap.log tests/~pipgap/~setup.log tests\~pipgap\~setup.log tests/~pipgap/~run.out.txt tests\~pipgap\~run.out.txt tests/~selftest_parse_warn_table/ tests\~selftest_parse_warn_table\ tests/~selftest_runtime_writeback/~runtime_writeback_bootstrap.log tests\~selftest_runtime_writeback\~runtime_writeback_bootstrap.log tests/~selftest_runtime_writeback/~runtime_writeback_bootstrap2.log tests\~selftest_runtime_writeback\~runtime_writeback_bootstrap2.log tests/~selftest_runtime_writeback/~setup.log tests\~selftest_runtime_writeback\~setup.log tests/~selftest_runtime_writeback/~pipreqs.diff.txt tests\~selftest_runtime_writeback\~pipreqs.diff.txt tests/~selftest_runtime_writeback/runtime.txt tests\~selftest_runtime_writeback\runtime.txt tests/~pyvisa/~pyvisa_bootstrap.log tests\~pyvisa\~pyvisa_bootstrap.log tests/~pyvisa/~setup.log tests\~pyvisa\~setup.log tests/~pyvisa/~pipreqs.diff.txt tests\~pyvisa\~pipreqs.diff.txt tests/~pyvisa_disabled/~pyvisa_disabled_bootstrap.log tests\~pyvisa_disabled\~pyvisa_disabled_bootstrap.log tests/~pyvisa_disabled/~setup.log tests\~pyvisa_disabled\~setup.log tests/~pyproject_prec/~pyproject_prec_bootstrap.log tests\~pyproject_prec\~pyproject_prec_bootstrap.log tests/~pyproject_prec/~setup.log tests\~pyproject_prec\~setup.log tests/~pyproject_prec/~pipreqs.diff.txt tests\~pyproject_prec\~pipreqs.diff.txt tests/~pyproject_prec/runtime.txt tests\~pyproject_prec\runtime.txt tests/~selftest_pip_warn/~pip_warn_bootstrap.log tests\~selftest_pip_warn\~pip_warn_bootstrap.log tests/~selftest_pip_warn/~setup.log tests\~selftest_pip_warn\~setup.log tests/~selftest_pip_warn/~pipreqs.diff.txt tests\~selftest_pip_warn\~pipreqs.diff.txt tests/~selftest_pip_warn/~bootstrap.status.json tests\~selftest_pip_warn\~bootstrap.status.json tests/~selftest_pipreqs_version_fail/~pipreqs_version_fail_bootstrap.log tests\~selftest_pipreqs_version_fail\~pipreqs_version_fail_bootstrap.log tests/~selftest_pipreqs_version_fail/~setup.log tests\~selftest_pipreqs_version_fail\~setup.log tests/~selftest_pipreqs_version_fail/~pipreqs.diff.txt tests\~selftest_pipreqs_version_fail\~pipreqs.diff.txt tests/~selftest_pipreqs_version_fail/~bootstrap.status.json tests\~selftest_pipreqs_version_fail\~bootstrap.status.json tests/~selftest_pipreqs_version_fail/~run.out.txt tests\~selftest_pipreqs_version_fail\~run.out.txt tests/~selftest_OneDrive/~onedrive_bootstrap.log tests\~selftest_OneDrive\~onedrive_bootstrap.log tests/~selftest_OneDrive/~setup.log tests\~selftest_OneDrive\~setup.log tests/~selftest_OneDrive/~pipreqs.diff.txt tests\~selftest_OneDrive\~pipreqs.diff.txt tests/~selftest_OneDrive/~bootstrap.status.json tests\~selftest_OneDrive\~bootstrap.status.json tests/~selftest_longpath/ tests\~selftest_longpath\ tests/~selftest_path_negative/~path_negative_bootstrap.log tests\~selftest_path_negative\~path_negative_bootstrap.log tests/~selftest_path_negative/~setup.log tests\~selftest_path_negative\~setup.log tests/~selftest_path_negative/~pipreqs.diff.txt tests\~selftest_path_negative\~pipreqs.diff.txt tests/~selftest_path_negative/~bootstrap.status.json tests\~selftest_path_negative\~bootstrap.status.json tests/~selftest_pep723_valid/~pep723_valid_bootstrap.log tests\~selftest_pep723_valid\~pep723_valid_bootstrap.log tests/~selftest_pep723_valid/~setup.log tests\~selftest_pep723_valid\~setup.log tests/~selftest_pep723_valid/~pipreqs.diff.txt tests\~selftest_pep723_valid\~pipreqs.diff.txt tests/~selftest_pep723_valid/~bootstrap.status.json tests\~selftest_pep723_valid\~bootstrap.status.json tests/~selftest_pep723_mal/~pep723_mal_bootstrap.log tests\~selftest_pep723_mal\~pep723_mal_bootstrap.log tests/~selftest_pep723_mal/~setup.log tests\~selftest_pep723_mal\~setup.log tests/~selftest_pep723_mal/~pipreqs.diff.txt tests\~selftest_pep723_mal\~pipreqs.diff.txt tests/~selftest_pep723_mal/~bootstrap.status.json tests\~selftest_pep723_mal\~bootstrap.status.json tests/~selftest_pyproj_malformed/~pyproj_mal_bootstrap.log tests\~selftest_pyproj_malformed\~pyproj_mal_bootstrap.log tests/~selftest_pyproj_malformed/~setup.log tests\~selftest_pyproj_malformed\~setup.log tests/~selftest_pyproj_malformed/~bootstrap.status.json tests\~selftest_pyproj_malformed\~bootstrap.status.json tests/~selftest_gitconfig/~gitconfig_run1.log tests\~selftest_gitconfig\~gitconfig_run1.log tests/~selftest_gitconfig/~gitconfig_run2.log tests\~selftest_gitconfig\~gitconfig_run2.log tests/~selftest_gitconfig/~setup.log tests\~selftest_gitconfig\~setup.log tests/~selftest_gitconfig/~pipreqs.diff.txt tests\~selftest_gitconfig\~pipreqs.diff.txt tests/~selftest_gitconfig/.gitignore tests\~selftest_gitconfig\.gitignore tests/~selftest_gitconfig/.gitattributes tests\~selftest_gitconfig\.gitattributes tests/~selftest_connectivity/~conn_test.log tests\~selftest_connectivity\~conn_test.log tests/~selftest_connectivity/~setup.log tests\~selftest_connectivity\~setup.log tests/~selftest_connectivity/~pipreqs.diff.txt tests\~selftest_connectivity\~pipreqs.diff.txt tests/~selftest_connectivity_online/~conn_online_test.log tests\~selftest_connectivity_online\~conn_online_test.log tests/~selftest_connectivity_online/~setup.log tests\~selftest_connectivity_online\~setup.log tests/~selftest_connectivity_retry/~conn_retry_test.log tests\~selftest_connectivity_retry\~conn_retry_test.log tests/~selftest_connectivity_retry/~setup.log tests\~selftest_connectivity_retry\~setup.log tests/~selftest_sysgate/~sys_test.log tests\~selftest_sysgate\~sys_test.log tests/~selftest_sysgate/~setup.log tests\~selftest_sysgate\~setup.log tests/~selftest_sysgate/~pipreqs.diff.txt tests\~selftest_sysgate\~pipreqs.diff.txt tests/~selftest_sysgate_real/~sys_real_test.log tests\~selftest_sysgate_real\~sys_real_test.log tests/~selftest_sysgate_real/~setup.log tests\~selftest_sysgate_real\~setup.log tests/~selftest_sysgate_real/~pipreqs.diff.txt tests\~selftest_sysgate_real\~pipreqs.diff.txt tests/~selftest_corrupt_conda/~corrupt_bootstrap.log tests\~selftest_corrupt_conda\~corrupt_bootstrap.log tests/~selftest_corrupt_conda/~setup.log tests\~selftest_corrupt_conda\~setup.log tests/~selftest_corrupt_conda/~pipreqs.diff.txt tests\~selftest_corrupt_conda\~pipreqs.diff.txt tests/~selftest_corrupt_conda/~bootstrap.status.json tests\~selftest_corrupt_conda\~bootstrap.status.json tests/~selftest_heal_decline/~heal_decline_bootstrap.log tests\~selftest_heal_decline\~heal_decline_bootstrap.log tests/~selftest_heal_decline/~setup.log tests\~selftest_heal_decline\~setup.log tests/~selftest_heal_decline/~pipreqs.diff.txt tests\~selftest_heal_decline\~pipreqs.diff.txt tests/~selftest_heal_decline/~bootstrap.status.json tests\~selftest_heal_decline\~bootstrap.status.json tests/~selftest_heal_accept/~heal_accept_bootstrap.log tests\~selftest_heal_accept\~heal_accept_bootstrap.log tests/~selftest_heal_accept/~setup.log tests\~selftest_heal_accept\~setup.log tests/~selftest_heal_accept/~pipreqs.diff.txt tests\~selftest_heal_accept\~pipreqs.diff.txt tests/~selftest_heal_accept/~bootstrap.status.json tests\~selftest_heal_accept\~bootstrap.status.json tests/~selftest_corrupt_uv/~corrupt_uv_bootstrap.log tests\~selftest_corrupt_uv\~corrupt_uv_bootstrap.log tests/~selftest_corrupt_uv/~setup.log tests\~selftest_corrupt_uv\~setup.log tests/~selftest_corrupt_uv/~pipreqs.diff.txt tests\~selftest_corrupt_uv\~pipreqs.diff.txt tests/~selftest_corrupt_uv/~bootstrap.status.json tests\~selftest_corrupt_uv\~bootstrap.status.json tests/~selftest_venv_fallback/~venv_fallback.log tests\~selftest_venv_fallback\~venv_fallback.log tests/~selftest_venv_fallback/~setup.log tests\~selftest_venv_fallback\~setup.log tests/~selftest_venv_fallback/~pipreqs.diff.txt tests\~selftest_venv_fallback\~pipreqs.diff.txt tests/~uv_pyver_test/~pyver_bootstrap.log tests\~uv_pyver_test\~pyver_bootstrap.log tests/~uv_pyver_test/~setup.log tests\~uv_pyver_test\~setup.log tests/~uv_pyver_test/~pipreqs.diff.txt tests\~uv_pyver_test\~pipreqs.diff.txt tests/~selftest_venv_fallback/~bootstrap.status.json tests\~selftest_venv_fallback\~bootstrap.status.json tests/~selftest_venv_fallback/~run.out.txt tests\~selftest_venv_fallback\~run.out.txt tests/~selftest_venv_canary_fail/~venv_canary.log tests\~selftest_venv_canary_fail\~venv_canary.log tests/~selftest_venv_canary_fail/~setup.log tests\~selftest_venv_canary_fail\~setup.log tests/~selftest_venv_canary_fail/~pipreqs.diff.txt tests\~selftest_venv_canary_fail\~pipreqs.diff.txt tests/~selftest_venv_canary_fail/~bootstrap.status.json tests\~selftest_venv_canary_fail\~bootstrap.status.json tests/~selftest_venv_nopip_retry/~venv_nopip.log tests\~selftest_venv_nopip_retry\~venv_nopip.log tests/~selftest_venv_nopip_retry/~setup.log tests\~selftest_venv_nopip_retry\~setup.log tests/~selftest_venv_nopip_retry/~pipreqs.diff.txt tests\~selftest_venv_nopip_retry\~pipreqs.diff.txt tests/~selftest_venv_nopip_retry/~bootstrap.status.json tests\~selftest_venv_nopip_retry\~bootstrap.status.json tests/~selftest_venv_nopip_retry/~run.out.txt tests\~selftest_venv_nopip_retry\~run.out.txt tests/~selftest_embed_decline/~embed_decline.log tests\~selftest_embed_decline\~embed_decline.log tests/~selftest_embed_decline/~setup.log tests\~selftest_embed_decline\~setup.log tests/~selftest_embed_decline/~bootstrap.status.json tests\~selftest_embed_decline\~bootstrap.status.json tests/~selftest_embed_real/~embed_real.log tests\~selftest_embed_real\~embed_real.log tests/~selftest_embed_real/~setup.log tests\~selftest_embed_real\~setup.log tests/~selftest_embed_real/~pipreqs.diff.txt tests\~selftest_embed_real\~pipreqs.diff.txt tests/~selftest_embed_real/~bootstrap.status.json tests\~selftest_embed_real\~bootstrap.status.json tests/~selftest_embed_real/~run.out.txt tests\~selftest_embed_real\~run.out.txt tests/~selftest_entry_override/~override.log tests\~selftest_entry_override\~override.log tests/~selftest_entry_override/~setup.log tests\~selftest_entry_override\~setup.log tests/~selftest_entry_override/~run.out.txt tests\~selftest_entry_override\~run.out.txt tests/~entry_cli/~entry_cli_bootstrap.log tests\~entry_cli\~entry_cli_bootstrap.log tests/~entry_cli/~setup.log tests\~entry_cli\~setup.log tests/~entry_cli/~pipreqs.diff.txt tests\~entry_cli\~pipreqs.diff.txt tests/~entry_run/~entry_run_bootstrap.log tests\~entry_run\~entry_run_bootstrap.log tests/~entry_run/~setup.log tests\~entry_run\~setup.log tests/~entry_run/~pipreqs.diff.txt tests\~entry_run\~pipreqs.diff.txt tests/~entry_sel1/~entry_sel1_bootstrap.log tests\~entry_sel1\~entry_sel1_bootstrap.log tests/~entry_sel1/~setup.log tests\~entry_sel1\~setup.log tests/~entry_sel1/~pipreqs.diff.txt tests\~entry_sel1\~pipreqs.diff.txt tests/~entryC_boot/~entryC_bootstrap.log tests\~entryC_boot\~entryC_bootstrap.log tests/~entryC_boot/~setup.log tests\~entryC_boot\~setup.log tests/~entryC_boot/~pipreqs.diff.txt tests\~entryC_boot\~pipreqs.diff.txt tests/~entryD_boot/~entryD_bootstrap.log tests\~entryD_boot\~entryD_bootstrap.log tests/~entryD_boot/~setup.log tests\~entryD_boot\~setup.log tests/~entryD_boot/~pipreqs.diff.txt tests\~entryD_boot\~pipreqs.diff.txt tests/~req010_test/~req010_bootstrap.log tests\~req010_test\~req010_bootstrap.log tests/~req010_test/~setup.log tests\~req010_test\~setup.log tests/~req010_test/~pipreqs.diff.txt tests\~req010_test\~pipreqs.diff.txt tests/~req010_test/~isolation_check.txt tests\~req010_test\~isolation_check.txt tests/~selftest_conda_retry/~conda_retry_bootstrap.log tests\~selftest_conda_retry\~conda_retry_bootstrap.log tests/~selftest_conda_retry/~setup.log tests\~selftest_conda_retry\~setup.log tests/~selftest_conda_retry/~pipreqs.diff.txt tests\~selftest_conda_retry\~pipreqs.diff.txt tests/~selftest_conda_create_retry/~conda_create_retry_bootstrap.log tests\~selftest_conda_create_retry\~conda_create_retry_bootstrap.log tests/~selftest_conda_create_retry/~setup.log tests\~selftest_conda_create_retry\~setup.log tests/~selftest_conda_create_retry/~pipreqs.diff.txt tests\~selftest_conda_create_retry\~pipreqs.diff.txt tests/~selftest_conda_perpkg/~conda_perpkg_bootstrap.log tests\~selftest_conda_perpkg\~conda_perpkg_bootstrap.log tests/~selftest_conda_perpkg/~setup.log tests\~selftest_conda_perpkg\~setup.log tests/~selftest_pep723_prio/~pep723_prio_bootstrap.log tests\~selftest_pep723_prio\~pep723_prio_bootstrap.log tests/~selftest_pep723_prio/~setup.log tests\~selftest_pep723_prio\~setup.log tests/~selftest_pep723_prio/~pipreqs.diff.txt tests\~selftest_pep723_prio\~pipreqs.diff.txt tests/~selftest_pep723_prio/~bootstrap.status.json tests\~selftest_pep723_prio\~bootstrap.status.json tests/~selftest_guardrail_g1/ tests\~selftest_guardrail_g1\ tests/~selftest_empty/~setup.log tests\~selftest_empty\~setup.log tests/~selftest-summary.txt tests\~selftest-summary.txt tests/~test-summary.txt tests/~test-results.ndjson tests\~test-results.ndjson tests/~selftest_lineending_no_ps/~lineending_bootstrap.log tests\~selftest_lineending_no_ps\~lineending_bootstrap.log tests/~selftest_lineending_no_ps/~bootstrap.status.json tests\~selftest_lineending_no_ps\~bootstrap.status.json tests/~selftest_lineending_ps_fail/~lineending_bootstrap.log tests\~selftest_lineending_ps_fail\~lineending_bootstrap.log tests/~selftest_lineending_ps_fail/~bootstrap.status.json tests\~selftest_lineending_ps_fail\~bootstrap.status.json tests/~selftest_lineending_lf_only/~lineending_bootstrap.log tests\~selftest_lineending_lf_only\~lineending_bootstrap.log tests/~selftest_lineending_lf_only/~bootstrap.status.json tests\~selftest_lineending_lf_only\~bootstrap.status.json tests/~selftest_lineending_cwd_not_writable/~lineending_bootstrap.log tests\~selftest_lineending_cwd_not_writable\~lineending_bootstrap.log tests/~selftest_lineending_cwd_not_writable/~bootstrap.status.json tests\~selftest_lineending_cwd_not_writable\~bootstrap.status.json tests/~selftest_subfolder_hint/~subfolder_bootstrap.log tests\~selftest_subfolder_hint\~subfolder_bootstrap.log tests/~selftest_subfolder_hint/~bootstrap.status.json tests\~selftest_subfolder_hint\~bootstrap.status.json tests/~selftest_subfolder_hint_neg/~subfolder_neg_bootstrap.log tests\~selftest_subfolder_hint_neg\~subfolder_neg_bootstrap.log tests/~selftest_subfolder_hint_neg/~bootstrap.status.json tests\~selftest_subfolder_hint_neg\~bootstrap.status.json tests/~selftest_subfolder_hint_pct/~subfolder_pct_bootstrap.log tests\~selftest_subfolder_hint_pct\~subfolder_pct_bootstrap.log tests/~selftest_subfolder_hint_pct/~bootstrap.status.json tests\~selftest_subfolder_hint_pct\~bootstrap.status.json tests/~selftest_entrysmoke_no_interpreter/~entrysmoke_no_interpreter_bootstrap.log tests\~selftest_entrysmoke_no_interpreter\~entrysmoke_no_interpreter_bootstrap.log tests/~selftest_entrysmoke_no_interpreter/~setup.log tests\~selftest_entrysmoke_no_interpreter\~setup.log tests/~selftest_entrysmoke_no_interpreter/~bootstrap.status.json tests\~selftest_entrysmoke_no_interpreter\~bootstrap.status.json tests/~selftest_die_emit_missing_python/~die_emit_missing_python_bootstrap.log tests\~selftest_die_emit_missing_python\~die_emit_missing_python_bootstrap.log tests/~selftest_die_emit_missing_python/~setup.log tests\~selftest_die_emit_missing_python\~setup.log tests/~selftest_die_emit_missing_python/~bootstrap.status.json tests\~selftest_die_emit_missing_python\~bootstrap.status.json tests/~selftest_die_emit_condarc/~die_emit_condarc_bootstrap.log tests\~selftest_die_emit_condarc\~die_emit_condarc_bootstrap.log tests/~selftest_die_emit_condarc/~setup.log tests\~selftest_die_emit_condarc\~setup.log tests/~selftest_die_emit_condarc/~bootstrap.status.json tests\~selftest_die_emit_condarc\~bootstrap.status.json tests/~selftest_die_emit_ci_skip_entry/~die_emit_ci_skip_entry_bootstrap.log tests\~selftest_die_emit_ci_skip_entry\~die_emit_ci_skip_entry_bootstrap.log tests/~selftest_die_emit_ci_skip_entry/~setup.log tests\~selftest_die_emit_ci_skip_entry\~setup.log tests/~selftest_die_emit_ci_skip_entry/~bootstrap.status.json tests\~selftest_die_emit_ci_skip_entry\~bootstrap.status.json tests/~selftest_die_emit_determine_entry/~die_emit_determine_entry_bootstrap.log tests\~selftest_die_emit_determine_entry\~die_emit_determine_entry_bootstrap.log tests/~selftest_die_emit_determine_entry/~setup.log tests\~selftest_die_emit_determine_entry\~setup.log tests/~selftest_die_emit_determine_entry/~bootstrap.status.json tests\~selftest_die_emit_determine_entry\~bootstrap.status.json tests/extracted/** if-no-files-found: ignore include-hidden-files: true retention-days: 14 # Professional note: cache/save runs separately so failed bootstrap attempts do not persist a partial Miniconda tree. # HP_CACHE_CORRUPTED guard on both validate and save: if the restored cache was corrupt (health # check fired) or bootstrap failed (catch step set HP_CACHE_CORRUPTED=1), skip the save so we # never write a corrupt tree back to the cache store (prevents the rolling-corruption-factory loop). - name: Validate Miniconda before cache save if: ${{ matrix.mode == 'cache' && steps.conda_cache_restore.outputs.cache-hit != 'true' && env.HP_CACHE_CORRUPTED != '1' }} id: conda_validate shell: pwsh run: | $condaMain = 'C:\Users\Public\Documents\Miniconda3\condabin\conda.bat' $condaAlt = 'C:\Users\Public\Documents\Miniconda3\Scripts\conda.bat' if ((Test-Path $condaMain) -or (Test-Path $condaAlt)) { echo "conda_ok=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append Write-Host "Miniconda validated: conda.bat present" } else { echo "conda_ok=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append Write-Host "Miniconda not found: skipping cache save" } - name: Save Miniconda cache if: ${{ matrix.mode == 'cache' && steps.conda_cache_restore.outputs.cache-hit != 'true' && env.HP_CACHE_CORRUPTED != '1' && steps.conda_validate.outputs.conda_ok == 'true' }} uses: actions/cache/save@v5 with: path: C:\Users\Public\Documents\Miniconda3 key: ${{ steps.conda_cache_restore.outputs.cache-primary-key }} - name: Summarize bootstrap result if: ${{ !cancelled() }} shell: pwsh run: | $statusPath = '.\~bootstrap.status.json' if (Test-Path $statusPath) { try { $status = Get-Content $statusPath -Raw | ConvertFrom-Json $line = "Bootstrap status: state={0}, exitCode={1}, pyFiles={2}" -f $status.state, $status.exitCode, $status.pyFiles Add-Content $env:GITHUB_STEP_SUMMARY -Value $line if ($status.state -eq 'no_python_files') { Add-Content $env:GITHUB_STEP_SUMMARY -Value '_Bootstrap reported no Python files; environment bootstrap skipped._' } } catch { Add-Content $env:GITHUB_STEP_SUMMARY -Value 'Bootstrap status: ' } } else { Add-Content $env:GITHUB_STEP_SUMMARY -Value '_~bootstrap.status.json not found_' } if (Test-Path .\bootstrap.log) { Add-Content $env:GITHUB_STEP_SUMMARY -Value "### Bootstrap (tail)" Add-Content $env:GITHUB_STEP_SUMMARY -Value '```text' Get-Content .\bootstrap.log -Tail 120 | ForEach-Object { Add-Content $env:GITHUB_STEP_SUMMARY -Value $_ } Add-Content $env:GITHUB_STEP_SUMMARY -Value '```' } else { Add-Content $env:GITHUB_STEP_SUMMARY -Value "_No bootstrap.log found_" } - name: Summarize dynamic tests result if: ${{ !cancelled() }} shell: pwsh run: | $notePath = '.\dynamic_summary_note.txt' if (Test-Path $notePath) { foreach ($line in Get-Content $notePath -Encoding Ascii) { Add-Content $env:GITHUB_STEP_SUMMARY -Value $line } } if (Test-Path .\dynamic_tests.log) { Add-Content $env:GITHUB_STEP_SUMMARY -Value "### Dynamic tests (tail)" Add-Content $env:GITHUB_STEP_SUMMARY -Value '```text' Get-Content .\dynamic_tests.log -Tail 120 | ForEach-Object { Add-Content $env:GITHUB_STEP_SUMMARY -Value $_ } Add-Content $env:GITHUB_STEP_SUMMARY -Value '```' } else { Add-Content $env:GITHUB_STEP_SUMMARY -Value "_No dynamic_tests.log found_" } - name: Summarize static tests result if: ${{ !cancelled() }} shell: pwsh run: | $summaryPath = '.\tests\~test-summary.txt' if (Test-Path $summaryPath) { $lines = Get-Content $summaryPath -Encoding Ascii $pfLine = $lines | Where-Object { $_ -match '^PASS:' } | Select-Object -First 1 if ($pfLine) { Add-Content $env:GITHUB_STEP_SUMMARY -Value $pfLine } Add-Content $env:GITHUB_STEP_SUMMARY -Value "### Static tests (tail)" Add-Content $env:GITHUB_STEP_SUMMARY -Value '```text' $lines | Select-Object -Last 120 | ForEach-Object { Add-Content $env:GITHUB_STEP_SUMMARY -Value $_ } Add-Content $env:GITHUB_STEP_SUMMARY -Value '```' } else { Add-Content $env:GITHUB_STEP_SUMMARY -Value "_tests\\~test-summary.txt not found_" } - name: Summarize self-tests (NDJSON -> Job Summary) if: ${{ !cancelled() }} uses: actions/github-script@v8 with: github-token: ${{ github.token }} script: | const fs = require('fs'); const path = 'tests\\~test-results.ndjson'; let rows = []; if (fs.existsSync(path)) { for (const line of fs.readFileSync(path, 'utf8').split(/\r?\n/)) { if (!line.trim()) continue; try { rows.push(JSON.parse(line)); } catch {} } } const passCt = rows.filter(r => r.pass === true).length; const failCt = rows.filter(r => r.pass === false).length; const unknownCt = rows.length - passCt - failCt; // Prefer self.* and entry.* at the top const rank = r => (r.id||'').startsWith('self.') || (r.id||'').startsWith('entry.') ? 0 : 1; rows.sort((a,b) => rank(a) - rank(b) || String(a.id||'').localeCompare(String(b.id||''))); const bullets = rows.map(r => { const icon = r.pass === true ? '[PASS]' : r.pass === false ? '[FAIL]' : '[-]'; const desc = r.desc ? ` -- ${r.desc}` : ''; return `${icon} ${r.id || '(no id)'}${desc}`; }); const summary = core.summary; await summary .addHeading('Self-test results', 2) .addList(bullets) .addRaw(`\n**Totals:** PASS ${passCt} - FAIL ${failCt}${unknownCt > 0 ? ` - UNKNOWN ${unknownCt}` : ''}\n`) .write(); // Optional tails (non-fatal if missing) function tail(p, n=80){ try{ if (!fs.existsSync(p)) return null; const L = fs.readFileSync(p, 'utf8').split(/\r?\n/); return L.slice(Math.max(0, L.length - n)).join('\n'); }catch{return null;} } const tails = [ ['tests\\~setup.log', 'tests\\~setup.log'], ['tests\\~selftest_empty\\~empty_bootstrap.log', 'tests\\~selftest_empty\\~empty_bootstrap.log'], ['tests\\~entry1\\~entry1_bootstrap.log', 'tests\\~entry1\\~entry1_bootstrap.log'], ['tests\\~entry_sel1\\~entry_sel1_bootstrap.log', 'tests\\~entry_sel1\\~entry_sel1_bootstrap.log'], ['tests\\~entry_sel1\\~setup.log', 'tests\\~entry_sel1\\~setup.log'], ['tests\\~entryA\\~entryA_bootstrap.log', 'tests\\~entryA\\~entryA_bootstrap.log'], ['tests\\~entryB\\~entryB_bootstrap.log', 'tests\\~entryB\\~entryB_bootstrap.log'], ['tests\\~entry_cli\\~entry_cli_bootstrap.log', 'tests\\~entry_cli\\~entry_cli_bootstrap.log'], ['tests\\~entry_cli\\~setup.log', 'tests\\~entry_cli\\~setup.log'], ['~envsmoke_bootstrap.log', 'tests\\~envsmoke\\~envsmoke_bootstrap.log'], ['~run.out.txt', 'tests\\~envsmoke\\~run.out.txt'], ].map(([label,p]) => [label, tail(p)]).filter(([,t]) => t); if (tails.length){ await summary.addHeading('Self-test logs (tail)', 3).write(); for (const [label,text] of tails){ await summary.addRaw(`
${label}\n\n`).addCodeBlock(text,'text').addRaw(`
\n`).write(); } } - name: Append diagnostics to Summary (public) if: ${{ !cancelled() }} shell: pwsh run: | function Read-Optional($title, $paths, $max=6000) { $found = $null foreach ($p in $paths) { if (Test-Path -LiteralPath $p) { $found = $p; break } } if (-not $found) { "### $title (missing)" | Out-File -Append $env:GITHUB_STEP_SUMMARY return } $text = Get-Content -Raw -LiteralPath $found if ($null -eq $text) { $text = "" } if ($text.Length -eq 0) { "### $title ($found -- empty)" | Out-File -Append $env:GITHUB_STEP_SUMMARY return } if ($text.Length -gt $max) { $text = $text.Substring(0,$max) + "`n... [truncated]" } "### $title ($found)" | Out-File -Append $env:GITHUB_STEP_SUMMARY '```text' | Out-File -Append $env:GITHUB_STEP_SUMMARY $text | Out-File -Append $env:GITHUB_STEP_SUMMARY '```' | Out-File -Append $env:GITHUB_STEP_SUMMARY } Read-Optional 'NDJSON (head/tail)' @('tests\~test-results.ndjson','ci_test_results.ndjson') 8000 Read-Optional 'Self-test summary.txt' @('tests\~test-summary.txt') 6000 Read-Optional 'Empty bootstrap (tail)' @('tests\~selftest_empty\~empty_bootstrap.log') 4000 Read-Optional 'Single-entry bootstrap (tail)' @('tests\~entry1\~entry1_bootstrap.log') 4000 Read-Optional 'Env smoke bootstrap (tail)' @('tests\~envsmoke\~envsmoke_bootstrap.log') 4000 - name: Append matrix mode meta row if: ${{ !cancelled() }} shell: pwsh run: | $row = [ordered]@{ id = 'meta.env.mode' pass = $true desc = 'matrix lane' details = [ordered]@{ mode = '${{ matrix.mode }}' } } $json = $row | ConvertTo-Json -Compress -Depth 8 Add-Content -LiteralPath tests\~test-results.ndjson -Value $json -Encoding Ascii Add-Content -LiteralPath ci_test_results.ndjson -Value $json -Encoding Ascii - name: Collect failing test IDs if: ${{ !cancelled() }} shell: pwsh run: | $root = Get-Location $basePath = $root.Path $diagDir = Join-Path $basePath 'diag' if (-not (Test-Path -LiteralPath $diagDir)) { New-Item -ItemType Directory -Path $diagDir -Force | Out-Null } $outPath = Join-Path $diagDir 'failing-tests.txt' $debugPath = Join-Path $diagDir 'fail-debug.txt' $allFailures = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) $perFile = [ordered]@{} function Get-RelativePath { param( [Parameter(Mandatory = $true)][string]$Base, [Parameter(Mandatory = $true)][string]$Path ) return [System.IO.Path]::GetRelativePath($Base, $Path) } function Ensure-PerFileEntry { param([string]$Key) if (-not $perFile.Contains($Key)) { $perFile[$Key] = [pscustomobject]@{ Set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) Hits = 0 } } return $perFile[$Key] } function Get-CandidateFiles { param([string]$BasePath) if (-not $BasePath) { return @() } # Professional note: Requirement "search under the job workspace using broad globs" -- # scan the entire workspace instead of the packaged artifact layout so we detect # freshly written NDJSON before diagnostics copies them elsewhere. $patterns = @('*~test-results.ndjson', 'ci_test_results*.ndjson') $files = foreach ($pattern in $patterns) { Get-ChildItem -Path $BasePath -Recurse -File -Filter $pattern -ErrorAction SilentlyContinue } return $files | Where-Object { ($_.FullName -notmatch '(?i)\.zip$') -and ($_.FullName -notmatch '(?i)\.gz$') } | Sort-Object -Property FullName -Unique } function Test-OutcomeFailed { param([object]$Root) if ($null -eq $Root) { return $false } $stack = [System.Collections.Stack]::new() $stack.Push($Root) while ($stack.Count -gt 0) { $current = $stack.Pop() if ($null -eq $current) { continue } if ($current -is [System.Collections.IDictionary] -or $current -is [PSCustomObject]) { foreach ($prop in $current.PSObject.Properties) { $name = $prop.Name if ([string]::IsNullOrEmpty($name)) { continue } $value = $prop.Value if ($name -ieq 'outcome' -and $value -is [string] -and $value -ieq 'failed') { return $true } if ($value -ne $null -and -not ($value -is [string])) { if ($value -is [System.Collections.IDictionary] -or $value -is [PSCustomObject]) { $stack.Push($value) continue } if ($value -is [System.Collections.IEnumerable]) { foreach ($item in $value) { $stack.Push($item) } } } } } elseif ($current -is [System.Collections.IEnumerable] -and -not ($current -is [string])) { foreach ($item in $current) { $stack.Push($item) } } } return $false } function Get-FirstId { param([object]$Root) if ($null -eq $Root) { return $null } $preferred = $null $fallback = $null $stack = [System.Collections.Stack]::new() $stack.Push($Root) while ($stack.Count -gt 0) { $current = $stack.Pop() if ($null -eq $current) { continue } if ($current -is [System.Collections.IDictionary] -or $current -is [PSCustomObject]) { foreach ($prop in $current.PSObject.Properties) { $name = $prop.Name if ([string]::IsNullOrEmpty($name)) { continue } $value = $prop.Value if ($value -is [string]) { if (-not $preferred -and $name -ieq 'nodeid') { $preferred = $value } elseif (-not $fallback -and $name -ieq 'name') { $fallback = $value } } if ($value -ne $null -and -not ($value -is [string])) { if ($value -is [System.Collections.IDictionary] -or $value -is [PSCustomObject]) { $stack.Push($value) continue } if ($value -is [System.Collections.IEnumerable]) { foreach ($item in $value) { $stack.Push($item) } } } } } elseif ($current -is [System.Collections.IEnumerable] -and -not ($current -is [string])) { foreach ($item in $current) { $stack.Push($item) } } if ($preferred) { break } } if ($preferred) { return $preferred } return $fallback } function Get-FallbackId { param([string]$Line) if (-not $Line) { return $null } foreach ($pattern in @('"nodeid"\s*:\s*"((?:[^"\\]|\\.)*)"', '"name"\s*:\s*"((?:[^"\\]|\\.)*)"')) { $m = [regex]::Match($Line, $pattern) if ($m.Success) { $capture = $m.Groups[1].Value try { return ('"' + $capture + '"') | ConvertFrom-Json -ErrorAction Stop } catch { return $capture } } } return $null } $files = @(Get-CandidateFiles -BasePath $basePath) foreach ($file in $files) { $rel = Get-RelativePath -Base $basePath -Path $file.FullName $entry = Ensure-PerFileEntry -Key $rel $lines = Get-Content -LiteralPath $file.FullName -Encoding UTF8 foreach ($line in $lines) { if ([string]::IsNullOrWhiteSpace($line)) { continue } $failed = $false $id = $null try { $obj = $line | ConvertFrom-Json -ErrorAction Stop } catch { $obj = $null } if ($obj -ne $null -and (Test-OutcomeFailed -Root $obj)) { $failed = $true $id = Get-FirstId -Root $obj } if (-not $failed -and $line -match '"outcome"\s*:\s*"failed"') { $failed = $true if (-not $id) { $id = Get-FallbackId -Line $line } } if (-not $failed -or -not $id) { continue } # Professional note: log total failing rows per file so fail-debug verifies coverage at a glance. $entry.Hits++ $null = $entry.Set.Add($id) $null = $allFailures.Add($id) } } if ($allFailures.Count -eq 0) { Set-Content -Encoding UTF8 -LiteralPath $outPath -Value 'none' } else { $sorted = $allFailures.ToArray() | Sort-Object $sorted | Set-Content -Encoding UTF8 -LiteralPath $outPath } $debugLines = [System.Collections.Generic.List[string]]::new() foreach ($kvp in $perFile.GetEnumerator() | Sort-Object Key) { $hits = 0 if ($kvp.Value -and $kvp.Value.PSObject.Properties['Hits']) { $hits = [int]$kvp.Value.Hits } $debugLines.Add([string]::Format('{0}`t{1}', $kvp.Key, $hits)) | Out-Null } if ($debugLines.Count -eq 0) { $debugLines.Add('none') | Out-Null } $debugLines | Set-Content -Encoding UTF8 -LiteralPath $debugPath - name: Upload failing test IDs if: ${{ !cancelled() }} uses: actions/upload-artifact@v6 with: name: batchcheck-failures-${{ matrix.mode }} path: | diag/failing-tests.txt diag/fail-debug.txt if-no-files-found: ignore retention-days: 14 - name: Preflight OpenAI auth (diagnostics) id: openai_auth if: ${{ !cancelled() }} shell: bash run: | set -euo pipefail if [ -z "${{ secrets.OPENAI_API_KEY }}" ]; then echo "present=false" >> "$GITHUB_OUTPUT" echo "auth_ok=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "present=true" >> "$GITHUB_OUTPUT" status=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${{ secrets.OPENAI_API_KEY }}" \ https://api.openai.com/v1/models || echo 000) if [ "$status" = "200" ]; then echo "auth_ok=true" >> "$GITHUB_OUTPUT" else echo "::warning title=OpenAI auth failed::HTTP $status from /v1/models (will skip Codex iteration)" echo "auth_ok=false" >> "$GITHUB_OUTPUT" fi - name: Iterate auth summary if: ${{ !cancelled() }} shell: pwsh run: | $present = '${{ steps.openai_auth.outputs.present }}' if (-not $present) { $present = 'false' } $auth = '${{ steps.openai_auth.outputs.auth_ok }}' if (-not $auth) { $auth = 'false' } "### Iterate auth" | Out-File -Append $env:GITHUB_STEP_SUMMARY "- api_key_present: $present" | Out-File -Append $env:GITHUB_STEP_SUMMARY "- auth_ok: $auth" | Out-File -Append $env:GITHUB_STEP_SUMMARY - name: Build public diagnostics tree if: ${{ !cancelled() }} id: build_public shell: pwsh run: | $pub = Join-Path $env:RUNNER_TEMP 'public' New-Item -Force -ItemType Directory -Path $pub | Out-Null $logsDir = Join-Path $pub 'logs' $srcDirPath = Join-Path $pub 'sources' $repoDir = Join-Path $pub 'repo' New-Item -Force -ItemType Directory -Path $logsDir | Out-Null New-Item -Force -ItemType Directory -Path $srcDirPath | Out-Null New-Item -Force -ItemType Directory -Path $repoDir | Out-Null $cwd = Get-Location Write-Host ("DIAG CWD: {0}" -f $cwd.Path) $rootPath = $null try { $rootPath = (Resolve-Path -LiteralPath .).Path Write-Host ("DIAG ROOT: {0}" -f $rootPath) } catch {} try { $treeBase = if ($rootPath) { $rootPath } else { $cwd.Path } $tree = Get-ChildItem -Path $treeBase -Recurse -Depth 2 -ErrorAction SilentlyContinue | Select-Object -First 200 if ($tree) { Write-Host 'DIAG TREE (first 200 within depth 2):' foreach ($item in $tree) { Write-Host (" {0}" -f $item.FullName) } } else { Write-Host 'DIAG TREE: no entries found within depth 2.' } } catch { Write-Host ("DIAG TREE: failed to enumerate - {0}" -f $_.Exception.Message) } $targets = @( @{ Label = 'tests~test-results.ndjson'; Pattern = 'tests\~test-results.ndjson'; Literal = $true }, @{ Label = 'ci_test_results.ndjson'; Pattern = 'ci_test_results.ndjson'; Literal = $true }, @{ Label = 'tests~test-summary.txt'; Pattern = 'tests\~test-summary.txt'; Literal = $true }, @{ Label = '*~*_bootstrap.log'; Pattern = '*~*bootstrap.log'; Literal = $false } ) foreach ($target in $targets) { if ($target.Literal) { if (Test-Path -LiteralPath $target.Pattern) { Resolve-Path -LiteralPath $target.Pattern | ForEach-Object { Write-Host ("FOUND {0} -> {1}" -f $target.Label, $_) } } else { Write-Host ("MISSING {0}" -f $target.Pattern) } } else { $matches = Get-ChildItem -Path . -Recurse -Include $target.Pattern -File -ErrorAction SilentlyContinue if ($matches) { foreach ($m in $matches) { Write-Host ("FOUND {0} -> {1}" -f $target.Label, $m.FullName) } } else { Write-Host ("MISSING {0}" -f $target.Pattern) } } } $ndjsonCandidates = @('tests\~test-results.ndjson','ci_test_results.ndjson') $wantLogs = @( 'tests\~test-results.ndjson', 'ci_test_results.ndjson', 'tests\~entry1\~entry1_bootstrap.log', 'tests\~entryA\~entryA_bootstrap.log', 'tests\~entryB\~entryB_bootstrap.log', 'tests\~envsmoke\~envsmoke_bootstrap.log', 'tests\~selftest_empty\~empty_bootstrap.log', 'tests\~selftest_stub\~setup.log', 'dynamic_tests.log', 'dependency_source.txt', 'tests\~test-summary.txt', 'iterate_gate.json', 'iterate_auth.json', 'ndjson_stats.json', 'first_failure.json' ) foreach ($p in $wantLogs) { if (Test-Path -LiteralPath $p) { Copy-Item -LiteralPath $p -Destination $logsDir -Force } } $iterAuthPath = Join-Path $logsDir 'iterate_auth.json' $iterPresent = '${{ steps.openai_auth.outputs.present }}' if (-not $iterPresent) { $iterPresent = 'false' } $iterAuthOk = '${{ steps.openai_auth.outputs.auth_ok }}' if (-not $iterAuthOk) { $iterAuthOk = 'false' } @{ api_key_present = $iterPresent; auth_ok = $iterAuthOk } | ConvertTo-Json -Compress -Depth 8 | Out-File -Encoding UTF8 $iterAuthPath $existingNd = @() foreach ($cand in $ndjsonCandidates) { if (Test-Path -LiteralPath $cand) { $existingNd += (Resolve-Path -LiteralPath $cand).Path } } $uniqueNd = $existingNd | Sort-Object -Unique if (-not $uniqueNd) { $note = @{ cwd = $cwd.Path; tried = $ndjsonCandidates; found = @() } | ConvertTo-Json -Compress -Depth 8 $note | Out-File -Encoding UTF8 (Join-Path $logsDir 'ndjson_missing.json') } $passCount = 0 $failCount = 0 $skipCount = 0 $firstFail = $null $firstSource = $null foreach ($path in $uniqueNd) { foreach ($line in Get-Content -LiteralPath $path -Encoding UTF8) { $trim = $line.Trim() if (-not $trim) { continue } try { $row = $trim | ConvertFrom-Json } catch { continue } $value = $null if ($row.PSObject.Properties['pass']) { $value = $row.pass } if ($value -is [bool]) { if ($value) { $passCount++ } else { $failCount++ if (-not $firstFail) { $firstFail = $row; $firstSource = $path } } } elseif ($value -is [string]) { $lower = $value.ToLowerInvariant() switch ($lower) { 'pass' { $passCount++ } 'fail' { $failCount++; if (-not $firstFail) { $firstFail = $row; $firstSource = $path } } 'skip' { $skipCount++ } default { $skipCount++ } } } else { $skipCount++ } } } $stats = [ordered]@{ pass = $passCount fail = $failCount skip = $skipCount sources = $uniqueNd } $stats | ConvertTo-Json -Compress -Depth 8 | Out-File -Encoding UTF8 'ndjson_stats.json' if ($firstFail) { $firstFail | ConvertTo-Json -Compress -Depth 8 | Out-File -Encoding UTF8 'first_failure.json' } elseif (Test-Path -LiteralPath 'first_failure.json') { Remove-Item -LiteralPath 'first_failure.json' -Force } $zipNames = @() foreach ($path in $uniqueNd) { $leaf = Split-Path -Leaf $path $safeLeaf = ($leaf -replace '[^0-9A-Za-z_.-]', '-') $zipName = "ci_test_results-$safeLeaf.zip" $zipPath = Join-Path $logsDir $zipName if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } try { Compress-Archive -LiteralPath $path -DestinationPath $zipPath -Force $zipNames += $zipName } catch { Write-Host ("DIAG export failed for {0}: {1}" -f $path, $_.Exception.Message) } } '### NDJSON totals' | Out-File -Append $env:GITHUB_STEP_SUMMARY ("- PASS: {0}" -f $passCount) | Out-File -Append $env:GITHUB_STEP_SUMMARY ("- FAIL: {0}" -f $failCount) | Out-File -Append $env:GITHUB_STEP_SUMMARY ("- SKIP: {0}" -f $skipCount) | Out-File -Append $env:GITHUB_STEP_SUMMARY if ($failCount -gt 0 -and $firstFail) { $firstInfo = [ordered]@{ id = $firstFail.id desc = $firstFail.desc source = $firstSource } if ($firstFail.PSObject.Properties['details']) { $details = $firstFail.details if ($details -and $details.PSObject.Properties['file']) { $firstInfo.file = $details.file } if ($details -and $details.PSObject.Properties['line']) { $firstInfo.line = $details.line } if ($details -and $details.PSObject.Properties['snippet']) { $firstInfo.snippet = $details.snippet } } if ($firstFail.PSObject.Properties['message']) { $firstInfo.message = $firstFail.message } '### First failure' | Out-File -Append $env:GITHUB_STEP_SUMMARY '```json' | Out-File -Append $env:GITHUB_STEP_SUMMARY ($firstInfo | ConvertTo-Json -Compress -Depth 8) | Out-File -Append $env:GITHUB_STEP_SUMMARY '```' | Out-File -Append $env:GITHUB_STEP_SUMMARY } else { '- First failure: none (all rows passed)' | Out-File -Append $env:GITHUB_STEP_SUMMARY } $wantSrc = @( '.github\workflows\batch-check.yml', 'run_setup.bat', 'tests\selftests.ps1', 'tests\harness.ps1' ) foreach ($s in $wantSrc) { if (Test-Path -LiteralPath $s) { Copy-Item -LiteralPath $s -Destination $srcDirPath -Force } } # derived requirement: this diagnostics snapshot previously blanket-copied the whole # post-test repo tree with only .git/site excluded, sweeping up every scratch # conda/venv/uv env, PyInstaller dist/build output, downloaded Miniconda/embed-Python # zips, and extracted embeddable-Python distribution any selfapps/selftest sub-bootstrap # left behind -- ballooning per-lane diag-selftest-* artifacts to ~5.4 GB combined # (confirmed via real per-artifact byte sizes) even though the actual published Pages # site only needs ~32 MB of logs/index. This is the most likely cause of the # "Publish diagnostics to Pages" job getting cancelled on a real merge-commit run. $diagExcludeExact = @('.', '..', '.git', 'site', 'dist', 'build', '.venv', '.uv_env', '~uv_bin', 'Miniconda3') $diagExcludePatterns = @('~embed_python*', '*.zip', '*.exe') Get-ChildItem -LiteralPath . -Force | Where-Object { $name = $_.Name if ($diagExcludeExact -contains $name) { return $false } foreach ($pat in $diagExcludePatterns) { if ($name -like $pat) { return $false } } return $true } | ForEach-Object { Copy-Item -Path $_.FullName -Destination (Join-Path $repoDir $_.Name) -Recurse -Force -ErrorAction SilentlyContinue } # derived requirement: run 19211170783-1 produced a non-empty iterate artifact # in Actions but only mirrored a zero-byte discovery log into Pages. Mirror # _ctx directly so diagnostics stay truthful without waiting on remote fetches. $iterateRoot = Join-Path $pub 'iterate' New-Item -Force -ItemType Directory -Path $iterateRoot | Out-Null $iterateDir = Join-Path $iterateRoot ("iterate-logs-{0}-{1}" -f $env:GITHUB_RUN_ID, $env:GITHUB_RUN_ATTEMPT) if (Test-Path -LiteralPath '_ctx') { New-Item -Force -ItemType Directory -Path $iterateDir | Out-Null Copy-Item -Path (Join-Path '_ctx' '*') -Destination $iterateDir -Recurse -Force $summaryPath = Join-Path $iterateRoot 'discovery.log.txt' $copied = Get-ChildItem -Path $iterateDir -Recurse -File | Sort-Object FullName $summaryLines = @() foreach ($item in $copied) { $relative = [System.IO.Path]::GetRelativePath($iterateDir, $item.FullName) $summaryLines += ("{0} {1}" -f ($relative.Replace('\\', '/')), $item.Length) } if (-not $summaryLines) { $summaryLines = @('no iterate files copied') } $summaryLines | Out-File -Encoding UTF8 $summaryPath } try { $ct = [TimeZoneInfo]::FindSystemTimeZoneById('Central Standard Time') } catch { $ct = $null } $utc = [DateTime]::UtcNow $ctNow = if ($ct) { [TimeZoneInfo]::ConvertTimeFromUtc($utc, $ct) } else { $utc } $header = @( '# CI Diagnostics', '', "* Repo: ${{ github.repository }}", "* Commit: ${{ github.sha }}", "* Run: ${{ github.run_id }}", "* Built (UTC): " + $utc.ToString('o'), "* Built (CT): " + $ctNow.ToString('o'), '' ) $idx = @() $keyArtifacts = @() $keyTargets = @( @{ Name = 'ci_test_results.ndjson'; Path = Join-Path $logsDir 'ci_test_results.ndjson' }, @{ Name = 'tests~test-results.ndjson'; Path = Join-Path $logsDir 'tests~test-results.ndjson' }, @{ Name = 'tests~test-summary.txt'; Path = Join-Path $logsDir 'tests~test-summary.txt' } ) foreach ($entry in $keyTargets) { if (Test-Path -LiteralPath $entry.Path) { $keyArtifacts += "- [logs/$($entry.Name)](logs/$($entry.Name))" } } if ($keyArtifacts) { $idx += '## Key artifacts' $idx += $keyArtifacts $idx += '' } $idx += '## NDJSON totals' $idx += ("- PASS: {0}" -f $passCount) $idx += ("- FAIL: {0}" -f $failCount) $idx += ("- SKIP: {0}" -f $skipCount) if ($failCount -gt 0 -and $firstFail) { $idx += '' $idx += '## NDJSON first failure' $idx += ("* Source: {0}" -f $firstSource) $idx += '```json' $idx += ($firstFail | ConvertTo-Json -Compress -Depth 8) $idx += '```' } if ($zipNames) { $idx += '' $idx += '## NDJSON exports' foreach ($zip in $zipNames) { $idx += "- [logs/$zip](logs/$zip)" } } $idx += '' $idx += '## Raw NDJSON / logs' $logItems = Get-ChildItem -Name $logsDir -ErrorAction SilentlyContinue if ($logItems) { foreach ($name in $logItems) { $idx += "- [logs/$name](logs/$name)" } } else { $idx += '- (no logs captured)' } $idx += '' $idx += '## Sources (what code ran)' $srcItems = Get-ChildItem -Name $srcDirPath -ErrorAction SilentlyContinue if ($srcItems) { foreach ($name in $srcItems) { $idx += "- [sources/$name](sources/$name)" } } else { $idx += '- (no sources captured)' } $idx += '' $idx += '## Repository snapshot' $repoItems = Get-ChildItem -Path $repoDir -Recurse -File -ErrorAction SilentlyContinue if ($repoItems) { foreach ($item in $repoItems) { $rel = $item.FullName.Substring($repoDir.Length + 1) -replace '\\','/' $idx += "- [repo/$rel](repo/$rel)" } } else { $idx += '- (repo snapshot empty)' } ($header + $idx) -join "`n" | Out-File -Encoding UTF8 (Join-Path $pub 'index.md') $html = @" CI Diagnostics -- run ${{ github.run_id }}

CI Diagnostics

Repo: ${{ github.repository }}
Commit: ${{ github.sha }}
Run: ${{ github.run_id }}
Built (UTC): $($utc.ToString('o'))
Built (CT): $($ctNow.ToString('o'))

See index.md for the Markdown listing.

"@ Set-Content -LiteralPath (Join-Path $pub 'index.html') -Value $html -Encoding UTF8 "public_dir=$pub" | Out-File -Append $env:GITHUB_OUTPUT - name: Upload diagnostics bundle if: ${{ !cancelled() }} uses: actions/upload-artifact@v6 with: name: diag-selftest-${{ matrix.mode }}-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.build_public.outputs.public_dir }} if-no-files-found: ignore retention-days: 7 - name: Gate on NDJSON results if: ${{ !cancelled() }} uses: actions/github-script@v8 with: github-token: ${{ github.token }} script: | const fs = require('fs'); const path = 'tests\\~test-results.ndjson'; if (!fs.existsSync(path)) { core.setFailed(`NDJSON results not found at ${path}`); return; } const raw = fs.readFileSync(path, 'utf8'); const lines = raw.split(/\r?\n/).filter(line => line.trim().length > 0); if (lines.length === 0) { core.setFailed(`NDJSON results empty at ${path}`); return; } const failing = []; let parseError = false; for (const line of lines) { try { const row = JSON.parse(line); if (!row || row.pass !== true) { const id = row && row.id ? row.id : ''; if (row && row.pass === false) { failing.push(id); } else if (!row || typeof row.pass === 'undefined') { core.error(`NDJSON row missing pass property: ${line}`); failing.push(id); } } } catch (error) { parseError = true; core.error(`Failed to parse NDJSON row: ${line}`); } } if (parseError) { core.setFailed('One or more NDJSON rows could not be parsed.'); return; } if (failing.length) { core.setFailed('NDJSON failures: ' + failing.join(', ')); } - name: Summarize NDJSON diagnostics payload if: ${{ !cancelled() }} shell: pwsh run: | function Read-NdjsonFile { param( [string]$Label, [string]$Path ) $result = [ordered]@{ label = $Label path = $Path present = $false rows = 0 passCount = 0 failCount = 0 hasConda = 0 hasEnvMode = 0 } if (-not (Test-Path -LiteralPath $Path)) { return $result } $lines = Get-Content -LiteralPath $Path | Where-Object { $_ -match '\S' } $result.present = $true $result.rows = $lines.Count foreach ($line in $lines) { try { $row = $line | ConvertFrom-Json -ErrorAction Stop } catch { continue } if ($row -is [psobject]) { if ($row.PSObject.Properties.Name -contains 'pass') { $value = $row.pass if ($value -eq $false -or ($value -is [string] -and $value.ToLowerInvariant() -eq 'false')) { $result.failCount++ } elseif ($value -eq $true -or ($value -is [string] -and $value.ToLowerInvariant() -eq 'true')) { $result.passCount++ } } if ($row.id -eq 'conda.url') { $result.hasConda++ } if ($row.id -eq 'env.mode') { $result.hasEnvMode++ } } } return $result } New-Item -ItemType Directory -Force -Path 'diag' | Out-Null $targets = @( @{ Label = 'real ci_test_results.ndjson'; Path = 'ci_test_results.ndjson' }, @{ Label = 'filtered tests~test-results.ndjson'; Path = (Join-Path 'tests' '~test-results.ndjson') } ) $lines = [System.Collections.Generic.List[string]]::new() $anyPresent = $false foreach ($target in $targets) { $summary = Read-NdjsonFile -Label $target.Label -Path $target.Path if (-not $summary.present) { $null = $lines.Add(("{0}: missing ({1})" -f $summary.label, $summary.path)) continue } $anyPresent = $true $null = $lines.Add(("{0}:" -f $summary.label)) $null = $lines.Add((" rows: {0}" -f $summary.rows)) $null = $lines.Add((" pass: {0}" -f $summary.passCount)) $null = $lines.Add((" fail: {0}" -f $summary.failCount)) $null = $lines.Add((" has_conda_url: {0}" -f $summary.hasConda)) $null = $lines.Add((" has_env_mode: {0}" -f $summary.hasEnvMode)) $null = $lines.Add("") } if (-not $anyPresent) { $lines.Clear() $null = $lines.Add('missing') Write-Host 'NDJSON summary missing; wrote placeholder summary.' } else { Write-Host 'NDJSON summary written to diag/ndjson_summary.txt' } $lines | Out-File -Encoding utf8 'diag/ndjson_summary.txt' - name: Stage self-test diagnostics payload if: ${{ !cancelled() }} shell: pwsh run: | $stage = Join-Path $PWD ('selftest-diag-${{ matrix.mode }}') if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } New-Item -ItemType Directory -Path $stage | Out-Null $testsDir = Join-Path $stage 'tests' New-Item -ItemType Directory -Path $testsDir -Force | Out-Null $diagDir = Join-Path $stage 'diag' New-Item -ItemType Directory -Path $diagDir -Force | Out-Null $pairs = @( @{ Source = (Join-Path 'tests' '~test-results.ndjson'); Destination = (Join-Path $testsDir '~test-results.ndjson') } @{ Source = (Join-Path 'tests' '~test-summary.txt'); Destination = (Join-Path $testsDir '~test-summary.txt') } @{ Source = 'dynamic_tests.log'; Destination = (Join-Path $stage 'dynamic_tests.log') } @{ Source = 'dependency_source.txt'; Destination = (Join-Path $stage 'dependency_source.txt') } ) foreach ($pair in $pairs) { if (Test-Path $pair.Source) { New-Item -ItemType Directory -Force -Path (Split-Path $pair.Destination -Parent) | Out-Null Copy-Item -LiteralPath $pair.Source -Destination $pair.Destination -Force } } $ndjsonSummary = 'diag/ndjson_summary.txt' if (Test-Path $ndjsonSummary) { Copy-Item -LiteralPath $ndjsonSummary -Destination (Join-Path $diagDir 'ndjson_summary.txt') -Force } $summaryTargets = Get-ChildItem -Path $PWD -Filter 'summary_raw.*' -File -ErrorAction SilentlyContinue if ($summaryTargets) { $summaryDir = Join-Path $stage 'summary_raw' New-Item -ItemType Directory -Path $summaryDir -Force | Out-Null foreach ($file in $summaryTargets) { Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $summaryDir $file.Name) -Force } } $ndjsonDynamic = Join-Path 'tests' '~dynamic-results.ndjson' if (Test-Path $ndjsonDynamic) { $dynDir = Join-Path $testsDir 'dynamic' New-Item -ItemType Directory -Path $dynDir -Force | Out-Null Copy-Item -LiteralPath $ndjsonDynamic -Destination (Join-Path $dynDir '~dynamic-results.ndjson') -Force } - name: Upload self-test artifact if: ${{ !cancelled() }} uses: actions/upload-artifact@v6 with: name: selftest-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.mode }} path: selftest-diag-${{ matrix.mode }} if-no-files-found: warn retention-days: 7 - name: Summarize prep_requirements helper if: ${{ !cancelled() }} shell: pwsh run: | $helperBase = Join-Path (Join-Path '.' 'tests') 'extracted' $helper = Join-Path $helperBase '~prep_requirements.py' if (Test-Path $helper) { Add-Content $env:GITHUB_STEP_SUMMARY -Value "### ~prep_requirements.py (first 3 non-comment lines)" Add-Content $env:GITHUB_STEP_SUMMARY -Value '```python' $lines = Get-Content $helper -Encoding UTF8 $emit = @() foreach ($line in $lines) { $trim = $line.Trim() if ($trim -and -not $trim.StartsWith('#')) { $emit += $line } if ($emit.Count -ge 3) { break } } if ($emit.Count -eq 0) { $emit = @('') } foreach ($line in $emit) { Add-Content $env:GITHUB_STEP_SUMMARY -Value $line } Add-Content $env:GITHUB_STEP_SUMMARY -Value '```' } else { Add-Content $env:GITHUB_STEP_SUMMARY -Value "_tests\\extracted\\~prep_requirements.py not found_" } - name: Summarize detect_python helper if: ${{ !cancelled() }} shell: pwsh run: | $helperBase = Join-Path (Join-Path '.' 'tests') 'extracted' $helper = Join-Path $helperBase '~detect_python.py' if (Test-Path $helper) { Add-Content $env:GITHUB_STEP_SUMMARY -Value "### ~detect_python.py (first 3 non-comment lines)" Add-Content $env:GITHUB_STEP_SUMMARY -Value '```python' $lines = Get-Content $helper -Encoding UTF8 $emit = @() foreach ($line in $lines) { $trim = $line.Trim() if ($trim -and -not $trim.StartsWith('#')) { $emit += $line } if ($emit.Count -ge 3) { break } } if ($emit.Count -eq 0) { $emit = @('') } foreach ($line in $emit) { Add-Content $env:GITHUB_STEP_SUMMARY -Value $line } Add-Content $env:GITHUB_STEP_SUMMARY -Value '```' } else { Add-Content $env:GITHUB_STEP_SUMMARY -Value "_tests\\extracted\\~detect_python.py not found_" } - name: Warn if zero tests executed if: ${{ !cancelled() }} shell: pwsh run: | $logs = Get-ChildItem -Recurse -Include *.log,*.txt,*.out -File -ErrorAction SilentlyContinue $text = ($logs | Get-Content -Raw) -join "`n" if ($text -match '(?i)\b0\s+tests?\b') { Write-Host "WARNING: Detected 0 tests executed - investigate harness/test discovery." } - name: Summarize bootstrap self-tests if: ${{ !cancelled() }} shell: pwsh run: | $items = @( @{ Title = 'Self-test (empty folder bootstrap)'; Path = 'tests\~selftest_empty\~empty_bootstrap.log' }, @{ Title = 'Self-test (stub bootstrap)'; Path = 'tests\~selftest_stub\~stub_bootstrap.log' }, @{ Title = 'Self-test (stub run)'; Path = 'tests\~selftest_stub\~stub_run.log' } ) foreach ($item in $items) { if (Test-Path $item.Path) { Add-Content $env:GITHUB_STEP_SUMMARY -Value ("### {0} (tail)" -f $item.Title) Add-Content $env:GITHUB_STEP_SUMMARY -Value '```text' Get-Content $item.Path -Tail 120 | ForEach-Object { Add-Content $env:GITHUB_STEP_SUMMARY -Value $_ } Add-Content $env:GITHUB_STEP_SUMMARY -Value '```' } } - name: "Diag: cascade execution evidence (uv lane; stdout so it lands in the log tail)" if: ${{ !cancelled() && matrix.mode == 'uv' }} shell: pwsh run: | $bl = 'tests\~selftest_cascade_exec\~cascade_exec_bootstrap.log' $sl = 'tests\~selftest_cascade_exec\~setup.log' Write-Host '=== self.cascade.exec row (ci_test_results.ndjson) ===' if (Test-Path 'ci_test_results.ndjson') { Select-String -Path 'ci_test_results.ndjson' -Pattern 'self\.cascade\.exec' | ForEach-Object { Write-Host $_.Line } } else { Write-Host 'ci_test_results.ndjson not found' } foreach ($f in @($bl, $sl)) { if (Test-Path $f) { Write-Host ("=== {0}: REQ-009 / provider / warnfix / cascade lines ===" -f $f) Select-String -Path $f -Pattern 'REQ-009|Selected Python provider|REPAIR|HP_ENV_MODE|cascade|Installing Miniconda|conda create|warnfix' | ForEach-Object { Write-Host $_.Line } } else { Write-Host ("MISSING: {0}" -f $f) } } - name: Publish failure summary if: failure() shell: pwsh run: | $runUrl = "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/actions/runs/$env:GITHUB_RUN_ID" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "# CI failure: Batch syntax/run check" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Run: $runUrl`n" $patterns = @('*.log','*install_log*.txt','~*.txt','*.out','*.ndjson') $files = @() foreach ($p in $patterns) { $files += Get-ChildItem -Path . -Recurse -Include $p -File -ErrorAction SilentlyContinue } $files += Get-ChildItem -Path .\tests -Recurse -Include *.txt,*.log,*.ndjson -File -ErrorAction SilentlyContinue $files = $files | Sort-Object LastWriteTime -Descending -Unique $files = $files | Where-Object { $_.Name -ne 'README_TESTS.txt' } if ($files -and $files.Count -gt 0) { $files | Select-Object -First 4 | ForEach-Object { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "## Log: $($_.FullName)" Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '```text' Get-Content -Path $_.FullName -Tail 120 | ForEach-Object { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $_ } Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value '```' } } else { Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "_No logs found_" } - name: Distribute codex failure summary (PR comment) if: ${{ failure() && github.event_name == 'pull_request' && env.AUTOMERGE_TOKEN != '' }} uses: actions/github-script@v8 env: AUTOMERGE_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }} with: github-token: ${{ env.AUTOMERGE_TOKEN }} script: | const fs = require('fs'); const path = 'codex_body.txt'; if (!fs.existsSync(path)) { core.info('codex_body.txt not found; skipping commenter.'); return; } const body = fs.readFileSync(path, { encoding: 'ascii' }); const number = context.payload.pull_request.number; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: number, body }); selftest-gate: name: Aggregate self-test verdicts needs: [selftest] if: ${{ always() }} runs-on: windows-latest outputs: has_failures: ${{ steps.aggregate.outputs.has_failures }} steps: # derived requirement (CodeRabbit review, PR #454): this job previously never checked out # the repository at all -- its original "Aggregate verdicts" step was fully self-contained # inline PowerShell with no dependency on any repo file. Extracting that logic to # tools/aggregate_selftest_verdicts.ps1 introduced a genuine new dependency on the repo # being present on this runner that the job never had before; without this step, the # "Aggregate verdicts" step below would fail to find the script on every single run. - name: Checkout repository uses: actions/checkout@v5 - name: Download lane verdicts uses: actions/download-artifact@v6 continue-on-error: true with: pattern: selftest-verdict-* path: ${{ runner.temp }}/selftest-verdicts # derived requirement: NOT merge-multiple -- every lane's artifact zips a file with the # identical local name (lane_verdict.json), so merging them into one flat directory made # each download silently overwrite the last, leaving the aggregation step below able to # see only ONE lane's verdict (Get-ChildItem found a single surviving file) instead of # all matrix lanes'. Found while auditing this exact artifact-collision pattern after # tools/check_ndjson_registry.py's own download step hit the identical bug (see # docs/agent-lessons-learned.md). Without merge-multiple, each artifact lands in its own # // subdirectory; the aggregation step's Get-ChildItem already uses # -Recurse, so no other change is needed to see every lane's verdict. - name: Aggregate verdicts id: aggregate shell: pwsh # derived requirement: the actual aggregation logic now lives in # tools/aggregate_selftest_verdicts.ps1 so tests/test_aggregate_selftest_verdicts.ps1 can # exercise it deterministically against fixture directories on every CI run -- mirrors # tools/ci_cache_selfheal.ps1's own extraction (CLAUDE.md Active Backlog Item 35's # "Precondition" caveat: fix the missing-artifact fallback to fail CLOSED per lane before # this step's own conclusion is ever wired into branch protection). -ExpectedLanes must be # kept in sync with this file's own matrix.include list above (8 modes: cache, real, # conda-full, justme-test, uv, contract-uv, contract-uv-fail, uv-dl-fallback) -- already a # third duplication of that list alongside the matrix include: block and the # continue-on-error: condition, not a new pattern. # derived requirement (CodeRabbit review, PR #454, zizmor template-injection warning): # needs.selftest.result is read via env: below instead of interpolated directly into this # run: block -- the value itself is always one of a small fixed GitHub-defined enum # ('success'/'failure'/'cancelled'/'skipped'), never attacker-controlled free text, but # routing it through env: avoids the generic pattern zizmor flags entirely rather than # arguing the specific case is safe. env: HP_SELFTEST_RESULT: ${{ needs.selftest.result }} run: | $reportPath = Join-Path $env:RUNNER_TEMP 'selftest-gate.json' & .\tools\aggregate_selftest_verdicts.ps1 ` -VerdictsDir (Join-Path $env:RUNNER_TEMP 'selftest-verdicts') ` -FallbackResult $env:HP_SELFTEST_RESULT ` -ExpectedLanes @('cache', 'real', 'conda-full', 'justme-test', 'uv', 'contract-uv', 'contract-uv-fail', 'uv-dl-fallback') ` -ReportPath $reportPath $rc = $LASTEXITCODE # derived requirement (CodeRabbit review, PR #454): treat anything OTHER than a clean # exit 0 as has_failures=true, not just the script's own documented exit 1. Aggregate_ # selftest_verdicts.ps1 only ever exits 0 or 1 by its own contract, but the INVOCATION # itself can fail before that contract even applies -- e.g. a bad path/parameter binding # error from the call operator (&) is typically non-terminating under this shell's # default $ErrorActionPreference, so execution would continue past it with $LASTEXITCODE # left at whatever it was BEFORE this line (commonly $null, since this is the step's # first command) -- the old "-eq 1" check silently classified that as has_failures=false # (fail OPEN: the aggregator never ran, but the gate reported clean). "$rc -ne 0" instead # (with $null -ne 0 evaluating true) fails CLOSED on any anomaly, not just the one # documented failure code. $value = if ($rc -eq 0) { 'false' } else { 'true' } "has_failures=$value" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append # derived requirement (CodeRabbit review, PR #454): the gate's own verdict was # previously observable only via the job summary/raw report, with no NDJSON row -- # this repo's own coding guideline requires "New observable logs, files, artifacts, or # behavior require an NDJSON row." Named to match the ci_test_results-* wildcard # publish_diag's own "Download CI NDJSON artifacts" step already uses (see # ndjson-registry-check's own precedent immediately below in this file), so it lands on # the diagnostics site with zero new download-step wiring. $gateRow = [ordered]@{ id = 'diag.selftest_gate.verdict' pass = ($rc -eq 0) desc = 'selftest-gate: aggregate self-test verdict across all matrix lanes' details = [ordered]@{ has_failures = ($rc -ne 0); exit_code = $rc; fallback_result = $env:HP_SELFTEST_RESULT } } | ConvertTo-Json -Compress -Depth 8 $gateRow | Out-File -FilePath 'ci_test_results.ndjson' -Encoding ascii -Append # derived requirement (CodeRabbit review, PR #454): explicitly reset the step's own # exit status. GitHub Actions appends its own "exit $LASTEXITCODE" to every pwsh run: # block; aggregate_selftest_verdicts.ps1 is invoked via the call operator (&), so a # has_failures=true run leaves $LASTEXITCODE at 1 with nothing after it to clear that -- # every later line here (string building, Out-File) is pure PowerShell and never # touches $LASTEXITCODE. Without this, the step (and therefore this job's own # conclusion) would silently start failing on real has_failures=true evidence today, # even though CLAUDE.md Active Backlog Item 35's own process discipline is explicit that # making selftest-gate's conclusion fail for real is a SEPARATE, not-yet-taken next # step (its own future "if has_failures: exit 1" addition, only after this mechanism has # soaked and is added to branch protection) -- not an accidental side effect of this # precondition slice. steps.aggregate.outputs.has_failures (set above via # $env:GITHUB_OUTPUT) already carries the real verdict to model-quick-fix regardless of # this step's own exit code. exit 0 # derived requirement (CLAUDE.md Active Backlog Item 35): the precondition slice above # (fail-closed per-lane set comparison, proven via tests/test_aggregate_selftest_ # verdicts.ps1's fixtures on every real CI run since) deliberately left this job's own # conclusion always-success. This step is the actual gating step -- mirrors the identical, # already-proven "Enforce NDJSON failures for gated lanes" 3-line pattern used per-lane for # `real`/`conda-full` (this file, ~line 1430), just reading the AGGREGATE verdict instead of # one lane's own. Re-verified before adding: contract-uv/contract-uv-fail/uv-dl-fallback # (each intentionally simulates a failure/fallback scenario) have reported a clean, non- # has_failures verdict on every real run observed to date -- their own simulated condition # is a correctly-handled recovery, not a real failure, so this check does not turn them into # permanent false blockers. # # derived requirement (2026-08-30, PR #471): reverted to advisory (continue-on-error) after # this step's very first two real activations both hard-failed the job on a genuine, # reproducible (not flaky) pre-existing condition -- byte-identical failure-detail payloads # on two separate runs hours apart -- in the `cache` (self.exe.smokerun, exitCode 1) and # `uv` (self.cascade.exec falling through to embed instead of stopping at conda; # self.exe.warnfix.venv_repair's repair-install precondition never firing) lanes. Neither # lane's own test script nor run_setup.bat was touched by this PR (a CI-YAML/docs-only # diff), so the underlying condition predates this PR and is unrelated to it -- but because # THIS gate is what first turned an already-non-gating lane's failure into a repo-wide merge # blocker, leaving it hard-failing would block every PR until the underlying cascade/warnfix # bug is separately root-caused and fixed (its own real investigation, out of scope here). # continue-on-error keeps this step's own red result visible in the PR checks UI (so the # regression is not silently lost) without failing the job. Re-remove continue-on-error once # cache/uv are fixed and the mechanism has re-soaked per this item's own process discipline. - name: Enforce aggregate self-test verdict if: ${{ !cancelled() && steps.aggregate.outputs.has_failures == 'true' }} continue-on-error: true shell: bash run: | echo "Aggregated self-test verdicts indicate a failure in one or more lanes; failing this check." exit 1 - name: Append gate summary if: ${{ always() }} shell: pwsh run: | $path = Join-Path $env:RUNNER_TEMP 'selftest-gate.json' "### Self-test gate" | Out-File -Append $env:GITHUB_STEP_SUMMARY if (Test-Path $path) { '```json' | Out-File -Append $env:GITHUB_STEP_SUMMARY Get-Content -Raw $path | Out-File -Append $env:GITHUB_STEP_SUMMARY '```' | Out-File -Append $env:GITHUB_STEP_SUMMARY } else { '- No gate summary generated.' | Out-File -Append $env:GITHUB_STEP_SUMMARY } # derived requirement (CodeRabbit review, PR #454): the report was previously surfaced only # via the job summary above, with no way to download or retain it -- the job summary page # is convenient for a human reading the run live, but is not a durable artifact the way the # per-lane lane_verdict.json files already are. Uploading it here gives future diagnosis # (or a future automated consumer) the same durability the inputs to this step already have. - name: Upload gate report if: ${{ always() }} uses: actions/upload-artifact@v6 with: name: selftest-gate-report-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ runner.temp }}/selftest-gate.json if-no-files-found: warn retention-days: 7 # derived requirement (CodeRabbit review, PR #454): uploads the diag.selftest_gate.verdict # NDJSON row (emitted above) as its own artifact -- named to match the ci_test_results-* # wildcard pattern publish_diag's "Download CI NDJSON artifacts" step already uses, so it # lands in _artifacts/batch-check/_ci_artifacts/ alongside the per-lane NDJSON files with # zero new download-step wiring, mirroring ndjson-registry-check's own identical precedent # a few jobs below in this same file. - name: Upload gate NDJSON row if: ${{ always() }} uses: actions/upload-artifact@v6 with: name: ci_test_results-selftest-gate-${{ github.run_id }}-${{ github.run_attempt }} path: ci_test_results.ndjson if-no-files-found: warn retention-days: 7 crlf-check: name: CRLF line-ending check (.bat/.cmd) # derived requirement: enforces docs/agent-lessons-learned.md's ".bat files: -text, not # eol=crlf" contract -- .gitattributes disables git's own line-ending normalization for # *.bat/*.cmd (so raw.githubusercontent.com serves exactly what is committed), which means # CRLF byte-uniformity must now be enforced by tooling instead of by git itself. Independent # of the selftest matrix on purpose (a pure static check on the checked-out tree, no # environment dependency) -- no "needs:", so it runs immediately and fails fast instead of # waiting on the ~90-minute matrix, and does not touch publish_diag's own needs/if:always() # graph (CLAUDE.md Active Backlog Item 35's own caution: a new job must never silently # narrow that guard). Deliberately not continue-on-error -- a violation here is real and # deterministic (not flaky), so this job's own conclusion should report it as a failure; # whether that actually BLOCKS a PR merge is a separate branch-protection required-status- # checks decision (Item 35's own "gating is a GitHub setting, not a YAML edit" rule), not # something this job's YAML alone can enforce. runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v5 - name: Check CRLF line endings on tracked .bat/.cmd files run: python3 tools/check_crlf.py ndjson-registry-check: name: NDJSON registry cross-check (doc vs code vs log) # derived requirement: advisory only -- cross-checks docs/agent-ndjson.md's row registry # against PowerShell/run_setup.bat emission sites (code) and this run's own observed NDJSON # artifacts (log). continue-on-error keeps this fully non-gating: a finding here is real and # worth fixing, but must never block a PR merge on its own (this tool is new and advisory by # design, matching how other non-mature lanes in this repo graduate to gating only after a # soak period -- see AGENTS.md and CLAUDE.md's CI lane gating maturity notes). needs: [selftest] if: ${{ always() }} runs-on: ubuntu-latest continue-on-error: true steps: - name: Checkout repository uses: actions/checkout@v5 - name: Download lane NDJSON artifacts (small files only, not the diag bundles) uses: actions/download-artifact@v6 continue-on-error: true with: pattern: ci_test_results-selftest-* path: ${{ runner.temp }}/ndjson-logs # derived requirement: NOT merge-multiple -- every lane's artifact zips a file with # the identical name (ci_test_results.ndjson), so merging them into one flat directory # makes each download silently overwrite the last, leaving only one lane's rows visible # to the --log-dir scan. Without merge-multiple, each artifact lands in its own # // subdirectory; scan_log_ids() already globs recursively # (Path.rglob), so all lanes' rows are still picked up. - name: Run NDJSON registry cross-check shell: bash run: | set -o pipefail python3 tools/check_ndjson_registry.py --repo-root . --log-dir "${{ runner.temp }}/ndjson-logs" \ | tee ~ndjson-registry-report.txt - name: Upload NDJSON registry report # derived requirement: makes this advisory job's findings visible on the diagnostics # site with zero new download-artifact wiring -- name it to match the ci_test_results-* # wildcard pattern already used by publish_diag's "Download CI NDJSON artifacts" step, so # it lands in _artifacts/batch-check/_ci_artifacts/ alongside the per-lane NDJSON files # without a dedicated download step. See tools/diag/publish_index.py's # _collect_batch_ndjson_links() for the Quick Links entry that surfaces it by name. if: ${{ always() }} uses: actions/upload-artifact@v6 with: name: ci_test_results-ndjson-registry-${{ github.run_id }}-${{ github.run_attempt }} path: ~ndjson-registry-report.txt if-no-files-found: warn retention-days: 7 model-quick-fix: name: Model quick-fix (inline) needs: - selftest - selftest-gate # derived requirement: publish_diag needs this job present in the dependency graph even # when upstream work is cancelled; guard the inline helper at the step level so # manual cancellations skip the model call without breaking downstream diagnostics. if: ${{ always() }} runs-on: ubuntu-latest permissions: contents: write actions: read outputs: branch: ${{ steps.branch.outputs.branch }} branch_slug: ${{ steps.branch.outputs.branch_slug }} env: HAS_FAILURES: ${{ needs.selftest-gate.outputs.has_failures }} GH_TOKEN: ${{ github.token }} steps: - name: Checkout repository uses: actions/checkout@v5 with: fetch-depth: 0 - name: Determine branch context id: branch shell: bash env: BRANCH_REF: ${{ github.event.pull_request.head.ref || github.ref_name }} FALLBACK_REF: ${{ github.ref_name }} run: | set -euo pipefail branch="$BRANCH_REF" if [ -z "$branch" ]; then branch="$FALLBACK_REF" fi slug=$(echo "$branch" | tr ':/ ' '---') echo "branch=$branch" >> "$GITHUB_OUTPUT" echo "branch_slug=$slug" >> "$GITHUB_OUTPUT" - name: Download CI NDJSON for inline helper if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} uses: actions/download-artifact@v6 with: pattern: ci_test_results-*-${{ github.run_id }}-${{ github.run_attempt }} path: _artifacts/batch-check/_ci_artifacts - name: Aggregate batch-check fail list if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} env: DIAG: ${{ github.workspace }} run: | set -euo pipefail python tools/diag/ndjson_fail_list.py mkdir -p _artifacts/batch-check install -D batchcheck_failing.txt _artifacts/batch-check/batchcheck_failing.txt install -D batchcheck_fail-debug.txt _artifacts/batch-check/batchcheck_fail-debug.txt - name: Install Python dependencies if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} run: | python -m pip install --upgrade pip # derived requirement: tools/apply_patch.py imports pydantic; preinstall it so inline commits do not fail mid-run. python -m pip install requests pydantic - name: Stage iterate context if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} working-directory: ${{ github.workspace }} run: | python tools/inline_model_fix.py stage \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ --run-attempt "${{ github.run_attempt }}" \ --token "$GH_TOKEN" # derived requirement: when cancellations short-circuit the inline helper, leave # breadcrumbs so diagnostics explain why no model diff exists. - name: Note cancellation before model call if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' && cancelled() }} shell: bash working-directory: ${{ github.workspace }} run: | set -euo pipefail mkdir -p _ctx note_path="_ctx/notes.txt" { printf 'final_status=no_commit\n' printf 'final_reason=workflow_cancelled\n' } >> "$note_path" - name: Call model # toggle comments on these two lines to enable or disable the model. # if: ${{ always() && (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} if: false working-directory: ${{ github.workspace }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: | python tools/inline_model_fix.py call --model gpt-5-codex - name: Apply model patch if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} shell: bash working-directory: ${{ github.workspace }} run: | set -euo pipefail if [ ! -s _ctx/fix.patch ]; then echo "No patch produced." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" # derived requirement: keep CI aligned with the shared patch engine in tools/apply_patch.py. if python tools/apply_patch.py < _ctx/fix.patch; then staged=0 staged_list=() skipped_list=() note_path="_ctx/notes.txt" mkdir -p "$(dirname "$note_path")" if [ ! -e "$note_path" ]; then : > "$note_path" fi should_stage() { case "$1" in _ctx/*|_iter/*|_artifacts/*|_mirrors/*|__pycache__/*|.pytest_cache/*) return 1 ;; *.log|~*.log|*.ndjson|~*.ndjson|*.html) return 1 ;; esac return 0 } # derived requirement: limit staging to actual repository edits so scratch context # workspaces (_ctx, _iter) remain ephemeral and never pollute commits. while IFS= read -r line; do path="${line:3}" if [[ "$path" == *" -> "* ]]; then old="${path%% -> *}" new="${path##* -> }" skip=0 for candidate in "$old" "$new"; do if [[ -z "$candidate" ]]; then continue fi if ! should_stage "$candidate"; then skip=1 break fi done if [[ "$skip" -eq 1 ]]; then skipped_list+=("$old -> $new") continue fi git add -- "$old" "$new" staged_list+=("$old" "$new") staged=1 continue fi if [[ -z "$path" ]]; then continue fi if ! should_stage "$path"; then skipped_list+=("$path") continue fi git add -- "$path" staged_list+=("$path") staged=1 done < <(git status --porcelain) if [ ${#staged_list[@]} -eq 0 ]; then printf 'staged_paths=none\n' >> "$note_path" else printf 'staged_paths=%s\n' "$(printf '%s;' "${staged_list[@]}" | sed 's/;$//')" >> "$note_path" fi if [ ${#skipped_list[@]} -eq 0 ]; then printf 'skipped_paths=none\n' >> "$note_path" else printf 'skipped_paths=%s\n' "$(printf '%s;' "${skipped_list[@]}" | sed 's/;$//')" >> "$note_path" fi if [ "$staged" -eq 0 ]; then printf 'patch_apply=no_changes\n' >> _ctx/notes.txt else if git diff --staged --quiet; then printf 'patch_apply=no_changes\n' >> _ctx/notes.txt else if git commit -m "codex: inline quick-fix"; then git push printf 'patch_apply=success\n' >> _ctx/notes.txt else printf 'patch_commit=failed\n' >> _ctx/notes.txt fi fi fi else printf 'patch_apply=failed\n' >> _ctx/notes.txt fi - name: Package iterate context if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} shell: bash working-directory: ${{ github.workspace }} run: | set -euo pipefail if [ ! -d _ctx ]; then # derived requirement: run 19234337453-1 surfaced a bare iterate zip with no # _ctx folder; create the workspace so downstream packaging always emits # breadcrumbs instead of an empty archive. mkdir -p _ctx fi mkdir -p logs zip_path="logs/iterate-${{ github.run_id }}-${{ github.run_attempt }}.zip" rm -f "$zip_path" # derived requirement: include decision breadcrumbs so diagnostics enumerate # the iterate payload even when the model cannot return a diff mid-run. note_path="_ctx/notes.txt" if [ ! -e "$note_path" ]; then # derived requirement: when stage/call short-circuit, still leave a breadcrumb so diagnostics stay truthful. printf 'notes_stub=created_by_packager\n' > "$note_path" fi decision_path="_ctx/decision.json" if [ ! -e "$decision_path" ]; then # derived requirement: prevent "Model rationale: missing" when the inline helper never produced a decision payload. printf '{"status":"error","reason":"not_recorded"}\n' > "$decision_path" printf 'decision_stub=added_by_packager\n' >> "$note_path" fi response_path="_ctx/response.json" if [ ! -e "$response_path" ]; then printf '{}\n' > "$response_path" fi fix_path="_ctx/fix.patch" if [ ! -e "$fix_path" ]; then : > "$fix_path" fi declare -a files for path in _ctx/guide.json _ctx/failpack.log _ctx/iterate_context_manifest.tsv _ctx/response.json _ctx/decision.json _ctx/decision.txt _ctx/fix.patch _ctx/notes.txt; do if [ -e "$path" ]; then files+=("$path") fi done if [ -d _ctx/attached ]; then files+=("_ctx/attached") fi if [ ${#files[@]} -eq 0 ]; then # Professional note: the diagnostics site expects an iterate zip even when the model call is a no-op; # create a minimal notes + manifest pair so the artifact contract stays satisfied. mkdir -p _ctx note_path="_ctx/notes.txt" if [ ! -e "$note_path" ]; then printf 'placeholder=iterate\n' > "$note_path" fi manifest_path="_ctx/iterate_context_manifest.tsv" if [ ! -e "$manifest_path" ]; then note_size=$(stat -c %s "$note_path" 2>/dev/null || echo 0) printf 'notes.txt\t%s\n' "$note_size" > "$manifest_path" fi files=("$note_path" "$manifest_path") fi zip -r "$zip_path" "${files[@]}" >/dev/null # derived requirement: expose the iterate payload contents inline so field triage # can confirm breadcrumbs exist before upload. - name: List _ctx prior to artifact upload (debug) if: ${{ always() }} shell: bash working-directory: ${{ github.workspace }} run: | echo "PWD=$(pwd)" if [ -d _ctx ]; then find _ctx -maxdepth 2 -type f -printf "%p (%s bytes)\n" | sort else echo "_ctx MISSING" fi - name: Upload iterate artifact if: ${{ (needs['selftest-gate'].result == 'success' || needs['selftest-gate'].result == 'failure') && needs['selftest-gate'].outputs.has_failures == 'true' }} uses: actions/upload-artifact@v6 with: name: iterate-logs-${{ github.run_id }}-${{ github.run_attempt }} path: logs/iterate-${{ github.run_id }}-${{ github.run_attempt }}.zip if-no-files-found: warn retention-days: 30 publish_diag: name: Publish diagnostics to Pages # derived requirement: keep diagnostics serialized behind model-quick-fix so iterate # artifacts are definitely uploaded before the publisher attempts to fetch them, # otherwise Pages may ship an empty _iter if publish_diag races ahead. needs: [selftest, selftest-gate, model-quick-fix] # derived requirement: widened from 25 to accommodate the Pages-deploy retry-with-backoff # sequence below (up to a 20-minute wait before the final attempt), plus normal setup time. timeout-minutes: 40 concurrency: # derived requirement: run 19236437263-1 exhausted the runner disk while a prior # publish was still active; serialize by ref and bound runtime so hanging jobs # cannot overlap and consume the remaining quota. group: pages-${{ github.ref }} cancel-in-progress: true if: ${{ always() }} runs-on: ubuntu-latest environment: name: github-pages permissions: contents: read pages: write id-token: write actions: read steps: - name: Disk preflight + gentle cleanup (best effort) if: ${{ always() }} shell: bash run: | echo "== disk before ==" df -h || true if [ -n "${RUNNER_TEMP:-}" ] && [ -d "$RUNNER_TEMP" ]; then rm -rf "$RUNNER_TEMP"/* 2>/dev/null || true fi if [ -n "${GITHUB_WORKSPACE:-}" ] && [ -d "$GITHUB_WORKSPACE" ]; then find "$GITHUB_WORKSPACE" -maxdepth 2 -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true fi rm -rf _site 2>/dev/null || true echo "== disk after ==" df -h || true - name: Checkout repository # derived requirement: this job's own if: always() at the job level only guarantees # the JOB starts regardless of needs' outcomes -- it does NOT make every step run # regardless of an earlier step's failure (GitHub Actions gives every step an implicit # `if: success()` unless it declares its own condition). Checkout is the first step that # can genuinely fail (network/auth), and its failure would otherwise skip every # following default-condition step, starting with "Prep site directories" -- which is # what actually creates _site. `!cancelled()` (not `always()`, per CodeRabbit review and # GitHub's own documented guidance) closes that gap while still bypassing this and every # later `!cancelled()` step outright if the WORKFLOW RUN itself is cancelled, instead of # `always()`'s risk of hanging a checkout mid-teardown until it times out. if: ${{ !cancelled() }} uses: actions/checkout@v5 with: fetch-depth: 0 - name: Prep site directories # derived requirement: must run even if Checkout above failed -- it only touches # $PWD/github context, not repo content, so it can still lay down the _site/.nojekyll # skeleton "Upload Pages artifact" needs later. Without this here, a checkout # hiccup would leave _site never created at all, and the deploy steps further down # (which DO already have their own always()-equivalent override via their explicit # event_name condition) would fail for real instead of just publishing a thinner site. if: ${{ !cancelled() }} id: prep shell: pwsh run: | $RunId = "${{ github.run_id }}" $Attempt = "${{ github.run_attempt }}" $SiteRoot = Join-Path $PWD '_site' if (Test-Path $SiteRoot) { Remove-Item -Recurse -Force $SiteRoot } New-Item -ItemType Directory -Path $SiteRoot | Out-Null $DiagRoot = Join-Path $SiteRoot 'diag' New-Item -ItemType Directory -Path $DiagRoot | Out-Null $Diag = Join-Path $DiagRoot "$RunId-$Attempt" New-Item -ItemType Directory -Path $Diag | Out-Null $Artifacts = Join-Path $Diag '_artifacts' New-Item -ItemType Directory -Path $Artifacts | Out-Null # Professional note: .nojekyll keeps GitHub Pages from stripping the leading underscore # directories that we rely on to surface raw diagnostics. New-Item -ItemType File -Path (Join-Path $SiteRoot '.nojekyll') -Force | Out-Null # Professional note: duplicate .nojekyll at the per-run diag root so direct links into # underscore-prefixed folders (e.g., _artifacts) stay browsable when users open the # diagnostics bundle without hitting the site root first. New-Item -ItemType File -Path (Join-Path $Diag '.nojekyll') -Force | Out-Null $short = "${{ github.sha }}" if ($short.Length -gt 7) { $short = $short.Substring(0,7) } echo "SITE=$SiteRoot" >> $env:GITHUB_OUTPUT echo "DIAG=$Diag" >> $env:GITHUB_OUTPUT echo "ARTIFACTS=$Artifacts" >> $env:GITHUB_OUTPUT echo "SHORTSHA=$short" >> $env:GITHUB_OUTPUT - name: Publish run_setup.bat to Pages (guaranteed-CRLF fallback for raw downloads) # derived requirement: docs/open-questions.md item 2 (Option C) -- a second, independent # distribution point for the Prime Directive's own bootstrapper, decoupled entirely from # git blob/raw-URL semantics (unlike raw.githubusercontent.com, a GitHub Pages asset is # not governed by .gitattributes at all -- it is just bytes this job copies). Verifies # CRLF explicitly before publishing rather than trusting checkout alone, so a future # .gitattributes regression (e.g. someone reverting "-text" back to normalization) would # be caught here too, not just by the gating crlf-check job. See README.md's top-of-file # Prime Directive pointer, which documents this URL as the fallback if the raw link ever # misbehaves. continue-on-error: a hiccup publishing this one file must never block the # rest of the diagnostics site from deploying. if: ${{ always() }} continue-on-error: true shell: bash run: | set -euo pipefail python3 tools/check_crlf.py run_setup.bat cp run_setup.bat "${{ steps.prep.outputs.SITE }}/run_setup.bat" echo "Published run_setup.bat to Pages site root." - name: Download iterate logs artifact (if present) if: ${{ always() }} continue-on-error: true uses: actions/download-artifact@v6 with: name: iterate-logs-${{ github.run_id }}-${{ github.run_attempt }} # derived requirement: match the inline helper's artifact name exactly so diagnostics always # fetch the iterate payload and avoid wildcard drift when job/mode prefixes change again. merge-multiple: true path: _iter - name: Show downloaded iterate payload (debug) if: ${{ always() }} shell: bash run: | set -euo pipefail if [ -d "_iter" ]; then echo "_iter present" find _iter -maxdepth 2 -type f -printf "%p (%s bytes)\n" | sort | head -50 else echo "_iter MISSING" fi - name: Normalize iterate artifact layout if: ${{ always() }} shell: bash run: | set -euo pipefail iter_src="_iter" iter_root="${{ steps.prep.outputs.ARTIFACTS }}/iterate" mkdir -p "$iter_root" if [ -d "$iter_src" ]; then shopt -s dotglob nullglob for item in "$iter_src"/*; do base=$(basename "$item") rm -rf "$iter_root/$base" cp -a "$item" "$iter_root/$base" done fi nested_dir="$iter_root/_artifacts/iterate" if [ -d "$nested_dir" ]; then echo "Flattening iterate artifact from $nested_dir" shopt -s dotglob nullglob for item in "$nested_dir"/*; do base=$(basename "$item") rm -rf "$iter_root/$base" mv "$item" "$iter_root/" done rm -rf "$iter_root/_artifacts" fi shopt -s dotglob nullglob entries=("$iter_root"/*) if [ "${#entries[@]}" -eq 1 ] && [ -d "${entries[0]}" ]; then echo "Flattening iterate artifact wrapper ${entries[0]}" for item in "${entries[0]}"/*; do base=$(basename "$item") rm -rf "$iter_root/$base" mv "$item" "$iter_root/" done rm -rf "${entries[0]}" fi # Professional note: ensure the canonical iterate zip lands at the root so the # diagnostics publisher locates it without chasing nested logs/ directories. shopt -s nullglob shopt -s globstar for archive in "$iter_root"/**/*.zip "$iter_root"/*.zip; do [ -f "$archive" ] || continue base=$(basename "$archive") target="$iter_root/$base" if [ "$archive" != "$target" ]; then rm -f "$target" mv "$archive" "$target" fi done # derived requirement: unzip iterate-logs-* immediately so diagnostics expose # _ctx breadcrumbs even when downstream fetches are skipped mid-run. for archive in "$iter_root"/iterate-logs-*.zip; do [ -f "$archive" ] || continue stem=$(basename "$archive" .zip) dest="$iter_root/$stem" rm -rf "$dest" mkdir -p "$dest" if ! unzip -oq "$archive" -d "$dest"; then echo "Warning: failed to extract $archive" >&2 rm -rf "$dest" fi done # derived requirement: download-artifact@v6 nests the uploaded # _artifacts/iterate payload one directory deeper; flatten it so the # diagnostics publisher detects '* Iterate logs: found'. if [ -d "$iter_src" ]; then # derived requirement: run 19232295127-1 proved the workspace copy can # remain empty when normalization short-circuits; fall back to the # freshly downloaded payload so diagnostics mirror the real iterate bundle. if ! find "$iter_root" -mindepth 1 -type f -print -quit | grep -q .; then echo "Mirroring raw iterate download because $iter_root is still empty" cp -a "$iter_src"/. "$iter_root"/ fi fi - name: Mirror iterate logs into diagnostics root if: ${{ always() }} shell: bash run: | set -euo pipefail src="${{ steps.prep.outputs.ARTIFACTS }}/iterate" dst="${{ steps.prep.outputs.DIAG }}/_artifacts/iterate" mkdir -p "$dst" if [ -d "$src" ]; then # derived requirement: publisher consumers expect the diagnostics bundle to mirror # _artifacts/iterate exactly so downstream readers never fall back to the site copy. # Guard against the src/dst alias case (they're both ${{ steps.prep.outputs.ARTIFACTS }}/iterate) # so we do not accidentally clear the freshly normalized iterate payload. if [ "$src" != "$dst" ]; then shopt -s dotglob nullglob rm -rf "$dst"/* cp -a "$src"/. "$dst"/ || true fi fi - name: Download CI NDJSON artifacts if: ${{ always() }} uses: actions/download-artifact@v6 with: pattern: ci_test_results-*-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.prep.outputs.ARTIFACTS }}/batch-check/_ci_artifacts - name: Download CI test logs artifacts if: ${{ always() }} continue-on-error: true uses: actions/download-artifact@v6 with: pattern: test-logs-selftest-*-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ steps.prep.outputs.ARTIFACTS }}/batch-check/test-logs - name: Mirror NDJSON into diagnostics bundle if: ${{ always() }} shell: bash env: RUN_ID: ${{ github.run_id }} RUN_ATTEMPT: ${{ github.run_attempt }} ARTIFACTS_DIR: ${{ steps.prep.outputs.ARTIFACTS }} DIAG_DIR: ${{ steps.prep.outputs.DIAG }} SITE_DIR: ${{ steps.prep.outputs.SITE }} run: | set -euo pipefail # Professional note: Quote "Mirror NDJSON locally for the publisher" so diagnostics reuse # current-run evidence without waiting on remote polling. src_root="$ARTIFACTS_DIR/batch-check/_ci_artifacts" artifacts_root="$ARTIFACTS_DIR/batch-check" diag_root="$DIAG_DIR/_artifacts/batch-check" site_root="$SITE_DIR/_artifacts/batch-check" mkdir -p "$artifacts_root" "$diag_root" "$site_root" shopt -s dotglob nullglob for artifact_dir in "$src_root"/*; do [ -d "$artifact_dir" ] || continue ndjson=$(find "$artifact_dir" -type f -name '*.ndjson' -print -quit) [ -n "$ndjson" ] || continue base=$(basename "$artifact_dir") suffix="${base#ci_test_results-}" suffix="${suffix%-${RUN_ATTEMPT}}" suffix="${suffix%-${RUN_ID}}" target="ci_test_results-${suffix}.ndjson" install -D "$ndjson" "$artifacts_root/$target" install -D "$ndjson" "$diag_root/$target" install -D "$ndjson" "$site_root/$target" done test_logs_src="$ARTIFACTS_DIR/batch-check/test-logs" if find "$test_logs_src" -type f -print -quit >/dev/null 2>&1; then for target_root in "$DIAG_DIR/_artifacts/batch-check/test-logs" "$SITE_DIR/_artifacts/batch-check/test-logs"; do # derived requirement: publish_diag may mirror back into the artifacts tree, so # skip the copy when source and destination resolve to the same directory. real_src=$(realpath "$test_logs_src" 2>/dev/null || echo "$test_logs_src") real_dst=$(realpath "$target_root" 2>/dev/null || echo "$target_root") [ "$real_src" = "$real_dst" ] && continue mkdir -p "$target_root" rm -rf "$target_root"/* cp -a "$test_logs_src"/. "$target_root"/ done fi - name: Package iterate logs archive if: ${{ always() }} shell: bash run: | set -euo pipefail src="${{ steps.prep.outputs.ARTIFACTS }}/iterate" log_dir="${{ steps.prep.outputs.DIAG }}/logs" mkdir -p "$log_dir" zip_path="$log_dir/iterate-${{ github.run_id }}-${{ github.run_attempt }}.zip" rm -f "$zip_path" if [ -d "$src" ]; then shopt -s dotglob nullglob entries=("$src"/*) if [ ${#entries[@]} -gt 0 ]; then # Professional note: diagnostics quick links rely on this archive existing even for # partial iterate payloads; create the zip whenever any staged file is present. ( cd "$src" zip -rq "$zip_path" . ) fi fi - name: Mirror iterate logs into site bundle if: ${{ always() }} shell: pwsh run: | $diagIter = Join-Path "${{ steps.prep.outputs.ARTIFACTS }}" 'iterate' $siteRoot = "${{ steps.prep.outputs.SITE }}" $siteIter = Join-Path $siteRoot '_artifacts/iterate' $publicIterRoot = Join-Path $siteRoot 'iterate' $publicIterDir = Join-Path $publicIterRoot ("iterate-logs-{0}-{1}" -f $env:GITHUB_RUN_ID, $env:GITHUB_RUN_ATTEMPT) if (Test-Path -LiteralPath $diagIter) { New-Item -ItemType Directory -Path $siteIter -Force | Out-Null Get-ChildItem -LiteralPath $diagIter -Force | ForEach-Object { $target = Join-Path $siteIter $_.Name if ($_.PSIsContainer) { Copy-Item -LiteralPath $_.FullName -Destination $siteIter -Recurse -Force } else { Copy-Item -LiteralPath $_.FullName -Destination $target -Force } } New-Item -ItemType Directory -Path $publicIterDir -Force | Out-Null Get-ChildItem -LiteralPath $diagIter -Force | ForEach-Object { $target = Join-Path $publicIterDir $_.Name if ($_.PSIsContainer) { Copy-Item -LiteralPath $_.FullName -Destination $publicIterDir -Recurse -Force } else { Copy-Item -LiteralPath $_.FullName -Destination $target -Force } } } else { New-Item -ItemType Directory -Path $publicIterRoot -Force | Out-Null } $summaryDir = if (Test-Path -LiteralPath $publicIterDir) { $publicIterDir } else { $publicIterRoot } New-Item -ItemType Directory -Path $publicIterRoot -Force | Out-Null $summaryPath = Join-Path $publicIterRoot 'discovery.log.txt' $entries = @() if (Test-Path -LiteralPath $summaryDir) { $files = Get-ChildItem -LiteralPath $summaryDir -Recurse -File -ErrorAction SilentlyContinue foreach ($file in $files) { $relative = [System.IO.Path]::GetRelativePath($summaryDir, $file.FullName) $normalized = $relative.Replace('\', '/') $entries += ("{0} ({1} bytes)" -f $normalized, $file.Length) } } if (-not $entries -or $entries.Count -eq 0) { $entries = @('no iterate files copied') } Set-Content -LiteralPath $summaryPath -Value $entries -Encoding Ascii - name: Record iterate artifact status # derived requirement (CodeRabbit review, PR #471): if "Prep site directories" itself # failed before writing its outputs, $Artifacts below would be empty, and Join-Path # throws on an empty/null Path argument (confirmed directly) rather than degrading # gracefully -- this step would then fail for real instead of recording the sentinel # it exists to write. Fall back to a scratch directory under the workspace so this # step still completes even in that scenario. `!cancelled()`, not `always()`, per # CodeRabbit review and GitHub's own documented guidance (see "Checkout repository"'s # own comment above for why). if: ${{ !cancelled() }} shell: pwsh run: | $Artifacts = "${{ steps.prep.outputs.ARTIFACTS }}" if (-not $Artifacts) { $Artifacts = Join-Path $PWD '_site_prep_failed' } New-Item -ItemType Directory -Path $Artifacts -Force | Out-Null $Iter = Join-Path $Artifacts 'iterate' $sentinel = Join-Path $Artifacts 'MISSING.txt' $iterSentinel = Join-Path $Artifacts 'iterate.MISSING.txt' # Professional note: _artifacts/ mirrors producer payloads exactly; the sentinel documents gaps for external analysts. $files = @() if (Test-Path $Iter) { $files = Get-ChildItem -Path $Iter -Recurse -File -ErrorAction SilentlyContinue } if (-not $files -or $files.Count -eq 0) { "iterate artifact missing or empty" | Out-File -Append -Encoding UTF8 $sentinel "iterate artifact missing or empty" | Out-File -Append -Encoding UTF8 $iterSentinel } - name: Fetch batch-check artifacts # !cancelled(), not always() -- see "Checkout repository"'s own comment above. if: ${{ !cancelled() }} id: fetch_batch shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $ArtifactsRoot = "${{ steps.prep.outputs.ARTIFACTS }}" if (-not $ArtifactsRoot) { $ArtifactsRoot = Join-Path $PWD '_site_prep_failed' } New-Item -ItemType Directory -Path $ArtifactsRoot -Force | Out-Null $BatchRoot = Join-Path $ArtifactsRoot 'batch-check' New-Item -ItemType Directory -Path $BatchRoot -Force | Out-Null $statusPath = Join-Path $BatchRoot 'STATUS.txt' $missingPath = Join-Path $ArtifactsRoot 'MISSING.txt' $localRunJson = Join-Path $BatchRoot 'run.json' if ((Test-Path -LiteralPath $localRunJson) -and (Test-Path -LiteralPath $statusPath)) { # derived requirement: when the publisher already mirrored run.json/STATUS locally, skip the GitHub polling loop entirely. try { $meta = Get-Content -Raw -LiteralPath $localRunJson | ConvertFrom-Json } catch { $meta = $null } if ($meta) { if ($meta.run_id) { "run_id=$($meta.run_id)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append } if ($meta.run_attempt) { "run_attempt=$($meta.run_attempt)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append } if ($meta.html_url) { "run_url=$($meta.html_url)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append } } return } # Professional note: correlate the batch-check workflow by commit SHA so the diagnostics bundle mirrors # the matching self-test run; missing runs append a sentinel entry so downstream readers understand why. $repoParts = "${{ github.repository }}".Split('/') if ($repoParts.Count -ne 2) { 'repository parsing failed' | Set-Content -Encoding UTF8 $statusPath 'batch-check artifact lookup failed: repository parse error' | Out-File -Append -Encoding UTF8 $missingPath return } if (-not $env:GH_TOKEN) { 'GH_TOKEN unavailable; batch-check artifacts skipped.' | Set-Content -Encoding UTF8 $statusPath 'batch-check artifact lookup failed: GH_TOKEN unavailable' | Out-File -Append -Encoding UTF8 $missingPath return } $owner = $repoParts[0] $repo = $repoParts[1] $base = "https://api.github.com/repos/$owner/$repo" $headers = @{ Accept = 'application/vnd.github+json' Authorization = "Bearer $env:GH_TOKEN" 'User-Agent' = 'iterate-publish' } $workflow = $null try { $workflow = Invoke-RestMethod -Uri "$base/actions/workflows/batch-check.yml" -Headers $headers -ErrorAction Stop } catch { $response = $_.Exception.Response $statusCode = $null if ($response -and $response.StatusCode) { $statusCode = [int]$response.StatusCode.value__ } if ($statusCode -eq 404) { # Professional note: fall back to enumerating the workflow catalog so forks with relocated files still resolve batch-check. try { $catalog = Invoke-RestMethod -Uri "$base/actions/workflows" -Headers $headers -ErrorAction Stop $workflow = @($catalog.workflows | Where-Object { $_.path -and $_.path.ToLower().EndsWith('batch-check.yml') })[0] } catch { "workflow lookup failed (fallback enumeration): $($_.Exception.Message)" | Set-Content -Encoding UTF8 $statusPath "batch-check artifact lookup failed: fallback enumeration error: $($_.Exception.Message)" | Out-File -Append -Encoding UTF8 $missingPath return } } else { "workflow lookup failed: $($_.Exception.Message)" | Set-Content -Encoding UTF8 $statusPath "batch-check artifact lookup failed: $($_.Exception.Message)" | Out-File -Append -Encoding UTF8 $missingPath return } } if (-not $workflow) { 'workflow lookup failed: batch-check workflow not found' | Set-Content -Encoding UTF8 $statusPath 'batch-check artifact lookup failed: batch-check workflow not found' | Out-File -Append -Encoding UTF8 $missingPath return } if (-not $workflow.id) { 'workflow id missing' | Set-Content -Encoding UTF8 $statusPath 'batch-check artifact lookup failed: workflow id missing' | Out-File -Append -Encoding UTF8 $missingPath return } $pollSeconds = 10 $deadline = [DateTime]::UtcNow.AddSeconds(60) $headSha = "${{ github.event.pull_request.head.sha || github.sha }}" $run = $null while ([DateTime]::UtcNow -lt $deadline) { try { $runs = Invoke-RestMethod -Uri "$base/actions/workflows/$($workflow.id)/runs?per_page=20&head_sha=$headSha" -Headers $headers -ErrorAction Stop } catch { "list runs failed: $($_.Exception.Message)" | Set-Content -Encoding UTF8 $statusPath return } $candidates = @($runs.workflow_runs | ForEach-Object { $_ }) | Where-Object { $_ -and $_.status -eq 'completed' } if ($candidates.Count -gt 0) { $run = $candidates | Sort-Object run_attempt -Descending | Select-Object -First 1 break } Start-Sleep -Seconds $pollSeconds } if (-not $run) { 'no completed run found before timeout' | Set-Content -Encoding UTF8 $statusPath 'batch-check artifact lookup failed: no completed run for this commit' | Out-File -Append -Encoding UTF8 $missingPath return } $meta = [ordered]@{ workflow_id = $workflow.id run_id = $run.id run_attempt = $run.run_attempt status = $run.status conclusion = $run.conclusion html_url = $run.html_url } $meta | ConvertTo-Json -Depth 6 | Set-Content -Encoding UTF8 (Join-Path $BatchRoot 'run.json') "run_id=$($run.id)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append "run_attempt=$($run.run_attempt)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append if ($run.html_url) { "run_url=$($run.html_url)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding ascii -Append } try { $artifacts = Invoke-RestMethod -Uri "$base/actions/runs/$($run.id)/artifacts?per_page=100" -Headers $headers -ErrorAction Stop } catch { "artifact list failed: $($_.Exception.Message)" | Set-Content -Encoding UTF8 $statusPath return } if (-not $artifacts.artifacts) { 'no artifacts published by batch-check' | Set-Content -Encoding UTF8 $statusPath 'batch-check artifact lookup failed: no artifacts in run' | Out-File -Append -Encoding UTF8 $missingPath return } Remove-Item -LiteralPath $statusPath -ErrorAction SilentlyContinue foreach ($artifact in $artifacts.artifacts) { if (-not $artifact -or $artifact.expired) { continue } $safeName = ($artifact.name -replace '[^A-Za-z0-9_.-]', '_') $dest = Join-Path $BatchRoot $safeName New-Item -ItemType Directory -Path $dest -Force | Out-Null $zipPath = Join-Path $env:RUNNER_TEMP ("batch-" + $artifact.id + '.zip') try { Invoke-WebRequest -Uri $artifact.archive_download_url -Headers $headers -OutFile $zipPath -ErrorAction Stop Expand-Archive -LiteralPath $zipPath -DestinationPath $dest -Force } catch { "download failed: $($artifact.name) -> $($_.Exception.Message)" | Out-File -Append -Encoding UTF8 $statusPath } finally { Remove-Item -LiteralPath $zipPath -ErrorAction SilentlyContinue } } - name: Summarize failing tests if: ${{ always() }} shell: bash env: DIAG: ${{ steps.prep.outputs.DIAG }} run: | python tools/diag/ndjson_fail_list.py - name: Download workflow logs if: ${{ always() }} shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $Diag = "${{ steps.prep.outputs.DIAG }}" $repoParts = "${{ github.repository }}".Split('/') if ($repoParts.Count -ne 2) { return } if (-not $env:GH_TOKEN) { 'GH_TOKEN unavailable; workflow logs skipped.' | Set-Content -Encoding UTF8 (Join-Path $Diag 'logs-note.txt') return } $owner = $repoParts[0] $repo = $repoParts[1] $headers = @{ Accept = 'application/vnd.github+json' Authorization = "Bearer $env:GH_TOKEN" 'User-Agent' = 'iterate-publish' } # Professional note: maintainers requested "add a short retry loop ... before writing # the missing sentinel", so we wrap Invoke-WebRequest with a bounded retry helper. function Download-WithRetry { param( [Parameter(Mandatory=$true)][string]$Uri, [Parameter(Mandatory=$true)][string]$Destination, [Parameter(Mandatory=$true)][hashtable]$Headers, [int]$Attempts = 3, [int]$DelaySeconds = 5 ) for ($i = 1; $i -le $Attempts; $i++) { try { Invoke-WebRequest -Uri $Uri -Headers $Headers -OutFile $Destination -ErrorAction Stop return $true } catch { if ($i -ge $Attempts) { return $false } Start-Sleep -Seconds $DelaySeconds } } } $logDir = Join-Path $Diag 'logs' New-Item -ItemType Directory -Path $logDir -Force | Out-Null $iterateZip = Join-Path $logDir ("iterate-${{ github.run_id }}-${{ github.run_attempt }}.zip") $iterateSentinel = Join-Path $logDir 'iterate.MISSING.txt' $iterateError = Join-Path $logDir 'iterate-log-error.txt' # Professional note: prefer the staged iterate artifact; retain the run-log fallback only when # the archive failed to materialize so unauthenticated readers still see an explanation. $needDownload = $true if (Test-Path $iterateZip) { $info = Get-Item -LiteralPath $iterateZip -ErrorAction SilentlyContinue if ($info -and $info.Length -gt 0) { $needDownload = $false } } if ($needDownload) { $iterateSource = Join-Path $Diag '_artifacts/iterate' $hasPayload = $false if (Test-Path $iterateSource) { $probe = Get-ChildItem -Path $iterateSource -Recurse -Force -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($probe) { $hasPayload = $true } } if ($hasPayload) { try { Remove-Item -LiteralPath $iterateZip -ErrorAction SilentlyContinue Compress-Archive -Path (Join-Path $iterateSource '*') -DestinationPath $iterateZip -Force $info = Get-Item -LiteralPath $iterateZip -ErrorAction Stop if ($info -and $info.Length -gt 0) { $needDownload = $false Remove-Item -LiteralPath $iterateSentinel -ErrorAction SilentlyContinue Remove-Item -LiteralPath $iterateError -ErrorAction SilentlyContinue } else { Remove-Item -LiteralPath $iterateZip -ErrorAction SilentlyContinue } } catch { Remove-Item -LiteralPath $iterateZip -ErrorAction SilentlyContinue } } } if ($needDownload) { Remove-Item -LiteralPath $iterateZip -ErrorAction SilentlyContinue $iterateLogUri = "https://api.github.com/repos/$owner/$repo/actions/runs/${{ github.run_id }}/logs" if (-not (Download-WithRetry -Uri $iterateLogUri -Destination $iterateZip -Headers $headers)) { "iterate log download failed after retries: $iterateLogUri" | Out-File -Encoding UTF8 -FilePath $iterateError } if (-not (Test-Path $iterateZip)) { 'iterate log archive missing' | Set-Content -Encoding UTF8 $iterateSentinel } else { $info = Get-Item -LiteralPath $iterateZip if (-not $info -or $info.Length -eq 0) { 'iterate log archive empty' | Set-Content -Encoding UTF8 $iterateSentinel } else { Remove-Item -LiteralPath $iterateError -ErrorAction SilentlyContinue } } } $batchRunId = "${{ steps.fetch_batch.outputs.run_id }}" if ($batchRunId) { $batchAttempt = "${{ steps.fetch_batch.outputs.run_attempt }}" if (-not $batchAttempt) { $batchAttempt = '1' } $batchZip = Join-Path $logDir ("batch-check-$batchRunId-$batchAttempt.zip") $batchSentinel = Join-Path $logDir 'batch-check.MISSING.txt' $batchLogUri = "https://api.github.com/repos/$owner/$repo/actions/runs/$batchRunId/logs" if (-not (Download-WithRetry -Uri $batchLogUri -Destination $batchZip -Headers $headers)) { "batch-check log download failed after retries: $batchLogUri" | Out-File -Encoding UTF8 -FilePath (Join-Path $logDir 'batch-check-log-error.txt') } if (-not (Test-Path $batchZip)) { 'batch-check log archive missing' | Set-Content -Encoding UTF8 $batchSentinel } else { $bInfo = Get-Item -LiteralPath $batchZip if (-not $bInfo -or $bInfo.Length -eq 0) { 'batch-check log archive empty' | Set-Content -Encoding UTF8 $batchSentinel } } } else { $missing = Join-Path $logDir 'batch-check.MISSING.txt' 'batch-check logs not located for this commit' | Set-Content -Encoding UTF8 $missing } - name: Capture repository snapshot if: ${{ always() }} shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $Diag = "${{ steps.prep.outputs.DIAG }}" $short = "${{ steps.prep.outputs.SHORTSHA }}" if (-not $short) { $short = "${{ github.sha }}" if ($short.Length -gt 7) { $short = $short.Substring(0,7) } } git ls-tree -r --name-only HEAD | Set-Content -Encoding UTF8 (Join-Path $Diag 'repo-tree.txt') $repoDir = Join-Path $Diag 'repo' New-Item -ItemType Directory -Path $repoDir -Force | Out-Null $repoZip = Join-Path $repoDir ("repo-$short.zip") $repoMissing = Join-Path $repoDir 'repo.MISSING.txt' # Professional note: the repo/ folder mirrors GitHub's zipball for this commit so analysts can download # an authenticated snapshot without signing into Actions. $parts = "${{ github.repository }}".Split('/') if ($parts.Count -eq 2 -and $env:GH_TOKEN) { $owner = $parts[0] $repo = $parts[1] $headers = @{ Accept = 'application/vnd.github+json' Authorization = "Bearer $env:GH_TOKEN" 'User-Agent' = 'iterate-publish' } $zipUrl = "https://api.github.com/repos/$owner/$repo/zipball/${{ github.sha }}" try { Invoke-WebRequest -Uri $zipUrl -Headers $headers -OutFile $repoZip -ErrorAction Stop } catch { "repo zip download failed: $($_.Exception.Message)" | Set-Content -Encoding UTF8 $repoMissing } } else { 'repo zip download skipped: missing GH token or repo metadata' | Set-Content -Encoding UTF8 $repoMissing } if (Test-Path $repoZip) { $repoExtract = Join-Path $repoDir 'files' if (Test-Path $repoExtract) { Remove-Item -LiteralPath $repoExtract -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $repoExtract -Force | Out-Null try { # Professional note: maintainers asked for "an unzipped set alongside/sub page" so analysts can # browse without downloading the archive; we unpack the commit zip alongside the original bundle. Expand-Archive -LiteralPath $repoZip -DestinationPath $repoExtract -Force } catch { "repo unzip failed: $($_.Exception.Message)" | Set-Content -Encoding UTF8 (Join-Path $repoDir 'repo-unpack-error.txt') } } Get-ChildItem Env: | Sort-Object Name | ForEach-Object { if ($_.Name -notmatch 'TOKEN|SECRET|KEY|PASSWORD|COOKIE') { '{0}={1}' -f $_.Name, $_.Value } } | Set-Content -Encoding UTF8 (Join-Path $Diag 'env.txt') if (Test-Path '.github\workflows') { $wfDest = Join-Path $Diag '.github\workflows' New-Item -ItemType Directory -Path $wfDest -Force | Out-Null Copy-Item -Recurse -Force '.github\workflows\*' $wfDest $wfCopyDir = Join-Path $Diag 'wf' New-Item -ItemType Directory -Path $wfCopyDir -Force | Out-Null Get-ChildItem -Path '.github\workflows' -Filter *.yml -File -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item -Force $_.FullName (Join-Path $wfCopyDir $_.Name) Copy-Item -Force $_.FullName (Join-Path $wfCopyDir ($_.Name + '.txt')) } } - name: Inventory collected files if: ${{ always() }} id: inventory shell: pwsh run: | $Diag = "${{ steps.prep.outputs.DIAG }}" $items = Get-ChildItem -Path $Diag -Recurse -File -Force -ErrorAction SilentlyContinue if (-not $items) { $items = @() } $manifestFiles = @() foreach ($item in $items) { $relative = $item.FullName.Substring($Diag.Length + 1).Replace('\','/') $manifestFiles += [PSCustomObject]@{ path = $relative size = $item.Length sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $item.FullName).Hash modified_utc = $item.LastWriteTimeUtc.ToString('o') } } $manifest = [PSCustomObject]@{ run_id = '${{ github.run_id }}' run_attempt = '${{ github.run_attempt }}' sha = '${{ github.sha }}' generated_utc = (Get-Date).ToUniversalTime().ToString('o') files = $manifestFiles } $json = $manifest | ConvertTo-Json -Depth 6 $json | Set-Content -Encoding UTF8 (Join-Path $Diag 'inventory.json') try { $manifestObj = $json | ConvertFrom-Json -Depth 6 } catch { $manifestObj = $manifest } if (-not $manifestObj) { $manifestObj = $manifest } $filesForRender = @() if ($manifestObj -and $manifestObj.files) { $filesForRender = @($manifestObj.files) } # derived requirement: inventory.json went blank while HTML/TXT stayed populated; derive the mirrors from the parsed # JSON payload so all formats stay in sync with the canonical manifest. $bulletLines = [System.Collections.Generic.List[string]]::new() $plainLines = [System.Collections.Generic.List[string]]::new() $htmlLines = [System.Collections.Generic.List[string]]::new() foreach ($seed in @('', '', 'Artifact inventory', '

Artifact inventory

    ')) { $null = $htmlLines.Add($seed) } foreach ($entry in ($filesForRender | Sort-Object path)) { $relative = $entry.path if (-not $relative) { continue } $sizeValue = 0 if ($entry.size -ne $null) { [void][int64]::TryParse($entry.size.ToString(), [ref]$sizeValue) } $sizeText = ('{0:N0}' -f $sizeValue) $safe = $relative -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' $null = $bulletLines.Add(('- {0} bytes - `{1}`' -f $sizeText, $relative)) $null = $plainLines.Add(('{0} bytes{1}{2}' -f $sizeText, [char]9, $relative)) $null = $htmlLines.Add("
  • $safe ($sizeText bytes)
  • ") } if ($bulletLines.Count -eq 0) { $null = $bulletLines.Add('- (no files captured)') $null = $plainLines.Add('no files captured') $null = $htmlLines.Add('
  • (no files captured)
  • ') } $null = $htmlLines.Add('
') $joined = $bulletLines.ToArray() -join "`n" $joined | Set-Content -Encoding UTF8 (Join-Path $Diag 'inventory.md') ($plainLines.ToArray() -join "`n") | Set-Content -Encoding UTF8 (Join-Path $Diag 'inventory.txt') ($htmlLines.ToArray() -join "`n") | Set-Content -Encoding UTF8 (Join-Path $Diag 'inventory.html') # Note: the inventory is consumed by publish_index.py from inventory.md on disk. # We deliberately do NOT emit an inventory_b64 step output: a large value routed # through the publish step's env overflows execve (E2BIG) when bash launches. - name: Write batch-check metadata if: ${{ always() }} shell: bash env: ARTIFACTS_ROOT: ${{ steps.prep.outputs.ARTIFACTS }} RUN_ID: ${{ github.run_id }} RUN_ATTEMPT: ${{ github.run_attempt }} RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} JOB_CONCLUSION: ${{ job.status }} run: | set -euo pipefail root="${ARTIFACTS_ROOT}/batch-check" mkdir -p "$root" # Professional note: per "add a final always() step that writes ... STATUS.txt = 'completed'", # keep the sentinel truthful for the current run rather than leaving the remote timeout. printf 'completed\n' > "$root/STATUS.txt" cat > "$root/run.json" </inventory.md. BATCH_RUN_ID: ${{ steps.fetch_batch.outputs.run_id }} BATCH_RUN_ATTEMPT: ${{ steps.fetch_batch.outputs.run_attempt }} SITE: ${{ steps.prep.outputs.SITE }} run: | python tools/diag/publish_index.py \ --run-id "${{ github.run_id }}" \ --run-attempt "${{ github.run_attempt }}" \ --out-dir "_site" - name: Append job summary # !cancelled(), not always() -- see "Checkout repository"'s own comment above. if: ${{ !cancelled() }} shell: pwsh run: | $Run = "${{ github.run_id }}" $Att = "${{ github.run_attempt }}" $ownerRepo = "${{ github.repository }}" $parts = $ownerRepo.Split('/') if ($parts.Count -eq 2) { $bundle = "https://$($parts[0]).github.io/$($parts[1])/diag/$Run-$Att/" "### Diagnostics bundle" | Out-File -Append $env:GITHUB_STEP_SUMMARY "- [$bundle]($bundle)" | Out-File -Append $env:GITHUB_STEP_SUMMARY $cacheBusted = "${bundle}index.html?v=$Run-$Att" "- Diagnostics site (cache-busted): $cacheBusted" | Out-File -Append $env:GITHUB_STEP_SUMMARY } $coveragePath = Join-Path $PWD "_site" "diag" "$Run-$Att" "_artifacts" "req_coverage.json" if (Test-Path $coveragePath) { "## Requirement Coverage" | Out-File -Append $env:GITHUB_STEP_SUMMARY $cov = Get-Content -Raw $coveragePath | ConvertFrom-Json $emojiMap = @{ pass = ([char]0x2705).ToString(); fail = ([char]0x274C).ToString() } foreach ($prop in ($cov.PSObject.Properties | Sort-Object Name)) { $emoji = if ($emojiMap.ContainsKey($prop.Value)) { $emojiMap[$prop.Value] } else { ([char]0x26A0).ToString() } "- $($prop.Name) $emoji" | Out-File -Append $env:GITHUB_STEP_SUMMARY } } - uses: actions/configure-pages@v6 if: ${{ github.event_name != 'pull_request' }} - name: Upload Pages artifact if: ${{ github.event_name != 'pull_request' }} uses: actions/upload-pages-artifact@v5 with: path: ${{ steps.prep.outputs.SITE }} - name: Deploy to GitHub Pages (attempt 1) if: ${{ github.event_name != 'pull_request' }} id: deploy1 continue-on-error: true uses: actions/deploy-pages@v5 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Wait before Pages deploy retry (short backoff) # derived requirement: GitHub Pages deployment backend occasionally returns a transient # "Deployment failed, try again later" error (observed directly in this repo's own CI, # e.g. run 28798318708) that a short wait usually resolves. Mirrors the existing # detect-transient-failure-then-retry pattern already used for conda create/bulk install. if: ${{ github.event_name != 'pull_request' && steps.deploy1.outcome == 'failure' }} shell: bash run: sleep 30 - name: Deploy to GitHub Pages (attempt 2) if: ${{ github.event_name != 'pull_request' && steps.deploy1.outcome == 'failure' }} id: deploy2 continue-on-error: true uses: actions/deploy-pages@v5 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Wait before Pages deploy retry (long backoff) # A second consecutive failure suggests a longer-lived backend issue rather than a # one-off blip; wait substantially longer (20 min) to give it real room to recover # before the final attempt, rather than hammering the API on a short cadence. This job # is non-gating and has concurrency.cancel-in-progress set on this ref (see the job's # concurrency block above), so a sleeping attempt is safely superseded, not wasted, if a # newer push arrives in the meantime. if: ${{ github.event_name != 'pull_request' && steps.deploy1.outcome == 'failure' && steps.deploy2.outcome == 'failure' }} shell: bash run: sleep 1200 - name: Deploy to GitHub Pages (attempt 3, final) if: ${{ github.event_name != 'pull_request' && steps.deploy1.outcome == 'failure' && steps.deploy2.outcome == 'failure' }} id: deploy3 uses: actions/deploy-pages@v5 with: token: ${{ secrets.GITHUB_TOKEN }}