From 9ed5be1db8c4eac46ffc5b6d25715cec95c56ca5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:17:44 -0400 Subject: [PATCH 1/3] The at-the-cap aggregate test waits for the scheduler to go idle before the sweep (#5603) The live quiesce tests that need a running job worker now start it through one helper that returns only when the worker is blocked on the gate and the scheduler has finished the wake that launched it, so the scheduler's next wake is about 5 s away while the sweep and its assertions run. The product comments that said the scheduler ends a worker 'within a few seconds' now state the measured timing. --- .../Darling.Tests/AggregateJobQuiesceTests.cs | 114 ++++++++++++++---- .../TimescaleSupport.cs | 13 +- 2 files changed, 99 insertions(+), 28 deletions(-) diff --git a/Darling/Darling.Tests/AggregateJobQuiesceTests.cs b/Darling/Darling.Tests/AggregateJobQuiesceTests.cs index 357f55971e..475b73eee6 100644 --- a/Darling/Darling.Tests/AggregateJobQuiesceTests.cs +++ b/Darling/Darling.Tests/AggregateJobQuiesceTests.cs @@ -17,6 +17,7 @@ using Darling.Tests; using Microsoft.Extensions.Logging; using Npgsql; +using NpgsqlTypes; using PerformanceMonitor.Darling.Storage; using Xunit; @@ -264,8 +265,19 @@ await Assert.ThrowsAnyAsync(() => TimescaleSupport.R /// The worker is made to run deterministically: the aggregate selects through an IMMUTABLE-labelled /// function that takes a shared advisory lock, and the test holds the exclusive one, so the refresh job the scheduler /// launches sits in pg_stat_activity as Refresh Continuous Aggregate Policy [id] until the test -/// lets it go. The scheduler is left running (nothing here calls stop_background_workers); every policy starts a -/// day out so none fires on its own. +/// lets it go. Every policy starts a day out so none fires on its own. +/// +/// The scheduler (#5603). It is left running: a job worker does not outlive it (measured: +/// _timescaledb_functions.stop_background_workers() ends a parked worker within a millisecond), and it is what +/// ends a worker whose job was stopped. Measured on PostgreSQL 18 with TimescaleDB 2.30.1: the scheduler wakes on a +/// timer 5.005 s apart, and each wake shows in its own pg_stat_activity row (state goes active +/// then idle and state_change moves). A job is launched, and a stopped job is noticed, only at a wake. +/// At the wake that sees a stopped job it cancels the worker (SIGINT), waits 3 s, then terminates it (SIGTERM). So a +/// worker is safe from the scheduler for most of the 5 s after the wake that launched it, but that wake is still +/// running when the worker first shows up, and a stop that commits before it ends is acted on at once (in the #5603 CI +/// failure the worker was cancelled about 30 ms after the stop). therefore returns +/// only once the worker is blocked on the gate AND the scheduler is idle again after launching it: the next wake is +/// then about 4.7 s away, and the sweep and every assertion about the worker run inside that gap. /// public sealed class AggregateJobQuiesceLiveTests { @@ -274,7 +286,9 @@ public sealed class AggregateJobQuiesceLiveTests private static readonly TimescaleSupport.AggregateJobQuiesceOptions FastCap = new(TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(100)); - /// One look and no wait: the skip path of (c) runs before the scheduler can end the worker. + /// One look and no wait: the skip path of (c) is decided by the single look the sweep takes right after the + /// stop, so the whole sweep takes milliseconds and is over long before the scheduler's next wake (see the class + /// remarks, #5603). private static readonly TimescaleSupport.AggregateJobQuiesceOptions ZeroCap = new(TimeSpan.Zero, TimeSpan.FromMilliseconds(50)); /// (a) The jobs are stopped before the drop, and the drop still removes the aggregate and its jobs. @@ -330,16 +344,13 @@ public async Task RunningJobWorker_HoldsTheDrop_UntilItExits_AgainstDevPostgres( var gateOpen = false; try { - await StartRefreshWorkerAsync(connection, refreshJob, ct); - Assert.True(await WaitForAsync(async () => await WorkerCountAsync(connection, jobIds, ct) > 0, TimeSpan.FromSeconds(60), ct), - "the scheduler never started the refresh worker"); + var worker = await StartParkedWorkerAsync(connection, refreshJob, ct); + var workerPid = worker.Pid; var log = new CapturingTestLogger(); sweep = Task.Run(() => TimescaleSupport.DropRetiredBaselineAggregatesAsync( sweeper, log, DateTime.UtcNow, new TimescaleSupport.AggregateJobQuiesceOptions(TimeSpan.FromSeconds(120), TimeSpan.FromMilliseconds(100)), ct), ct); - var workerPid = await ScalarAsync(connection, $"SELECT pid FROM pg_stat_activity WHERE datname = current_database() AND backend_type LIKE '% [{refreshJob}]'", ct); - /* The jobs are stopped while the worker runs... */ await WaitForJobsStoppedAsync(connection, jobIds, log, ct); @@ -350,10 +361,9 @@ public async Task RunningJobWorker_HoldsTheDrop_UntilItExits_AgainstDevPostgres( has not removed the view yet. Each look reads the worker, then the sweeper's query, then the worker again, and judges only when the same worker was alive on both sides. The statement text is matched by its start (DO $do$) as well as DROP MATERIALIZED VIEW, because track_activity_query_size cuts the long - DO block short. TimescaleDB's own scheduler may end a worker whose job was just stopped (measured at - about 3-4 s): then nothing is left to hold the sweep, and the test only passes if it OBSERVED the hold - first - at least one look with the worker alive and the sweeper inside its wait loop (its last - statement is the worker count). Looked at for two seconds. */ + DO block short. The scheduler cannot end the worker inside this loop (#5603: it is idle for about 4.7 s + after StartParkedWorkerAsync), so the hold must be OBSERVED: at least one look with the worker alive and + the sweeper inside its wait loop (its last statement is the worker count). Looked at for two seconds. */ var sweeperPid = sweeper.ProcessID; var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); var observedHold = false; @@ -379,7 +389,7 @@ its start (DO $do$) as well as DROP MATERIALIZED VIEW, because track_activity_qu if (!observedHold) { - Assert.Skip($"TimescaleDB ended the job worker before the hold could be observed, so this run proves nothing about the wait: {log.Joined}"); + Assert.Fail($"the hold was never observed: the job worker (pid {workerPid}) was gone, or the sweeper was not waiting on it, within 2 s. The scheduler {(await SchedulerWokeSinceAsync(connection, worker.SchedulerIdleSince, ct) ? "HAD" : "had not")} woken since the worker was parked: {log.Joined}"); } await ExecuteAsync(gate, $"SELECT pg_advisory_unlock({GateKey})", ct); @@ -426,15 +436,14 @@ public async Task AtTheCap_TheJobsAreScheduledAgain_TheAggregateStays_AndTheNext var gateOpen = false; try { - await StartRefreshWorkerAsync(connection, refreshJob, ct); - Assert.True(await WaitForAsync(async () => await WorkerCountAsync(connection, jobIds, ct) > 0, TimeSpan.FromSeconds(60), ct), - "the scheduler never started the refresh worker"); - var workerPid = await ScalarAsync(connection, $"SELECT pid FROM pg_stat_activity WHERE datname = current_database() AND backend_type LIKE '% [{refreshJob}]'", ct); + var worker = await StartParkedWorkerAsync(connection, refreshJob, ct); + var workerPid = worker.Pid; var log = new CapturingTestLogger(); /* A zero cap: the first look sees the worker, so the skip and the resume follow within milliseconds of the - stop. TimescaleDB's scheduler ends a worker whose job was stopped within a few seconds (measured at - 3-4 s), and a cap of seconds could let that land inside the wait, which would turn this into a drop. */ + stop. The worker is parked on the gate and the scheduler is idle until a wake about 4.7 s away + (StartParkedWorkerAsync, #5603), so nothing but this sweep can touch the worker until the assertions + below are done; a cap of seconds would let that wake land inside the wait and turn this into a drop. */ Assert.Equal(0, await TimescaleSupport.DropRetiredBaselineAggregatesAsync(connection, log, DateTime.UtcNow, ZeroCap, ct)); Assert.True(await RelationExistsAsync(connection, Retired, ct), "the aggregate stays at the cap"); @@ -442,7 +451,11 @@ public async Task AtTheCap_TheJobsAreScheduledAgain_TheAggregateStays_AndTheNext Assert.Equal(1, log.CountAtLevel(LogLevel.Warning)); Assert.Contains(log.Lines, l => l.StartsWith("Warning:", StringComparison.Ordinal) && l.Contains(Retired, StringComparison.Ordinal) && l.Contains("#5551", StringComparison.Ordinal)); /* The sweep itself ended nothing: the very same backend is still running. */ - Assert.Equal(1L, await ScalarAsync(connection, $"SELECT count(*) FROM pg_stat_activity WHERE pid = {workerPid} AND backend_type LIKE '% [{refreshJob}]'", ct)); + var stillRunning = await ScalarAsync(connection, $"SELECT count(*) FROM pg_stat_activity WHERE pid = {workerPid} AND backend_type LIKE '% [{refreshJob}]'", ct); + if (stillRunning != 1L) + { + Assert.Fail($"the job worker (pid {workerPid}) the sweep must leave running is gone. The scheduler {(await SchedulerWokeSinceAsync(connection, worker.SchedulerIdleSince, ct) ? "HAD" : "had not")} woken since the worker was parked: {log.Joined}"); + } await ExecuteAsync(gate, $"SELECT pg_advisory_unlock({GateKey})", ct); gateOpen = true; @@ -487,9 +500,7 @@ public async Task CancelledWhileWaiting_SchedulesTheJobsAgain_OnATokenOfItsOwn_A using var cancel = CancellationTokenSource.CreateLinkedTokenSource(ct); try { - await StartRefreshWorkerAsync(connection, refreshJob, ct); - Assert.True(await WaitForAsync(async () => await WorkerCountAsync(connection, jobIds, ct) > 0, TimeSpan.FromSeconds(60), ct), - "the scheduler never started the refresh worker"); + await StartParkedWorkerAsync(connection, refreshJob, ct); var log = new CapturingTestLogger(); var sweep = Task.Run(() => TimescaleSupport.DropRetiredBaselineAggregatesAsync( @@ -755,6 +766,63 @@ IF EXISTS (SELECT 1 FROM pg_event_trigger_dropped_objects() WHERE object_name = private static async Task StartRefreshWorkerAsync(NpgsqlConnection connection, int refreshJob, CancellationToken ct) => await ExecuteAsync(connection, $"SELECT alter_job({refreshJob}, next_start => now(), scheduled => true)", ct); + private const string SchedulerBackend = "TimescaleDB Background Worker Scheduler"; + + /// A refresh worker parked on the gate, and the state_change of the scheduler's idle state after the + /// wake that launched it (the scheduler's next wake is about 5 s after that wake began). + private readonly record struct ParkedWorker(int Pid, DateTime SchedulerIdleSince); + + /// + /// #5603: starts the refresh worker and returns only when it is in the one state a test about a RUNNING worker can + /// rely on. (1) It is listed in pg_stat_activity. (2) It is BLOCKED on the gate: its pg_locks row for + /// the shared advisory key is not granted, matched by the worker's pid. Listed is not enough: a listed worker can + /// still be in the DELETE that opens the refresh. (3) The scheduler is idle again after launching it (its + /// pg_stat_activity row is idle with a state_change later than the moment the job was made + /// due). The scheduler launches a job and notices a stopped job only at a wake, and the wake that launched the + /// worker is still running when the worker first shows up; a stop that committed inside it was acted on at once + /// (cancelled about 30 ms later in the CI failure). Once it has ended the next wake is about 5 s after it began, + /// so everything the caller does next, a sweep and its assertions, finishes inside that gap. + /// + private static async Task StartParkedWorkerAsync(NpgsqlConnection connection, int refreshJob, CancellationToken ct) + { + var madeDueAt = await ScalarAsync(connection, "SELECT clock_timestamp()", ct); + await StartRefreshWorkerAsync(connection, refreshJob, ct); + + var pid = 0; + Assert.True( + await WaitForAsync(async () => (pid = await ScalarAsync(connection, $"SELECT coalesce(max(pid), 0) FROM pg_stat_activity WHERE datname = current_database() AND backend_type LIKE '% [{refreshJob}]'", ct)) != 0, TimeSpan.FromSeconds(60), ct), + "the scheduler never started the refresh worker"); + + Assert.True( + await WaitForAsync(async () => await ScalarAsync(connection, $"SELECT count(*) FROM pg_locks WHERE pid = {pid} AND locktype = 'advisory' AND NOT granted AND classid = {GateKey >> 32} AND objid = {GateKey & 0xFFFFFFFFL} AND objsubid = 1", ct) > 0, TimeSpan.FromSeconds(30), ct), + $"the refresh worker (pid {pid}) never blocked on the gate"); + + DateTime? idleSince = null; + Assert.True( + await WaitForAsync(async () => (idleSince = await SchedulerIdleSinceAsync(connection, madeDueAt, ct)) is not null, TimeSpan.FromSeconds(30), ct), + "the scheduler never went idle again after launching the refresh worker (its pg_stat_activity row did not move)"); + return new ParkedWorker(pid, idleSince!.Value); + } + + /// The scheduler's state_change when it is idle and went idle after , else null. + private static async Task SchedulerIdleSinceAsync(NpgsqlConnection connection, DateTime after, CancellationToken ct) + { + await using var command = new NpgsqlCommand( + $"SELECT state_change FROM pg_stat_activity WHERE datname = current_database() AND backend_type = '{SchedulerBackend}' AND state = 'idle' AND state_change > $1", connection) { CommandTimeout = 120 }; + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.TimestampTz, Value = after }); + return await command.ExecuteScalarAsync(ct) is DateTime changed ? changed : null; + } + + /// True when the scheduler has woken since it went idle at : it is running now, + /// or went idle again later. Read only after a failure, to say whether the scheduler could have ended the worker. + private static async Task SchedulerWokeSinceAsync(NpgsqlConnection connection, DateTime idleSince, CancellationToken ct) + { + await using var command = new NpgsqlCommand( + $"SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND backend_type = '{SchedulerBackend}' AND state = 'idle' AND state_change <= $1", connection) { CommandTimeout = 120 }; + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.TimestampTz, Value = idleSince }); + return (long)(await command.ExecuteScalarAsync(ct))! == 0; + } + private static async Task> JobIdsAsync(NpgsqlConnection connection, string view, CancellationToken ct) { var ids = new List(); diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index 4cdcbcedd1..b307dde6a8 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -1418,9 +1418,11 @@ public sealed record AggregateJobQuiesceOptions(TimeSpan Cap, TimeSpan PollInter /// 5 minutes for every step (DarlingWorker's s_storeObjectConvergenceBudget). A drop happens once /// per store, on the pass that retires or reshapes that aggregate, and only a refresh that is ALREADY running /// when the jobs are stopped is waited for (stopping the jobs first means no new one starts). TimescaleDB's - /// scheduler usually ends such a worker itself within a few seconds of the stop (measured: about 3-4 s; - /// the worker's refresh rolls back), and one that finishes first is just as good, so the wait is a few - /// seconds for the baseline and reshape aggregates, which are small or only a day or two old. 30 seconds + /// scheduler ends such a worker itself at its next wake (measured on 2.30.1: it wakes on a timer 5.005 s + /// apart, so that is anywhere from tens of milliseconds to about 5 s after the stop; it cancels the worker, + /// and terminates one that has not left 3 s later, so 8 s at most; the worker's refresh rolls back), and one + /// that finishes first is just as good, so the wait is a few seconds for the baseline and reshape + /// aggregates, which are small or only a day or two old. 30 seconds /// covers that with room, and it is a cap a start can afford. The worst case is every /// relation of both sweeps (two superseded, two retired, four reshaped = eight) stuck behind a running /// worker, 8 x 30 s = 4 minutes, which still fits inside the hourly pass's 5. A longer cap would let a stuck @@ -1527,8 +1529,9 @@ FROM pg_stat_activity AS a /// pass. A final look that lists none lets the drop go ahead. /// /// This method does not cancel or terminate a backend itself, but stopping a job makes TimescaleDB's - /// scheduler end that job's running worker (measured on TimescaleDB 2.30.1: within a few seconds of the - /// stop). The refresh then rolls back, which is harmless to the data, the server log records the worker's error + /// scheduler end that job's running worker (measured on TimescaleDB 2.30.1: at the scheduler's next wake, + /// which comes every 5.005 s, so from tens of milliseconds to about 5 s after the stop; it cancels the worker and + /// terminates it if it has not left 3 s later). The refresh then rolls back, which is harmless to the data, the server log records the worker's error /// ("job N threw an error"), and the job's statistics record one failed run (total_failures + 1, /// last_run_status = Failed). After a successful drop the job and its statistics row are gone with the /// aggregate; on the skip and failed-drop paths the job stays, scheduled again, with that failure on its record From b876a5263a921f8ed774b9c67870ecc36388d1bd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:38:42 -0400 Subject: [PATCH 2/3] Proof run for #5603: repeat AggregateJobQuiesceLiveTests under CPU stress (throwaway, never merged) --- .github/workflows/build.yml | 3108 +-------------------- .github/workflows/check-pr-branch.yml | 21 - .github/workflows/check-version-bump.yml | 94 - .github/workflows/claude-review-guard.yml | 754 ----- .github/workflows/claude-review.yml | 993 ------- .github/workflows/description-drift.yml | 90 - .github/workflows/nightly.yml | 1595 ----------- .github/workflows/probe-vpk-signing.yml | 141 - .github/workflows/release-signatures.yml | 63 - .github/workflows/runtime-currency.yml | 44 - .github/workflows/sql-validation.yml | 117 - 11 files changed, 50 insertions(+), 6970 deletions(-) delete mode 100644 .github/workflows/check-pr-branch.yml delete mode 100644 .github/workflows/check-version-bump.yml delete mode 100644 .github/workflows/claude-review-guard.yml delete mode 100644 .github/workflows/claude-review.yml delete mode 100644 .github/workflows/description-drift.yml delete mode 100644 .github/workflows/nightly.yml delete mode 100644 .github/workflows/probe-vpk-signing.yml delete mode 100644 .github/workflows/release-signatures.yml delete mode 100644 .github/workflows/runtime-currency.yml delete mode 100644 .github/workflows/sql-validation.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3d9740d48..4902086679 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,1575 +1,38 @@ name: Build +# Throwaway proof workflow for #5603 (closed unmerged). The setup steps below are copied from the +# darling-pg job of .github/workflows/build.yml: same runner, same PostgreSQL 18 + TimescaleDB runtime, +# same cluster settings. Then a CPU burner runs for the job's life and AggregateJobQuiesceLiveTests runs +# 10 times per shard, counting passes and failures. on: - push: - branches: [main, dev] pull_request: - # `ready_for_review` is here because the `gate` job below skips every PR job while the PR is a - # draft: marking it ready is the event that starts the real run. - types: [opened, synchronize, reopened, ready_for_review] branches: [main, dev] - release: - types: [published] - # Merge-queue runs. Inert until a queue ruleset is enabled on a branch (a repo setting), - # but the required checks must handle the event BEFORE that click, or every queued PR - # stalls on checks that never report. dorny/paths-filter v4.0.1+ resolves merge_group - # diffs from the payload's base_sha/head_sha whenever the base input is empty — exactly - # what the filter steps pass for non-push events — so path classification works - # unchanged in a queue run. - merge_group: permissions: - contents: write - id-token: write - actions: read - -# A re-push to a PR cancels that PR's superseded in-flight run, and a push to dev/main -# cancels that BRANCH's superseded in-flight run — newest SHA wins. Finishing a build of -# code that is no longer the head helps nobody, and the shared Windows runner pool is what -# serializes everyone's CI (#1697 sat queued behind two dev builds; on 2026-07-26 a -# ~20-merge train left 13 of the day's 30 dev-push runs finishing SHAs a newer merge had -# already replaced — ~60 reclaimable runner-minutes in one evening). Cancelling a -# superseded PUSH run is safe because no run's RESULT depends on another run's output: every -# upload-artifact step in this workflow is gated to the release event (the SignPath signing -# path) or to failure() (darling-pg diagnostics), except the per-run Lite timing uploads -# (#5208) and the Guard job's two test-build uploads (#5459), which only jobs of the SAME run -# download (no run-id; a cancelled run's build is never read by another run), and exactly ONE -# thing reads a previous run's artifacts: the Lite shard packer (#5208). The gate job picks, once per run, the newest successful push run on dev (of the 30 -# newest successful dev runs) whose lite-tests-timing artifacts ALL have more than two days of -# retention left, and pins that run and its exact artifact set; every Lite shard downloads that -# set and fails if it gets anything else. A cancelled run is not a successful run, so a -# cancelled push run is never read. With no such run the cut is the plain class-name hash, and -# timings the packer cannot use (none readable, all zero, or covering under 80% of this commit's -# classes) send every shard to that same hash. So cancelling can cost shard balance but never a -# test class or a verdict. The one other reader of another run's artifacts is the shadow-mode -# test map (#5459): it reads the NIGHTLY's `test-map` artifact (nightly.yml runs are never in this -# workflow's concurrency group, so nothing here cancels them), selects nothing from it, and a -# missing map only means the shadow selection is FULL. No other cross-run consumer (a -# download-artifact with run-id, a gh run download, or a workflow_run trigger) exists -# (CrossAppGuardCiGateTests pins both halves), -# nightly.yml builds its own tree from its own checkout, and a release compiles fresh on the -# release event. The accepted -# trade: push builds are diff-scoped, so a -# cancelled run's areas are not re-verified until the next change touches them — the -# nightly and the all-areas dev->main release PR are the backstops. Release and -# merge-queue runs deliberately keep a UNIQUE group per run (run_id) and are NEVER -# cancelled: a release build waits on SignPath's manual approval gate, and a queue -# validation is the last check before its result lands on dev. -concurrency: - group: ${{ github.event_name == 'pull_request' && format('build-pr-{0}', github.event.pull_request.number) || github.event_name == 'push' && format('build-push-{0}', github.ref) || format('build-run-{0}', github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + contents: read jobs: - # THE ONE DECISION about whether this run does any work. Every PR job below `needs: gate` and runs - # only while `run` is true, so the rule lives in one place instead of being repeated on each job. - # - # Runs, in order: - # - any event that is not a pull request (push, release, merge_group): always. - # - a pull request from a `train/` branch: always, draft or not. A merge-train pull request exists - # only to run CI, so skipping it would defeat it. - # - a pull request carrying the `merge-train` label: never. The train's own run already covered the - # member, which is then marked ready and merged straight away, so a second run is wasted. The label - # is read from the event payload, so it has to be on the pull request BEFORE it is marked ready. - # - a draft pull request: never. Marking it ready starts the run (`ready_for_review`). - # - any other pull request: yes. - # - # A skipped job reports as skipped, and GitHub counts skipped as passing for a required check. A run - # the gate declined therefore must not read as green, so three jobs turn it into a FAILURE on a pull - # request: `build` (its first step), `Darling PostgreSQL tests` and `Lite tests (result)`. Those are the - # required checks, and each keeps its own name. The failure is the intended outcome: a draft or - # merge-train pull request shows red until it is marked ready (or, for a merge-train member, until an - # admin merges it after the train covered it). Branch protection does not bind admins, so - # `gh pr merge --admin` still works. - gate: - name: Decide whether CI runs - runs-on: ubuntu-latest - timeout-minutes: 5 - # actions: read is for the timing-source and test-map steps below: they list runs and artifacts. - # contents: read is for the test-map step's ancestry check (the compare API on the base). - permissions: - actions: read - contents: read - outputs: - run: ${{ steps.decide.outputs.run }} - lite_timing_run: ${{ steps.timing.outputs.run_id }} - lite_timing_artifacts: ${{ steps.timing.outputs.artifacts }} - test_map_run: ${{ steps.testmap.outputs.run_id }} - test_map_sha: ${{ steps.testmap.outputs.sha }} - steps: - - name: Decide - id: decide - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - IS_DRAFT: ${{ github.event.pull_request.draft }} - HEAD_REF: ${{ github.event.pull_request.head.ref }} - HAS_MERGE_TRAIN_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'merge-train') }} - run: | - set -euo pipefail - - if [ "${EVENT_NAME}" != "pull_request" ]; then - run=true; why="a ${EVENT_NAME} event always runs." - elif [[ "${HEAD_REF}" == train/* ]]; then - run=true; why="a merge-train pull request exists to run CI." - elif [ "${HAS_MERGE_TRAIN_LABEL}" = "true" ]; then - run=false; why="the pull request carries the merge-train label, so its train run already covered it." - elif [ "${IS_DRAFT}" = "true" ]; then - run=false; why="the pull request is a draft. Marking it ready for review starts the run." - else - run=true; why="a pull request that is ready for review runs." - fi - - echo "run=${run}" >> "$GITHUB_OUTPUT" - if [ "${run}" = "true" ]; then - echo "::notice title=CI runs::${why}" - else - echo "::notice title=CI skipped::${why}" - fi - - # #5208: the ONE timing source every Lite shard of this run balances its cut from. It is picked - # here, once, because the four shards start minutes apart and a pick made inside each shard would - # race a dev run finishing in between: two shards would then cut the suite from different timings, - # and a class could land in two shards or none, which no check inside a single shard can see. - # Only SUCCESSFUL push runs on dev qualify (a cancelled run is never read, and a push to dev is - # code that already merged), and ALL of its timing artifacts must outlive this run by two days, so - # they cannot expire while the shards are queued or being re-run. The exact artifact names are - # pinned too (the artifacts output), and each shard checks that it downloaded exactly that set. - # Every failure here means "no source", which the shards all read the same way: the plain - # class-name hash cut. The step cannot fail the gate. - - name: Pin the Lite timing source - id: timing - if: github.event_name != 'release' && steps.decide.outputs.run == 'true' - continue-on-error: true - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - run: | - set -uo pipefail - - export KEEP_UNTIL FRESH_AFTER - tries=3 - stale_days=5 - KEEP_UNTIL=$(date -u -d '+2 days' +%Y-%m-%dT%H:%M:%SZ) - FRESH_AFTER=$(date -u -d "-${stale_days} days" +%Y-%m-%dT%H:%M:%SZ) - source_run="" - source_artifacts="" - runs="" - stale_tries=0 - failed_tries=0 - # Filtered on the event here, not in the query: the API's branch+event+status combination - # returns a stale page of old runs. Branch+status can return one TOO, now and then: 30 rows - # from weeks ago, whose artifacts have long expired, where the current rows should be, and then - # every Lite shard cuts by hash although a qualifying run exists (#5208). So the listing is - # tried up to three times (tries), five seconds apart, and a page is taken only when a push run - # on it FINISHED in the last five days (stale_days), judged on updated_at and not on created_at: - # artifacts expire seven days after their UPLOAD, a run uploads before it finishes, and a re-run - # uploads again, so the two days of margin the check below wants can only be met by a run that - # finished within five days, however long ago it was created. A page whose latest finish is older - # cannot hold a usable source anyway. A failed call counts as a try too. When no try gets a - # current page the cut is the hash, and the notice below says what the tries saw. (A pull request - # FROM dev carries head_branch dev too, hence the event test.) - for attempt in $(seq 1 "${tries}"); do - if [ "${attempt}" -gt 1 ]; then - sleep 5 - fi - if page=$(gh api "repos/${GH_REPO}/actions/workflows/build.yml/runs?branch=dev&status=success&per_page=30" --jq '[.workflow_runs[] | select(.event == "push")] | (map(.updated_at) | max) as $newest | if $newest > env.FRESH_AFTER then .[].id else "stale \($newest // "none")" end'); then - case "${page}" in - stale*) - stale_tries=$((stale_tries + 1)) - echo "Try ${attempt} of ${tries}: no push run on the listing finished in the last ${stale_days} days (latest: ${page#stale }), so the page counts as stale." - ;; - *) - runs="${page}" - break - ;; - esac - else - failed_tries=$((failed_tries + 1)) - echo "Try ${attempt} of ${tries}: the run listing call failed." - fi - done - for id in ${runs}; do - # EVERY timing artifact of the run must outlive this run by two days, not just one, and the exact set is - # pinned so each shard can prove it downloaded the same files as the others. - names=$(gh api "repos/${GH_REPO}/actions/runs/${id}/artifacts?per_page=100" --jq '[.artifacts[] | select(.name | test("^lite-tests-timing-[0-9]+$"))] | if length > 0 and all(.[]; (.expired | not) and (.expires_at > env.KEEP_UNTIL)) then (map(.name) | unique | join(",")) else "" end') || names="" - if [ -n "${names}" ]; then - source_run="${id}" - source_artifacts="${names}" - break - fi - done - - echo "run_id=${source_run}" >> "$GITHUB_OUTPUT" - echo "artifacts=${source_artifacts}" >> "$GITHUB_OUTPUT" - # Each notice says what the tries saw: a run was pinned; every call failed; no try got a current - # page (stale, empty or push-less pages, with failed calls among them); or a current page was read - # and no run on it qualified. Empty runs means no page was taken, so no run was tested at all. - if [ -n "${source_run}" ]; then - echo "::notice title=Lite timing source::The Lite shards balance their cut from the timings of run ${source_run}, the newest successful push run on dev that still has them." - elif [ "${failed_tries}" -eq "${tries}" ]; then - echo "::notice title=Lite timing source::The run listing call failed on all ${tries} tries, so every Lite shard cuts by class-name hash." - elif [ -z "${runs}" ]; then - echo "::notice title=Lite timing source::The run listing gave no current page in ${tries} tries (${stale_tries} returned no push run that finished in the last ${stale_days} days, a stale API page or no push run that recent, and ${failed_tries} failed), so every Lite shard cuts by class-name hash." - else - echo "::notice title=Lite timing source::No successful dev push run has unexpired Lite timings (or the lookup failed), so every Lite shard cuts by class-name hash." - fi - - # #5459 change 4, shadow mode: the ONE test map this run's shadow job reads, picked here once for the - # same reason as the Lite timing source above (two readers must not see two maps). It is the newest - # successful nightly run whose `test-map` artifact has not expired (two days of margin) and whose - # commit is an ancestor of the pull request's base, so a map built from code the base does not contain - # is never used. Pull requests only; a push, a merge group and a release never read a map. Every failure - # here means "no map", which the shadow job reads as FULL. The step cannot fail the gate, and the shadow - # work never skips or narrows anything: every shard still runs every class. - - name: Pin the test map (shadow mode) - id: testmap - if: github.event_name == 'pull_request' && steps.decide.outputs.run == 'true' - continue-on-error: true - # A hang in one of the gh api calls must end this step (a step failure, which continue-on-error absorbs), not - # run into the gate job's own 5-minute timeout, which continue-on-error does not cover and which would red the PR. - timeout-minutes: 2 - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - set -uo pipefail - - export KEEP_UNTIL - KEEP_UNTIL=$(date -u -d '+2 days' +%Y-%m-%dT%H:%M:%SZ) - map_run="" - map_sha="" - seen=0 - if runs=$(gh api "repos/${GH_REPO}/actions/workflows/nightly.yml/runs?status=success&per_page=20" --jq '.workflow_runs[] | select(.event == "schedule" or .event == "workflow_dispatch") | "\(.id) \(.head_sha)"'); then - while read -r id sha; do - [ -n "${id}" ] || continue - seen=$((seen + 1)) - [ "${seen}" -le 10 ] || break - has=$(gh api "repos/${GH_REPO}/actions/runs/${id}/artifacts?per_page=100" --jq '[.artifacts[] | select(.name == "test-map" and (.expired | not) and .expires_at > env.KEEP_UNTIL)] | length') || has=0 - [ "${has}" = "1" ] || continue - rel=$(gh api "repos/${GH_REPO}/compare/${sha}...${BASE_SHA}" --jq '.status') || rel="" - case "${rel}" in - ahead|identical) - map_run="${id}" - map_sha="${sha}" - break - ;; - esac - done <<< "${runs}" - fi - - echo "run_id=${map_run}" >> "$GITHUB_OUTPUT" - echo "sha=${map_sha}" >> "$GITHUB_OUTPUT" - if [ -n "${map_run}" ]; then - echo "::notice title=Test map::Shadow mode reads the map of nightly run ${map_run} (commit ${map_sha:0:10}), the newest one built from an ancestor of this base." - else - echo "::notice title=Test map::No successful nightly has an unexpired test map built from an ancestor of this base (${seen} runs looked at), so the shadow selection is FULL." - fi - - # #5459: the guard classes of BOTH test suites, first, so a source pin, a census or a hygiene scan that a - # pull request breaks fails in about ten minutes with every shard skipped, instead of after the whole run. - # Before this job, 6 PostgreSQL shards, 4 Lite shards, the build job's own suites and the Linux image all - # ran to the end on a pull request one stacked doc comment had already broken. The guard classes carry - # [Trait("Stage", "Guard")]; GuardStageCensusTests (one per suite) fails a class whose name says it is a - # guard, or that walks the whole repository, and carries no trait, and fails a tagged class that needs a - # store, because this job has no PostgreSQL (and no DARLING_TEST_* variable). - # - # NOT a required check. The four required checks (`build`, `Darling PostgreSQL tests`, `Lite tests (result)` - # and the Claude review guard) keep their names; the first three read this job's result and go red when it - # is not success, so a guard failure is visible on each of them and nothing waits on a check that never - # reports. Pull requests only: a push, a merge-queue run and a release run every shard whatever the guards - # say (dev pushes are the safety net and the source of the Lite timings), so the shards' `if:` below read - # this job's result only on a pull request. Skipped on release, where `build` runs the suites whole. - # - # The build is shared (#5459 change 2). Right after the Release build, and before any test runs (so a guard - # failure still leaves it), this job uploads the two test projects' bin/Release trees plus the few obj files - # `dotnet run --no-build` reads (project.assets.json, the nuget props/targets, the cache file), and a small - # origin file recording the producer's github.workspace and sha. The consumers (the six PostgreSQL shards, the - # four Lite shards, the whole-tree guards job) download it into the same relative paths and skip their own - # restore and build. The obj/Release intermediates and every other project's obj are not needed, and not - # uploaded: `dotnet run --no-build` was proven locally with exactly this set and an empty NuGet package folder. - # Two tests read ANOTHER project's build output through the repo layout, so those are uploaded too: the Darling - # service's bin/Release (DarlingEncryptPasswordStreamTests starts its exe) and the Lite app's runtimeconfig - # (LiteRuntimePrerequisiteDocsTests reads Lite/bin/**). A first version without them failed both on the shards. - # The workspace path is recorded because many tests find the repo root through [CallerFilePath], so a binary - # built in one workspace path reads its source from that path; a consumer whose workspace or sha differs fails - # loudly instead of testing the wrong tree. Only a consumer that USED the artifact leaves out Stage=Guard (this - # job ran those classes on the same binaries in this run); one that built for itself runs every class. - # - # Docs-only changes: the filter below holds ONLY the build job's `docs` and `all` entries (a byte copy, - # pinned by GuardStageWorkflowTests), and a change whose every file is on the documentation allowlist - # reports a notice and succeeds without building anything, exactly as the build job's docs fast path does. - # - # The two suites are built once here (Release), and the class lists come off the built assemblies, so the - # set is whatever carries the trait on THIS commit: no roster. An empty list is a hard error (a runner given - # no class filter would run nothing or everything, and neither is a guard). Both suites run even when the - # first fails, so one red run names everything that is broken. - guard-tests: - name: Guard tests - needs: gate - if: ${{ needs.gate.outputs.run == 'true' && github.event_name != 'release' }} - runs-on: windows-latest - # Measured locally at ~1 minute of tests per suite (Debug); a hosted runner adds setup, restore and two - # Release builds (~6 minutes). 20 minutes is a hang guard, as on the other Windows jobs. - timeout-minutes: 20 - permissions: - contents: read - # One artifact per suite, so a Darling consumer does not download the Lite tree and the other way round. Each - # output holds the artifact name, set only when its upload step succeeded (empty on a docs-only change, a failed - # build, or a failed upload). The consumers download only when it is set; a job output is still published when - # a later step of this job fails, which is the case that matters (the guards failed after the upload). - outputs: - darling_build_artifact: ${{ steps.upload-darling-build.outcome == 'success' && 'guard-test-build-darling' || '' }} - lite_build_artifact: ${{ steps.upload-lite-build.outcome == 'success' && 'guard-test-build-lite' || '' }} - - steps: - - uses: actions/checkout@v7 - - - name: Detect changed paths - id: filter - uses: dorny/paths-filter@v4 - with: - base: ${{ github.event_name == 'push' && github.event.before || '' }} - filters: | - # Byte copy of the build job's `docs` and `all` entries (GuardStageWorkflowTests pins the patterns - # equal): the documentation allowlist, and the catch-all counter that is equal to it exactly when - # every changed file is documentation. - docs: - - '**/*.md' - - 'LICENSE' - - 'CITATION.cff' - - '.gitignore' - - '.gitattributes' - - 'llms.txt' - - 'docs/**/*.{md,svg,png,jpg,jpeg,gif}' - - 'Screenshots/**/*.{md,svg,png,jpg,jpeg,gif}' - all: - - '**' - - - name: Decide whether the guard classes run - id: decide - shell: bash - env: - ALL_COUNT: ${{ steps.filter.outputs.all_count }} - DOCS_COUNT: ${{ steps.filter.outputs.docs_count }} - run: | - set -euo pipefail - - if [ "${ALL_COUNT:-0}" -gt 0 ] && [ "${ALL_COUNT:-0}" -eq "${DOCS_COUNT:-0}" ]; then - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Guard tests skipped::All ${ALL_COUNT} changed files are on the documentation allowlist, so there is no code for the guard classes to catch. This job still reports success." - else - echo "run=true" >> "$GITHUB_OUTPUT" - echo "::notice title=Guard tests running::${DOCS_COUNT:-0} of ${ALL_COUNT:-0} changed files are on the documentation allowlist." - fi - - - name: Setup .NET 10.0 - if: steps.decide.outputs.run == 'true' - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - # Locked mode and `set -euo pipefail` for the reason the build job's restore step gives (and - # LockedModeRestoreCoverageTests enforces): a multi-line run block under the default shell reports only - # its last command's exit code, so the first restore could fail unseen. - - name: Restore both test projects - if: steps.decide.outputs.run == 'true' - shell: bash - run: | - set -euo pipefail - - dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - - - name: Build both test projects - if: steps.decide.outputs.run == 'true' - shell: bash - run: | - set -euo pipefail - - dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - # Written by the producer for the consumers' check (see the header above): where this build was made and from - # what. Plain key=value lines, no dot-prefix (upload-artifact skips hidden files). - - name: Record where this build was made - if: steps.decide.outputs.run == 'true' - shell: pwsh - env: - PRODUCER_WORKSPACE: ${{ github.workspace }} - PRODUCER_SHA: ${{ github.sha }} - run: | - $ErrorActionPreference = 'Stop' - Set-Content -Path guard-build-origin.txt -Value @("workspace=$env:PRODUCER_WORKSPACE", "sha=$env:PRODUCER_SHA") -Encoding utf8NoBOM - - # Before the tests, so a guard failure still leaves the artifacts. One day of retention: they are read only by - # the jobs of this run. overwrite: a re-run of this job in a later attempt uploads the same names again. - - name: Upload the built Darling.Tests for the shards - id: upload-darling-build - if: steps.decide.outputs.run == 'true' - uses: actions/upload-artifact@v6 - with: - name: guard-test-build-darling - path: | - guard-build-origin.txt - Darling/Darling.Tests/bin/Release - Darling/PerformanceMonitor.Darling.Service/bin/Release - Darling/Darling.Tests/obj/project.assets.json - Darling/Darling.Tests/obj/project.nuget.cache - Darling/Darling.Tests/obj/*.nuget.g.props - Darling/Darling.Tests/obj/*.nuget.g.targets - Darling/Darling.Tests/obj/*.nuget.dgspec.json - retention-days: 1 - compression-level: 1 - overwrite: true - if-no-files-found: error - - - name: Upload the built Lite.Tests for the shards - id: upload-lite-build - if: steps.decide.outputs.run == 'true' - uses: actions/upload-artifact@v6 - with: - name: guard-test-build-lite - path: | - guard-build-origin.txt - Lite.Tests/bin/Release - Lite/bin/**/PerformanceMonitorLite.runtimeconfig.json - Lite.Tests/obj/project.assets.json - Lite.Tests/obj/project.nuget.cache - Lite.Tests/obj/*.nuget.g.props - Lite.Tests/obj/*.nuget.g.targets - Lite.Tests/obj/*.nuget.dgspec.json - retention-days: 1 - compression-level: 1 - overwrite: true - if-no-files-found: error - - - name: Run the Stage=Guard classes of both suites - if: steps.decide.outputs.run == 'true' - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - # Both suites must run even when the first fails, so a failing native command has to set $LASTEXITCODE - # and not throw (#5471 review L1). The default is already $false; the three loops below that need the - # same say it out loud, so this one does too. - $PSNativeCommandUseErrorActionPreference = $false - $failed = @() - $suites = @( - @{ Name = 'Darling.Tests'; Project = 'Darling/Darling.Tests/Darling.Tests.csproj' }, - @{ Name = 'Lite.Tests'; Project = 'Lite.Tests/Lite.Tests.csproj' } - ) - - foreach ($suite in $suites) { - $lines = @(dotnet run --project $suite.Project -c Release --no-build -- -list classes/json -trait Stage=Guard) - if ($LASTEXITCODE -ne 0) { throw "$($suite.Name): the class listing failed with exit code $LASTEXITCODE" } - # Only the runner's one-line JSON array is parsed; a CLI notice can share stdout. - $json = @($lines | Where-Object { $_ -match '^\s*\[' }) - if ($json.Count -ne 1) { throw "$($suite.Name): expected one JSON array line from the class listing, got $($json.Count): $($lines -join ' | ')" } - $classes = @($json[0] | ConvertFrom-Json) - if ($classes.Count -eq 0) { throw "$($suite.Name): no class carries Stage=Guard, so this job would run nothing" } - Write-Host "$($suite.Name): $($classes.Count) Stage=Guard classes" - - # -failSkips: this job has no PostgreSQL, so a tagged test that needs a store would SKIP, and a skip - # exits 0. Failing skips turns that into a red job (#5471 review M1). Both suites skip none today. - dotnet run --project $suite.Project -c Release --no-build -- -trait Stage=Guard -failSkips - if ($LASTEXITCODE -ne 0) { - Write-Host "::error title=Guard tests failed::$($suite.Name) failed its Stage=Guard classes (exit $LASTEXITCODE). The shards were skipped on this pull request." - $failed += $suite.Name - } - } - - if ($failed.Count -gt 0) { throw "Guard classes failed in: $($failed -join ', ')" } - - build: - needs: [gate, guard-tests] - # `build` is a required check, so it has to run to report anything but "skipped" (which counts as - # passing). When the gate declines the run the job still starts, on a cheap Linux runner, and its - # first step fails; no later step has an `always()`/`failure()` condition, so the rest are skipped. - if: ${{ !cancelled() }} - # A pull request whose guard classes failed fails fast on the cheap Linux runner too (#5459): the step - # below reports it, and no Windows runner starts for a run that cannot pass. Push, merge_group and - # release keep the Windows runner whatever the guards said. - runs-on: ${{ needs.gate.outputs.run == 'true' && (github.event_name != 'pull_request' || needs.guard-tests.result == 'success') && 'windows-latest' || 'ubuntu-latest' }} - - # Whether THIS job ran Darling.Tests, taken from the step's own outcome rather than restated as - # a copy of the step's path gate. The darling-tree-guards job at the bottom of this file runs - # the suite when this reads `skipped`; a second list of paths there would be free to fall out - # of agreement with the list here, and area lists falling out of agreement with what a guard - # reads is the thing that job exists to stop. - # - # docs-fastpath is published the same way, from the classify step's OWN decision rather than a - # second copy of the docs allowlist: darling-tree-guards must not pay ~5 minutes running the whole - # suite on a docs-only change just because that change also left Darling.Tests `skipped` - the two - # skip reasons look identical from `outcome` alone, so the guards job needs to tell them apart. - outputs: - darling-tests: ${{ steps.darling-tests.outcome }} - docs-fastpath: ${{ steps.fastpath.outputs.engaged }} - - steps: - - name: Fail when CI did not run - if: ${{ needs.gate.outputs.run != 'true' }} - shell: bash - env: - GATE_RESULT: ${{ needs.gate.result }} - run: | - if [ "${GATE_RESULT}" != "success" ]; then - echo "::error title=CI gate failed::needs.gate.result='${GATE_RESULT}' - the job that decides whether CI runs did not succeed, so nothing ran. Re-run the workflow." - exit 1 - fi - echo "::error title=CI not run::This pull request's CI was skipped (draft or merge-train label). It can be merged only by an admin override after a merge train covered it. (gate result: ${GATE_RESULT})" - exit 1 - - # #5459: the Guard tests job runs first on a pull request. When it did not succeed, this required check - # fails at once, before checkout, instead of building and running the suites for a run that is already - # lost; the shards were skipped for the same reason. Pull requests only: a push, a merge-queue run and a - # release carry on whatever the guards said (a release skips the guard job, so its result is `skipped`). - - name: Fail when the guard tests failed - if: ${{ github.event_name == 'pull_request' && needs.gate.outputs.run == 'true' && needs.guard-tests.result != 'success' }} - shell: bash - env: - GUARD_RESULT: ${{ needs.guard-tests.result }} - run: | - echo "::error title=Guard tests failed::The Guard tests job did not succeed (result: ${GUARD_RESULT}), so the rest of CI was skipped. Read that job's log for the failing class; it runs first and fails in about ten minutes." - exit 1 - - - uses: actions/checkout@v7 - - - name: Detect changed paths - id: filter - if: github.event_name != 'release' - uses: dorny/paths-filter@v4 - with: - # On push events, compare against the previous commit on this branch - # (github.event.before). Without this, the action defaults to comparing - # against the default branch on non-default branch pushes, which would - # match every accumulated change and defeat the filter. - base: ${{ github.event_name == 'push' && github.event.before || '' }} - # Emit the matched file list so the fast-path step can NAME what it classified - # as documentation. A fast path that silently under-builds is the failure mode - # worth guarding against, so the reason is always printed, never inferred. - # - # json, for the reason the two Darling jobs' filters give: the list reaches the - # step through `env:` rather than a `${{ }}` interpolation into script text, and a - # runtime parameter expansion does not honour the `shell` format's backslash - # escapes. Eight tracked paths under Screenshots/ carry spaces and that whole - # directory is on the allowlist below, so the docs notice is the one that meets a - # spaced path first. A JSON array is self-delimiting and prints verbatim. - list-files: json - filters: | - # A change to a root build file (the solution, restore config, or THIS workflow) can - # affect every product, so it forces a full build/test/publish. - root: - - 'PerformanceMonitor.sln' - # #3222: every project in the tree inherits the product version from here, so a - # release bump touches this file and NOTHING else. Absent this entry that bump - # matches no area filter at all and the job that stamps the artifacts never builds - # them: the enumerated-list failure this file's other comments keep describing. - # Spelled without the angle brackets on purpose. ProductVersionDeclarationTests - # treats that spelling as a version READ and would score this comment as one. - - 'Directory.Build.props' - - 'global.json' - - 'nuget.config' - - 'NuGet.config' - - '.github/workflows/build.yml' - # The shared PerformanceMonitor.* core libraries feed Lite, the Full Dashboard, AND - # Darling (verified via ProjectReference), so a change here fans out to all three. - # NOT the CLI Installer — it references only Installer.Core. - # - # Every area pattern says `dir/**/!(*.md)` — any non-markdown file under the - # area — instead of the old `dir/**` include plus a bare `!**/*.md` exclude. - # That is not style: dorny v4 evaluates each pattern as an INDEPENDENT - # predicate under the default predicate-quantifier 'some' (a filter is true - # when any changed file matches at least one rule), so a bare `!**/*.md` line - # is not a subtraction — it is its own rule meaning "any file that is not - # markdown", which silently made every area filter true for ANY non-markdown - # change anywhere in the repo. Measured proof: a single root .gitignore edit - # built and tested all four products and ran the full Darling PG suite - # (PR #1714, run 30219202642, filter log: "Filter darling = true, Matching - # files: .gitignore"). The extglob keeps the markdown carve-out INSIDE the - # include, where quantifier semantics cannot detach it. - core: - - 'PerformanceMonitor.Alerting/**/!(*.md)' - - 'PerformanceMonitor.Analysis/**/!(*.md)' - - 'PerformanceMonitor.Collectors/**/!(*.md)' - - 'PerformanceMonitor.Common/**/!(*.md)' - - 'PerformanceMonitor.Notifications/**/!(*.md)' - - 'PerformanceMonitor.PlanAnalysis/**/!(*.md)' - - 'PerformanceMonitor.Ui/**/!(*.md)' - # Installer.Core is shared by the CLI Installer AND the Full Dashboard's integrated - # installer — a change rebuilds both, and nothing else. - installer_core: - - 'deprecated/Installer.Core/**/!(*.md)' - dashboard: - - 'deprecated/Dashboard/**/!(*.md)' - - 'deprecated/Dashboard.Tests/**/!(*.md)' - # ThemeParityTests compares the Dashboard's theme files with Lite's, so a change to Lite's - # themes alone must run it too. #4658 changed a Lite theme color, its CI skipped the test, - # and the next run that included it failed on dev (#4668). - - 'Lite/Themes/**/!(*.md)' - lite: - - 'Lite/**/!(*.md)' - - 'Lite.Tests/**/!(*.md)' - # #2489, changed by Erik's 2026-09-24 ruling that docs-only changes skip CI: - # README.md and Lite/README.md used to sit here so LiteRuntimePrerequisiteDocsTests - # (which reads these two READMEs against the built runtimeconfig and this file's - # publish shapes) could still run on a README-only change. That guard now runs on - # every CODE pull request and in the nightly instead - both of which build Lite - - # so a README edit alone no longer has to pay the Lite suite to keep it exercised. - # A README-only PR takes the docs fast path below and does not build or test - # anything; the trade is deliberate and is Erik's call, not an oversight. - # Same silently-stops-guarding reason as the darling filter's Lite entries below: - # Lite.Tests/ThemeParityLiteDarlingTests.cs READS the Darling viewer's theme - # dictionaries to assert the two apps' shared brush keys still resolve to the same - # colors. A Darling-theme-only edit is exactly the drift that guard exists to catch, - # so it has to reach the suite. - # - # WIDENED from Themes/*.xaml to the whole Viewer tree (#2839): Lite.Tests reads eight - # other Viewer files as source - MainWindow.xaml, SettingsWindow.xaml.cs, - # CollectorSchedulePresets.cs, ViewerDataService.AlertSettings.cs, - # ViewerDataService.Fleet.cs, AvailabilityGroupsTab.xaml, ViewerServerTab.xaml and - # AddServerDialog.xaml.cs - so the Themes-only pattern left every guard reading any of - # them unable to run on the edit it exists to catch. - - 'Darling/PerformanceMonitor.Darling.Viewer/**/!(*.md)' - # #2839, and the one that actually broke dev: Lite.Tests/QueryStoreSliceTieBreakSourceTests.cs - # reads DARLING'S counterpart guard as source and asserts on the site total declared - # there. #2830 raised that total 12 -> 18 touching only Darling/**, which fires `darling` - # but not `lite`, so the mirror half was skipped and the merge went green before the - # nightly failed on it. A guard that compares the two apps CANNOT live behind a filter - # that only fires for one of them. - - 'Darling/Darling.Tests/**/!(*.md)' - # Same reason again, and a whole tree this time: WatermarkPolicyTests' - # TheCatchUpHorizon_IsWrittenDownInExactlyOnePlace (#2468) scans the Darling SERVICE for - # comments that restate the catch-up horizon's number instead of pointing at - # WatermarkPolicy.MaxCatchup. Three of the twelve sites it was written for live here, so a - # Darling-only PR reintroducing one would fire `darling` but not `lite` and skip the step - # that runs the guard — caught a day later by the nightly, which is exactly the silent - # drift the pin exists to close. A diff that reaches only these three Darling trees runs just - # the Lite.Tests classes tagged Reads=Darling, in one shard (the lite-tests job's scope step). - - 'Darling/PerformanceMonitor.Darling.Service/**/!(*.md)' - installer: - - 'deprecated/Installer/**/!(*.md)' - - 'deprecated/Installer.Tests/**/!(*.md)' - - 'install/**/!(*.md)' - - 'upgrades/**/!(*.md)' - darling: - - 'Darling/**/!(*.md)' - # nightly.yml is not a build input, but Darling.Tests PARSES it: the #1888 - # guard reads both workflows' throwaway-cluster settings and compares them - # against the product's worker-sizing formula. Without this, a nightly-only - # edit would change a file the guard asserts on while never running the - # guard — a guard that silently stops guarding, which is the exact failure - # mode the source-parsing tests here exist to prevent. - - '.github/workflows/nightly.yml' - # Same reason, Lite side: the #1949 pin in Darling.Tests asserts every twinned - # query grid carries the SAME column sequence in both front ends, so it reads - # these six Lite files. A Lite-only XAML edit has to reach the suite or the - # parity half of that guard stops guarding. - - 'Lite/Controls/ServerTab.xaml' - - 'Lite/Controls/FinOpsTab.xaml' - - 'Lite/Windows/WaitDrillDownWindow.xaml' - - 'Lite/Windows/ProcedureHistoryWindow.xaml' - - 'Lite/Windows/QueryStatsHistoryWindow.xaml' - - 'Lite/Windows/QueryStoreHistoryWindow.xaml' - # #2114: XamlStaticResourceHygieneTests scans EVERY Lite XAML file — a StaticResource - # regression in one outside the six named above must still trigger the Darling job - # that runs the guard, or it slips to the nightly. - - 'Lite/**/*.xaml' - # #2839, mirror of the lite filter's Darling entries: Darling.Tests reads Lite .cs - # source too, not just XAML - Lite/Analysis, Lite/Mcp/McpHealthTools.cs and - # Lite/Services. Naming only *.xaml meant a Lite .cs-only edit skipped the Darling - # job that runs those parity guards. Derived (**/*.cs) rather than the three - # directories, for the same reason #2114 added the *.xaml catch-all above an - # enumerated six: an enumerated list stops covering the next file added. - - 'Lite/**/*.cs' - # #3222, same reason again and a project file this time: ProductVersionDeclarationTests - # enumerates every csproj/props/targets in the tree to assert that exactly one place - # declares a product version. Lite's project file is in that population, so adding a - # version property back to it must run the Darling guard that would catch it. Derived - # rather than the one path, for the reason the two entries above are derived. - - 'Lite/**/*.csproj' - # #4966: Darling.Tests' CommentFilterAdoptionTests reads this Lite.Tests file, so an edit to it has to reach the Darling suite. - - 'Lite.Tests/DataStartBannerQueriesTabTests.cs' - # #5562: same, for the Lite.Tests file CommentFilterAdoptionTests lists as a stated bound. - - 'Lite.Tests/LiteTimeRangeGapTests.cs' - # #5596: Darling.Tests' WpfExitSwitchPinTests reads this project file for the WPF exit switch, so an edit to it has to reach the Darling suite. - - 'Lite.Tests/Lite.Tests.csproj' - # #5459 cut 3a: the part of `darling` that is NOT a Lite file. A diff that reaches `darling` through - # these (or core, root, a release) runs the whole Darling suite; a diff that reaches it only through the - # Lite entries above runs the Stage=Guard classes and the classes tagged Reads=Lite, which are the ones - # that read a Lite file (LiteReadsTraitGuardTests derives the tag from the source, and pins that - # `darling` minus this filter is Lite paths only). The decision lives in .github/scripts/ci-select.py. - darling_full: - - 'Darling/**/!(*.md)' - - '.github/workflows/nightly.yml' - # The DOCUMENTATION allowlist: files that cannot affect a build under any - # job in this workflow. Deliberately an allowlist of non-executable content, - # not a "everything that isn't code" subtraction — a new file type defaults - # to being treated as code, which is the safe direction to be wrong in. - # - # NOT here, on purpose: *.sql (the installer and sql-validation compile it), - # *.yml (workflows), *.csproj / *.props / packages.lock.json (build inputs), - # and *.cs regardless of how comment-only the change looks — an XML doc - # comment still recompiles, and the compiler is what proves it still builds. - # - # The docs/ and Screenshots/ entries are extension-explicit rather than bare - # directory globs for the same reason: everything in them today is markdown, - # SVG, or a screenshot image, and a .sql or script dropped into either - # directory tomorrow should default to being code, not inherit a free pass - # from its parent directory. - docs: - - '**/*.md' - - 'LICENSE' - - 'CITATION.cff' - - '.gitignore' - - '.gitattributes' - # The exact root path, not '**/*.txt': Darling/Darling.Tests/McpToolsListBudget/*.txt - # (and Lite's copy) are test INPUTS, not documentation, and a bare extension glob - # would wave every budget-pin edit through the fast path unbuilt and untested. - - 'llms.txt' - - 'docs/**/*.{md,svg,png,jpg,jpeg,gif}' - - 'Screenshots/**/*.{md,svg,png,jpg,jpeg,gif}' - # Catch-all COUNTER, not a boolean gate: the classify step below decides - # "documentation-only" by comparing all_count to docs_count — they are equal - # exactly when every changed file sits on the docs allowlist. Stated as a - # count comparison because the previous shape ('**' plus '!' exclusions, - # a code: filter) could never be false under predicate-quantifier 'some' — - # every file matches '**', so the #1712 fast path shipped unable to engage - # (throwaway PR #1714: a .gitignore-only diff still paid setup + restore and, - # via the predicate bug above, a full build). - all: - - '**' - - # Decides the docs fast path ONCE, in one place, and says so out loud. Guards keep - # it off every path where a skipped restore would be a real loss: - # release — the filter step does not even run there, and a release must always - # compile and publish from a cold, fully restored tree. - # areas — belt and suspenders: even when the counts say docs-only, any lit - # area filter vetoes the fast path, because an area=true with restore - # skipped would run `dotnet build --no-restore` against nothing. The - # two classifications are built from the same allowlist so they cannot - # disagree today; this guard is for the day someone edits one and not - # the other. - # Everything else — including push and merge_group, per Erik's 2026-09-24 ruling — - # is eligible, and engages only when EVERY changed file is on the documentation - # allowlist (all_count == docs_count). push/merge_group used to force the restore - # back on unconditionally as "cheap insurance" against NuGet drift that a lock-file - # diff would not show; that insurance buys nothing on a docs-only commit specifically - # BECAUSE it is docs-only — no lock file, Directory.Packages.props, or global.json is - # among the changed files (all of those are `root`, which is off the docs allowlist - # and vetoes the fast path via the areas guard above), so nothing this restore - # validates could have moved since the push that last validated it. - - name: Classify change for the docs fast path - id: fastpath - shell: bash - env: - ALL_COUNT: ${{ steps.filter.outputs.all_count }} - DOCS_COUNT: ${{ steps.filter.outputs.docs_count }} - DOCS_FILES: ${{ steps.filter.outputs.docs_files }} - AREAS: 'root=${{ steps.filter.outputs.root }} core=${{ steps.filter.outputs.core }} installer_core=${{ steps.filter.outputs.installer_core }} dashboard=${{ steps.filter.outputs.dashboard }} lite=${{ steps.filter.outputs.lite }} installer=${{ steps.filter.outputs.installer }} darling=${{ steps.filter.outputs.darling }}' - run: | - set -euo pipefail - - if [ "${{ github.event_name }}" = "release" ]; then - echo "engaged=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Full build::Release event - the docs fast path never applies to a release." - exit 0 - fi - - echo "Changed files: ${ALL_COUNT:-0} total, ${DOCS_COUNT:-0} on the documentation allowlist. Areas: ${AREAS}" - - if [ "${ALL_COUNT:-0}" -gt 0 ] && [ "${ALL_COUNT:-0}" -eq "${DOCS_COUNT:-0}" ] && [[ "${AREAS}" != *"=true"* ]]; then - echo "engaged=true" >> "$GITHUB_OUTPUT" - echo "::notice title=DOCS FAST PATH ENGAGED::All ${ALL_COUNT} changed files are on the documentation allowlist, so .NET setup, restore and versioning are skipped. This job still reports its result." - echo "Documentation files classified in this change: ${DOCS_FILES:-[]}" - else - echo "engaged=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Full build::At least one changed file is off the documentation allowlist (${DOCS_COUNT:-0} of ${ALL_COUNT:-0} classified as documentation)." - fi - - # Every web-dashboard module must parse and link (#4666, #4672). The C# tests only READ the - # dashboard's JavaScript, so a syntax error, a missing file or a missing named export passes - # every other check and blanks the whole dashboard in the browser. A step INSIDE build, not a - # job of its own: a failing job that build `needs:` would SKIP build, and a skipped build reads - # as success, so the gate would fail open. It runs on every change the docs fast path does not - # engage for (about two seconds), so a docs-only change stays fast. - - name: Parse and link every wwwroot/js module - if: steps.fastpath.outputs.engaged != 'true' - shell: bash - run: bash .github/scripts/check-web-js.sh - - - name: Setup .NET 10.0 - if: steps.fastpath.outputs.engaged != 'true' - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - # Central package management pins the DIRECT versions in Directory.Packages.props; the committed - # packages.lock.json files pin the TRANSITIVE closure, which is the half no diff shows and no review - # reads. This step is where a change that moves one without the other is rejected, so two things have to - # hold of it, and neither did (#3143). - # - # `shell: bash` with errexit, not the runner default. A multi-line run block executes under pwsh on - # Windows, which does not abandon the block on a native command's non-zero exit and reports the block's - # exit code as the LAST command's — so under the default shell any restore but the last is unable to - # fail this step. Measured on the Dependabot pull requests #3143 was filed from: the required `build` - # check passed carrying `error NU1004` in its own log, and the only job that went red was - # darling-tree-guards, whose restore is a single-command step. - # - # Every project with a committed lock file bar one is reached from this list, directly or through a - # ProjectReference — a locked-mode restore validates the lock file of every project in the graph it - # walks, not only the one it was handed. The exception is deprecated/Installer, and it is deliberate. - # That project is the only one publishing single-file, so the SDK adds an implicit - # Microsoft.NET.ILLink.Tasks reference at the SDK's OWN version, and its lock file is the only one - # carrying that entry; global.json rolls forward across patches, so a runner on 10.0.303 wants - # [10.0.11, ) where a file written under 10.0.302 records [10.0.10, ). No restore line makes that file - # right for both, and nothing references the project, so nothing else reaches it either: a restore - # pointed at it reports NU1004 from dev today, with no dependency change involved (#3143). Whether it - # should carry a lock file at all, and whether global.json should pin the SDK exactly, are decisions. - # - # LockedModeRestoreCoverageTests derives both requirements — from the lock files on disk and the - # reference edges between projects — so a tenth lock file makes its restore mandatory rather than - # remembered, and re-spelling this block in a shell that swallows failures fails the suite. It derives - # the exception from the SDK coupling too, and ratchets its membership, so pinning the SDK exactly - # makes this restore mandatory and a second project taking an SDK-coupled reference fails rather than - # joining the exemption quietly. - - name: Restore dependencies - if: steps.fastpath.outputs.engaged != 'true' - shell: bash - run: | - set -euo pipefail - - # NU1004 offers two remedies and one of them is dropping locked mode, which trades the closure pin - # rather than restoring it. Dependabot moves Directory.Packages.props and does not regenerate the - # lock files that belong with it, so its weekly pull request lands on this step; naming the command - # that reconciles them is the difference between a red check that gets acted on and one that gets - # waited out. The message rides an ERR trap rather than a following step, so nothing can report - # green beside a red restore. - trap 'echo "::error title=Lock files disagree with Directory.Packages.props::Regenerate with '"'"'dotnet restore PerformanceMonitor.sln --force-evaluate'"'"' and commit every packages.lock.json it rewrites. Do not remove --locked-mode: the lock files are the only pin on the transitive closure, and the props diff does not show it moving."' ERR - - dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode - dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - dotnet restore deprecated/Installer.Tests/Installer.Tests.csproj --locked-mode - dotnet restore deprecated/Dashboard.Tests/Dashboard.Tests.csproj --locked-mode - dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - dotnet restore Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj --locked-mode - - - name: Build Lite.Tests - if: steps.filter.outputs.lite == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - - name: Build Installer.Tests - if: steps.filter.outputs.installer == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet build deprecated/Installer.Tests/Installer.Tests.csproj -c Release --no-restore - - # The 'dashboard' path filter was defined when the Full Dashboard moved to deprecated/ (#1612) but - # never wired to a step, so its build and tests silently stopped running — which is how a batch of - # compiler warnings and three broken ThemeParityTests accumulated unnoticed (#1643). Deprecated means - # bug-fix-only, not unverified: it still compiles warning-free and its tests still guard cross-app - # parity (the theme palettes it checks are LITE's too). - # installer_core too: the Dashboard references Installer.Core, and the filter's own comment says a - # change there rebuilds both products, which only held for the CLI Installer until now. - - name: Build Dashboard.Tests - if: steps.filter.outputs.dashboard == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet build deprecated/Dashboard.Tests/Dashboard.Tests.csproj -c Release --no-restore - - - name: Build Darling - if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: | - dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - dotnet build Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release --no-restore - - # RELEASE-ONLY here since #3887; on pull requests and pushes the suite runs in the lite-tests - # matrix job below, four shards cut over CLASS NAMES (balanced by recorded duration since #5208, - # the class-name hash otherwise), because this one step had grown to - # ~9½ of the build job's ~15 minutes — 62% of the PR wall's critical path. The name cut is the - # deliberate opposite of the SEMANTIC split this step's history retired: it was split into - # fast / analysis-heavy halves when the seven analysis classes rebuilt the full DuckDB schema - # inside every test and their subset alone cost ~9 minutes; after the shared class fixtures - # (#1693, #1698) and batched seeding (#1694) that subset ran in ~1 minute, so the split — and - # the narrower lite_analysis path gate that let non-analysis Lite changes skip it — stopped - # earning its second test-host spin-up and its FILTER-DRIFT RISK. A cut over class names - # cannot drift: every class lands in exactly one shard (by arithmetic for the hash, by a - # proof for the duration cut), a class added tomorrow included, and no list anywhere has - # to be maintained. Releases keep this single unsharded - # pass so the artifact-producing run still executes the suite whole, in one log. - - name: Run Lite tests - if: github.event_name == 'release' - run: dotnet run --project Lite.Tests/Lite.Tests.csproj -c Release --no-build -- -xml TestResults/lite-timing-release.xml - - # #5208: per-test timings of the unsharded release pass, same shape as the lite-tests shards. - - name: Lite test timing summary - if: always() && github.event_name == 'release' - continue-on-error: true - run: python .github/scripts/lite-timing-top.py TestResults - - - name: Upload Lite test timings - if: always() && github.event_name == 'release' - continue-on-error: true - uses: actions/upload-artifact@v6 - with: - name: lite-tests-timing-release - path: | - TestResults/lite-timing-*.xml - TestResults/lite-timing-*.xml.cpu.json - retention-days: 7 - if-no-files-found: ignore - - - name: Run Installer tests - if: steps.filter.outputs.installer == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet run --project deprecated/Installer.Tests/Installer.Tests.csproj -c Release --no-build -- -class- "Installer.Tests.VersionDetectionTests" -class- "Installer.Tests.IdempotencyTests" -class- "Installer.Tests.AdversarialTests" - - - name: Run Dashboard tests - if: steps.filter.outputs.dashboard == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.installer_core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet run --project deprecated/Dashboard.Tests/Dashboard.Tests.csproj -c Release --no-build - - # #5459 cut 3b: the PostgreSQL shards run EVERY Darling.Tests class, with a live store, whenever the Darling - # gate matches (the same .github/darling-paths-filter.yml the darling-pg job reads). This no-store pass would - # repeat their non-live classes, so while the shards run it keeps only the Stage=Guard classes. Where the gate - # does not match (a Lite-only change, a root build file) and on a release it still runs the whole suite. - # .github/scripts/ci-select.py holds the rule (darling_scope); the replay of the failure corpus checks it. - - name: Detect the Darling PostgreSQL gate - id: darling-gate - if: github.event_name != 'release' && (steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true') - uses: dorny/paths-filter@v4 - with: - base: ${{ github.event_name == 'push' && github.event.before || '' }} - filters: .github/darling-paths-filter.yml - - - name: Decide the Darling test scope - id: darling-scope - if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - GATE: ${{ steps.darling-gate.outputs.darling }} - FULL: ${{ steps.filter.outputs.darling_full }} - CORE: ${{ steps.filter.outputs.core }} - ROOT: ${{ steps.filter.outputs.root }} - run: | - set -euo pipefail - python .github/scripts/ci-select.py darling-scope --event "${EVENT_NAME}" --gate "${GATE:-false}" --full "${FULL:-false}" --core "${CORE:-false}" --root "${ROOT:-false}" | tee -a "$GITHUB_OUTPUT" - - - name: Run Darling tests - id: darling-tests - if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - env: - DARLING_SCOPE_FILTER: ${{ steps.darling-scope.outputs.filter }} - # #5459 change 5: the slow-skip lookup reads the pull request's file list through the API. - GH_TOKEN: ${{ github.token }} - SLOW_EVENT: ${{ github.event_name }} - SLOW_PR: ${{ github.event.pull_request.number }} - SLOW_REPO: ${{ github.repository }} - shell: pwsh - run: | - # An empty filter is the whole suite; `-trait Stage=Guard` is the Guard stage alone. - $scopeArgs = @($env:DARLING_SCOPE_FILTER -split ' ' | Where-Object { $_ }) - # #5459 change 5: the whole-suite pass on a pull request leaves out the Cost=Slow classes the change does - # not reach (ci-select.py slow_skip). The Guard-only pass has none of them (a Guard class is never Slow). - $slowArgs = @() - if ($env:SLOW_EVENT -eq 'pull_request' -and $scopeArgs.Count -eq 0) { - $slowArgs = @(python .github/scripts/ci-select.py slow-skip --suite darling --event $env:SLOW_EVENT --repo $env:SLOW_REPO --pr $env:SLOW_PR | Where-Object { $_ } | ForEach-Object { '-class-'; "*.$_" }) - } - dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- @scopeArgs @slowArgs - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - # #5320: the statement-filter release gate. StatementCensus_PendingEntries_BlockARelease fails - # while any statement-column census entry or watched writer is pending, in every normal Darling - # test run (the step above), so a pull request that adds an unjudged column fails on its own. - # This step stays as the release-time backstop: it sets DARLING_RELEASE_CUT=1 and runs the one - # test by name, ahead of every publish and package step below, and only on the release event, - # so a release cannot ship an unhooked column even if the step above was skipped. - # ReleaseStatementGateWorkflowTests pins both halves, and that no other workflow sets the - # variable. Darling.Tests is already built (Release) by the build step above, so this runs - # the one test by name and nothing else. - - name: Statement filter release gate - if: github.event_name == 'release' - env: - DARLING_RELEASE_CUT: '1' - shell: pwsh - run: | - # A -method filter that matches nothing exits 0 with "Total: 0", so a renamed test would pass this gate - # without running. Fail unless the runner's own summary shows exactly the one test, with nothing failed, - # errored, skipped or not run. ReleaseStatementGateWorkflowTests binds the name to the test with nameof. - dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- -method "*StatementCensus_PendingEntries_BlockARelease*" 2>&1 | Tee-Object -FilePath statement-gate.log - if ($LASTEXITCODE -ne 0) { throw "The statement filter release gate failed (exit $LASTEXITCODE)." } - if (-not (Select-String -Path statement-gate.log -Pattern 'Total: 1, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0' -SimpleMatch -Quiet)) { - throw "The statement filter release gate did not run exactly one passing test; the summary above is not 'Total: 1, Errors: 0, Failed: 0, Skipped: 0, Not Run: 0'." - } - - - name: Get version - if: steps.fastpath.outputs.engaged != 'true' - id: version - shell: pwsh - run: | - $version = ([xml](Get-Content Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ } - if ([string]::IsNullOrWhiteSpace($version)) { throw "Read no version from Directory.Build.props." } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "Version: $version" - - # #2501: -r win-x64 --self-contained, and it makes the portable ZIP SMALLER, not bigger. - # The RID-agnostic framework-dependent publish this replaced copied every platform its - # packages ship - 537 MB of runtimes\ on a 565 MB tree, of which only the 52 MB win-x64 - # folder can ever load on Windows (DuckDB.NET.Bindings.Full is most of it, SkiaSharp and - # SqlClient behind it). Pinning the RID drops ~485 MB of unloadable native payload, which - # is far more than the bundled .NET/WPF/ASP.NET runtime adds back. Measured on the same - # commit and SDK: 565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB. - # - # The size was the secondary reason. The primary one is #2489: a framework-dependent ZIP - # unzipped onto a stock Windows Server dies on the .NET host's own "You must install .NET - # to run this application" before a line of our code runs, so Lite cannot report it, and - # #2499 could only ship a READ-ME-FIRST.txt next to the exe. Self-contained there is - # nothing to install and nothing to fail. That matters most for the nightly ZIP, which is - # the UAT download and is not offered as a Setup.exe at all. - # - # Lite/PerformanceMonitorLite.csproj declares RuntimeIdentifiers so the committed lock - # file covers this RID; see the comment there before removing either half. - - name: Publish Lite - if: steps.filter.outputs.lite == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite - - - name: Publish Lite (self-contained for Velopack) - if: github.event_name == 'release' - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack - - - name: Publish Darling Service - if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService - - - name: Publish Darling Viewer - if: steps.filter.outputs.darling == 'true' || steps.filter.outputs.core == 'true' || steps.filter.outputs.root == 'true' || github.event_name == 'release' - run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer - - - name: Publish Darling Viewer (self-contained for Velopack) - if: github.event_name == 'release' - run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack - - # Darling bundles a PostgreSQL 18 + TimescaleDB runtime (pg-runtime.zip) that ships beside - # the service exe; DarlingManagedPostgres extracts it on first run. The fetch script pulls - # ~340MB of pinned EDB/TimescaleDB archives, so this is release-only and cached. The key is - # the fetch script's own content hash (the SHA256 pins live inside it): a re-release with - # unchanged pins restores the assembled zip and skips both the download and the assembly, - # and any pin/version bump edits the script and invalidates the cache automatically. - - name: Cache Darling pg-runtime.zip - if: github.event_name == 'release' - id: cache-pg-runtime - uses: actions/cache@v6 - with: - path: Darling/artifacts/pg-runtime.zip - key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} - - - name: Build Darling pg-runtime.zip - if: github.event_name == 'release' && steps.cache-pg-runtime.outputs.cache-hit != 'true' - shell: pwsh - run: ./Darling/tools/fetch-pg-runtime.ps1 - - - name: Package release artifacts - if: github.event_name == 'release' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - New-Item -ItemType Directory -Force -Path releases - - # Lite ZIP - portable artifact for advanced/air-gapped users. The README points end - # users at Setup.exe (Velopack); this ZIP is the explicit fallback. - Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - - # upload-artifact is deliberately HELD at v6 (#1653): every signing step below consumes - # `steps.upload-*.outputs.artifact-id`, and v7 changes artifact archiving semantics (the - # `archive` parameter). The signing path only executes on `release: [published]`, so a broken - # bump surfaces at release time — bump only alongside a validated real signing run. - # Dependabot is configured to skip this major (see .github/dependabot.yml). - - name: Upload Lite for signing - if: github.event_name == 'release' - id: upload-lite - uses: actions/upload-artifact@v6 - with: - name: Lite-unsigned - path: publish/Lite/ - - - name: Stage Darling for signing - if: github.event_name == 'release' - shell: pwsh - run: | - $stage = 'publish/Darling-signing' - if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } - New-Item -ItemType Directory -Force -Path "$stage/viewer" | Out-Null - Copy-Item 'publish/DarlingService/*' $stage -Recurse - Copy-Item 'publish/DarlingViewer/*' "$stage/viewer" -Recurse - - - name: Upload Darling for signing - if: github.event_name == 'release' - id: upload-darling - uses: actions/upload-artifact@v6 - with: - name: Darling-unsigned - path: publish/Darling-signing/ - - # v3 of this action talks to SignPath's Pipeline Connector, which needs the SignPath - # GitHub App installed on this repository (v2's connector did not). Without it every - # attempt fails with "Failed to retrieve GitHub App token" (erikdarlingdata/PerformanceStudio#569, - # which hit this on that repo's first v3 release run). The app has been installed here - # since 2026-09-24. SignPath signs only a run's first 3 attempts: if signing keeps - # failing, fix the cause and start a fresh release run instead of re-running this one. - - name: Sign Lite - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v3 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'Lite' - github-artifact-id: '${{ steps.upload-lite.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Lite' - - - name: Sign Darling - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v3 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'Darling' - github-artifact-id: '${{ steps.upload-darling.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Darling' - - - name: Replace with signed artifacts - if: github.event_name == 'release' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - # Re-zip signed files into release archives - Remove-Item "releases/PerformanceMonitorLite-$version.zip" -ErrorAction SilentlyContinue - Compress-Archive -Path 'signed/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - - - name: Package Darling (signed) - if: github.event_name == 'release' - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - # One product, one zip (mirrors the one-zip-per-product convention above). signed/Darling - # already holds the signed tree in its final layout — the service at the archive root (its - # darling.sample.json alongside), the viewer in a viewer\ subfolder. Drop pg-runtime.zip - # beside the service exe, exactly where DarlingManagedPostgres looks (AppContext.BaseDirectory) - # and extracts it on first run. The EDB PostgreSQL binaries inside pg-runtime.zip are shipped - # as opaque data and were never signed. - Copy-Item 'Darling/artifacts/pg-runtime.zip' 'signed/Darling' - - Remove-Item "releases/PerformanceMonitorDarling-$version.zip" -ErrorAction SilentlyContinue - Compress-Archive -Path 'signed/Darling/*' -DestinationPath "releases/PerformanceMonitorDarling-$version.zip" -Force - - # The Velopack path packs a SEPARATE self-contained build (publish/Lite-velopack, - # publish/DarlingViewer-velopack -- the "self-contained for Velopack" steps above), and - # these rounds sign that payload before `vpk pack` consumes it. They are what puts a - # signature on lib/app/.exe inside each .nupkg and on current/.exe inside each - # Portable.zip; `vpk pack` below reads the signed output, not the raw publish directory. - - name: Upload Lite (Velopack) for signing - if: github.event_name == 'release' - id: upload-lite-velopack - uses: actions/upload-artifact@v6 - with: - name: Lite-Velopack-unsigned - path: publish/Lite-velopack/ - - - name: Upload Darling Viewer (Velopack) for signing - if: github.event_name == 'release' - id: upload-darlingviewer-velopack - uses: actions/upload-artifact@v6 - with: - name: DarlingViewer-Velopack-unsigned - path: publish/DarlingViewer-velopack/ - - - name: Sign Lite (Velopack) - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v3 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'Lite' - github-artifact-id: '${{ steps.upload-lite-velopack.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Lite-Velopack' - - # The remote-seat viewer (#1555) has its OWN 'DarlingViewer' artifact configuration — the - # co-located-zip 'Darling' slug signs a service+viewer\ tree layout, which does not match - # this self-contained viewer-at-root publish. Every artifact configuration named in this - # workflow lives outside the repo on signpath.io, and a step naming a slug that does not - # exist fails the release — the standing #1340 SignPath prerequisite. - - name: Sign Darling Viewer (Velopack) - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v3 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'DarlingViewer' - github-artifact-id: '${{ steps.upload-darlingviewer-velopack.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/DarlingViewer-Velopack' - - - name: Create Velopack releases (Lite + Darling Viewer) - if: github.event_name == 'release' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.VERSION }} - run: | - # Pin vpk to the Velopack library version (keep in sync with the Velopack - # PackageReference in PerformanceMonitorLite.csproj). - dotnet tool install -g vpk --version 1.2.158 - New-Item -ItemType Directory -Force -Path releases/velopack-lite - New-Item -ItemType Directory -Force -Path releases/velopack-darlingviewer - - # Lite: download previous + pack (from the SIGNED velopack output, not the raw publish dir) - vpk download github --repoUrl https://github.com/${{ github.repository }} --channel lite -o releases/velopack-lite --token $env:GH_TOKEN - vpk pack -u PerformanceMonitorLite -v $env:VERSION -p signed/Lite-Velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite - - # Darling Viewer remote-seat installer (#1555): download previous + pack (from the SIGNED - # velopack output). Its own 'darlingviewer' channel/delta feed, separate from the co-located - # viewer inside PerformanceMonitorDarling-*.zip (which stays plain-zip only). - vpk download github --repoUrl https://github.com/${{ github.repository }} --channel darlingviewer -o releases/velopack-darlingviewer --token $env:GH_TOKEN - vpk pack -u PerformanceMonitorDarlingViewer -v $env:VERSION -p signed/DarlingViewer-Velopack -e PerformanceMonitor.Darling.Viewer.exe -o releases/velopack-darlingviewer --channel darlingviewer - - # #3288: Velopack GENERATES three executables during `vpk pack` — the portable launcher - # stub, Update.exe (Squirrel.exe under its internal name) and Setup.exe — so no pre-pack - # round can reach them. They are signed here, in one more Action request covering both - # products. - # - # It has to be an Action step. SignPath's open-source signing policy requires origin - # verification through a trusted build system, so a request is accepted only through - # signpath/github-action-submit-signing-request, which attests repository, branch, commit - # and build-job URL. `vpk pack --signTemplate` would have to reach SignPath from inside - # packing, where no workflow step can run, and a raw API-token submission is answered - # "the selected signing policy requires that the signing request is submitted via a - # trusted build system" with 403. - # - # This reaches the two standalone assets and not the .nupkg interior. releases..json - # records a SHA256 and a Size for each .nupkg and for nothing else, and the delta patches - # against those bytes, so the copies of the stub and Squirrel.exe inside the .nupkg stay - # unsigned — allowlisted by member path AND container in verify_release_signatures.py, so - # the same two names anywhere else still fail the guard below. - # - # Both products go in one request, so this costs one approval however many files it - # carries. The staging layout gives each product its own subdirectory because both - # portable archives contain an Update.exe and a flat batch would collide. - - name: Collect the executables Velopack generates - if: github.event_name == 'release' - shell: pwsh - run: | - python3 .github/scripts/postpack_signing.py collect --stage postpack/unsigned --manifest postpack/manifest.json --product lite=releases/velopack-lite --product darlingviewer=releases/velopack-darlingviewer - - - name: Upload the Velopack-generated executables for signing - if: github.event_name == 'release' - id: upload-postpack - uses: actions/upload-artifact@v6 - with: - name: Velopack-generated-unsigned - path: postpack/unsigned/ - - # 'SignTemplate' is the artifact configuration for a flat set of PE files at arbitrary - # paths, declared on signpath.io as `**/*.exe` and `**/*.dll` with min-matches="0" on - # both. min-matches="0" means an EMPTY submission would succeed and sign nothing, which - # the per-product configurations refuse with min-matches="1"; postpack_signing.py's - # collect mode asserts a non-empty staging set for exactly that reason. - - name: Sign the executables Velopack generates - if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v3 - with: - api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' - organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' - project-slug: 'PerformanceMonitor' - signing-policy-slug: 'release-signing' - artifact-configuration-slug: 'SignTemplate' - github-artifact-id: '${{ steps.upload-postpack.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'postpack/signed' - - # Setup.exe is overwritten; each Portable.zip is rebuilt member by member so every entry - # it does not replace keeps its local header, name, extra field and raw compressed bytes, - # and the rebuild is verified against a snapshot of the original before it replaces it. - - name: Write the signed Velopack executables back - if: github.event_name == 'release' - shell: pwsh - run: | - python3 .github/scripts/postpack_signing.py apply --signed postpack/signed --manifest postpack/manifest.json - - # After the write-back, so it records the bytes that ship. Its census is `releases/*.zip`, - # which is not recursive: it covers the two plain product archives and does not reach the - # Velopack assets in releases/velopack-*/, so Setup.exe and Portable.zip carry no entry - # here. releases..json is the integrity record for the .nupkg files, and nothing - # publishes a checksum for the two files the step above signs. - - name: Generate checksums - if: github.event_name == 'release' - shell: pwsh - run: | - $checksums = Get-ChildItem releases/*.zip | ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() - "$hash $($_.Name)" - } - # LF and no BOM, written explicitly (#2383). Out-File on Windows emits CRLF whatever - # the encoding, and `shasum -c` on macOS/Linux then takes the trailing \r as part of - # the FILENAME - every entry reports "No such file or directory / FAILED open or - # read", which is precisely what a tampered download looks like. The hashes were - # always right; the file just could not be used by the tool it exists for, on the two - # platforms where that tool is the default. A BOM breaks the first line the same way. - # - # There is a SECOND copy of this step in nightly.yml. #2384 fixed only this one, and the - # nightly went on publishing CRLF for another day; keep them in step. - [IO.File]::WriteAllText( - "$PWD/releases/SHA256SUMS.txt", - (($checksums -join "`n") + "`n"), - [Text.UTF8Encoding]::new($false)) - Write-Host "Checksums:" - $checksums | ForEach-Object { Write-Host $_ } - - # #3288's guard. It walks the artifacts on disk -- including the executables INSIDE the - # zips and nupkgs, which is where the launcher stub and Update.exe live -- so it needs no - # network and no published release. Two nupkg members are allowlisted by member path and - # container; everything else must carry a certificate table, and an empty census is a - # failure rather than a pass. - - name: Assert every published executable is signed - if: github.event_name == 'release' - shell: pwsh - run: python3 .github/scripts/verify_release_signatures.py --verify-dir releases - - - name: Upload release assets - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload ${{ github.event.release.tag_name }} releases/*.zip releases/SHA256SUMS.txt --clobber - - - name: Upload Lite Velopack artifacts - if: github.event_name == 'release' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Upload the Velopack channel feed with gh, not `vpk upload github --merge`. - # vpk's Octokit uploader dies mid-transfer on the large .nupkg (SslStream write, - # exhausts its own retries) and is NOT idempotent -- once releases.lite.json exists - # on the release it hard-fails ("merging release files is not supported"), so a - # re-run can't recover (#3520). vpk download+pack has already assembled the complete - # channel feed locally; gh just transfers it and --clobber makes a retry idempotent. - # Upload the nupkgs (including the downloaded prior full that the on-disk - # releases.lite.json references -- the index and every package it names must land - # together), the Setup and Portable, and the channel index. Skip the inert - # assets.lite.json / RELEASES-lite, which the Velopack client never fetches. - $dir = "releases/velopack-lite" - $files = @(Get-ChildItem -LiteralPath $dir -File | Where-Object { $_.Name -match '\.nupkg$' -or $_.Name -like '*-Setup.exe' -or $_.Name -like '*-Portable.zip' -or $_.Name -eq 'releases.lite.json' }).FullName - gh release upload "${{ github.event.release.tag_name }}" $files --clobber - - - name: Upload Darling Viewer Velopack artifacts - if: github.event_name == 'release' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # See the Lite step above for why this uses gh instead of `vpk upload github --merge`. - $dir = "releases/velopack-darlingviewer" - $files = @(Get-ChildItem -LiteralPath $dir -File | Where-Object { $_.Name -match '\.nupkg$' -or $_.Name -like '*-Setup.exe' -or $_.Name -like '*-Portable.zip' -or $_.Name -eq 'releases.darlingviewer.json' }).FullName - gh release upload "${{ github.event.release.tag_name }}" $files --clobber - - # #1587: gated-live Darling coverage BEFORE merge, not only in the nightly. Darling.Tests has - # live-PostgreSQL tests (the *_AgainstDevPostgres classes) gated on DARLING_TEST_PG, which the - # build job above never sets — so those tests only ever ran post-merge in nightly.yml. That is - # exactly how #1586's alter_job(bigint) bug merged AND deployed clean: the test that catches it is - # gated-live, and PR CI did not run it. This job stands up a throwaway PostgreSQL + TimescaleDB - # from the bundled pg-runtime and runs the FULL Darling suite against it — but ONLY when Darling - # code (or this workflow) changed, so a Lite/Dashboard-only PR pays nothing. On a non-Darling - # change every step below no-ops via the path filter and the job still reports SUCCESS, so it can - # be made a required check without blocking unrelated PRs (the same always-runs-reports-a-result - # shape the build job uses for doc-only changes). Mirrors the nightly darling-pg job step-for-step; - # the one live-SQL-Server E2E stays skipped (DARLING_TEST_SQL unset — no SQL Server on the runner). - darling-pg: - name: Darling PG tests - # Fail fast (#5459): on a pull request the shards start only after the Guard tests job succeeded; every - # other event runs them whatever the guards said. `!cancelled()` is what lets a job whose `needs` includes - # a failed one decide for itself, and the result job below turns the skip into the failure it should be. - needs: [gate, guard-tests] - if: ${{ !cancelled() && needs.gate.outputs.run == 'true' && (needs.guard-tests.result == 'success' || github.event_name != 'pull_request') }} + proof: + name: Proof ${{ matrix.shard }} runs-on: windows-latest - # Max observed on a warm cache is ~3m40s; a cold pg-runtime cache adds a ~340MB fetch. - # 30 minutes is 3x headroom over the cold path — past that, something is hung (pg_ctl -w - # waiting on a cluster that will never come up), and the default 6h timeout would hold a - # shared-pool Windows runner hostage for the duration. The build job above deliberately - # has NO timeout: on release it waits on SignPath's manual approval gate, which can - # legitimately take hours. - timeout-minutes: 30 - # SIX shards cut by CLASS-NAME HASH (#3890 made it three, #5433 six), the machinery #3887 proved on - # the Lite suite. #3890 measured ~190s fixed plus 266s of tests; the suite has since grown to - # about 2,060 classes and 27,000 tests, and the three shards of eight merge-train and dev runs on - # 2026-10-06 took 13 to 21 minutes each. Per shard that was ~5m fixed (checkout and .NET setup ~1m, - # restore ~15s, Build Darling.Tests 2m20s to 3m30s, the PostgreSQL clusters ~30s) plus 10 to 17 - # minutes in the one step that runs the tests (two runner invocations, because the class list is - # chunked under the command-line cap), ~39 minutes of tests in all. The cut was - # also uneven: shard 1 ran 14 to 17 minutes of tests every time against 10 to 13 for shards 0 - # and 2, with about the same number of tests (time per test 0.10s against 0.065s), so a few slow - # classes decide a shard. A hash cut ignores that, and no per-class times are recorded for this - # job, so the model is the spread of the three observed shards (a relative standard deviation of - # ~9% of a shard's tests, which grows with the square root of N-1 for the same slow classes) and the - # expected longest shard of N: 3 shards ~19.5m (observed 19 to 21), 4 ~16m, 5 ~14m, 6 ~13m, - # 8 ~11m, 10 ~10m. Six is the stop: the longest shard drops under the build job (13 to 14 min) and - # the slowest Lite shard (15 to 16 min), which is what bounds the run, and 8 would buy about two - # more minutes of a number those two jobs already hide, for two more runners that each pay the ~5 - # minutes of fixed cost. Check the first merge trains: if the longest shard is over ~14 minutes, - # the cut is unlucky and eight is the next step. The cut divides by the matrix size - # (strategy.job-total), so changing the shard list below cannot leave a class in no shard. - # - # The prebuilt-ladder image this was filed alongside is dead on measurement: the migration - # ladder replays in ~1s (timed by running one live class twice against one store) and the whole - # cluster costs 8s to initdb and start. There was nothing there to save. + timeout-minutes: 50 strategy: fail-fast: false matrix: - shard: [0, 1, 2, 3, 4, 5] - permissions: - contents: read - + shard: [0, 1, 2, 3] steps: - uses: actions/checkout@v7 - # Only do the expensive TimescaleDB work when code Darling compiles changed — or when the gate - # itself changed, so a change to the gate is exercised by the gate (this is what makes the PR - # that edits it validate itself end-to-end). Doc-only Darling edits don't trigger it. Skipped - # entirely on release: the dev push that produced the release commit already ran it, so the - # filter step doesn't run and every step below no-ops. - - name: Detect changed paths - id: filter - if: github.event_name != 'release' - uses: dorny/paths-filter@v4 - with: - # On push, compare against the previous commit on this branch (mirrors the build job); - # on pull_request, an empty base makes the action diff against the PR base branch. - base: ${{ github.event_name == 'push' && github.event.before || '' }} - # Emit the matched file list so the gate-decision step below can NAME what it matched, or - # name what it did not match, instead of asserting a reason the decision came out as it did. - # - # json rather than shell. The lists reach the notice through `env:` and never through a - # `${{ }}` interpolation into the script text, which is the pull_request script-injection - # footgun — and a runtime parameter expansion does not honour shell's backslash escapes, so - # the `shell` format's `a\ b.md` field-splits into two entries that name no file. Eight - # tracked paths under Screenshots/ carry spaces, and that directory is on the documentation - # allowlist, so it is the shape a screenshot PR reaches rather than a hypothetical. A JSON - # array is self-delimiting, so the notice prints it verbatim and reads unambiguously for any - # filename without parsing it at all. - list-files: json - # ONE gate, shared with the Darling Linux build job below through dorny's `filters: ` - # form, rather than a list of paths per job: the shared libraries Darling compiles against - # are one dependency fact, and this job's own copy of it was empty while its sibling's was - # not (#3116). What is in the gate, and why each entry is there, lives in that file. - filters: .github/darling-paths-filter.yml - - # A job that reports success having quietly run nothing looks identical to one that tested - # everything. Costs one step; buys a log you can point at when asking "did this actually get - # tested?" — so what it prints has to be the answer to that question. - # - # It reports the DECISION and the files the decision was made from: which changed files are in - # the gate, or how many changed files are in it and what they were. Never a cause. A hard-coded - # cause is a second, unverified claim riding on a true one, and it reads as authoritative - # precisely because the surrounding gate is right — the skip notice named "documentation-only" - # as the only explanation for a false gate, and on #3114 (two files, a PostgreSQL collector and - # its test) both halves of that were false. - - name: Report the Darling PG gate decision - shell: bash - env: - DARLING: ${{ steps.filter.outputs.darling }} - DARLING_COUNT: ${{ steps.filter.outputs.darling_count }} - # A list goes into the environment only up to 1,000 files. Linux refuses to start a process whose - # single environment string passes 128 KiB, so the 2,843-file dev-to-main release PR failed here - # before the script ran ("Argument list too long"). The counts always print; the Detect changed - # paths step above lists every file either way. - DARLING_FILES: ${{ fromJSON(steps.filter.outputs.darling_count || '0') <= 1000 && steps.filter.outputs.darling_files || '"(more than 1,000 files, listed in the Detect changed paths step)"' }} - ALL_COUNT: ${{ steps.filter.outputs.all_count }} - ALL_FILES: ${{ fromJSON(steps.filter.outputs.all_count || '0') <= 1000 && steps.filter.outputs.all_files || '"(more than 1,000 files, listed in the Detect changed paths step)"' }} - run: | - set -euo pipefail - - if [ "${{ github.event_name }}" = "release" ]; then - echo "::notice title=Darling PG tests skipped::Release event - the dev push that produced this commit already ran them." - exit 0 - fi - - if [ "${DARLING:-}" = "true" ]; then - echo "::notice title=Darling PG tests running::${DARLING_COUNT:-0} of ${ALL_COUNT:-0} changed files are in the Darling gate (.github/darling-paths-filter.yml)." - echo "Changed files in the gate: ${DARLING_FILES:-[]}" - else - echo "::notice title=Darling PG tests skipped::None of the ${ALL_COUNT:-0} changed files are in the Darling gate (.github/darling-paths-filter.yml), so the TimescaleDB suite did not run on this commit." - echo "Changed files, none of them in the gate: ${ALL_FILES:-[]}" - fi - - # Shared build (#5459 change 2): this run's Guard job already built this test project in Release and uploaded - # it before running its tests (see the header on that job). Fetch it instead of restoring and building again. - # continue-on-error: no artifact (a release, a docs-only change, a Guard build that failed) is the normal - # fallback, not a failure, and the check below turns it into a notice and the old restore and build. - - name: Fetch the Guard job's build of the test project - id: guard-build - if: steps.filter.outputs.darling == 'true' && needs.guard-tests.outputs.darling_build_artifact != '' - continue-on-error: true - uses: actions/download-artifact@v6 - with: - name: ${{ needs.guard-tests.outputs.darling_build_artifact }} - path: . - - # A fetched build that is not for THIS workspace and commit is an error, never a quiet fallback: the tests - # find the repo root through [CallerFilePath], so a binary built in another path would read another tree. - - name: Check the fetched build is for this workspace and commit - id: guard-build-check - if: steps.filter.outputs.darling == 'true' - shell: pwsh - env: - FETCH_OUTCOME: ${{ steps.guard-build.outcome }} - THIS_WORKSPACE: ${{ github.workspace }} - THIS_SHA: ${{ github.sha }} - REQUIRED_FILES: Darling/Darling.Tests/bin/Release/net10.0-windows/Darling.Tests.dll;Darling/Darling.Tests/obj/project.assets.json - run: | - $ErrorActionPreference = 'Stop' - if ($env:FETCH_OUTCOME -ne 'success') { - Write-Host "::notice title=Building the test project here::No Guard job build was fetched for this run (fetch outcome '$env:FETCH_OUTCOME'; skipped means the Guard job published none), so this job restores and builds for itself and runs every class, Stage=Guard included." - 'used=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - exit 0 - } - $origin = @{} - if (Test-Path guard-build-origin.txt) { - foreach ($line in Get-Content guard-build-origin.txt) { if ($line -match '^(\w+)=(.*)$') { $origin[$Matches[1]] = $Matches[2] } } - } - if ($origin['workspace'] -ne $env:THIS_WORKSPACE -or $origin['sha'] -ne $env:THIS_SHA) { - Write-Host "::error title=Guard build is for another workspace or commit::The fetched build says workspace '$($origin['workspace'])' at '$($origin['sha'])'; this job is at '$env:THIS_WORKSPACE' at '$env:THIS_SHA'." - exit 1 - } - foreach ($file in ($env:REQUIRED_FILES -split ';')) { - if (-not (Test-Path $file)) { - Write-Host "::error title=Guard build is incomplete::The fetched build has no $file." - exit 1 - } - } - Write-Host "::notice title=Using the Guard job's build::The test project was built by the Guard job of this run at the same workspace and commit, so this job skips its restore and build and leaves out the Stage=Guard classes that job ran." - 'used=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - name: Setup .NET 10.0 - if: steps.filter.outputs.darling == 'true' uses: actions/setup-dotnet@v6 with: global-json-file: global.json - cache: ${{ steps.guard-build-check.outputs.used != 'true' }} + cache: true cache-dependency-path: '**/packages.lock.json' # Same cache key the release job and nightly.yml use (the fetch script's own content hash), so # a warm cache from any of the three means no ~340MB EDB/TimescaleDB download here. - name: Cache Darling pg-runtime.zip - if: steps.filter.outputs.darling == 'true' id: cache-pg-runtime uses: actions/cache@v6 with: @@ -1577,12 +40,10 @@ jobs: key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} - name: Build Darling pg-runtime.zip (cache miss only) - if: steps.filter.outputs.darling == 'true' && steps.cache-pg-runtime.outputs.cache-hit != 'true' shell: pwsh run: ./Darling/tools/fetch-pg-runtime.ps1 - name: Extract pg-runtime - if: steps.filter.outputs.darling == 'true' shell: pwsh run: | $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" @@ -1592,62 +53,13 @@ jobs: [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } - # store-upgrade-gated (#4326): the same fixture nightly.yml builds, only when a changed path is in - # .github/darling-paths-filter.yml's store-upgrade filter, so a PR that cannot move the upgrade E2Es' - # answer does not pay for the ~680MB of pinned archives. This runs on every shard, not just the ones - # today's upgrade classes happen to hash into: a class's shard must never decide whether it runs, so a - # renamed class or a new upgrade E2E landing on a different shard still gets the fixture. Same cache - # key as nightly.yml, so a warm cache from either means no download here. - - name: Cache upgrade-fixture downloads (previous-major runtime) - if: steps.filter.outputs.darling == 'true' && steps.filter.outputs.store-upgrade == 'true' - uses: actions/cache@v6 - with: - path: Darling/artifacts/upgrade-fixture/work/downloads - key: upgrade-fixture-${{ runner.os }}-${{ hashFiles('Darling/tools/new-upgraded-store-fixture.ps1') }} - - # -SkipNew: the CURRENT runtime is already extracted above from pg-runtime.zip, which is what - # DARLING_TEST_PGRUNTIME_NEWZIP points at below. This step only needs the old sides: the previous - # major, and (#3906) the previous release on the current major. - - name: Build previous-major runtime for the upgrade fixture - if: steps.filter.outputs.darling == 'true' && steps.filter.outputs.store-upgrade == 'true' - shell: pwsh - run: ./Darling/tools/new-upgraded-store-fixture.ps1 -SkipNew - - name: Restore Darling.Tests - if: steps.filter.outputs.darling == 'true' && steps.guard-build-check.outputs.used != 'true' run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - name: Build Darling.Tests - if: steps.filter.outputs.darling == 'true' && steps.guard-build-check.outputs.used != 'true' run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - # Throwaway cluster from the bundled runtime. initdb TRUST auth is acceptable ONLY here: an - # ephemeral CI runner, loopback-only, throwaway data (the product default is scram-sha-256 + - # a generated credential). Superuser is "darling" (not "postgres") so CREATE SCHEMA ... - # AUTHORIZATION darling in the V8 schema-split migration resolves. Settings mirror - # DarlingManagedPostgres.BuildConfAppend (timescaledb preload, port 5541, loopback bind) - # and BuildWorkerSizingConfAppend (the two worker settings below). - # - # #1888: the worker settings are NOT optional garnish. PostgreSQL's default - # max_worker_processes = 8 cannot launch TimescaleDB's per-hypertable compression, - # retention and continuous-aggregate policy jobs — the postmaster logs "failed to start a - # background worker" storms and most policy runs never happen. Without them this job tested a - # configuration NO customer runs (the product refuses to: BuildWorkerSizingConfAppend writes - # these on every managed start), and worse, made failures luck-of-the-slot rather than - # reproducible — which is exactly how #1862's compression flake failed twice on CI in two - # unrecognizably different ways and could not be reproduced locally at all. - # - # The values are the product's own derivation from the live hypertable count - # (TimescaleSupport.HypertableCount = the 62-collector catalog + collection_log = 63): - # timescaledb.max_background_workers = HypertableCount + 2 = 74 - # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 85 - # Hard-coded here because a workflow cannot call into the product — so - # CiClusterWorkerSizingTests parses THIS FILE and fails the build if either number stops - # matching the formula as collectors are added, and CiClusterWorkerSizingLiveTests asserts - # the running cluster actually serves them (a conf line that never took effect is - # indistinguishable from one that did, by inspection). - name: Initialize and start throwaway PostgreSQL - if: steps.filter.outputs.darling == 'true' shell: pwsh run: | $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" @@ -1668,1486 +80,66 @@ jobs: & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } - # #4160: three more small throwaway clusters, one per log-format shape, so the - # csvlog/jsonlog/auto_explain live tests stop skipping in CI (their env vars were never set — - # every run "passed" without ever exercising the planted-input proofs #4053/#4058 added). No - # TimescaleDB jobs run on these targets, so the worker settings above (sized for the 5541 - # store's hypertable count) would be needless headroom here; 8/16 is plenty. - # 5542 (csvlog): lights up PgLogEventsCsvlogLiveTests, PgDeadlocksCsvlogLiveTests, - # PgRaiseShapedRecordsLiveTests (DARLING_TEST_PG_CSVLOG) and PgPlanCaptureCsvLiveTests, - # which LOADs auto_explain per session on top of it (DARLING_TEST_PG_CSVLOG_AUTOEXPLAIN). - # deadlock_timeout is shortened so a two-session deadlock actually fires inside a test's - # patience; log_min_messages is notice, not the tighter warning/error, because LOG ranks - # ABOVE ERROR in Postgres's server-log severity order and a stricter filter would silently - # drop the very deadlock/duration LOG rows these tests read. - # 5543 (jsonlog): PgLogEventsJsonlogLiveTests (DARLING_TEST_PG_JSONLOG) — it also asserts - # that csvlog is OFF on this target, so this cluster must not preload csvlog as a sibling. - # 5544 (stderr,csvlog): PgPlanCaptureLiveTests (DARLING_TEST_PG_AUTOEXPLAIN) and - # PgLogTailStderrSiblingLiveTests (DARLING_TEST_PG_CSVLOG_SIBLING) — both need stderr - # present alongside csvlog, and the %Q-bearing log_line_prefix those tests parse. - # 5545 (stderr, %u@%d prefix): TheSelfHostedCollector_ReadsTheTargetsOwnLog_EndToEnd - # (DARLING_TEST_PG_LOG_TARGET) and AgainstAUtcTarget_TheLogQueriesReadTheSettingWithTheTail_ - # AndStoreWhatTheServerWrote (DARLING_TEST_PG_UTC_LOG_TARGET) share this one cluster: both need - # logging_collector on and log_timezone UTC, and the first also needs log_connections, - # log_disconnections and log_lock_waits on so its workload (the next step) leaves a real event of - # each family — error, connection, lock_wait — in the log before the tests run. The %u@%d-bearing - # prefix is what the second test's live assertion (reader.GetName(1) == "log_timezone") needs. - # 5546 (stderr), 5547 (csvlog), 5548 (jsonlog): the #4699 log-rotation pins - # (PgServerLogTailRotationLiveTests and PgDeadlocksResumeLiveTests share 5546 through - # DARLING_TEST_PG_LOGROTATE; PgServerLogTailCsvJsonRotationLiveTests uses 5547/5548 through - # DARLING_TEST_PG_LOGROTATE_CSV/_JSON). They call pg_rotate_logfile(), so each gets its own - # cluster: a rotation on 5542/5543/5545 would move the file under the other log classes reading - # them (#4702). All three need logging_collector on, log_timezone UTC, log_lock_waits on and the - # short deadlock_timeout the helper sets; 5546 also needs the %m [%p] %Q prefix the deadlock reader - # parses. The three classes share one serial xunit collection (pg-log-rotation). - - name: Initialize and start the log-format PostgreSQL clusters - if: steps.filter.outputs.darling == 'true' + - name: Start the CPU burners (one per logical processor, for the job's life) shell: pwsh run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - - # clusterDataDir, not dataDir: CiClusterWorkerSizingTests' ConfAppend regex is keyed on the - # literal path "$dataDir\postgresql.conf" the 5541 cluster's step writes, and these clusters' - # own worker counts (8/16, not the hypertable-derived 74/85) would fail that guard as drift if - # this function reused the same variable name. - function Start-LogFormatCluster($clusterDataDir, $logFile, $port, $extraConfLines) { - & "$bin\initdb.exe" -D $clusterDataDir -U darling -A trust --encoding=UTF8 - if ($LASTEXITCODE -ne 0) { throw "initdb failed for port $port ($LASTEXITCODE)" } - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "port = $port" - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 8" - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "max_worker_processes = 16" - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "deadlock_timeout = '100ms'" - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "compute_query_id = on" - Add-Content -Path "$clusterDataDir\postgresql.conf" -Value "log_min_messages = notice" - foreach ($line in $extraConfLines) { Add-Content -Path "$clusterDataDir\postgresql.conf" -Value $line } - & "$bin\pg_ctl.exe" -D $clusterDataDir -l $logFile -w start - if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed for port $port ($LASTEXITCODE)" } - & "$bin\createdb.exe" -h 127.0.0.1 -p $port -U darling darling - if ($LASTEXITCODE -ne 0) { throw "createdb failed for port $port ($LASTEXITCODE)" } + $n = [Environment]::ProcessorCount + Write-Host "logical processors: $n" + $pids = @() + for ($i = 0; $i -lt $n; $i++) { + $p = Start-Process -FilePath pwsh -ArgumentList '-NoProfile','-Command','$x = 1; while ($true) { $x = ($x * 31 + 7) % 1000003 }' -PassThru -WindowStyle Hidden + $pids += $p.Id } + $pids -join ',' | Out-File -FilePath "$env:RUNNER_TEMP\burner-pids.txt" -Encoding ascii + Write-Host "burner pids: $($pids -join ',')" - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-csvlog" "$env:RUNNER_TEMP\darling-pg-csvlog.log" 5542 @( - "logging_collector = on", - "log_destination = 'csvlog'", - "log_timezone = 'UTC'" - ) - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-jsonlog" "$env:RUNNER_TEMP\darling-pg-jsonlog.log" 5543 @( - "logging_collector = on", - "log_destination = 'jsonlog'" - ) - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-stderrcsvlog" "$env:RUNNER_TEMP\darling-pg-stderrcsvlog.log" 5544 @( - "logging_collector = on", - "log_destination = 'stderr,csvlog'", - "log_line_prefix = '%m [%p] %Q '" - ) - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-logtarget" "$env:RUNNER_TEMP\darling-pg-logtarget.log" 5545 @( - "logging_collector = on", - "log_destination = 'stderr'", - "log_timezone = 'UTC'", - "log_line_prefix = '%m %u@%d [%p] '", - "log_connections = on", - "log_disconnections = on", - "log_lock_waits = on" - ) - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-rotate-stderr" "$env:RUNNER_TEMP\darling-pg-rotate-stderr.log" 5546 @( - "logging_collector = on", - "log_destination = 'stderr'", - "log_timezone = 'UTC'", - "log_line_prefix = '%m [%p] %Q '", - "log_lock_waits = on" - ) - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-rotate-csvlog" "$env:RUNNER_TEMP\darling-pg-rotate-csvlog.log" 5547 @( - "logging_collector = on", - "log_destination = 'csvlog'", - "log_timezone = 'UTC'", - "log_lock_waits = on" - ) - Start-LogFormatCluster "$env:RUNNER_TEMP\darling-pgdata-rotate-jsonlog" "$env:RUNNER_TEMP\darling-pg-rotate-jsonlog.log" 5548 @( - "logging_collector = on", - "log_destination = 'jsonlog'", - "log_timezone = 'UTC'", - "log_lock_waits = on" - ) - - # The workload the first test's doc comment promises: one real event of each family — error, - # connection, lock_wait — written by the server itself before the tests read the log. Bounded and - # loud: every step has its own timeout, and a missing precondition throws rather than leaving the - # log quiet and the later test assertion to fail with no explanation of why. - # psql takes libpq flags, not an Npgsql-style connection string (the tests' own env vars below - # stay in Npgsql form; they're read by Npgsql, not psql). - $logTargetArgs = @("-h", "127.0.0.1", "-p", "5545", "-U", "darling", "-d", "darling") - - # Connection + disconnection: an ordinary short-lived connection, which with log_connections/ - # log_disconnections on writes both a "connection received"/"connection authorized" pair and a - # "disconnection" line. - & "$bin\psql.exe" @logTargetArgs -v ON_ERROR_STOP=1 -c "SELECT 1" - if ($LASTEXITCODE -ne 0) { throw "5545 connection-event workload failed ($LASTEXITCODE)" } - - # Error: a division by zero the server itself reports at ERROR. - & "$bin\psql.exe" @logTargetArgs -c "SELECT 1/0" - if ($LASTEXITCODE -eq 0) { throw "5545 error-event workload: SELECT 1/0 unexpectedly succeeded" } - - # Lock wait: one background session holds LOCK TABLE inside a transaction and sleeps past - # deadlock_timeout (100ms on this cluster, see Start-LogFormatCluster) while a second session - # requests the same table and blocks, so log_lock_waits fires a "still waiting for"/"acquired" pair. - # Both are bounded jobs so a stuck lock can never hang the workflow. - & "$bin\psql.exe" @logTargetArgs -v ON_ERROR_STOP=1 -c "CREATE TABLE IF NOT EXISTS lock_target_5545 (id int)" - if ($LASTEXITCODE -ne 0) { throw "5545 lock-wait workload: table setup failed ($LASTEXITCODE)" } - $holder = Start-Job -ScriptBlock { - param($bin, $connArgs) - & "$bin\psql.exe" @connArgs -v ON_ERROR_STOP=1 -c "BEGIN; LOCK TABLE lock_target_5545; SELECT pg_sleep(3); COMMIT;" - } -ArgumentList $bin, $logTargetArgs - Start-Sleep -Milliseconds 500 - & "$bin\psql.exe" @logTargetArgs -v ON_ERROR_STOP=1 -c "BEGIN; LOCK TABLE lock_target_5545; COMMIT;" - if ($LASTEXITCODE -ne 0) { throw "5545 lock-wait workload: waiter session failed ($LASTEXITCODE)" } - Wait-Job $holder -Timeout 15 | Out-Null - if ($holder.State -ne 'Completed') { throw "5545 lock-wait workload: holder job did not complete ($($holder.State))" } - Receive-Job $holder | Out-Null - Remove-Job $holder - - # Failed login: a FATAL the target's own authentication path writes, distinct from the ERROR above. - # Targets a deliberately nonexistent database. - & "$bin\psql.exe" "-h" "127.0.0.1" "-p" "5545" "-U" "darling" "-d" "does_not_exist_5545" -c "SELECT 1" 2>$null - if ($LASTEXITCODE -eq 0) { throw "5545 failed-login workload: connecting to a nonexistent database unexpectedly succeeded" } - # the failed login is expected; don't let its exit code end the step - $global:LASTEXITCODE = 0 - - # DARLING_TEST_PG lights up the [Collection("live-postgres")] classes; DARLING_TEST_PGRUNTIME - # lights up the managed-bootstrap E2E. DARLING_TEST_SQL is intentionally unset — the one - # live-SQL-Server E2E stays skipped (no SQL Server on the runner). Full suite (not a filtered - # subset): the ungated ~3s overlap with the build job is negligible and a filter could hide a - # test the way narrow filters have bitten before. - # - # DARLING_TEST_PG_CSVLOG/_CSVLOG_AUTOEXPLAIN point at the 5542 csvlog cluster, - # DARLING_TEST_PG_JSONLOG at the 5543 jsonlog cluster, DARLING_TEST_PG_AUTOEXPLAIN/ - # _CSVLOG_SIBLING at the 5544 stderr+csvlog cluster, and DARLING_TEST_PG_LOG_TARGET/ - # _UTC_LOG_TARGET at the 5545 cluster (the two self-hosted log end-to-end tests). DARLING_TEST_PG - # (5541) stays the store for the classes that read one target's log while writing to a separate store. - # #5587: run 37824250187 attempt 1 (shard 3) hit the JOB's 30-minute timeout inside this step. GitHub then - # cancelled the job and left every later step (the stop, both uploads) pending, kept no log for the job, and the - # per-test timing was never written (the runner writes it when a chunk ends). Shards 0 to 5 normally take 6 to - # 11 minutes in this step. A STEP-level timeout ends only this step: the step fails, the job goes on, and the - # `always()` steps below run, so the PostgreSQL log and the runner output are uploaded. It sits well under the - # job's 30 minutes (about 2 minutes of setup before it, a few after it); it is not a speed limit for the tests. - # `-longRunning 300` makes the runner print a test that has run for 5 minutes while the step is still alive, so a - # hang names its test (the slowest normal Darling test is 120 s, in the upgrade classes). - - name: Run Darling PG tests - if: steps.filter.outputs.darling == 'true' - timeout-minutes: 22 + - name: Run AggregateJobQuiesceLiveTests 10 times shell: pwsh + timeout-minutes: 35 env: DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" DARLING_TEST_PGRUNTIME: ${{ github.workspace }}\Darling\artifacts\pg-runtime - DARLING_TEST_PG_CSVLOG: "Host=127.0.0.1;Port=5542;Username=darling;Database=darling" - DARLING_TEST_PG_CSVLOG_AUTOEXPLAIN: "Host=127.0.0.1;Port=5542;Username=darling;Database=darling" - # csvlog-only from its first start, with log_timezone = 'UTC': PgLogTailStderrSiblingLiveTests' #4019 case. - DARLING_TEST_PG_CSVLOG_ONLY: "Host=127.0.0.1;Port=5542;Username=darling;Database=darling" - # store-upgrade-gated, exactly as nightly.yml sets them (#1706, #3906); empty when the fixture - # above did not run, which the consuming tests treat as "not set" (Environment.GetEnvironmentVariable - # returns null for an empty value read this way, and every one of them guards with - # string.IsNullOrWhiteSpace before touching the path), so they SkipWhen on a PR where the gate did - # not match rather than fail on a missing fixture. Set on every shard: a class's shard must never - # decide whether it runs. - DARLING_TEST_PGRUNTIME_OLD: ${{ steps.filter.outputs.store-upgrade == 'true' && format('{0}\Darling\artifacts\upgrade-fixture\old\pg-runtime', github.workspace) || '' }} - DARLING_TEST_PGRUNTIME_NEWZIP: ${{ steps.filter.outputs.store-upgrade == 'true' && format('{0}\Darling\artifacts\pg-runtime.zip', github.workspace) || '' }} - DARLING_TEST_PGRUNTIME_PREVIOUS: ${{ steps.filter.outputs.store-upgrade == 'true' && format('{0}\Darling\artifacts\upgrade-fixture\previous\pg-runtime', github.workspace) || '' }} - DARLING_TEST_PG_JSONLOG: "Host=127.0.0.1;Port=5543;Username=darling;Database=darling" - DARLING_TEST_PG_AUTOEXPLAIN: "Host=127.0.0.1;Port=5544;Username=darling;Database=darling" - DARLING_TEST_PG_CSVLOG_SIBLING: "Host=127.0.0.1;Port=5544;Username=darling;Database=darling" - DARLING_TEST_PG_LOG_TARGET: "Host=127.0.0.1;Port=5545;Username=darling;Database=darling" - DARLING_TEST_PG_UTC_LOG_TARGET: "Host=127.0.0.1;Port=5545;Username=darling;Database=darling" - # #4704: dedicated rotation clusters (5546 stderr, 5547 csvlog, 5548 jsonlog), set on every shard. - DARLING_TEST_PG_LOGROTATE: "Host=127.0.0.1;Port=5546;Username=darling;Database=darling" - DARLING_TEST_PG_LOGROTATE_CSV: "Host=127.0.0.1;Port=5547;Username=darling;Database=darling" - DARLING_TEST_PG_LOGROTATE_JSON: "Host=127.0.0.1;Port=5548;Username=darling;Database=darling" - # 'true' only when this shard used the Guard job's build of this run, which proves that job ran the Stage=Guard classes. - GUARD_BUILD_USED: ${{ steps.guard-build-check.outputs.used }} - # #5459 change 5: the slow-skip lookup below reads the pull request's file list through the API. - GH_TOKEN: ${{ github.token }} - SLOW_EVENT: ${{ github.event_name }} - SLOW_PR: ${{ github.event.pull_request.number }} - SLOW_REPO: ${{ github.repository }} run: | $ErrorActionPreference = 'Stop' - # The cut and the run must agree (#5459): the listing every shard cuts from leaves the Guard classes out exactly - # when the Guard job ran them, so each remaining class still lands in exactly one shard. No artifact: all classes. - $guardFilter = @(if ($env:GUARD_BUILD_USED -eq 'true') { '-trait-'; 'Stage=Guard' }) - $lines = @(dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- -list classes/json @guardFilter) - if ($LASTEXITCODE -ne 0) { throw "class listing failed with exit code $LASTEXITCODE" } - # Only the runner's one-line JSON array is parsed: the dotnet CLI can print its own notice on stdout, - # and a line starting "W" after the array failed a shard's ConvertFrom-Json on 2026-09-23. - $json = @($lines | Where-Object { $_ -match '^\s*\[' }) - if ($json.Count -ne 1) { throw "expected one JSON array line from the class listing, got $($json.Count): $($lines -join ' | ')" } - $classes = @($json[0] | ConvertFrom-Json) - if ($classes.Count -eq 0) { throw 'the class listing returned no classes - the shard would silently run nothing' } - - $sha = [System.Security.Cryptography.SHA256]::Create() - $mine = @($classes | Where-Object { - ([System.BitConverter]::ToUInt32($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($_)), 0) % ${{ strategy.job-total }}) -eq ${{ matrix.shard }} - }) - Write-Host "shard ${{ matrix.shard }}: $($mine.Count) of $($classes.Count) classes" - if ($mine.Count -eq 0) { throw "shard ${{ matrix.shard }} selected zero of $($classes.Count) classes - refusing to run, because a runner with no -class arguments runs the whole suite" } - - # #5459 change 5: a pull request leaves out the Cost=Slow classes its change does not reach (the rule is - # ci-select.py's slow_skip; a push, a merge-queue run, the nightly and a release run every class). The - # cut above is untouched, so every shard still agrees on which shard owns which class, and a shard that - # cannot list the pull request's files skips nothing. The skip happens AFTER the cut and the zero check, - # and an emptied shard ends here rather than reaching a runner with no -class arguments. - if ($env:SLOW_EVENT -eq 'pull_request') { - $skip = @(python .github/scripts/ci-select.py slow-skip --suite darling --event $env:SLOW_EVENT --repo $env:SLOW_REPO --pr $env:SLOW_PR | Where-Object { $_ }) - $kept = @($mine | Where-Object { $skip -notcontains ($_ -split '\.')[-1] }) - Write-Host "shard ${{ matrix.shard }}: $($mine.Count - $kept.Count) Cost=Slow classes left out on this pull request, $($kept.Count) to run" - $mine = $kept - if ($mine.Count -eq 0) { Write-Host "shard ${{ matrix.shard }}: every class of this shard is a skipped Cost=Slow class, so nothing runs"; exit 0 } - } - - # One runner invocation per chunk, never one for the whole shard: Windows CreateProcess caps the - # complete command line at 32,767 characters, and a Darling PG shard's -class list alone reached ~32,660 - # (#5100). The budget counts only the -class arguments; the other ~170 characters (the dotnet - # path, 'run --project ... --no-build --', and the -trx argument) must fit in what is left, and - # 'dotnet run' hands the same arguments on to the test host. Every chunk runs even after one - # fails, so a failure in an early chunk never hides the rest of the shard's results. - $argBudget = 30000 - $chunks = [System.Collections.Generic.List[string[]]]::new() - $current = [System.Collections.Generic.List[string]]::new() - $currentLength = 0 - foreach ($class in $mine) { - $cost = '-class'.Length + 1 + $class.Length + 1 - if ($current.Count -gt 0 -and ($currentLength + $cost) -gt $argBudget) { - $chunks.Add($current.ToArray()) - $current = [System.Collections.Generic.List[string]]::new() - $currentLength = 0 - } - $current.Add($class) - $currentLength += $cost - } - if ($current.Count -gt 0) { $chunks.Add($current.ToArray()) } - - # A failing chunk must set $LASTEXITCODE, not throw, or the later chunks would be skipped. $PSNativeCommandUseErrorActionPreference = $false - # The runner output of every chunk is kept beside the reports (the darling-runner-log-N artifact, #5587). New-Item -ItemType Directory -Force -Path TestResults | Out-Null - $failedChunks = 0 - $chunkNumber = 0 - foreach ($chunk in $chunks) { - $chunkNumber++ - $chunkArgs = foreach ($class in $chunk) { '-class'; $class } - $chunkLength = ($chunk | ForEach-Object { '-class'.Length + 1 + $_.Length + 1 } | Measure-Object -Sum).Sum - Write-Host "shard ${{ matrix.shard }} chunk $chunkNumber of $($chunks.Count): $($chunk.Count) classes, $chunkLength characters" - # -xml: per-test durations for the darling-tests-timing artifact (#5459 change 4: the test map's - # selection estimates need a per-class Darling time, and none was recorded before). - dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- @chunkArgs -trx TestResults/darling-pr-${{ matrix.shard }}-$chunkNumber.trx -xml TestResults/darling-timing-${{ matrix.shard }}-$chunkNumber.xml -longRunning 300 | Tee-Object -FilePath TestResults/darling-runner-${{ matrix.shard }}.log -Append - if ($LASTEXITCODE -ne 0) { - $failedChunks++ - Write-Host "shard ${{ matrix.shard }} chunk $chunkNumber of $($chunks.Count) failed with exit code $LASTEXITCODE" + $passes = 0; $failures = 0 + for ($i = 1; $i -le 10; $i++) { + $log = "TestResults/iteration-$i.log" + dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- -class "*AggregateJobQuiesceLiveTests" *> $log + $code = $LASTEXITCODE + $total = (Select-String -Path $log -Pattern 'Total:\s*(\d+)' | Select-Object -Last 1) + $ran = if ($total) { $total.Line.Trim() } else { '(no Total line)' } + if ($code -eq 0) { + $passes++ + Write-Host "shard ${{ matrix.shard }} iteration ${i}: PASS ($ran)" + } else { + $failures++ + Write-Host "::error title=shard ${{ matrix.shard }} iteration ${i} FAILED::exit code $code ($ran)" + $hits = @(Select-String -Path $log -Pattern '\[FAIL\]') + if ($hits.Count -eq 0) { Get-Content $log -Tail 40 } + foreach ($hit in $hits) { + Write-Host "FAILED TEST: $($hit.Line.Trim())" + Get-Content $log | Select-Object -Skip $hit.LineNumber -First 14 | ForEach-Object { Write-Host " $_" } } + } } - if ($failedChunks -gt 0) { Write-Host "shard ${{ matrix.shard }}: $failedChunks of $($chunks.Count) chunks failed"; exit 1 } - exit 0 + Write-Host "RESULT shard ${{ matrix.shard }}: $passes passed, $failures failed, of 10 iterations" + "RESULT shard ${{ matrix.shard }}: $passes passed, $failures failed, of 10 iterations" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 + if ($failures -gt 0) { exit 1 } - # PR #5480: a TimescaleDB job worker died with 0xC0000005 in two CI runs and both runs read "success", - # because the postmaster restarts and the tests retry. The crash shows only in the PostgreSQL log, as - # "terminated by exception", so this step turns it into a warning on the run. It only warns: it never - # fails the job, and it runs when the tests failed too. - - name: Warn if a PostgreSQL worker crashed - if: always() && steps.filter.outputs.darling == 'true' + - name: Stop the CPU burners + if: always() shell: pwsh run: | - $log = "$env:RUNNER_TEMP\darling-pg.log" - if (Test-Path $log) { - $hits = @(Select-String -Path $log -Pattern 'terminated by exception' -SimpleMatch) - foreach ($hit in ($hits | Select-Object -First 5)) { - $line = $hit.Line - if ($line.Length -gt 300) { $line = $line.Substring(0, 300) } - Write-Host "::warning title=PostgreSQL worker terminated by exception (shard ${{ matrix.shard }})::$line" - } - if ($hits.Count -gt 0) { Write-Host "$($hits.Count) 'terminated by exception' line(s) in darling-pg.log" } - } - exit 0 + $f = "$env:RUNNER_TEMP\burner-pids.txt" + if (Test-Path $f) { foreach ($id in ((Get-Content $f) -split ',')) { Stop-Process -Id ([int]$id) -Force -ErrorAction SilentlyContinue } } - # #4160: stop all four clusters (the 5541 store plus the three 5542/5543/5544 log-format - # clusters started above). Each stop is independent and best-effort: a cluster that never - # started (an earlier step threw) has no data dir to stop, and one shard's stop failing must - # not skip the others' cleanup. - name: Stop PostgreSQL - if: always() && steps.filter.outputs.darling == 'true' + if: always() shell: pwsh run: | $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - if (Test-Path "$bin\pg_ctl.exe") { - foreach ($dataDir in @( - "$env:RUNNER_TEMP\darling-pgdata", - "$env:RUNNER_TEMP\darling-pgdata-csvlog", - "$env:RUNNER_TEMP\darling-pgdata-jsonlog", - "$env:RUNNER_TEMP\darling-pgdata-stderrcsvlog", - "$env:RUNNER_TEMP\darling-pgdata-logtarget", - "$env:RUNNER_TEMP\darling-pgdata-rotate-stderr", - "$env:RUNNER_TEMP\darling-pgdata-rotate-csvlog", - "$env:RUNNER_TEMP\darling-pgdata-rotate-jsonlog" - )) { - if (Test-Path $dataDir) { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } - } - } - exit 0 - - # #5459 change 4: per-test durations of this shard, kept a week like the Lite shards' lite-tests-timing-N. - # Informational: a failed upload never turns the shard red. - - name: Upload Darling test timings - if: always() && steps.filter.outputs.darling == 'true' - continue-on-error: true - timeout-minutes: 5 - uses: actions/upload-artifact@v6 - with: - name: darling-tests-timing-${{ matrix.shard }} - path: TestResults/darling-timing-*.xml - retention-days: 7 - if-no-files-found: ignore - - # #5587: the runner output, kept when the shard fails, is cancelled or its test step times out. The job's own - # timeout leaves this step pending (see the note on the test step), which is why that step has a timeout of its own. - - name: Upload Darling runner output - if: always() && steps.filter.outputs.darling == 'true' - continue-on-error: true - timeout-minutes: 5 - uses: actions/upload-artifact@v6 - with: - name: darling-runner-log-${{ matrix.shard }} - path: TestResults/darling-runner-*.log - retention-days: 7 - if-no-files-found: ignore - - - name: Upload PG log and test results on failure - if: (failure() || cancelled()) && steps.filter.outputs.darling == 'true' - uses: actions/upload-artifact@v6 - with: - # Per-shard: matrix legs sharing one artifact name collide and fail the upload. - name: darling-pg-failure-${{ matrix.shard }} - path: | - ${{ runner.temp }}/darling-pg.log - ${{ runner.temp }}/darling-pg-csvlog.log - ${{ runner.temp }}/darling-pg-jsonlog.log - ${{ runner.temp }}/darling-pg-stderrcsvlog.log - ${{ runner.temp }}/darling-pg-logtarget.log - ${{ runner.temp }}/darling-pg-rotate-stderr.log - ${{ runner.temp }}/darling-pg-rotate-csvlog.log - ${{ runner.temp }}/darling-pg-rotate-jsonlog.log - TestResults/ - if-no-files-found: ignore - - # The single answer branch protection already requires. Matrixing darling-pg renames its own - # checks to "Darling PG tests (0/1/2)", so the context dev has required since this job existed - - # "Darling PostgreSQL tests" - would have stopped reporting and blocked every pull request on a - # check that never runs, including the one making this change. Rather than a protection edit with - # a deadlock window for every branch cut before the merge, the join job INHERITS that name: the - # required context keeps reporting, and it now means "every shard passed" instead of "the one - # sequential run passed". Nothing in repo settings has to change. - # - # It reports on every non-cancelled event, so it cannot be the silent-green shape the whole-tree - # guards job exists to prevent: a leg that never starts leaves needs.darling-pg.result at - # something other than success, and this fails loudly and says which. - darling-pg-result: - name: Darling PostgreSQL tests - needs: [gate, guard-tests, darling-pg] - if: ${{ !cancelled() }} - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: {} - steps: - - name: Report the shards' one answer - shell: bash - env: - RESULT: ${{ needs.darling-pg.result }} - GUARD_RESULT: ${{ needs.guard-tests.result }} - GATE_RESULT: ${{ needs.gate.result }} - GATE_RUN: ${{ needs.gate.outputs.run }} - EVENT_NAME: ${{ github.event_name }} - run: | - set -euo pipefail - # A gate that did not succeed leaves every job it feeds skipped, which would read as green here. - if [ "${GATE_RESULT}" != "success" ]; then - echo "::error title=CI gate failed::needs.gate.result='${GATE_RESULT}' - the job that decides whether CI runs did not succeed, so nothing ran. Re-run the workflow." - exit 1 - fi - # A declined run (draft or merge-train) leaves every job skipped, which would also read as green. - if [ "${EVENT_NAME}" = "pull_request" ] && [ "${GATE_RUN}" != "true" ]; then - echo "::error title=CI not run::This pull request's CI was skipped (draft or merge-train label). It can be merged only by an admin override after a merge train covered it." - exit 1 - fi - # The Guard tests job runs first on a pull request (#5459). When it did not succeed the shards were - # skipped on purpose, and a skipped matrix reads as green, so this is where that becomes a failure. On - # a push, a merge-queue run or a release the shards ran whatever the guards said, but a guard that did - # not pass is still a failure. `skipped` is fine on a release only: the guard job does not run there. - if [ "${GUARD_RESULT}" != "success" ] && ! { [ "${EVENT_NAME}" = "release" ] && [ "${GUARD_RESULT}" = "skipped" ]; }; then - if [ "${EVENT_NAME}" = "pull_request" ]; then - echo "::error title=Guard tests failed::needs.guard-tests.result='${GUARD_RESULT}' - the Guard tests job did not succeed, so the shards did not run. Read that job's log for the failing class." - else - echo "::error title=Guard tests failed::needs.guard-tests.result='${GUARD_RESULT}' - the Guard tests job did not succeed. The shards ran anyway on this event; read that job's log for the failing class." - fi - exit 1 - fi - case "${RESULT}" in - success) - echo "::notice title=Darling PG shards green::Every shard succeeded, or the Darling gate skipped the suite on all of them." ;; - skipped) - # Not a by-design skip any more (#5459). The matrix job is skipped on purpose in two cases only: the - # gate declined the run, and the guard job failed on a pull request. Both are errors above, so a - # skip that reaches this line is unplanned, and a skipped matrix must not read as a pass. - echo "::error title=Darling PG shards skipped::needs.darling-pg.result='skipped' although the gate ran this event and the guards passed, so no shard reported. The suite has NOT passed on this commit." - exit 1 ;; - *) - echo "::error title=Darling PG shards failed::needs.darling-pg.result='${RESULT}' - at least one shard failed or was cancelled. The suite has NOT passed on this commit; read the failing shard's log." - exit 1 ;; - esac - - # ── Linux service build + container image (#1804) ──────────────────────────────────────────────── - # The Darling service is cross-platform .NET on purpose, but until this job nothing PROVED it on - # every PR — the linux-x64 publish and the container image both built for the first time at release - # time or never. Same path-filter shape as darling-pg above: only runs the expensive work when - # Darling/service code (or this workflow, or the Dockerfile) changed, always reports a result so it - # can be a required check. No tests run here — the test projects are net10.0-windows (they reference - # the WPF apps); the cross-platform behavior they pin is exercised by the Windows jobs, and the - # container smoke lives in the compose quickstart. On PRs/pushes this job answers exactly two - # questions: does the service still publish for linux-x64, and does the image still build. - # - # On the RELEASE event this job is the Linux PUBLISHER: before it, a stable release shipped Windows - # zips and Setup.exes while the linux tar.gz and the ghcr image existed only at nightly quality - # (nightly.yml, tag :nightly) — the compose quickstart pointed released users at a nightly image. - # Now the release uploads PerformanceMonitorDarling-linux-x64-.tar.gz + SHA256SUMS-linux.txt to - # the release and pushes ghcr : and :latest. Linux binaries are NOT SignPath-signed - # (SignPath signs Windows PEs); the checksums file is the integrity story, same as the nightly. - darling-linux: - name: Darling Linux build - # Fail fast on a pull request (#5459), exactly as the Darling PG shards: see the comment on that job. - needs: [gate, guard-tests] - if: ${{ !cancelled() && needs.gate.outputs.run == 'true' && (needs.guard-tests.result == 'success' || github.event_name != 'pull_request') }} - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write - packages: write - # Sigstore keyless signing + GitHub provenance (proven on the nightly first): id-token yields - # the OIDC identity Fulcio certifies against; attestations stores the tarball's provenance. - id-token: write - attestations: write - - steps: - - uses: actions/checkout@v7 - - - name: Detect changed paths - id: filter - if: github.event_name != 'release' - uses: dorny/paths-filter@v4 - with: - base: ${{ github.event_name == 'push' && github.event.before || '' }} - # json rather than shell. The lists reach the notice through `env:` and never through a - # `${{ }}` interpolation into the script text, which is the pull_request script-injection - # footgun — and a runtime parameter expansion does not honour shell's backslash escapes, so - # the `shell` format's `a\ b.md` field-splits into two entries that name no file. Eight - # tracked paths under Screenshots/ carry spaces, and that directory is on the documentation - # allowlist, so it is the shape a screenshot PR reaches rather than a hypothetical. A JSON - # array is self-delimiting, so the notice prints it verbatim and reads unambiguously for any - # filename without parsing it at all. - list-files: json - # The same gate the darling-pg job above reads. The list of shared libraries that reaches - # Darling belongs to both jobs, and holding it twice is how the copies came to differ: the - # copy here named three of the seven, missing PerformanceMonitor.Alerting and - # PerformanceMonitor.PlanAnalysis, which the service this job publishes references directly. - filters: .github/darling-paths-filter.yml - - # Reports the decision and the files behind it, for the same reason the PG job's notice does. - - name: Report the Linux gate decision - shell: bash - env: - DARLING: ${{ steps.filter.outputs.darling }} - DARLING_COUNT: ${{ steps.filter.outputs.darling_count }} - # A list goes into the environment only up to 1,000 files. Linux refuses to start a process whose - # single environment string passes 128 KiB, so the 2,843-file dev-to-main release PR failed here - # before the script ran ("Argument list too long"). The counts always print; the Detect changed - # paths step above lists every file either way. - DARLING_FILES: ${{ fromJSON(steps.filter.outputs.darling_count || '0') <= 1000 && steps.filter.outputs.darling_files || '"(more than 1,000 files, listed in the Detect changed paths step)"' }} - ALL_COUNT: ${{ steps.filter.outputs.all_count }} - ALL_FILES: ${{ fromJSON(steps.filter.outputs.all_count || '0') <= 1000 && steps.filter.outputs.all_files || '"(more than 1,000 files, listed in the Detect changed paths step)"' }} - run: | - set -euo pipefail - - if [ "${{ github.event_name }}" = "release" ]; then - echo "::notice title=Darling Linux publishing::Release event - packaging the versioned linux tar.gz and pushing the ghcr image." - exit 0 - fi - - if [ "${DARLING:-}" = "true" ]; then - echo "::notice title=Darling Linux build running::${DARLING_COUNT:-0} of ${ALL_COUNT:-0} changed files are in the Darling gate (.github/darling-paths-filter.yml)." - echo "Changed files in the gate: ${DARLING_FILES:-[]}" - else - echo "::notice title=Darling Linux build skipped::None of the ${ALL_COUNT:-0} changed files are in the Darling gate (.github/darling-paths-filter.yml), so the linux-x64 publish and the image build did not run on this commit." - echo "Changed files, none of them in the gate: ${ALL_FILES:-[]}" - fi - - - name: Setup .NET 10.0 - if: github.event_name == 'release' || steps.filter.outputs.darling == 'true' - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Publish service (linux-x64) - if: github.event_name == 'release' || steps.filter.outputs.darling == 'true' - run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux - - - name: Build container image - if: github.event_name != 'release' && steps.filter.outputs.darling == 'true' - run: docker build -f Darling/Dockerfile -t performancemonitor-darling:pr . - - # #5366: the test project runs on Windows, so the Unix file checks behind the password key (owner, link count, file - # identity) and the Linux crypto path run here instead, inside the image the service ships in. The hidden verb runs - # the real code in a fresh temporary directory and opens a committed, labelled test vector made on Windows. Any - # failure exits non-zero and fails the job. The vector is mounted read-only. - - name: Check the password key on Linux (self-check verb in the built image) - if: github.event_name != 'release' && steps.filter.outputs.darling == 'true' - shell: bash - run: | - set -euo pipefail - vector="${GITHUB_WORKSPACE}/Darling/Darling.Tests/Fixtures/PasswordSeal/linux-self-check-vector.json" - test -f "${vector}" - docker run --rm \ - -v "${vector}:/vector/linux-self-check-vector.json:ro" \ - performancemonitor-darling:pr \ - --self-check-password-key /vector/linux-self-check-vector.json - - # ── Release-only publishing (mirrors nightly.yml's linux job, versioned instead of :nightly) ── - - - name: Get version - if: github.event_name == 'release' - id: version - shell: bash - run: | - set -euo pipefail - version="$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props)" - [ -n "${version}" ] || { echo "::error::Read no version from Directory.Build.props."; exit 1; } - echo "VERSION=${version}" >> "$GITHUB_OUTPUT" - - - name: Package linux artifact + checksum - if: github.event_name == 'release' - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - mkdir -p releases - tar -C publish/DarlingService-linux -czf "releases/PerformanceMonitorDarling-linux-x64-${version}.tar.gz" . - (cd releases && sha256sum "PerformanceMonitorDarling-linux-x64-${version}.tar.gz" > SHA256SUMS-linux.txt && cat SHA256SUMS-linux.txt) - - - name: Upload linux artifact to the release - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload ${{ github.event.release.tag_name }} releases/PerformanceMonitorDarling-linux-x64-*.tar.gz releases/SHA256SUMS-linux.txt --clobber - - - name: Build and push container image (ghcr, versioned + latest) - if: github.event_name == 'release' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" - echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - docker build -f Darling/Dockerfile -t "${image}:${version}" -t "${image}:latest" . - docker push "${image}:${version}" - docker push "${image}:latest" - - # Keyless Sigstore signature on the released image — the same steps the nightly runs (verified - # end-to-end from a client 2026-08-06: cosign validated the claims, the Rekor log entry, and the - # workflow identity). SignPath's cosign support is edition-gated, and this path needs no keys or - # subscription at all. Verify: - # cosign verify ghcr.io/erikdarlingdata/performancemonitor-darling: \ - # --certificate-identity-regexp 'github.com/erikdarlingdata/PerformanceMonitor' \ - # --certificate-oidc-issuer https://token.actions.githubusercontent.com - - name: Install cosign - if: github.event_name == 'release' - uses: sigstore/cosign-installer@v3 - - - name: Sign container image (keyless, Sigstore) - if: github.event_name == 'release' - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" - digest="$(docker inspect --format='{{index .RepoDigests 0}}' "${image}:${version}")" - cosign sign --yes "${digest}" - - # GitHub-native SLSA provenance for the released tarball: gh attestation verify -R . - - name: Attest the linux tarball (GitHub provenance) - if: github.event_name == 'release' - uses: actions/attest-build-provenance@v4 - with: - subject-path: releases/PerformanceMonitorDarling-linux-x64-*.tar.gz - - # Darling.Tests is not only Darling's test suite: some of its guards take the WHOLE REPOSITORY as - # input rather than the Darling tree. - # MigrationUpgradeLadderLiveTests.TheMostRecentRelease_HasALadderFixture derives the ladder - # fixture it requires from CHANGELOG.md's newest released heading, so a release cut committed as a - # CHANGELOG-only change is precisely the input it exists to fail on; FleetIdentifierScrubTests - # enumerates every tracked file carrying one of nine extensions, `.md` among them. - # - # The build job's `darling`, `core` and `root` filters describe Darling PRODUCT code, which is - # what they are for: they decide what to compile and publish. They are not a description of what - # those guards read, so the two sets differ, and a change confined to markdown or to `.github/` - # leaves all three false and the suite unrun. Measured over the last 100 pull requests: 92 lit - # one of the three, 8 lit none, and 7 of those 8 changed markdown. - # - # So the gate here is the COMPLEMENT of the build job's, read off that job's own step outcome - # rather than written out again as a second set of paths. A second set is what would drift: an - # enumerated area list stops covering the next tree added, which #2114, #2830 and #2839 each hit - # in turn. `steps.darling-tests.outcome` is `skipped` exactly when the build job did not run the - # suite, so there is no pair of lists to keep in agreement. - # - # Being the complement, it never double-runs the suite - 92 of those 100 PRs report here without - # spinning up .NET at all - and it needs no upkeep as the area filters change. It ALSO runs - # whenever this workflow changes, so a change to the gate is exercised by the gate, the same - # self-validation darling-pg takes. - # - # No PostgreSQL here, deliberately. The whole-tree guards need none, and standing up a throwaway - # TimescaleDB cluster would make a documentation change depend on the flakiest part of CI - # (#1862's compression flake failed twice in two unrecognizably different ways). Runs on every - # non-cancelled event, with one exception (#5459): on a pull request whose Guard tests job did not - # succeed it skips, because `build` is already red there. So it is NOT a required check, and must not - # be made one without reading that skip, which GitHub counts as passing. - darling-tree-guards: - name: Darling whole-tree guards - # guard-tests too (#5459): when the Guard tests job failed on a pull request, `build` failed at once, and - # a Windows runner started here would only report "not decided". It skips with the shards instead. - needs: [gate, build, guard-tests] - if: ${{ !cancelled() && needs.gate.outputs.run == 'true' && (needs.guard-tests.result == 'success' || github.event_name != 'pull_request') }} - runs-on: windows-latest - # Measured on a warm NuGet cache: ~40s of .NET setup, ~10s restore, ~95s to build - # Darling.Tests, ~35s for the suite. 20 minutes is ~6x that, past which something is hung and a - # shared-pool Windows runner is being held for nothing. - timeout-minutes: 20 - permissions: - contents: read - - steps: - - uses: actions/checkout@v7 - - # Feeds the self-validation arm below only. Skipped on release for the same reason the other - # jobs skip it there: a release has no diff to classify. - - name: Detect changed paths - id: filter - if: github.event_name != 'release' - uses: dorny/paths-filter@v4 - with: - base: ${{ github.event_name == 'push' && github.event.before || '' }} - filters: | - workflow: - - '.github/workflows/build.yml' - # #5459 cut 3c: a change made ONLY of Lite files (lite_tree_count == all_count) needs just the Darling - # classes that read Lite, not the whole suite. Counts rather than booleans, as the build job's docs - # fast path does it: "every changed file is a Lite file" is a count comparison, never a path predicate. - lite_tree: - - 'Lite/**' - - 'Lite.Tests/**' - all: - - '**' - - # Names the arm that decided, out loud, either way. A job reporting success having quietly run - # nothing is indistinguishable from one that ran everything, and that indistinguishability is - # the whole reason this job exists. - # - # An EMPTY outcome is its own arm rather than part of the else. A job's declared outputs do not - # reach a downstream consumer when the producing job's conclusion is failure, so a build job - # that fails - at Lite.Tests, before ever reaching the Darling step, or at the Darling step - # itself - can leave this reading empty no matter what that step actually did. Folding that - # into the else would print "the suite has already run on this commit" for a commit where it - # may not have run at all, which is the same dishonest-log failure this job exists to close. - # Skipping is still right there: a red build blocks the merge on its own, and re-running the - # suite on a commit nothing can merge buys no decision and would cost three minutes on every - # failing build. So the arm skips and says why, and which log holds the real answer. - - name: Decide whether the whole-tree guards run here - id: gate - shell: bash - env: - DARLING_TESTS: ${{ needs.build.outputs.darling-tests }} - DOCS_FASTPATH: ${{ needs.build.outputs.docs-fastpath }} - WORKFLOW: ${{ steps.filter.outputs.workflow }} - run: | - set -euo pipefail - - if [ "${{ github.event_name }}" = "release" ]; then - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Whole-tree guards skipped::Release event - the build job compiles and runs every suite unconditionally." - elif [ "${DOCS_FASTPATH:-}" = "true" ]; then - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Whole-tree guards skipped::The build job took the docs fast path - every changed file is on the documentation allowlist, so there is no code change for the whole-tree guards to catch. They still run on the next code PR and in the nightly." - elif [ "${DARLING_TESTS:-}" = "skipped" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - echo "::notice title=Whole-tree guards running::The build job's path filters left Darling.Tests unrun, so the guards that read the whole repository run here." - elif [ "${WORKFLOW:-}" = "true" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - echo "::notice title=Whole-tree guards running::build.yml changed, so this job's own gate is exercised by the gate." - elif [ -z "${DARLING_TESTS:-}" ]; then - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Whole-tree guards not decided here::The build job published no outcome for its 'Run Darling tests' step, which is what a failed build job leaves behind - so whether the suite ran on this commit is not knowable from here. Read it off the build job's own log; that job is not green, so nothing merges on this result." - else - echo "run=false" >> "$GITHUB_OUTPUT" - echo "::notice title=Whole-tree guards skipped::The build job's 'Run Darling tests' step reported '${DARLING_TESTS}', so the suite has already run on this commit." - fi - - # #5459 cut 3c: when the whole suite would run here for a change made only of Lite files, run the Guard stage and - # the classes tagged Reads=Lite instead (the rule is ci-select.py's tree_scope; the replay checks it). build.yml - # itself changing keeps the whole suite: that arm exists to exercise this job's own gate. - - name: Decide the whole-tree guards scope - id: scope - if: steps.gate.outputs.run == 'true' - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - WORKFLOW: ${{ steps.filter.outputs.workflow }} - ALL_COUNT: ${{ steps.filter.outputs.all_count }} - LITE_COUNT: ${{ steps.filter.outputs.lite_tree_count }} - run: | - set -euo pipefail - if [ "${WORKFLOW:-}" = "true" ]; then - echo "scope=full" >> "$GITHUB_OUTPUT" - echo "filter=" >> "$GITHUB_OUTPUT" - else - python .github/scripts/ci-select.py tree-scope --event "${EVENT_NAME}" --all-count "${ALL_COUNT:-0}" --lite-count "${LITE_COUNT:-0}" | tee -a "$GITHUB_OUTPUT" - fi - - # Shared build (#5459 change 2): this run's Guard job already built this test project in Release and uploaded - # it before running its tests (see the header on that job). Fetch it instead of restoring and building again. - # continue-on-error: no artifact (a release, a docs-only change, a Guard build that failed) is the normal - # fallback, not a failure, and the check below turns it into a notice and the old restore and build. - - name: Fetch the Guard job's build of the test project - id: guard-build - if: steps.gate.outputs.run == 'true' && needs.guard-tests.outputs.darling_build_artifact != '' - continue-on-error: true - uses: actions/download-artifact@v6 - with: - name: ${{ needs.guard-tests.outputs.darling_build_artifact }} - path: . - - # A fetched build that is not for THIS workspace and commit is an error, never a quiet fallback: the tests - # find the repo root through [CallerFilePath], so a binary built in another path would read another tree. - - name: Check the fetched build is for this workspace and commit - id: guard-build-check - if: steps.gate.outputs.run == 'true' - shell: pwsh - env: - FETCH_OUTCOME: ${{ steps.guard-build.outcome }} - THIS_WORKSPACE: ${{ github.workspace }} - THIS_SHA: ${{ github.sha }} - REQUIRED_FILES: Darling/Darling.Tests/bin/Release/net10.0-windows/Darling.Tests.dll;Darling/Darling.Tests/obj/project.assets.json - run: | - $ErrorActionPreference = 'Stop' - if ($env:FETCH_OUTCOME -ne 'success') { - Write-Host "::notice title=Building the test project here::No Guard job build was fetched for this run (fetch outcome '$env:FETCH_OUTCOME'; skipped means the Guard job published none), so this job restores and builds for itself and runs every class, Stage=Guard included." - 'used=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - exit 0 - } - $origin = @{} - if (Test-Path guard-build-origin.txt) { - foreach ($line in Get-Content guard-build-origin.txt) { if ($line -match '^(\w+)=(.*)$') { $origin[$Matches[1]] = $Matches[2] } } - } - if ($origin['workspace'] -ne $env:THIS_WORKSPACE -or $origin['sha'] -ne $env:THIS_SHA) { - Write-Host "::error title=Guard build is for another workspace or commit::The fetched build says workspace '$($origin['workspace'])' at '$($origin['sha'])'; this job is at '$env:THIS_WORKSPACE' at '$env:THIS_SHA'." - exit 1 - } - foreach ($file in ($env:REQUIRED_FILES -split ';')) { - if (-not (Test-Path $file)) { - Write-Host "::error title=Guard build is incomplete::The fetched build has no $file." - exit 1 - } - } - Write-Host "::notice title=Using the Guard job's build::The test project was built by the Guard job of this run at the same workspace and commit, so this job skips its restore and build and leaves out the Stage=Guard classes that job ran." - 'used=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Setup .NET 10.0 - if: steps.gate.outputs.run == 'true' - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: ${{ steps.guard-build-check.outputs.used != 'true' }} - cache-dependency-path: '**/packages.lock.json' - - - name: Restore Darling.Tests - if: steps.gate.outputs.run == 'true' && steps.guard-build-check.outputs.used != 'true' - run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - - - name: Build Darling.Tests - if: steps.gate.outputs.run == 'true' && steps.guard-build-check.outputs.used != 'true' - run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - - # The FULL suite, not a class filter naming the whole-tree guards. Such a filter would be one - # more enumeration: a guard added tomorrow would not be in it, and this job would report green - # having skipped exactly the test it was added for. DARLING_TEST_PG and DARLING_TEST_SQL stay - # unset, so the gated-live classes skip here just as they do in the build job. - - name: Run the whole-tree guards - if: steps.gate.outputs.run == 'true' - env: - # 'true' only when this job used the Guard job's build of this run, which proves that job ran the Stage=Guard classes. - GUARD_BUILD_USED: ${{ steps.guard-build-check.outputs.used }} - # Empty for the whole suite; `-trait Stage=Guard -trait Reads=Lite` for a Lite-only change (cut 3c). - SCOPE_FILTER: ${{ steps.scope.outputs.filter }} - run: | - $scopeArgs = @($env:SCOPE_FILTER -split ' ' | Where-Object { $_ }) - # The Guard job already ran the Guard classes when its build was used: leave them out, whatever else runs. - $guardFilter = @(if ($env:GUARD_BUILD_USED -eq 'true') { '-trait-'; 'Stage=Guard' }) - # The whole suite, with no Cost=Slow skip (#5459 change 5 leaves this job alone): CrossAppGuardCiGateTests pins - # this invocation because the exemptions in DarlingTestsBackstopped rest on this job running every class. - # The reads-lite scope (#5505) only narrows it for a Lite-only change. - if ($scopeArgs.Count -gt 0 -and $env:GUARD_BUILD_USED -eq 'true') { $scopeArgs = @('-trait', 'Reads=Lite') } - $guardFilter = @($scopeArgs) + @($guardFilter) - dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- @guardFilter - - # The Lite suite on pull requests and pushes: four shards cut over CLASS NAMES (#3887), balanced - # by recorded duration since #5208. The build job's single "Run Lite tests" step had grown to - # ~9½ minutes of a ~15-minute job — 62% of the PR wall's critical path over the eight runs - # measured — and it is release-only there now. - # - # Two cuts, both over the class NAME: never a list somebody maintains, never a semantic filter. - # This job's own history is the argument (see the retired lite_analysis split this file - # documents above the release-only step): any filter somebody has to MAINTAIN drifts, and a - # class added tomorrow is exactly the class such a filter misses. - # - The DURATION cut (#5208). The hash balances class COUNT, not time: on one measured run the - # four shards held 12.8, 48.1, 36.6 and 25.3 minutes of test time, and the slowest decides the - # PR wall (at the 30-minute cap below it turns the run red without naming a failing test). - # .github/scripts/lite-shard-pack.py packs classes longest-first into the least-loaded shard, - # weighing each class by the per-test timings the shards already upload (lite-tests-timing-N) - # from the run the gate pinned (its timing-source step). A class with no timing history gets - # the mean weight, so it is placed, never dropped. A class in a DisableParallelization collection - # (run one at a time) weighs its seconds in full, any other class a third of that (about 3.4 - # classes overlap), so the serial classes spread across the shards instead of piling on one. - # - The HASH cut, the original: a SHA-256 over the class name, modulo the shard count (SHA-256 - # rather than string.GetHashCode(), which is randomized per process ON PURPOSE). It is the - # fallback: no timing source, or timings the packer cannot use (missing, corrupt, empty, all - # zero, or covering under 80% of this commit's classes), cut this way. The shard count comes - # from the matrix (strategy.job-total), so the cut and the matrix cannot disagree: that holds - # while `shard` is the matrix's only axis, and a leg whose job-index is not its shard value - # fails (see the run step). - # Totality used to hold by arithmetic; the duration cut earns it by proof. The packer's reconcile - # check fails the leg unless the shard files hold every discovered class exactly once. And a leg - # whose pinned timings cannot be downloaded, or whose download is not exactly the artifact set the - # gate pinned (one expired or was deleted since), FAILS instead of falling back or cutting on its - # own: all four legs must cut from the same inputs, or the four cuts would disagree and a class - # could run twice or not at all, which no check inside one leg can see. A re-run of a failed leg - # reuses the pin. - # - # FOUR and not three, decided on measurement (of the hash cut) rather than taste: the hash balances CLASSES, and - # Lite's classes carry uneven test counts, so a 3-way cut left one shard holding 42% of the tests - # (~6½ minutes with setup) while another held 25%. At four the worst shard holds 27% (~5 minutes); - # five would buy 0.4 minutes more for another Windows runner out of the shared pool this file's - # own comments blame for serializing everyone's CI, and six is worse than five. Four is the knee. - # - # Each shard pays its own checkout/setup/restore/build (~2½ minutes measured on a warm NuGet - # cache) — the price of taking ~10 minutes off the PR wall. They run in parallel with the build - # job, off its critical path. The filter predicates below are BYTE-FOR-BYTE copies of - # the build job's lite/core/root (renamed *_shard so the census can find each set unambiguously); - # CrossAppGuardCiGateTests pins the copies equal, so they cannot drift apart silently. - lite-tests: - name: Lite tests - # Fail fast on a pull request (#5459), exactly as the Darling PG shards: see the comment on that job. - needs: [gate, guard-tests] - if: ${{ !cancelled() && needs.gate.outputs.run == 'true' && github.event_name != 'release' && (needs.guard-tests.result == 'success' || github.event_name != 'pull_request') }} - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - shard: [0, 1, 2, 3] - # A shard is at most ~27% of a ~9½-minute suite plus ~2½ minutes of setup; 20 minutes is ~4x that, - # past which something is hung and a shared-pool Windows runner is being held for nothing. - # - # Raised from 20 to 30. The suite now runs roughly five times slower on hosted runners than the - # ~9.5 minutes above assumed, and it is still getting slower: the four shard durations summed to - # 18-22 min on Sep 30, 20-27 on Oct 1, 26-34 on Oct 2, 29-42 on Oct 3, 33-45 on Oct 4 and 41-55 - # on Oct 5, about +7 min/day, while the suite gained only ~110 tests in the last day. So the cost - # is growing per test with the SIZE OF THE REPO, not with the number of tests - the suspects are - # the ~54 Lite.Tests classes that walk a directory or glob many files, whose work grows every - # time anyone adds a file. On a developer VM the whole suite still runs in ~10 minutes in one - # process, so this is specific to hosted runners' disk. - # - # A shard that crosses the cap is a RED THAT IS A TIMEOUT, not a test failure, and is easy to - # misread as a bug in whatever pull request happened to be in the train. This is a guard against - # those spurious reds, NOT a fix: the slowdown is tracked in #5208, and this value should come - # back down when it is fixed. At +7 min/day the slowest shard crosses 30 within about a week. - # - # The duration cut (#5208) attacks the imbalance, not the growth: on the run measured the slowest - # shard held 1.6x the mean test time, and equal shards put every shard near the mean. So the cap - # stays at 30 as the guard, and comes down when the slowest shard's measured time says it can. - timeout-minutes: 30 - permissions: - contents: read - # Reads the pinned run's lite-tests-timing artifacts (gh run download in the shard step), nothing else. - actions: read - steps: - - uses: actions/checkout@v7 - - - name: Detect changed paths - id: filter - uses: dorny/paths-filter@v4 - with: - base: ${{ github.event_name == 'push' && github.event.before || '' }} - filters: | - # COPIES of the build job's root/core/lite predicates, generated from them and pinned - # byte-equal by CrossAppGuardCiGateTests.TheShardJobsFilters_AreByteForByteCopiesOfTheBuildJobs - # (the build job's `lite` set is carried here as lite_shard plus darling_reads_shard). - # The REASONING for every entry lives at the build job's own filter block and is not - # duplicated here — duplicated prose is prose that rots out of step. What matters is that - # the sets stay identical: the shards must run the Lite suite in exactly the cases the - # build job's step used to, or the split loses coverage silently. That includes the - # entries that look foreign to Lite (the two READMEs, the three Darling trees) — Lite.Tests - # reads those files in its cross-app and docs guards, so an edit there has to reach this - # suite. A hand-typed copy of this block dropped two of them and was caught by the pin - # above before CI ever ran (#3887). - root_shard: - - 'PerformanceMonitor.sln' - - 'Directory.Build.props' - - 'global.json' - - 'nuget.config' - - 'NuGet.config' - - '.github/workflows/build.yml' - core_shard: - - 'PerformanceMonitor.Alerting/**/!(*.md)' - - 'PerformanceMonitor.Analysis/**/!(*.md)' - - 'PerformanceMonitor.Collectors/**/!(*.md)' - - 'PerformanceMonitor.Common/**/!(*.md)' - - 'PerformanceMonitor.Notifications/**/!(*.md)' - - 'PerformanceMonitor.PlanAnalysis/**/!(*.md)' - - 'PerformanceMonitor.Ui/**/!(*.md)' - lite_shard: - - 'Lite/**/!(*.md)' - - 'Lite.Tests/**/!(*.md)' - # The rest of the build job's `lite` set: the three Darling trees Lite.Tests reads as source. - # A diff that reaches ONLY these (no lite_shard, core_shard or root_shard entry) runs just the - # Lite.Tests classes tagged [Trait("Reads", "Darling")], in one shard; see the scope step below. - # lite_shard followed by darling_reads_shard is the build job's `lite` set, entry for entry. - darling_reads_shard: - - 'Darling/PerformanceMonitor.Darling.Viewer/**/!(*.md)' - - 'Darling/Darling.Tests/**/!(*.md)' - - 'Darling/PerformanceMonitor.Darling.Service/**/!(*.md)' - # Darling.Tests files that Lite.Tests.csproj COMPILES into its own assembly. An edit to one - # changes code that dozens of Lite.Tests classes run, not just text they read, so it takes the - # whole suite. Not part of the build job's `lite` set (those entries are covered there by the - # Darling.Tests tree); DarlingReadsTraitGuardTests pins this list to the project's linked compiles. - lite_linked_shard: - - 'Darling/Darling.Tests/CSharpSourceWalker.cs' - - 'Darling/Darling.Tests/McpInProcessHost.cs' - - 'Darling/Darling.Tests/McpArgumentTypeRows.cs' - - 'Darling/Darling.Tests/StatementScrubCanary.cs' - - 'Darling/Darling.Tests/GuardStageScanner.cs' - - 'Darling/Darling.Tests/WpfStaGate.cs' - - 'Darling/Darling.Tests/WpfExitSwitchTests.cs' - # How much of the Lite suite THIS leg runs, decided from the filter answers alone (no build needed): - # full - a Lite, Lite.Tests, core or root path changed, or a Darling.Tests file that Lite.Tests - # compiles into itself, or the event is not a pull request (a push - # to dev or main, a merge-queue run): the whole suite, cut across the four legs by - # duration (or by class-name hash when no timing source is pinned), see the job header. - # reads - a pull request whose diff reaches ONLY the Darling trees Lite.Tests reads as source: just - # the classes tagged [Trait("Reads", "Darling")], all of them in shard 0. Legs 1-3 do nothing. - # none - nothing the Lite suite depends on changed. - # `run` is this leg's own answer: every step below keys on it. - - name: Decide how much of the Lite suite runs - id: scope - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - SHARD: ${{ matrix.shard }} - LITE: ${{ steps.filter.outputs.lite_shard }} - CORE: ${{ steps.filter.outputs.core_shard }} - ROOT: ${{ steps.filter.outputs.root_shard }} - DARLING_READS: ${{ steps.filter.outputs.darling_reads_shard }} - LINKED: ${{ steps.filter.outputs.lite_linked_shard }} - run: | - set -euo pipefail - - # The ladder (full / reads / none) and the which-shard rule live in .github/scripts/ci-select.py, the one - # place the selection rules are kept (#5459). It prints `mode=...` and `run=...`; the replay of the - # failure corpus checks the same function. - decision=$(python .github/scripts/ci-select.py lite-scope --event "${EVENT_NAME}" --shard "${SHARD}" \ - --lite "${LITE}" --core "${CORE}" --root "${ROOT}" --reads "${DARLING_READS}" --linked "${LINKED}") - echo "${decision}" >> "$GITHUB_OUTPUT" - mode=$(echo "${decision}" | sed -n 's/^mode=//p') - run=$(echo "${decision}" | sed -n 's/^run=//p') - echo "::notice title=Lite scope::mode=${mode}, shard ${SHARD} runs=${run}" - - # Shared build (#5459 change 2): this run's Guard job already built this test project in Release and uploaded - # it before running its tests (see the header on that job). Fetch it instead of restoring and building again. - # continue-on-error: no artifact (a release, a docs-only change, a Guard build that failed) is the normal - # fallback, not a failure, and the check below turns it into a notice and the old restore and build. - - name: Fetch the Guard job's build of the test project - id: guard-build - if: steps.scope.outputs.run == 'true' && needs.guard-tests.outputs.lite_build_artifact != '' - continue-on-error: true - uses: actions/download-artifact@v6 - with: - name: ${{ needs.guard-tests.outputs.lite_build_artifact }} - path: . - - # A fetched build that is not for THIS workspace and commit is an error, never a quiet fallback: the tests - # find the repo root through [CallerFilePath], so a binary built in another path would read another tree. - - name: Check the fetched build is for this workspace and commit - id: guard-build-check - if: steps.scope.outputs.run == 'true' - shell: pwsh - env: - FETCH_OUTCOME: ${{ steps.guard-build.outcome }} - THIS_WORKSPACE: ${{ github.workspace }} - THIS_SHA: ${{ github.sha }} - REQUIRED_FILES: Lite.Tests/bin/Release/net10.0-windows/Lite.Tests.dll;Lite.Tests/obj/project.assets.json - run: | - $ErrorActionPreference = 'Stop' - if ($env:FETCH_OUTCOME -ne 'success') { - Write-Host "::notice title=Building the test project here::No Guard job build was fetched for this run (fetch outcome '$env:FETCH_OUTCOME'; skipped means the Guard job published none), so this job restores and builds for itself and runs every class, Stage=Guard included." - 'used=false' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - exit 0 - } - $origin = @{} - if (Test-Path guard-build-origin.txt) { - foreach ($line in Get-Content guard-build-origin.txt) { if ($line -match '^(\w+)=(.*)$') { $origin[$Matches[1]] = $Matches[2] } } - } - if ($origin['workspace'] -ne $env:THIS_WORKSPACE -or $origin['sha'] -ne $env:THIS_SHA) { - Write-Host "::error title=Guard build is for another workspace or commit::The fetched build says workspace '$($origin['workspace'])' at '$($origin['sha'])'; this job is at '$env:THIS_WORKSPACE' at '$env:THIS_SHA'." - exit 1 - } - foreach ($file in ($env:REQUIRED_FILES -split ';')) { - if (-not (Test-Path $file)) { - Write-Host "::error title=Guard build is incomplete::The fetched build has no $file." - exit 1 - } - } - Write-Host "::notice title=Using the Guard job's build::The test project was built by the Guard job of this run at the same workspace and commit, so this job skips its restore and build and leaves out the Stage=Guard classes that job ran." - 'used=true' | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Setup .NET 10.0 - if: steps.scope.outputs.run == 'true' - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: ${{ steps.guard-build-check.outputs.used != 'true' }} - cache-dependency-path: '**/packages.lock.json' - - - name: Restore Lite.Tests - if: steps.scope.outputs.run == 'true' && steps.guard-build-check.outputs.used != 'true' - run: dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - - - name: Build Lite.Tests - if: steps.scope.outputs.run == 'true' && steps.guard-build-check.outputs.used != 'true' - run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - # #5208: the packer's own proof (totality, input-order determinism, the hash fallback, serial-aware weighting). No build needed. - - name: Shard packer self-test - if: matrix.shard == 0 - run: python .github/scripts/lite-shard-pack.py --self-test - - # #5208: the timing summary's own proof (per-class CPU column, the coverage line). No build needed. - - name: Lite timing summary self-test - if: matrix.shard == 0 - run: python .github/scripts/lite-timing-top.py --self-test - - # The class list comes off the built assembly itself (-list classes/json), so the population is - # whatever exists on THIS commit — no roster. An empty selection is a hard error, not a skip: - # with zero -class arguments the runner executes the WHOLE suite, so "my shard is empty" and - # "run everything" are one argument-list apart — the loudest possible difference to get wrong - # quietly. (Hundreds of classes across four shards make an empty one implausible under either cut, - # so the arm is a guard, not an expectation.) - # #5587: the Darling PG job lost a shard to the JOB's timeout (its later steps stayed pending and no log was kept). - # This job has the same shape, so its test step gets a timeout of its own, under the job's 30 minutes, and the - # runner prints a test that has run for 5 minutes. The slowest Lite shard measured 14 minutes in this step. - - name: Run Lite tests (shard) - if: steps.scope.outputs.run == 'true' - timeout-minutes: 22 - shell: pwsh - env: - # The timing source the gate pinned for the WHOLE run (empty: none, so the hash cut). Read from - # the gate's output, never looked up here: see the job header and the gate's timing-source step. - LITE_TIMING_RUN: ${{ needs.gate.outputs.lite_timing_run }} - LITE_TIMING_ARTIFACTS: ${{ needs.gate.outputs.lite_timing_artifacts }} - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - LITE_SCOPE_MODE: ${{ steps.scope.outputs.mode }} - # #5459 change 5: the slow-skip lookup reads the pull request's file list through the API (GH_TOKEN above). - SLOW_EVENT: ${{ github.event_name }} - SLOW_PR: ${{ github.event.pull_request.number }} - SLOW_REPO: ${{ github.repository }} - # 'true' only when this shard used the Guard job's build of this run, which proves that job ran the Stage=Guard classes. - GUARD_BUILD_USED: ${{ steps.guard-build-check.outputs.used }} - run: | - $ErrorActionPreference = 'Stop' - # The cut and the run must agree (#5459): every listing below (the full one the shards cut from and the - # Darling-reads one) leaves the Guard classes out exactly when the Guard job ran them. No artifact: all classes. - $guardFilter = @(if ($env:GUARD_BUILD_USED -eq 'true') { '-trait-'; 'Stage=Guard' }) - function Get-LiteClasses([string[]] $filter) { - $lines = @(dotnet run --project Lite.Tests/Lite.Tests.csproj -c Release --no-build -- -list classes/json @guardFilter @filter) - if ($LASTEXITCODE -ne 0) { throw "class listing failed with exit code $LASTEXITCODE" } - # Only the runner's one-line JSON array is parsed; see the Darling shard step (a CLI notice on stdout). - $json = @($lines | Where-Object { $_ -match '^\s*\[' }) - if ($json.Count -ne 1) { throw "expected one JSON array line from the class listing, got $($json.Count): $($lines -join ' | ')" } - return @($json[0] | ConvertFrom-Json) - } - - $classes = @(Get-LiteClasses @()) - if ($classes.Count -eq 0) { throw 'the class listing returned no classes - the shard would silently run nothing' } - - if ($env:LITE_SCOPE_MODE -eq 'reads') { - # A diff that reaches only the Darling trees Lite.Tests reads: run the classes that carry the - # trait, all of them, in this one leg. The trait set is read off the built assembly, so it is - # whatever exists on THIS commit; DarlingReadsTraitGuardTests fails a class that reads a - # Darling path without the trait. An empty set is not "nothing to run": it would mean the trait - # went missing, so this leg runs the WHOLE suite rather than none of it. - $mine = @(Get-LiteClasses @('-trait', 'Reads=Darling')) - if ($mine.Count -eq 0) { - Write-Host '::warning title=Lite Darling-reads selection is empty::No Lite.Tests class carries [Trait("Reads", "Darling")], so this leg runs the whole Lite suite instead.' - $mine = $classes - } - Write-Host "Darling-reads run: $($mine.Count) of $($classes.Count) classes" - } - else { - # #5208: all four legs must cut the suite from the SAME inputs (see the job header). The gate - # pinned one timing source for the whole run. With none pinned the cut is the class-name hash - # (SHA-256, first byte, modulo the shard count), exactly as before the packer. With one, this - # leg downloads that run's timings and packs by duration. A pinned source this leg cannot - # fetch FAILS the leg: falling back to the hash here alone, while the other legs cut by - # duration, would leave the four cuts not partitioning the suite. - $shards = ${{ strategy.job-total }} - # job-total is the shard count only while `shard` is the matrix's one axis. A second axis would cut into - # buckets no leg reads; on a one-axis matrix job-index equals the shard value. - if (${{ strategy.job-index }} -ne ${{ matrix.shard }}) { throw "the lite-tests matrix is no longer the single shard axis (job-index ${{ strategy.job-index }}, shard ${{ matrix.shard }}), so strategy.job-total is not the shard count" } - if ($env:LITE_TIMING_RUN -match '^\d+$') { - $planDir = Join-Path $env:RUNNER_TEMP 'lite-shard-plan' - $timingDir = Join-Path $planDir 'timings' - New-Item -ItemType Directory -Force -Path $timingDir | Out-Null - $classesFile = Join-Path $planDir 'classes.txt' - Set-Content -Path $classesFile -Value $classes -Encoding utf8NoBOM - - $PSNativeCommandUseErrorActionPreference = $false - $downloaded = $false - foreach ($attempt in 1..3) { - # Start every try from an empty folder: gh will not overwrite a file that a partial try left. - Remove-Item -Recurse -Force -Path $timingDir -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path $timingDir | Out-Null - gh run download $env:LITE_TIMING_RUN --pattern 'lite-tests-timing-*' --dir $timingDir - if ($LASTEXITCODE -eq 0) { $downloaded = $true; break } - Write-Host "timing download attempt $attempt of 3 failed with exit code $LASTEXITCODE" - if ($attempt -lt 3) { Start-Sleep -Seconds (5 * $attempt) } - } - if (-not $downloaded) { throw "could not download the Lite timings of run $env:LITE_TIMING_RUN that the gate pinned - failing this shard rather than cutting alone, because the other shards may have cut by duration" } - # The gate pinned the exact artifact set. A leg that got fewer (one expired or was deleted since, - # which gh skips without failing) would cut from different inputs than the other legs. - $expected = @(($env:LITE_TIMING_ARTIFACTS -split ',') | Where-Object { $_ } | Sort-Object) - $got = @(Get-ChildItem -Path $timingDir -Directory | ForEach-Object { $_.Name } | Sort-Object) - if ($expected.Count -eq 0 -or ($expected -join ',') -ne ($got -join ',')) { throw "downloaded timing artifacts [$($got -join ', ')] are not the [$($expected -join ', ')] the gate pinned from run $env:LITE_TIMING_RUN - failing this shard rather than cutting from different inputs than the other shards" } - - $packOutput = @(python .github/scripts/lite-shard-pack.py pack --classes $classesFile --shards $shards --out $planDir --timings $timingDir) - $packExit = $LASTEXITCODE - $packOutput | ForEach-Object { Write-Host $_ } - if ($packExit -ne 0) { throw 'the shard packer failed or could not prove its cut total - failing this shard rather than guessing' } - # The packer itself falls back to the hash cut when the timings are unusable (missing, corrupt, - # empty, all zero, or under 80% of this commit's classes); it says which it used, and so does - # the notice below. - $method = if (($packOutput -join ' ') -match 'shards by (\w+);') { $Matches[1] } else { 'an unreported method' } - # The guard again, on the very files this shard reads. - python .github/scripts/lite-shard-pack.py reconcile --classes $classesFile --shards $shards --out $planDir - if ($LASTEXITCODE -ne 0) { throw 'the shard files are not exactly the discovered classes - failing this shard' } - - $mine = @(Get-Content -Path (Join-Path $planDir "shard-${{ matrix.shard }}.txt") | Where-Object { $_.Trim() }) - # A fingerprint of the whole plan, identical on every leg when they cut from the same inputs. Each - # shard file is labelled, so the same classes split differently across shards give a different id - # (joined bare, [A, B] + [C] and [A] + [B, C] hashed the same). Nothing compares the legs' ids: it - # is a diagnostic to read side by side, and the pinned artifact set above is what keeps them equal. - $planText = (0..($shards - 1) | ForEach-Object { "shard-$_`n" + (Get-Content -Raw -Path (Join-Path $planDir "shard-$_.txt")) }) -join '' - $planId = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($planText))).Substring(0, 12).ToLower() - Write-Host "::notice title=Lite shard plan::cut by $method (timings of run $env:LITE_TIMING_RUN); plan $planId. Every shard of this run must print this same id." - } - else { - $sha = [System.Security.Cryptography.SHA256]::Create() - $mine = @($classes | Where-Object { - $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($_)) - ($hash[0] % $shards) -eq ${{ matrix.shard }} - }) - Write-Host "::notice title=Lite shard plan::no timing source pinned, so this run cuts by class-name hash" - } - Write-Host "shard ${{ matrix.shard }}: $($mine.Count) of $($classes.Count) classes" - } - if ($mine.Count -eq 0) { throw "shard ${{ matrix.shard }} selected zero of $($classes.Count) classes - refusing to run, because a runner with no -class arguments runs the whole suite" } - - # #5459 change 5: a pull request leaves out the Cost=Slow classes its change does not reach (the rule is - # ci-select.py's slow_skip; a push, a merge-queue run, the nightly and a release run every class). The - # cut above is untouched, so every shard still agrees on which shard owns which class, and a shard that - # cannot list the pull request's files skips nothing. The skip happens AFTER the cut and the zero check, - # and an emptied shard ends here rather than reaching a runner with no -class arguments. - if ($env:SLOW_EVENT -eq 'pull_request') { - $skip = @(python .github/scripts/ci-select.py slow-skip --suite lite --event $env:SLOW_EVENT --repo $env:SLOW_REPO --pr $env:SLOW_PR | Where-Object { $_ }) - $kept = @($mine | Where-Object { $skip -notcontains ($_ -split '\.')[-1] }) - Write-Host "shard ${{ matrix.shard }}: $($mine.Count - $kept.Count) Cost=Slow classes left out on this pull request, $($kept.Count) to run" - $mine = $kept - if ($mine.Count -eq 0) { Write-Host "shard ${{ matrix.shard }}: every class of this shard is a skipped Cost=Slow class, so nothing runs"; exit 0 } - } - - # One runner invocation per chunk, never one for the whole shard: Windows CreateProcess caps the - # complete command line at 32,767 characters, and a Darling PG shard's -class list alone reached ~32,660 - # (#5100). The budget counts only the -class arguments; the other ~170 characters (the dotnet - # path and 'run --project ... --no-build --') must fit in what is left, and - # 'dotnet run' hands the same arguments on to the test host. Every chunk runs even after one - # fails, so a failure in an early chunk never hides the rest of the shard's results. - $argBudget = 30000 - $chunks = [System.Collections.Generic.List[string[]]]::new() - $current = [System.Collections.Generic.List[string]]::new() - $currentLength = 0 - foreach ($class in $mine) { - $cost = '-class'.Length + 1 + $class.Length + 1 - if ($current.Count -gt 0 -and ($currentLength + $cost) -gt $argBudget) { - $chunks.Add($current.ToArray()) - $current = [System.Collections.Generic.List[string]]::new() - $currentLength = 0 - } - $current.Add($class) - $currentLength += $cost - } - if ($current.Count -gt 0) { $chunks.Add($current.ToArray()) } - - # A failing chunk must set $LASTEXITCODE, not throw, or the later chunks would be skipped. - $PSNativeCommandUseErrorActionPreference = $false - New-Item -ItemType Directory -Force -Path TestResults | Out-Null - $failedChunks = 0 - $chunkNumber = 0 - foreach ($chunk in $chunks) { - $chunkNumber++ - $chunkArgs = foreach ($class in $chunk) { '-class'; $class } - $chunkLength = ($chunk | ForEach-Object { '-class'.Length + 1 + $_.Length + 1 } | Measure-Object -Sum).Sum - Write-Host "shard ${{ matrix.shard }} chunk $chunkNumber of $($chunks.Count): $($chunk.Count) classes, $chunkLength characters" - # -xml: per-test durations for the lite-tests-timing artifact (#5208). - dotnet run --project Lite.Tests/Lite.Tests.csproj -c Release --no-build -- @chunkArgs -xml TestResults/lite-timing-${{ matrix.shard }}-$chunkNumber.xml -longRunning 300 | Tee-Object -FilePath TestResults/lite-runner-${{ matrix.shard }}.log -Append - if ($LASTEXITCODE -ne 0) { - $failedChunks++ - Write-Host "shard ${{ matrix.shard }} chunk $chunkNumber of $($chunks.Count) failed with exit code $LASTEXITCODE" - } - } - if ($failedChunks -gt 0) { Write-Host "shard ${{ matrix.shard }}: $failedChunks of $($chunks.Count) chunks failed"; exit 1 } - exit 0 - - # #5208: where the time goes. Informational; a parse problem must never turn the shard red. - - name: Lite test timing summary - if: always() - continue-on-error: true - run: python .github/scripts/lite-timing-top.py TestResults - - - name: Upload Lite test timings - if: always() - continue-on-error: true - uses: actions/upload-artifact@v6 - with: - name: lite-tests-timing-${{ matrix.shard }} - path: | - TestResults/lite-timing-*.xml - TestResults/lite-timing-*.xml.cpu.json - retention-days: 7 - if-no-files-found: ignore - - # #5587: the runner output, uploaded whatever the test step did (see the note on the test step). - - name: Upload Lite runner output - if: always() - continue-on-error: true - timeout-minutes: 5 - uses: actions/upload-artifact@v6 - with: - name: lite-runner-log-${{ matrix.shard }} - path: TestResults/lite-runner-*.log - retention-days: 7 - if-no-files-found: ignore - - # #5459 change 4, shadow mode (slice 4). Computes which classes the test map WOULD run for this pull request and - # prints them in the step summary. It narrows nothing: every shard above and below still runs every class, so the - # classes run on a pull request are the ones that ran before this job existed. NOT a required check, and nothing - # lists it in `needs` but the shadow-check job below, which is also optional: both are continue-on-error, so their - # own failure is a notice at most. A missing, unreadable, old or wrong-schema map, a failed download and a failed - # diff all mean FULL (never an error, never a skip). Pull requests only. - test-map-shadow: - name: Test map shadow - needs: [gate] - if: github.event_name == 'pull_request' && needs.gate.outputs.run == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - continue-on-error: true - permissions: - contents: read - actions: read - steps: - - uses: actions/checkout@v7 - continue-on-error: true - with: - fetch-depth: 0 - - - name: Select from the pinned map - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - MAP_RUN: ${{ needs.gate.outputs.test_map_run }} - MAP_SHA: ${{ needs.gate.outputs.test_map_sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - set -uo pipefail - - mkdir -p shadow - full() { - echo "::notice title=Test map shadow::The selection is FULL: $1" - printf '### Test map shadow (#5459): nothing was skipped, every shard ran everything\n\nSelection: FULL (%s).\n' "$1" > shadow/summary.md - } - if [ -z "${MAP_RUN}" ]; then - full "no test map is pinned for this run" - else - ok=0 - for attempt in 1 2 3; do - rm -rf shadow/map - if gh run download "${MAP_RUN}" -n test-map -D shadow/map; then ok=1; break; fi - sleep 5 - done - if [ "${ok}" != "1" ]; then - full "the pinned map could not be downloaded" - elif ! git diff --no-renames --name-only "${BASE_SHA}" HEAD > shadow/changed.txt; then - full "the changed files could not be listed" - elif ! timeout 120 python .github/scripts/ci-select.py map-select --map shadow/map/test-map.json.gz --event pull_request --base "${BASE_SHA}" --files-from shadow/changed.txt --out shadow/selection.json --summary shadow/summary.md; then - rm -f shadow/selection.json - full "the selection script failed" - else - echo "Map: nightly run ${MAP_RUN}, commit ${MAP_SHA:0:10}." >> shadow/summary.md - fi - fi - cat shadow/summary.md >> "$GITHUB_STEP_SUMMARY" || true - exit 0 - - - name: Upload the shadow selection - continue-on-error: true - uses: actions/upload-artifact@v6 - with: - name: test-map-shadow-selection - path: shadow/selection.json - retention-days: 7 - if-no-files-found: ignore - - # The second half of shadow mode: after every shard has finished, list each class that FAILED although the map - # would not have run it (a shadow miss), and keep one JSONL row per run (artifact test-map-shadow-row, 30 days). - # Failed classes come from the shards' xunit reports (darling-tests-timing-N, lite-tests-timing-N). A shard that - # wrote no readable report shows up as a lower report count in the row, never as an error. Optional and - # continue-on-error like the job above: it can lengthen the run by its own thirty seconds and cannot redden it. - shadow-check: - name: Test map shadow check - needs: [gate, test-map-shadow, darling-pg, lite-tests] - if: always() && github.event_name == 'pull_request' && needs.gate.outputs.run == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - continue-on-error: true - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - continue-on-error: true - with: - sparse-checkout: .github/scripts - - - name: Download the shadow selection - continue-on-error: true - uses: actions/download-artifact@v6 - with: - name: test-map-shadow-selection - path: shadow/selection - - - name: Download the shard reports - continue-on-error: true - uses: actions/download-artifact@v6 - with: - pattern: '*-tests-timing-*' - path: shadow/reports - - - name: List the shadow misses - shell: bash - env: - RUN_ID: ${{ github.run_id }} - RUN_ATTEMPT: ${{ github.run_attempt }} - PR_NUMBER: ${{ github.event.pull_request.number }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - MAP_SHA: ${{ needs.gate.outputs.test_map_sha }} - run: | - set -uo pipefail - - meta=$(printf '{"run":"%s","attempt":"%s","pr":"%s","head":"%s","map_sha":"%s"}' "${RUN_ID}" "${RUN_ATTEMPT}" "${PR_NUMBER}" "${HEAD_SHA}" "${MAP_SHA}") - mkdir -p shadow - if timeout 120 python .github/scripts/ci-select.py shadow-check --selection shadow/selection/selection.json --reports shadow/reports --out shadow/row.jsonl --meta "${meta}"; then - { echo "### Test map shadow check (#5459)"; echo; echo '```json'; cat shadow/row.jsonl; echo '```'; } >> "$GITHUB_STEP_SUMMARY" || true - else - echo "::notice title=Test map shadow check::The check script failed, so this run has no shadow row." - fi + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + if ((Test-Path "$bin\pg_ctl.exe") -and (Test-Path $dataDir)) { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } exit 0 - - - name: Upload the shadow row - continue-on-error: true - uses: actions/upload-artifact@v6 - with: - name: test-map-shadow-row - path: shadow/row.jsonl - retention-days: 30 - if-no-files-found: ignore - - # The one result branch protection can require. Four matrix legs cannot each be a required check - # without hard-coding the shard count into repo settings, and a leg that never starts reports - # nothing — the same silent green the whole-tree guards job exists to prevent. This job always - # reports: success when every leg succeeded or the filters skipped the suite everywhere, failure - # for anything else, and it says which out loud. (Branch protection must list "Lite tests - # (result)" for the shards to gate merges — noted on #3887.) - lite-tests-result: - name: Lite tests (result) - needs: [gate, guard-tests, lite-tests] - if: ${{ !cancelled() && github.event_name != 'release' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: {} - steps: - - name: Report the shards' one answer - shell: bash - env: - RESULT: ${{ needs.lite-tests.result }} - GUARD_RESULT: ${{ needs.guard-tests.result }} - GATE_RESULT: ${{ needs.gate.result }} - GATE_RUN: ${{ needs.gate.outputs.run }} - EVENT_NAME: ${{ github.event_name }} - run: | - set -euo pipefail - # A gate that did not succeed leaves every job it feeds skipped, which would read as green here. - if [ "${GATE_RESULT}" != "success" ]; then - echo "::error title=CI gate failed::needs.gate.result='${GATE_RESULT}' - the job that decides whether CI runs did not succeed, so nothing ran. Re-run the workflow." - exit 1 - fi - # A declined run (draft or merge-train) leaves every job skipped, which would also read as green. - if [ "${EVENT_NAME}" = "pull_request" ] && [ "${GATE_RUN}" != "true" ]; then - echo "::error title=CI not run::This pull request's CI was skipped (draft or merge-train label). It can be merged only by an admin override after a merge train covered it." - exit 1 - fi - # The Guard tests job runs first on a pull request (#5459). When it did not succeed the shards were - # skipped on purpose, and a skipped matrix reads as green, so this is where that becomes a failure. On - # a push, a merge-queue run or a release the shards ran whatever the guards said, but a guard that did - # not pass is still a failure. `skipped` is fine on a release only: the guard job does not run there. - if [ "${GUARD_RESULT}" != "success" ] && ! { [ "${EVENT_NAME}" = "release" ] && [ "${GUARD_RESULT}" = "skipped" ]; }; then - if [ "${EVENT_NAME}" = "pull_request" ]; then - echo "::error title=Guard tests failed::needs.guard-tests.result='${GUARD_RESULT}' - the Guard tests job did not succeed, so the shards did not run. Read that job's log for the failing class." - else - echo "::error title=Guard tests failed::needs.guard-tests.result='${GUARD_RESULT}' - the Guard tests job did not succeed. The shards ran anyway on this event; read that job's log for the failing class." - fi - exit 1 - fi - case "${RESULT}" in - success) - echo "::notice title=Lite shards green::All four shards succeeded (or every leg's filters said the Lite suite is untouched by this diff)." ;; - skipped) - # Not a by-design skip any more (#5459). The matrix job is skipped on purpose in three cases: the - # gate declined the run, the guard job failed on a pull request, and a release (the build job runs - # the suite whole there). The first two are errors above, and this job does not run on a release - # (its own `if:`), so a skip that reaches this line is unplanned and must not read as a pass. - echo "::error title=Lite shards skipped::needs.lite-tests.result='skipped' although the gate ran this event and the guards passed, so no shard reported. The suite has NOT passed on this commit." - exit 1 ;; - *) - echo "::error title=Lite shards failed::needs.lite-tests.result='${RESULT}' - at least one shard failed or was cancelled. The suite has NOT passed on this commit." - exit 1 ;; - esac diff --git a/.github/workflows/check-pr-branch.yml b/.github/workflows/check-pr-branch.yml deleted file mode 100644 index 1c14086f68..0000000000 --- a/.github/workflows/check-pr-branch.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Check pull request target branch -on: - pull_request_target: - types: - - opened - - reopened - - synchronize - - edited -jobs: - check-branches: - runs-on: ubuntu-latest - steps: - - name: Check branches - env: - HEAD_REF: ${{ github.head_ref }} - BASE_REF: ${{ github.base_ref }} - run: | - if [ "$HEAD_REF" != "dev" ] && [ "$BASE_REF" == "main" ]; then - echo "::error::Pull requests to main are only allowed from dev. Please target the dev branch instead." - exit 1 - fi diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml deleted file mode 100644 index 85afcc9d02..0000000000 --- a/.github/workflows/check-version-bump.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Check version bump -on: - pull_request: - branches: [main] - -jobs: - check-version: - if: github.head_ref == 'dev' - runs-on: ubuntu-latest - - steps: - - name: Checkout PR branch - uses: actions/checkout@v7 - - # Documentation-only PRs (e.g. a CHANGELOG correction) do not bump the - # version. The workflow still runs so the required check reports a result - # — using paths-ignore would skip the run entirely and leave the check - # stuck pending. Instead, the version comparison below is gated on a count - # comparison: all_count == docs_count means every changed file is on the - # documentation allowlist, which MIRRORS build.yml's docs: filter — keep - # the two in step, or a LICENSE-only correction will demand a version bump - # here while build.yml calls it documentation. A boolean '**'-plus- - # exclusions filter cannot express "any non-documentation file" under - # dorny v4's default predicate-quantifier ('some'): every changed file - # matches '**', so that filter shape is unconditionally true — which is - # why this gate had silently stopped skipping md-only PRs. - - name: Detect non-documentation changes - id: changes - uses: dorny/paths-filter@v4 - with: - filters: | - docs: - - '**/*.md' - - 'LICENSE' - - 'CITATION.cff' - - '.gitignore' - - '.gitattributes' - - 'docs/**/*.{md,svg,png,jpg,jpeg,gif}' - - 'Screenshots/**/*.{md,svg,png,jpg,jpeg,gif}' - all: - - '**' - - - name: Get PR version - if: steps.changes.outputs.all_count != steps.changes.outputs.docs_count - id: pr - shell: pwsh - run: | - $version = ([xml](Get-Content Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ } - if ([string]::IsNullOrWhiteSpace($version)) { throw "Read no version from Directory.Build.props." } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "PR version: $version" - - - name: Checkout main - if: steps.changes.outputs.all_count != steps.changes.outputs.docs_count - uses: actions/checkout@v7 - with: - ref: main - path: main-branch - - - name: Get main version - if: steps.changes.outputs.all_count != steps.changes.outputs.docs_count - id: main - shell: pwsh - run: | - # main does not carry Directory.Build.props until the release that centralises the - # version (#3222) lands there, so fall back to deprecated/Dashboard/Dashboard.csproj, - # which is where main declares its version. That release PR has to be able to pass - # against a main that predates the file. Removable once main has it. - $path = 'main-branch/Directory.Build.props' - if (-not (Test-Path $path)) { $path = 'main-branch/deprecated/Dashboard/Dashboard.csproj' } - $version = ([xml](Get-Content $path)).Project.PropertyGroup.Version | Where-Object { $_ } - # Two empty reads compare EQUAL, so an unguarded pair fails this gate for the wrong reason - # while one empty side passes it for the wrong reason. - if ([string]::IsNullOrWhiteSpace($version)) { throw "Read no version from $path." } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "Main version: $version (from $path)" - - - name: Compare versions - if: steps.changes.outputs.all_count != steps.changes.outputs.docs_count - env: - PR_VERSION: ${{ steps.pr.outputs.VERSION }} - MAIN_VERSION: ${{ steps.main.outputs.VERSION }} - run: | - echo "Main version: $MAIN_VERSION" - echo "PR version: $PR_VERSION" - if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then - echo "::error::Version in Directory.Build.props ($PR_VERSION) has not changed from main. Bump the version before merging to main." - exit 1 - fi - echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION" - - - name: Skip notice - if: steps.changes.outputs.all_count == steps.changes.outputs.docs_count - run: echo "Only documentation files changed — version bump check skipped." diff --git a/.github/workflows/claude-review-guard.yml b/.github/workflows/claude-review-guard.yml deleted file mode 100644 index 6f9f14fa6e..0000000000 --- a/.github/workflows/claude-review-guard.yml +++ /dev/null @@ -1,754 +0,0 @@ -name: Claude review guard - -# #2229: the review workflow can finish green and leave nothing an operator can read. Two ways, -# both observed on this repo, both silent: -# -# 1. claude-code-action refuses to run when .github/workflows/claude-review.yml differs from the -# DEFAULT branch -- the check is server-side, during the OIDC token exchange. It exits SUCCESS -# in about four seconds and sets no output a caller can read. -# 2. The review runs for real and posts nothing anyway. PR #2213 did this eleven times; one run -# spent $4.18 over 24 turns with 6 permission denials, reported subtype=success, and left zero -# comments. A 104-file change merged unreviewed and nothing anywhere said so. -# -# What it must NOT do is call a CLEAN review a failure. The prompt tells the reviewer HOW to report -# findings, not to report when it has none, so silence on a one-line change is the correct outcome -- -# and a mark that goes red there is one people learn to ignore, which costs more than it catches. So -# the zero-posted verdict is keyed on whether claude-review.yml actually promises a post. -# -# #3470: posting is not the same as being read. One night's PR train collected six review findings -# as comments (one real) and auto-merge outran every one, because nothing made a finding cost -# anything. So when the prompt promises a verdict review (the same sentinel), this guard also reads -# the bot's newest VERDICT -- its newest review that carries a body, because the inline-comment -# tool files each comment in a bodiless review of its own -- and fails while it says -# CHANGES_REQUESTED. Newest-verdict-wins: the fresh clean verdict a re-review submits clears the -# check by itself, no --approve required. -# -# This guard is a SEPARATE workflow on purpose. Editing claude-review.yml makes this branch's copy -# differ from the default branch, which triggers cause (1) for EVERY pull request in the repo until -# the next release -- so a guard living inside the file it protects would disable review each time -# it was touched. Nothing here invokes claude-code-action, so this file can be edited freely. -# -# It polls rather than using `on: workflow_run` because workflow_run only ever fires from the copy -# of a workflow that is on the default branch: that variant would be dormant, and untestable, until -# a release shipped it. -# -# #3657's landing produced a third case the never-ran arm had no name for. That arm read "Claude -# never ran and this PR does not touch claude-review.yml" as one thing -- an unreviewed edit to the -# file somewhere -- and failed every PR in the repo red for it. But pull requests to main are only -# allowed from dev (check-pr-branch.yml), so a fix to the review workflow CANNOT land on main and -# dev together outside a release: it merges to dev through a reviewed PR, and from that moment until -# the next dev -> main release, cause (1) skips the action on every dev PR. That window is a NORMAL -# state of this repo's branching model, not an incident, and the arm now names it. When the base -# branch's copy of the file was put there by a MERGED pull request into that branch -- the newest -# commit touching the file resolves to one, that PR's diff includes the file, and the branch's blob -# is that merge's blob -- the verdict is "review pending release": a warning, exit 0, and the PR -# still needs a human. When no merged PR accounts for the branch's copy -- a direct push, or an -# unreviewed edit -- the drift is hostile and the arm fails exactly as before. When the guard cannot -# tell, because a lookup died, it fails CLOSED with its own message: an unexplained drift is the one -# condition this check exists to make loud, and a 503 must not wave one through. -# -# It runs on pull requests into dev only, the branch claude-review.yml reviews. A release pull request -# into main gets no review, so this check has no verdict to read there and does not run. -# -# What the review does per event (claude-review.yml decides it, once, in its `decide` job; this guard -# does not repeat the decision, it reads what the review run did): a full review when a pull request -# opens, reopens or is marked ready, and on the `re-review` label; ONE cheaper follow-up review per -# full one on a push, and nothing for the push after that; nothing for a draft or another label. So -# "the review did not run" is no longer always a defect. A run whose review job was SKIPPED on -# purpose (the decide job succeeded, the review job was skipped) is not a failure here, but it is -# not a clean bill either: the newest verdict still stands, and a changes-requested one still fails -# the check until a fresh full review (the `re-review` label) or a human dismissal replaces it. A -# decide job that FAILED is not a skip on purpose and fails the check. A review run that was -# CANCELLED is not one either: it fails the check unless a newer push has replaced the head it was -# reviewing, in which case that push's own check judges the new head, or a newer review run for the -# same head has replaced it, in which case this check waits for that run and judges it instead. -# -# Every read that decides the result fails the check when it fails. A GET that hits a rate limit, a -# server error or a failed later page is not a verdict, and it is not "no review" or "no standing -# objection" either, so the check goes red, names the read, and a re-run of the guard retries it. -# The one retried read is the review-run listing: a failed poll uses up one attempt, and "no review -# run exists" is concluded only from a listing that succeeded and came back empty. A review that -# was skipped on purpose, a review the action itself skipped, a pull request that edits the review -# workflow, and a review that did not finish inside the guard window each read the standing verdict -# before they pass. -# -# Known limit: `pull_request` runs the pull request's own copy of this file, so a pull request can -# edit the guard itself. -on: - pull_request: - # A draft is skipped, as claude-review.yml skips it: with no review to wait for, a draft run could - # only fail. Marking the pull request ready for review runs both. `labeled` re-checks the standing - # verdict (and waits for a `re-review` review); the poll below ignores the review runs that a - # draft or an unrelated label skipped, so such a run finds the review that matters. - types: [opened, synchronize, reopened, ready_for_review, labeled] - # Pull requests into dev only, the same branch claude-review.yml reviews. - branches: [dev] - -# Match the review's own cancel-in-progress behavior. A new push cancels the review it is watching, -# so the guard has to go too, or it waits out its deadline on a run that was cancelled by design. -# A run that supersedes nothing (a draft, or a label other than re-review) gets a group of its own, -# for the reason the review gives: with cancel-in-progress a run that will not wait on anything new -# would cancel the guard that is. The suffix is `cond && value || ''`, never `cond && '' || value` -# (the empty string is falsy in an expression). A guard in a group of its own is not cancelled by -# the next guard, so when the review it watches is cancelled it looks for the review run that -# replaced it (the cancelled arm below). An unrelated label still runs the guard rather than -# skipping it: a job skipped by a condition reports as passing, and that report would be the -# newest result on the head. -concurrency: - group: claude-review-guard-${{ github.event.pull_request.number }}${{ (github.event.pull_request.draft || (github.event.action == 'labeled' && github.event.label.name != 're-review')) && format('-solo-{0}', github.run_id) || '' }} - cancel-in-progress: true - -jobs: - verify: - # The DISPLAY name is the check-run name. Anything that matches this check by name (a branch - # rule that lists it as a required check, a script that reads check runs) sees this string and - # not the job id: before this line the check reported as the bare job id "verify". Renaming it - # changes the name every such match has to use. Whether a branch rule requires this check is a - # repository setting that this file neither reads nor sets. - # - # What the check does. It FAILS when the review bot's newest verdict (its newest review that - # has a body) is CHANGES_REQUESTED; when the review run for this head failed, was cancelled and - # not replaced, or left nothing the review workflow promises; when the review step cannot be - # found, or the action never ran for a reason nothing explains; and when a read it needs fails. - # It PASSES otherwise, for example: a review that finished with a clean verdict, a review - # skipped on purpose or by the action while no CHANGES_REQUESTED stands, a pull request that no - # review run covers, and a head that a newer push has replaced. - name: Claude review guard - # Skips while the pull request is a draft, as claude-review.yml does: there is no review to wait for. - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - # The review is capped at 30 minutes; leave room to observe one that uses all of it. - timeout-minutes: 40 - # No issues: scope here, despite the tally reading repos/.../issues/$PR/comments for - # top-level output. A pull request IS an issue to that API, and the PullRequests scope - # covers its comments: run 31726598542 was granted exactly Actions/Checks/Contents/ - # Metadata/PullRequests read and counted issue: 1 from that endpoint without a 403. - permissions: - actions: read - checks: read - contents: read - pull-requests: read - steps: - - name: Verify the review posted something - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - R: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - SHA: ${{ github.event.pull_request.head.sha }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - # The branch this PR merges INTO: dev, the one branch the trigger lists. The - # pending-release arm asks whose copy of $WF this branch carries, so it must - # read the base, not the head and not the default branch (which is the other side of the - # comparison). - BASE: ${{ github.base_ref }} - WF: .github/workflows/claude-review.yml - # Coupling to a step NAME in the other workflow. Deliberate: the step's conclusion is the - # only structured way to tell "no token, skipped cleanly" from "ran and said nothing". - REVIEW_STEP: Claude review - # The review JOB's name (its id, since it sets no name:). A decided skip shows up here as - # this job concluding `skipped` while the run itself succeeded. Renaming the job in - # claude-review.yml blinds that arm; update this to match. - REVIEW_JOB: review - # Author of every artifact the review leaves behind (the action reports bot_name). - REVIEW_BOT: claude[bot] - # Whether silence, or a missing verdict, is a DEFECT depends on what the review prompt - # promises -- and the copy that decides is the DEFAULT branch's, because that is the copy - # claude-code-action validates against and runs. Keyed on a sentinel in the prompt rather - # than on prose so the coupling is exact, and read at run time so the strict arms below - # turn on only once the promising prompt has actually shipped. The sentinel debuted WITH - # the VERDICT PROTOCOL (one commit, deliberately), so its presence means the prompt - # promises BOTH: post findings as comments AND end every run with one formal verdict - # review (gh pr review --request-changes, or --comment when clean). Absent the sentinel - # -- an older $WF, or a future prompt that drops the protocol -- both strict arms stand - # down: silence reads as a clean review, and the verdict arm must not enforce, because - # runs that never promise fresh verdicts can never clear a stale changes-requested, which - # would wedge the gate shut with no self-heal. - MUST_POST_SENTINEL: ALWAYS POST - run: | - set -euo pipefail - - # "$*", not "$1": every echo in this step wraps across lines with a trailing backslash, - # which produces SEPARATE ARGUMENTS that echo joins with a space. A helper reading only $1 - # silently truncates at the first wrap -- the summary keeps its opening clause and drops - # the rest, with nothing to show that it did. Identical for the single-argument calls. - summary() { echo "$*" >> "$GITHUB_STEP_SUMMARY"; } - - # #2309: every one-shot catalog lookup below goes through one of the two wrappers here, - # because under `set -e` a bare $(gh api ...) assignment that fails KILLS the step with one - # context-free stderr line -- which is how a transient 404/503 became a - # deterministic-looking hard red on three PRs in one afternoon, with no annotation naming - # the call that died. The helpers assign to LOOKUP_OUT instead of being command-substituted - # because an exit inside $() only leaves the subshell -- the one shape that CANNOT work - # here. - # - # One capture, two wrappers, differing ONLY in the message a failed call becomes. Both end - # the step with exit 1, because a lookup failure is not a review verdict and it is not a - # pass either: every read below decides the result, so a read that fails (a rate limit, - # a 5xx, a failed later page) fails the check, names the read, and a re-run of the guard - # retries it. - # lookup() names the endpoint and says the result depends on that read. lookup_closed() - # serves the drift-origin read in the never-ran arm, where the question is no longer "did - # the review post" but "is this drift a merged change awaiting release, or an unreviewed - # edit" -- and there a lookup that dies must NOT pass, because passing IS the false - # verdict: a hostile drift waved through on a 503. It names the endpoint the same way. - LOOKUP_OUT=''; LOOKUP_ERR='' - gh_capture() { - # stderr goes to its OWN capture, never into LOOKUP_OUT: a SUCCESSFUL gh call that - # prints a warning (deprecation notice, scope hint) must not pollute a value the call - # sites compare ("$step" != "success") or feed to arithmetic -- that would be this - # exact defect class reintroduced through the fix (review catch on #2309's own PR). - local errfile - # The plumbing itself must not be able to kill the step either (review catch on the - # #2309 fix): a failed mktemp degrades to /dev/null (stderr is then lost but the - # verdict path survives), and the cat/rm are failure-tolerant for the same reason. - errfile=$(mktemp 2>/dev/null || echo /dev/null) - if ! LOOKUP_OUT=$(gh api "$@" 2>"$errfile"); then - LOOKUP_ERR=$(cat "$errfile" 2>/dev/null || true) - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - return 1 - fi - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - } - lookup() { - local what=$1; shift - if ! gh_capture "$@"; then - echo "::error title=Guard read failed::gh api (${what}) failed: ${LOOKUP_ERR:-$LOOKUP_OUT}."\ - "The result of this check depends on that read, and a read that fails is not a"\ - "verdict (#2309), so the check fails. Re-run this guard to retry it." - summary "- Guard read failed at ${what}; failing the check (#2309)." - exit 1 - fi - } - lookup_closed() { - local what=$1; shift - if ! gh_capture "$@"; then - echo "::error title=Drift origin unknown::gh api (${what}) failed: ${LOOKUP_ERR:-$LOOKUP_OUT}."\ - "Claude never ran on this PR, and this guard could not determine whether $WF's drift"\ - "from $DEFAULT_BRANCH is a merged $BASE change awaiting release or an unreviewed edit."\ - "An unexplained drift is the one condition this check exists to make loud, so it"\ - "fails CLOSED rather than guess. Re-run this guard; if the failure repeats, read"\ - "$WF's history on $BASE by hand." - summary "- Drift origin UNKNOWN: lookup failed at ${what}. Failing closed." - exit 1 - fi - } - - # The obligation read: what does $WF promise, on the DEFAULT branch -- the copy the action - # validated and ran? Sets must_post to 1 when the sentinel is in it and to '' when it is - # not, and a prompt without the sentinel promises no verdict, so nothing is enforced. The - # read itself is not allowed to answer "no obligation" by failing: a GET that fails, or - # comes back empty or undecodable, fails the check, because that is not the answer "the - # prompt has no sentinel". The decode reads a here-string rather - # than a pipe, so a grep that stops at its first match cannot fail the producer. - read_must_post() { - local decoded - lookup "$WF on $DEFAULT_BRANCH (verdict promise)" \ - "repos/$R/contents/$WF?ref=$DEFAULT_BRANCH" --jq '.content // empty' - if [ -z "$LOOKUP_OUT" ] \ - || ! decoded=$(printf '%s' "$LOOKUP_OUT" | base64 -d 2>/dev/null); then - echo "::error title=Guard read failed::$WF on $DEFAULT_BRANCH came back empty or"\ - "undecodable, so the verdict promise in its prompt cannot be read. A read that"\ - "fails is not a verdict (#2309), so the check fails. Re-run this guard to retry it." - summary "- Guard read failed: $WF on $DEFAULT_BRANCH is empty or undecodable;"\ - "failing the check (#2309)." - exit 1 - fi - must_post='' - if grep -qF "$MUST_POST_SENTINEL" <<<"$decoded"; then must_post=1; fi - } - - # The newest verdict, for a run that has no review of its own to read: a decided skip, no - # secret, a pull request that edits the review workflow, a workflow copy the action will - # not run, or a review that did not finish inside the guard window. Having no review is not - # a clean bill: the standing verdict still counts, newest wins, and a changes-requested - # one still fails the check, as it does after a review that ran. $1 says why this head - # has no review of its own, as a clause for the failure message ("this push got no - # review on purpose"); the rest say what clears it, joined with "$*" so that a message - # wrapped across lines is one text, not a first argument and lost ones. It ends the step - # either way (exit 0 or 1), like lookup(), so the caller never falls through. - # Same obligation read as the verdict arm below: with no sentinel on the default branch's - # copy there is no verdict promise to hold anything to, so nothing is enforced. - # Only a review WITH A BODY is a verdict. The inline-comment tool files each comment inside - # a review of its own with an EMPTY body, so a run that died after its comments leaves a - # bodiless COMMENTED review newest, and reading that as the verdict would clear a standing - # CHANGES_REQUESTED. Both verdict shapes carry a body, and a DISMISSED review keeps its - # own, so the filter drops only those carriers and a human dismissal still overrides. - standing_verdict() { - local why=$1 fix newest verdict_state verdict_url - shift - fix="$*" - read_must_post - if [ -z "$must_post" ]; then exit 0; fi - lookup "verdict scan (standing verdict)" "repos/$R/pulls/$PR/reviews" --paginate \ - --jq ".[] | select(.user.login == env.REVIEW_BOT) | select(.submitted_at != null) - | select(((.body // \"\") | length) > 0) | \"\(.state)\t\(.html_url)\"" - newest=$(printf '%s\n' "$LOOKUP_OUT" | tail -n 1) - if [ -z "$newest" ]; then exit 0; fi - IFS=$'\t' read -r verdict_state verdict_url <<<"$newest" - summary "- standing verdict: $verdict_state -- $verdict_url" - if [ "$verdict_state" = "CHANGES_REQUESTED" ]; then - echo "::error title=Review requested changes::The newest verdict by $REVIEW_BOT on"\ - "PR #$PR is CHANGES_REQUESTED: $verdict_url, and $why. $fix" - summary "### Claude review requested changes; $why" - summary "- $fix" - exit 1 - fi - echo "::notice title=Review verdict clean::Newest verdict by $REVIEW_BOT is"\ - "$verdict_state; no standing changes-requested." - exit 0 - } - - # Wait for the review run on this exact commit. Nothing appearing inside the grace window - # means review is not operating here at all -- no secret, or a fork PR, which the review - # workflow no-ops by design and which is not this guard's business. Runs that a draft or - # an unrelated label SKIPPED are not the run to wait for and are filtered out: the newest - # run that is not skipped is the one whose review (or decided skip) this check judges. - # The poll deliberately does NOT use lookup(): a transient API failure mid-poll should - # consume one attempt and try again, not end the guard (#2309). What a failed listing may - # not do is answer "no run exists": that is concluded only from a listing that succeeded - # and came back empty, and when no run was ever seen and no listing from the grace point - # on succeeded, the check fails. - RUNS_URL="repos/$R/actions/workflows/claude-review.yml/runs?head_sha=$SHA&per_page=100" - # The newest run for $SHA that is not skipped, as "id status conclusion created_at", or - # nothing. $1 keeps only the runs whose id is above it (ids rise with creation order), and - # 0 keeps them all. A failed call returns non-zero; the caller must not read LOOKUP_OUT then. - newest_run() { - gh_capture "$RUNS_URL" \ - --jq "[.workflow_runs[] | select(.conclusion != \"skipped\" and .id > $1)][0] // empty - | \"\(.id) \(.status) \(.conclusion // \"-\") \(.created_at)\"" - } - # One 35-minute budget for the whole step: a cancelled review that a newer run replaced is - # followed with what is left of it, so the step stays inside its own timeout. - run_id=''; status=''; conclusion=''; created=''; seen=''; attempt=0 - wait_for_review() { - local line listed - while [ "$status" != "completed" ] && [ "$attempt" -lt 70 ]; do - attempt=$((attempt + 1)) - line=''; listed='' - if newest_run 0; then listed=1; line=$LOOKUP_OUT; fi - if [ -n "$line" ]; then - read -r run_id status conclusion created <<<"$line" - seen=1 - [ "$status" = "completed" ] && break - # Keyed on never having seen a run, not on this poll being empty: a transient empty - # listing after one was found must not be read as "review is not operating here". - elif [ -z "$seen" ] && [ -n "$listed" ] && [ "$attempt" -ge 6 ]; then - echo "::notice title=No review run::No Claude Auto Review run exists for $SHA, so"\ - "review is not operating on this PR (missing secret, or a fork). Nothing to verify." - exit 0 - fi - sleep 30 - done - if [ -z "$seen" ]; then - echo "::error title=Review runs unreadable::This guard could not list the Claude Auto"\ - "Review runs for $SHA (${LOOKUP_ERR:-no listing returned}), so it cannot tell whether"\ - "a review is running. A read that fails is not an answer, so the check fails. Re-run"\ - "this guard." - summary "- The review-run listing for $SHA failed on every poll; failing the check." - exit 1 - fi - } - - while :; do - wait_for_review - - # Out of budget with the run still going: the guard stops waiting, and reads the standing - # verdict before it passes. The run's own 30-minute timeout marks it, and a standing - # CHANGES_REQUESTED still fails this check. - if [ "$status" != "completed" ]; then - echo "::warning title=Review did not finish::Gave up waiting on review run $run_id for"\ - "PR #$PR after 35 minutes. Its own 30-minute timeout will mark it; this PR is NOT"\ - "Claude-reviewed." - summary '### Claude review did not finish inside the guard window' - standing_verdict "review run $run_id did not finish inside the guard window" \ - "Re-run this check once that run has finished (its fresh verdict replaces this one)," \ - "or dismiss the review if the findings are wrong." - fi - - [ "$conclusion" = "cancelled" ] || break - - # A cancelled review run did not review this head unless something replaced it. Once - # only a newer push could cancel a review, and that push's own check judged the new head, - # so "cancelled" meant "nothing to verify". Now the review job joins its concurrency group - # after the decide job, so an older run's review can cancel a newer one, and a manual - # cancel does the same: the review meant to cover $SHA never finished, and passing here - # would let the pull request through with no review of it. So ask where the head is now. - # A different head: a newer push owns the review and its own check judges it. Still $SHA: - # ask which review run covers it now. A guard started by an unrelated label sits in a - # concurrency group of its own, so no newer guard cancels it while another review run for - # the same head replaces the one it watches, and it would fail on the cancelled run until - # the replacement's guard finished. So list the runs for $SHA again: a newer run (a higher - # id) that was not skipped is the one to judge, and the loop goes back to wait on it with - # what is left of the bound. None, or a listing that fails, and nothing replaced the - # cancelled review: the check fails. A read that fails (or returns no head) is neither - # answer, so it fails CLOSED, as the drift-origin lookups do -- through gh_capture - # directly, because lookup()'s message is not about the head or the runs and - # lookup_closed() words its error for the drift arm. - if ! gh_capture "repos/$R/pulls/$PR" --jq '.head.sha // empty' || [ -z "$LOOKUP_OUT" ]; then - echo "::error title=Review cancelled, head unknown::Review run $run_id was cancelled, and"\ - "this guard could not read the head of PR #$PR to tell whether a newer push replaced"\ - "it (${LOOKUP_ERR:-no head returned}). A cancelled review is not a pass, so this check"\ - "fails closed. Re-run this guard." - summary "- Review run $run_id was cancelled and the head of PR #$PR could not be read."\ - "Failing closed." - exit 1 - fi - current_head=$LOOKUP_OUT - if [ "$current_head" != "$SHA" ]; then - echo "::notice title=Review cancelled::run $run_id was cancelled, and the head of PR #$PR"\ - "has moved on to ${current_head:0:12}; that push's own check judges it." - summary "- Review run $run_id was cancelled; the head moved on to ${current_head:0:12},"\ - "whose own check judges it." - exit 0 - fi - cancelled_run=$run_id - if ! newest_run "$cancelled_run"; then - echo "::error title=Review cancelled, replacement unknown::Review run $cancelled_run was"\ - "cancelled, and this guard could not list the review runs for $SHA to tell whether a"\ - "newer run replaced it (${LOOKUP_ERR:-$LOOKUP_OUT}). A cancelled review is not a pass,"\ - "so this check fails closed. Re-run this guard." - summary "- Review run $cancelled_run was cancelled and the review runs for $SHA could not"\ - "be listed. Failing closed." - exit 1 - fi - if [ -z "$LOOKUP_OUT" ]; then - echo "::error title=Review cancelled::The review of this head ($SHA, run $cancelled_run) was"\ - "cancelled and nothing replaced it, so PR #$PR has no review of its current commit."\ - "Re-run the Claude Auto Review run, then re-run this check." - summary '### Claude review was cancelled; this head has no review' - summary '- Re-run the Claude Auto Review run, then re-run this check.' - exit 1 - fi - read -r run_id status conclusion created <<<"$LOOKUP_OUT" - echo "::notice title=Review cancelled, replaced::Review run $cancelled_run for $SHA was"\ - "cancelled, and run $run_id was created after it, so this check judges run $run_id." - summary "- Review run $cancelled_run was cancelled; run $run_id replaced it and is judged"\ - "instead." - done - if [ "$conclusion" = "skipped" ]; then - echo "::notice title=Review $conclusion::run $run_id was $conclusion; nothing to verify." - exit 0 - fi - - # The step's own conclusion, which separates a clean no-op from a real run. This read and - # the review-job read below take ONE item with `.[0]`, and --paginate would apply that once - # per page of the jobs list. It is safe here: a run of the review workflow has two jobs - # (decide and review), so the list is one page, and the step and the job named each - # appear in it once. - lookup "review-run jobs (step conclusion)" "repos/$R/actions/runs/$run_id/jobs" --paginate \ - --jq '[.jobs[].steps[]? | select(.name == env.REVIEW_STEP)] | .[0].conclusion // empty' - step=$LOOKUP_OUT - - if [ -z "$step" ]; then - # A SKIPPED review job has no steps. That is a decided skip (a push after the follow-up, - # or no secret) only when the run itself succeeded, which means the decide job did too: - # a decide job that failed skips the review job as well, and must not pass as a skip. - lookup "review-run jobs (review job conclusion)" "repos/$R/actions/runs/$run_id/jobs" --paginate \ - --jq '[.jobs[] | select(.name == env.REVIEW_JOB)] | .[0].conclusion // empty' - if [ "$LOOKUP_OUT" = "skipped" ] && [ "$conclusion" = "success" ]; then - echo "::notice title=Review skipped on purpose::The '$REVIEW_JOB' job of run $run_id was"\ - "skipped by the decide job (a push after the follow-up, or no CLAUDE_CODE_OAUTH_TOKEN"\ - "or a fork PR). This push has no review to verify; the standing verdict is checked." - summary '- Review skipped on purpose (decide job succeeded, review job skipped).' - standing_verdict "this push got no review on purpose (one follow-up per full review)" \ - "Add the re-review label to get a fresh full review (its verdict replaces this one)," \ - "or dismiss the review if the findings are wrong." - fi - echo "::error title=Guard cannot find the review step::No step named"\ - "'$REVIEW_STEP' in run $run_id, and the run did not skip its review job on purpose"\ - "(run conclusion: $conclusion). This guard reads that step to verify the review, so"\ - "renaming it in $WF blinds the guard -- update REVIEW_STEP here to match. A run that"\ - "ended '$conclusion' with the review job skipped means the decide job failed: read it." - summary "### Guard is blind: no step named \`$REVIEW_STEP\` in the review run" - exit 1 - fi - - if [ "$step" = "skipped" ]; then - echo "::notice title=Review skipped cleanly::The '$REVIEW_STEP' step was skipped"\ - "(no CLAUDE_CODE_OAUTH_TOKEN, or a fork PR). Nothing to verify, beyond the standing verdict." - standing_verdict "the review step was skipped (no CLAUDE_CODE_OAUTH_TOKEN, or a fork PR)" \ - "No new review can replace it while the step is skipped, so handle the findings and" \ - "dismiss the review." - fi - - # Did the action invoke Claude at all? Its workflow-validation skip reaches the outside - # world ONLY as a warning annotation: it sets skipped_due_to_workflow_validation_mismatch, - # but that output is not declared in the composite action's outputs block, and it returns - # before execution_file and conclusion are ever set. Actions job ids double as check-run - # ids, which is what makes the annotation readable from a different workflow. - # A job whose annotations cannot be read is not a job that has none: a read that fails - # fails the check, because "the action never ran" turns on exactly this scan. - never_ran='' - lookup "review-run jobs (annotation scan)" "repos/$R/actions/runs/$run_id/jobs" --paginate --jq '.jobs[].id' - for job in $LOOKUP_OUT; do - lookup "annotations of job $job" "repos/$R/check-runs/$job/annotations" --jq '.[].message' - if grep -qF 'Skipping action due to workflow validation' <<<"$LOOKUP_OUT"; then - never_ran=1 - break - fi - done - - if [ -n "$never_ran" ]; then - lookup "PR changed files" "repos/$R/pulls/$PR/files" --paginate --jq '.[].filename' - touched=$(printf '%s' "$LOOKUP_OUT" | { grep -Fx "$WF" || true; }) - summary '### Claude review never ran' - if [ -n "$touched" ]; then - # Expected and unavoidable: the action requires that file to match the default branch - # byte for byte, so a PR editing it cannot be reviewed until it merges. A warning, not - # a failure -- a mark that is always red on workflow PRs is one people learn to ignore. - echo "::warning title=Review skipped - PR edits the workflow::This PR changes $WF,"\ - "which claude-code-action refuses to run until it matches $DEFAULT_BRANCH. This PR"\ - "is NOT Claude-reviewed and cannot be until it merges -- a human must review it." - summary "- Expected: this PR edits \`$WF\`, so a human must review it." - standing_verdict "this PR changes $WF, so this head cannot be reviewed until it merges" \ - "No new review can replace it before then, so handle the findings and dismiss the" \ - "review." - fi - - # THE PENDING-RELEASE ARM. Nothing in this PR explains the skip, so the copy of $WF that - # the action validated -- the merge of this PR into $BASE, which carries $BASE's copy -- - # differs from the default branch's. That is one observation with two causes, and the - # week of #3657 showed they need different colors. PRs to main come only from dev - # (check-pr-branch.yml), so a REVIEWED fix to $WF necessarily merges to $BASE first and - # sits there, differing from $DEFAULT_BRANCH, until the next release ships it: a normal - # state of the branching model, during which every PR into $BASE is skipped and this - # arm went red on all of them for a drift a merged PR had already been read for. The - # other cause is the one the arm was built for: someone put a copy of $WF on $BASE that - # no pull request carried -- a direct push -- and NOTHING has read it. - # - # The discriminator is provenance, not content. Take the newest commit on $BASE that - # touched $WF (the commits list with a path filter is exact, whatever the branch's - # age -- a most-recently-updated PR list would have to be paged back through every PR - # that gained a comment since, and on a repo merging a train a night the fix falls out - # of any fixed window in a day), ask which pull request introduced that commit, and hold - # the answer to three facts: it MERGED into $BASE, its diff includes $WF, and $BASE's - # blob of $WF is the blob at its merge commit -- so the branch carries exactly what that - # merge produced and nothing after it. Blob shas compare content (git's are - # content-addressed), so no decode is needed. All three hold: pending release, a - # warning, then the standing verdict -- still NOT reviewed, still a human's job, the - # same as the arm above, and a CHANGES_REQUESTED that stands still fails the check. - # Any fails, or no PR claims the commit: the hostile error below, unchanged. Every read - # goes through lookup_closed(): a lookup that dies here fails the check with its own - # message, because an unresolved drift must not pass on a 503. - lookup_closed "$DEFAULT_BRANCH blob of $WF" \ - "repos/$R/contents/$WF?ref=$DEFAULT_BRANCH" --jq '.sha // empty' - default_blob=$LOOKUP_OUT - lookup_closed "$BASE blob of $WF" "repos/$R/contents/$WF?ref=$BASE" --jq '.sha // empty' - base_blob=$LOOKUP_OUT - if [ -n "$default_blob" ] && [ -n "$base_blob" ] && [ "$default_blob" != "$base_blob" ]; then - lookup_closed "newest $BASE commit touching $WF" \ - "repos/$R/commits?sha=$BASE&path=$WF&per_page=1" --jq '.[0].sha // empty' - drift_commit=$LOOKUP_OUT - if [ -z "$drift_commit" ]; then - # The branch carries a copy of the file and its history has no commit touching it: - # not a state git can produce, so the read is wrong, not the branch. - echo "::error title=Drift origin unknown::$BASE carries a copy of $WF that differs"\ - "from $DEFAULT_BRANCH, but the commit list for that path on $BASE came back empty,"\ - "so this guard cannot say what put it there. Failing closed; re-run this guard." - summary "- Drift origin UNKNOWN: no commit on \`$BASE\` touches \`$WF\`. Failing closed." - exit 1 - fi - # "The merged pull request that introduced the commit" -- for a commit not yet on the - # default branch the endpoint returns merged AND open PRs associated with it, which is - # why the base and merged_at filters are here and not assumed: once a release PR from - # $BASE is open, this commit is associated with it too. Newest-first is the endpoint's - # order; one PR is expected, and one is all the verdict needs. - lookup_closed "pull requests introducing ${drift_commit:0:12}" \ - "repos/$R/commits/$drift_commit/pulls" \ - --jq '.[] | select(.merged_at != null and .base.ref == env.BASE) - | "\(.number)\t\(.merge_commit_sha)\t\(.title)"' - origin=$(printf '%s\n' "$LOOKUP_OUT" | head -n 1) - if [ -n "$origin" ]; then - IFS=$'\t' read -r origin_pr origin_merge origin_title <<<"$origin" - lookup_closed "PR #$origin_pr changed files" "repos/$R/pulls/$origin_pr/files" \ - --paginate --jq '.[].filename' - origin_touched=$(printf '%s' "$LOOKUP_OUT" | { grep -Fx "$WF" || true; }) - lookup_closed "$WF blob at PR #$origin_pr merge ${origin_merge:0:12}" \ - "repos/$R/contents/$WF?ref=$origin_merge" --jq '.sha // empty' - if [ -n "$origin_touched" ] && [ "$LOOKUP_OUT" = "$base_blob" ]; then - echo "::warning title=Review pending release::Claude cannot run on any PR into"\ - "$BASE because $WF changed on $BASE in merged PR #$origin_pr ($origin_title)"\ - "and $DEFAULT_BRANCH has not synced yet. Reviews resume at the next $BASE ->"\ - "$DEFAULT_BRANCH release. This PR is NOT Claude-reviewed -- a human must review"\ - "it." - summary "- Pending release: \`$WF\` on \`$BASE\` is what merged PR #$origin_pr"\ - "produced, and \`$DEFAULT_BRANCH\` has not synced. Reviews resume at the next"\ - "release; a human must review this PR." - standing_verdict "$WF on $BASE is awaiting release, so this head cannot be reviewed" \ - "No new review can replace it before the release, so handle the findings and" \ - "dismiss the review." - fi - summary "- \`$WF\` on \`$BASE\` was last touched by \`${drift_commit:0:12}\` (PR"\ - "#$origin_pr), but that PR's diff and merge blob do not account for the branch's"\ - "current copy." - else - summary "- \`$WF\` on \`$BASE\` was last touched by \`${drift_commit:0:12}\`, which"\ - "no merged pull request into \`$BASE\` introduced." - fi - else - summary "- \`$WF\` on \`$BASE\` already matches \`$DEFAULT_BRANCH\`; nothing visible"\ - "from here explains the skip." - fi - # Nothing in this PR explains the skip, so $WF on this branch has drifted from the - # default branch -- which silently disables review for EVERY PR, not just this one. - echo "::error title=Review disabled repo-wide::Claude never ran and this PR does not"\ - "touch $WF, so that file has drifted from $DEFAULT_BRANCH. Every PR in the repo is"\ - "silently going unreviewed until the two match." - summary "- Unexpected: \`$WF\` has drifted from \`$DEFAULT_BRANCH\`; ALL PRs go unreviewed." - exit 1 - fi - - if [ "$step" != "success" ]; then - echo "::error title=Review step failed::The '$REVIEW_STEP' step reported '$step' in run"\ - "$run_id, so no review happened. Read that run for the cause; do NOT read this PR as"\ - "reviewed." - summary "### Claude review failed (step conclusion: \`$step\`)" - exit 1 - fi - - # Count all three artifact kinds, scoped to the reviewer. Reviews on this repo routinely - # carry an EMPTY body and put every finding in INLINE comments -- #2225 had fifteen - # reviews with body_len=0 and twelve findings inline, so counting review bodies alone - # would call it silent. The AUTHOR filter matters just as much: the review window runs up - # to 30 minutes, so without it any human or unrelated bot commenting inside that window - # satisfies the guard and recreates the false pass this exists to catch. If the app is - # ever renamed this reports a false "posted nothing" -- loud and wrong, which is the safer - # direction for a guard to fail. - mine='select(.user.login == env.REVIEW_BOT)' - # --paginate runs each --jq once PER PAGE, so a tally that ends in `length` prints one - # count per page: one number for a list of 30 items or fewer, several for a longer one. - # Every user's items count toward a page, and the inline-comment tool files one review - # per comment, so a long pull request does reach a second page. The count over every page - # is the SUM of those numbers, and sum_pages() adds them; the bare output would make the - # arithmetic below a syntax error. A one-page list sums to its own count, and a page that - # fails to read still fails the check in lookup(), before any sum. - sum_pages() { printf '%s\n' "$1" | awk '{ s += $1 } END { print s + 0 }'; } - lookup "issue-comment tally" "repos/$R/issues/$PR/comments" --paginate \ - --jq "[.[] | $mine | select(.created_at > \"$created\")] | length" - issue=$(sum_pages "$LOOKUP_OUT") - lookup "inline-comment tally" "repos/$R/pulls/$PR/comments" --paginate \ - --jq "[.[] | $mine | select(.created_at > \"$created\")] | length" - inline=$(sum_pages "$LOOKUP_OUT") - lookup "review tally" "repos/$R/pulls/$PR/reviews" --paginate \ - --jq "[.[] | $mine | select(.submitted_at != null - and .submitted_at > \"$created\")] | length" - reviews=$(sum_pages "$LOOKUP_OUT") - total=$(( issue + inline + reviews )) - echo "posted by $REVIEW_BOT since $created -- issue: $issue, inline: $inline,"\ - "reviews: $reviews" - summary '### Claude review output' - summary "- issue comments: $issue" - summary "- inline comments: $inline" - summary "- reviews: $reviews" - - # The obligation read, feeding BOTH arms below: what does $WF promise, on the DEFAULT - # branch -- the copy the action validated and ran? Only a prompt without the sentinel reads - # as "no obligation". A read of $WF that fails, or comes back empty or undecodable, is not - # that answer: read_must_post fails the check with a message naming the read, because a - # lookup failure is not a review verdict and must not stand the verdict arm down either. - read_must_post - - if [ "$total" -ne 0 ]; then - # The VERDICT ARM. Posting was never the whole story: the #3470-#3473 train shipped six - # review findings in one night (one real) as comments that auto-merge outran before a - # human read them. So when the prompt carries the sentinel, every run also promises to - # END with one formal review -- changes-requested when a finding is substantive, a - # comment review ("LGTM") when not -- and this arm turns that verdict into the check's - # own color. Without the sentinel there is no verdict promise to hold the run to, and - # posted-something is the entire old contract, already satisfied here. - if [ -z "$must_post" ]; then - exit 0 - fi - - # NEWEST-VERDICT-WINS, over the whole PR rather than scoped to this run's window. - # Staleness is the concurrency groups' problem, not this scan's: a new push cancels the - # in-flight review AND this guard (cancel-in-progress above, matching the review's own), - # and the replacement guard waits for the replacement review run to COMPLETE before it - # gets here -- so by the time this scan runs, the current head's review has submitted - # its fresh verdict and that verdict IS the newest. A changes-requested left on a - # superseded push cannot outlive the next verdict; while a re-review is still running - # there is no completed guard to mislead, only a pending one. A human dismissing a - # changes-requested review flips its state to DISMISSED, which reads as cleared here -- - # that is the manual override. --approve is no part of the protocol (the review token - # cannot approve without an org toggle this repo does not enable) and none is needed: - # a clean COMMENTED verdict clears the gate just as well. - # Only a review WITH A BODY is a verdict. The inline-comment tool files each comment - # inside a review of its own with an EMPTY body, so a run that dies after its comments - # leaves a bodiless COMMENTED review newest, and the scan would read it as a clean - # verdict over a standing CHANGES_REQUESTED. The body test is the one claude-review.yml's - # decide step and verdict check apply. A DISMISSED review keeps its body, so the manual - # override above still works. - # The API returns reviews oldest-first (documented: chronological order), and --paginate - # runs the --jq once PER PAGE -- a max_by here would be per-page, not global -- so the - # newest is the last LINE of the emitted stream, which pagination cannot break. - lookup "verdict scan (bot reviews)" "repos/$R/pulls/$PR/reviews" --paginate \ - --jq ".[] | $mine | select(.submitted_at != null) - | select(((.body // \"\") | length) > 0) | \"\(.state)\t\(.html_url)\"" - newest=$(printf '%s\n' "$LOOKUP_OUT" | tail -n 1) - - if [ -z "$newest" ]; then - # Comments landed but no verdict review (one with a body) ever did, and the prompt - # promises one every run. The same broken-promise class as "posted nothing" below -- - # and worse, it is exactly the pre-verdict failure shape: findings living only in - # comments, which is the thing auto-merge outruns. - echo "::error title=Review posted no verdict::Review run $run_id posted comments, but"\ - "$REVIEW_BOT has never submitted a verdict review (a review with a body) on PR"\ - "#$PR, and $WF promises one verdict review per run. Findings that live only in"\ - "comments are what auto-merge outruns (#3470), so do NOT read this PR as"\ - "verdict-clean. Re-run the review workflow, then re-run this guard." - summary '### Claude review posted comments but no verdict review' - summary '- The prompt promises a verdict every run; none exists. Re-run the review.' - exit 1 - fi - - IFS=$'\t' read -r verdict_state verdict_url <<<"$newest" - summary "- newest verdict: $verdict_state -- $verdict_url" - - if [ "$verdict_state" = "CHANGES_REQUESTED" ]; then - echo "::error title=Review requested changes::The newest verdict by $REVIEW_BOT on"\ - "PR #$PR is CHANGES_REQUESTED: $verdict_url. A substantive finding is a failed"\ - "check now, not only a comment. Address the findings and push --"\ - "a follow-up reads the new commits and submits a fresh verdict, and"\ - "newest-verdict-wins clears this check by itself. After a follow-up a push gets no"\ - "review: add the re-review label for a fresh full review. Or, if the findings are"\ - "wrong, a human dismisses the review and DISMISSED reads as cleared." - summary '### Claude review requested changes' - summary "- $verdict_url" - summary '- Address the findings and push (the fresh verdict clears this), or dismiss'\ - 'the review.' - exit 1 - fi - - # COMMENTED (the LGTM shape), DISMISSED (a human overrode), or anything the API grows - # later: the newest verdict is not a standing objection, and a clean review is never a - # failure. - echo "::notice title=Review verdict clean::Newest verdict by $REVIEW_BOT is"\ - "$verdict_state; no standing changes-requested." - exit 0 - fi - - # Nothing posted. Whether that is a defect or a clean bill of health is NOT decidable from - # the artifact count -- the two are the same observation from out here -- so it is decided - # by the obligation read above. - if [ -z "$must_post" ]; then - # The honest reading of today's prompt. Still surfaced, because "reviewed and clean" and - # "reviewed and swallowed" are the same observation, and a reader deciding whether to - # trust the green check needs to know that which one it is cannot be told from here. - echo "::warning title=Review posted nothing::Review run $run_id completed and left no"\ - "comment, inline comment, or review by $REVIEW_BOT on PR #$PR. $WF does not oblige"\ - "the review to post when it finds nothing, so this is consistent with a clean review"\ - "-- but it is ALSO what the #2229 swallowed-output failure looks like, and the two"\ - "cannot be told apart from here. Treat the review as unconfirmed." - summary '- Nothing posted. Consistent with a clean review, and also indistinguishable'\ - 'from the #2229 failure because the prompt does not require a post. UNCONFIRMED.' - exit 0 - fi - - # The prompt promises a post, so silence is a broken promise and nothing else. - echo "::error title=Review posted nothing::Review run $run_id completed but left no"\ - "comment, inline comment, or review by $REVIEW_BOT on PR #$PR, and $WF requires a post"\ - "even for a clean review. Real money was spent and the output vanished (#2229, and PR"\ - "#2213 which did this eleven times). Do NOT read this PR as reviewed; re-run the review"\ - "workflow." - summary "- Nothing posted, and \`$WF\` requires a post. The output was LOST." - exit 1 diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml deleted file mode 100644 index 46b866682b..0000000000 --- a/.github/workflows/claude-review.yml +++ /dev/null @@ -1,993 +0,0 @@ -name: Claude Auto Review - -# Reviews a pull request into dev, the one branch under `on:` below, but spends a full review only -# where one pays. A release pull request from dev into main gets no second review: its changes came -# into dev as pull requests, and those are where review happens. WHAT RUNS depends on the event, and -# the `decide` job makes that call once: -# - opened, reopened, ready_for_review: a FULL review, at the tier described further down. -# - a push (synchronize): at most ONE follow-up review after each full one. It runs on Sonnet -# with 30 turns and covers only the commits since the commit the newest verdict reviewed. That -# commit is compared with the pushed head FIRST, whatever kind the verdict is: only a head -# that is ahead of it is skipped or given a follow-up (a follow-up after a full review, no -# review after a follow-up). Everything else gets a FULL review: a push with no verdict yet -# (the full review was cancelled or never ran), one whose reviewed commit is no longer an -# ancestor of the head (a force-push), a re-run on the head the verdict already covers, a -# verdict whose marker head does not fit its commit (below), and any read that fails. A -# verdict with no `review-pass` line counts as full, so the push after it gets a follow-up. -# Two more reads can change a push's answer. (1) A push that would get no review, because the -# newest verdict is already a follow-up, gets a FULL one when another run of this workflow for -# the pull request has not finished. After a follow-up only a full trigger (ready for review, -# reopened, the label) starts a review, so that run may be a full review of an older head, -# and its verdict would land with no later check to read it; this push's full review replaces -# it through the concurrency group of the `review` job. A second push seconds after another -# sees the first push's run unfinished and gets the full review as well; that cost is -# accepted. (2) The step's last read: a push whose head is no longer the pull request's head -# gets no review. A newer push replaced it and that push's run decides, and a review job from -# this run could join the group after the newer one and cancel it. The other events skip that -# read, because the review they asked for would be lost when the newer push answers with no -# review. -# - the `re-review` label: a FULL review on request. The review removes the label when it is -# done. A push while the label is still on the pull request is also a full review, so a -# request that a push cancelled is not lost and the label cannot get stuck. -# - a draft, or any other label: no review, and no runner starts. -# Measured on 2026-10-02: a full-tier review (Opus, 120 turns) cost a median $7.53 and took 18 -# minutes, a light-tier one about $0.64, and every push to every pull request, drafts included, -# started a full one. Rounds after the first found mostly comment accuracy, so the later pushes -# now get the cheap follow-up. Each review marks its kind and the head it reviewed in its body -# with a hidden `` or `` line, which is what the next push reads. The head is the pull request's head -# when the run started. GitHub stamps a review with the head at the moment it is SUBMITTED (`gh pr -# review` sends no commit), so a review that runs while a push lands is stamped with the newer -# head, and a follow-up that started there would skip commits nobody reviewed. A verdict posted -# before the marker named a head has none, and its commit_id stands in. A marker's head is used -# only when the verdict's commit_id is that head or comes after it (a compare says identical or -# ahead). A head that is later than, diverged from or unknown beside the verdict's commit, or -# one that cannot be compared, does not fit it. The commit_id cannot stand in for that head -# either: it can name content that no review read (a force-push during the review, for one), so -# no commit is known to be reviewed, and the next push gets a FULL review, whatever kind the -# marker says. -# -# The review posts -# comments for its findings and ends every run with ONE formal verdict review (the VERDICT -# PROTOCOL in the prompt below): changes-requested when a finding is substantive, a comment -# review otherwise. The verdict's color lives in claude-review-guard.yml, not here: its verdict -# arm fails while the newest verdict is changes-requested, so a substantive finding shows as a -# failed check, which a branch rule can require, and not only as a comment (the #3470-#3473 -# train shipped six findings in one night as comments that auto-merge outran; one was -# real). Since #3650 the job also fails ITSELF when the run submitted no verdict, with the cause and -# the result block's counts in its log -- a green here means a verdict exists, and the guard is the -# second line. The review keeps no transcript: the file the action writes stays on the runner, and -# the log carries only that file's result fields, never its message text, even with debug logging -# on (the review step pins ACTIONS_STEP_DEBUG to "false"). It no-ops cleanly until -# the CLAUDE_CODE_OAUTH_TOKEN repo secret is set, and on fork PRs (which do not receive secrets), -# so neither case shows a failed check. -# -# #3710: across several hundred PRs the verdict was LGTM on very nearly all of them, and every -# defect it missed that later surfaced (#3487, #3612, #3613, #3636, #3648, the Full-edition -# integer division) was found by behaviour, never by the review. The shape was structural, not -# lazy: the reviewer started every run with no memory of this repository's recurring defect -# shapes, read the diff beside the author's essay (so it checked consistency, not correctness), -# and could stop at "LGTM" with nothing to show for what it had checked. Four changes here, all -# in the prompt and the verdict check, none in the tool allowlist: -# - .github/REVIEW-TRAPS.md -- one line per recurring shape with the PR that bit; the review -# must say, for each trap the diff is near, whether it applies and why. -# - A BLIND READ first: the prompt withholds the PR body (it interpolates only the repo, the PR -# number and the tier; it never did carry the body) and forbids `gh pr view` until the -# reviewer has read the diff, the changed files and their callers and written its own -# account. Phase 2 reads the body and reports DIVERGENCE between the two accounts. -# - The CALLERS RULE: every changed public signature or behaviour has its callers enumerated -# with Grep, each judged still-holds or not. -# - A STRUCTURED VERDICT: claims graded VERIFIED / UNVERIFIED / REFUTED, the riskiest lines and -# the test that pins each, the traps considered, the divergence, a reviewer checklist, and a -# one-line machine-readable ledger so a later pass can compute the miss rate against -# post-merge defects. A bare "LGTM" is no longer a valid verdict: the verdict check below -# fails the job when the newest verdict lacks the header or the ledger. -# Plus a model and a TIER: a paths-filter step routes changes to the shared libraries, the -# Darling Storage/Analysis/Service trees, Lite's service/MCP/database layers, install/ and every -# .sql file to a FULL pass (Opus, 1M context, 120 turns) and everything else (tests, docs, viewer -# XAML, wwwroot) to a LIGHT pass (Sonnet, 30 turns). Same prompt, same verdict contract, one -# review step -- the guard counts that step's conclusion and the bot's reviews, and neither -# accounting knows or cares which tier ran. -on: - pull_request: - # A draft gets no review: both jobs below skip while the pull request is a draft, and marking it - # ready for review starts one. `labeled` is here for the `re-review` label alone; the decide job - # skips every other label before a runner starts. - types: [opened, synchronize, reopened, ready_for_review, labeled] - # Pull requests into dev only. A release pull request into main is not reviewed again. - branches: [dev] - -# No workflow-level `concurrency`, on purpose. A group at this level is entered by EVERY run the -# trigger above starts, including the ones that will skip, and with cancel-in-progress a skipping -# run cancels the review it found running: a `labeled` event for an unrelated label (this -# repository adds labels such as in-progress) would kill a paid review and then do nothing itself. -# The group lives on the `review` job instead, which only exists once the decision says a review -# is due. See there. - -jobs: - # THE ONE DECISION -- full, follow-up or skip -- is made here and nowhere else. The guard - # (claude-review-guard.yml) does not repeat it: it reads what this run did (the review job ran, or - # was skipped on purpose), and recomputing it there would be wrong anyway, because by the time the - # guard looks, the review this run posted has changed the very history the decision reads. The - # decision cannot move into a shared script, composite action or reusable workflow either: - # claude-code-action runs only while THIS file matches the default branch's copy byte for byte, and - # a file the pull request can edit would sit outside that check while steering the review job, - # whose Claude step uses the OAuth token. The decision is a step of this file for that reason. - decide: - # No runner for a draft, for a label other than re-review, or for a pull request carrying the - # `merge-train` label. A train member is marked ready and merged straight away, and the train run - # already covered it, so a review of the ready_for_review event would be paid for and never read. - # The label is read from the event payload, so it has to be on the pull request BEFORE it is marked - # ready. The script below repeats the first two rules in its first lines, so a dry run of it covers - # that part of the table; this line only saves the runner. - if: github.event.pull_request.draft == false && !contains(github.event.pull_request.labels.*.name, 'merge-train') && (github.event.action != 'labeled' || github.event.label.name == 're-review') - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - # The one read of this workflow's own runs, which a push after a follow-up makes. Without - # the scope that read is refused, and every such push would fall back to a full review. - actions: read - contents: read - pull-requests: read - env: - # This job reads no secret. The flag only lets the step guard below skip cleanly when the - # repository has no token (a fork pull request gets none either); the comparison is made - # here, so the token's value never enters this job's environment. - HAS_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} - outputs: - pass: ${{ steps.pass.outputs.pass }} - base: ${{ steps.pass.outputs.base }} - # Set only for a follow-up, and only once the step has checked that `base` and `head` are both - # 40-character hex SHAs: the review job puts the two into its claude_args. - head: ${{ steps.pass.outputs.head }} - steps: - # Every input arrives through env:, never through a ${{ }} interpolation into the script text. - # `re-review` appears as a literal in four places in this file (the decide job's `if`, the - # HAS_LABEL expression below, the label-removal step, the RE_REVIEW value): rename it in all. - - name: Decide the review pass - id: pass - if: ${{ env.HAS_OAUTH_TOKEN == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - R: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - ACTION: ${{ github.event.action }} - LABEL: ${{ github.event.label.name }} - DRAFT: ${{ github.event.pull_request.draft }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - # The branch the head came from and this run's id, for the one read of the workflow's runs. - HEAD_REF: ${{ github.event.pull_request.head.ref }} - RUN_ID: ${{ github.run_id }} - HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 're-review') }} - RE_REVIEW: re-review - # Author of every review the action leaves behind (the action's bot_name default). - REVIEW_BOT: claude[bot] - run: | - set -euo pipefail - summary() { echo "$*" >> "$GITHUB_STEP_SUMMARY"; } - - # A read that fails must not turn into a SKIP: that is the direction that silently - # under-reviews. It turns into a FULL review with the cause in the log, the same - # fail-toward-thorough direction the tier step takes. The helper assigns to LOOKUP_OUT and - # LOOKUP_ERR instead of being command-substituted, so a failing gh call cannot kill the step - # under `set -e`, and stderr never lands in a value the callers compare. - LOOKUP_OUT=''; LOOKUP_ERR='' - lookup() { - local errfile - errfile=$(mktemp 2>/dev/null || echo /dev/null) - if ! LOOKUP_OUT=$(gh api "$@" 2>"$errfile"); then - LOOKUP_ERR=$(tr '\n' ' ' < "$errfile" 2>/dev/null || true) - LOOKUP_ERR=${LOOKUP_ERR:-$LOOKUP_OUT} - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - return 1 - fi - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - } - - pass=''; base=''; reason=''; marker_note='' - if [ "$DRAFT" = "true" ]; then - pass=skip; reason="the pull request is a draft" - elif [ "$ACTION" = "labeled" ] && [ "$LABEL" != "$RE_REVIEW" ]; then - pass=skip; reason="the label '$LABEL' asks for nothing" - elif [ "$ACTION" = "labeled" ]; then - pass=full; reason="the '$RE_REVIEW' label asks for a full review" - elif [ "$ACTION" != "synchronize" ]; then - # opened, reopened and ready_for_review, and anything the trigger list may grow. - pass=full; reason="the pull request was $ACTION" - elif [ "$HAS_LABEL" = "true" ]; then - # The label is a request that stays until a full review has answered it. A push that - # cancelled the requested review must not lose it, and a stuck label cannot be added again. - pass=full; reason="a push while the '$RE_REVIEW' label is still on the pull request" - else - # A push. Read the newest VERDICT by the review bot: a review with a body. The - # inline-comment tool files each comment inside a review of its own with an EMPTY body, in - # the same run and on the same commit as the verdict, so a run that died after posting - # comments but before its verdict left carriers and no verdict, and that is not a - # review of the pull request. The API returns reviews oldest-first and --paginate runs the - # --jq once per page, so the newest is the last LINE of the stream. - if ! lookup --paginate "repos/$R/pulls/$PR/reviews?per_page=100" \ - --jq '.[] | select(.user.login == env.REVIEW_BOT and .submitted_at != null - and ((.body // "") | length) > 0) - | "\(.id)\t\(.commit_id // "")"'; then - pass=full; reason="the reviews could not be read ($LOOKUP_ERR)" - else - newest=$(printf '%s\n' "$LOOKUP_OUT" | tail -n 1) - if [ -z "$newest" ]; then - # No verdict yet: its full review was cancelled or never ran. - pass=full; reason="$REVIEW_BOT has posted no verdict on this pull request yet" - else - IFS=$'\t' read -r last_id last_commit <<<"$newest" - if ! lookup "repos/$R/pulls/$PR/reviews/$last_id" --jq '.body // ""'; then - pass=full; reason="the newest verdict could not be read ($LOOKUP_ERR)" - else - # The kind the verdict marked itself with, and the head it reviewed. The LAST matching - # line wins: the prompt puts it directly above the ledger at the end of the body. The - # reviewed commit is the marker's head when it has one and the verdict's commit_id - # fits it (below). GitHub stamps a review with the pull request's head at the moment - # it is submitted, so a review that ran while a push landed carries a commit_id newer - # than the head it read, and a follow-up that started there would skip commits nobody - # reviewed. A verdict posted before the marker named a head has none, and its - # commit_id stands in. A verdict with no marker at all (posted before the marker - # existed) counts as full. - # - # The verdict check in the review job fails a run whose marker is wrong, but the - # verdict stays on the pull request, so this step holds the marker's head against the - # one thing it can read: the verdict's own commit_id. The head is used only when that - # commit is the head or a descendant of it (compare says identical or ahead). A head - # later than the verdict's commit (behind) would leave the commits between the two out - # of every follow-up range, and one that diverged, is unknown to the repository, or - # cannot be compared (the read failed) names no commit this verdict can vouch for. The - # verdict's commit_id does not stand in for that head either: it can name content that - # no review read (after a force-push during the review, for one), and a read that fails - # is a full review. For each of those the reviewed commit is cleared, so the check - # below gives a full review whatever kind the marker says, and its reason is the - # marker note. A head equal to the commit_id needs no read, and a marker with no head - # keeps the handling above. - marker=$(printf '%s\n' "$LOOKUP_OUT" | tr -d '\r' \ - | grep -E '^[[:space:]]*$' | tail -n 1 || true) - kind=''; reviewed=$last_commit - marker_re='^' - if [[ "$marker" =~ $marker_re ]]; then - kind=${BASH_REMATCH[1]} - marker_head=${BASH_REMATCH[3]:-} - if [ -z "$marker_head" ]; then - : # no head in the marker: the commit_id stands in, and the kind is the marker's - elif [ "$marker_head" = "$last_commit" ]; then - reviewed=$marker_head - elif [ -z "$last_commit" ]; then - # Nothing to hold the head against. The reviewed commit stays empty, and the - # check below answers with a full review. - kind=full - elif ! lookup "repos/$R/compare/$marker_head...$last_commit?per_page=1" --jq '.status'; then - # The head cannot be held against the commit, so no commit is known to be reviewed. - # The check below answers with a full review, and the note is its reason. - kind=full; reviewed='' - marker_note="the marker's head ${marker_head:0:12} cannot be compared with the verdict's commit ${last_commit:0:12} ($LOOKUP_ERR), so no reviewed commit is known and this push gets a full review" - elif [ "$LOOKUP_OUT" = identical ] || [ "$LOOKUP_OUT" = ahead ]; then - reviewed=$marker_head - else - # Later than the verdict's commit, or diverged from it: the same answer. - kind=full; reviewed='' - marker_note="the marker's head ${marker_head:0:12} is not an ancestor of the verdict's commit ${last_commit:0:12} (compare says $LOOKUP_OUT), so no reviewed commit is known and this push gets a full review" - fi - fi - # The reviewed commit is compared with this head BEFORE the kind is looked at, so only a - # head that is ahead of it is ever skipped or given a follow-up. - if [ -z "$reviewed" ]; then - # The verdict names no commit, or its marker's head does not fit the commit it names - # (the marker note says so and is the reason). Either way no commit is known to be - # reviewed, so there is nothing for a follow-up to start from. - pass=full; reason="${marker_note:-the newest verdict names no commit}" - elif ! lookup "repos/$R/compare/$reviewed...$HEAD_SHA?per_page=1" --jq '.status'; then - # Unknown to the repository: a force-push dropped it. - pass=full; reason="the last-reviewed commit ${reviewed:0:12} cannot be compared with this head ($LOOKUP_ERR)" - elif [ "$LOOKUP_OUT" != "ahead" ]; then - # diverged (a force-push or rebase), behind, or identical (a re-run on a head that - # already has its verdict): not a range a follow-up can cover, and not one to skip - # either, whatever kind the verdict is. A re-run of a review that failed its shape - # check must produce a review, so identical is full. - pass=full; reason="the last-reviewed commit ${reviewed:0:12} is not an ancestor of this head (compare says $LOOKUP_OUT)" - elif [ "$kind" = follow-up ]; then - # At most ONE follow-up after each full review, so this push gets no review, unless - # another run of this workflow for this pull request has not finished. After a - # follow-up only a full trigger (ready for review, reopened, the label) starts a - # review, so that run may be a full review of an older head, and the verdict it posts - # would have no check left to read it. This push's full review replaces it through - # the concurrency group instead. A run is this pull request's when its list names the - # number; a run with none listed (the field is empty once a pull request closes) counts - # through the branch the query is limited to. This run is not counted. One page is - # enough: a run that has not finished is among the newest. A second push seconds after - # another sees that run too and gets a full review; that cost is accepted, so there is - # no waiting. A read that fails is a full review, as above. - if ! lookup -X GET "repos/$R/actions/workflows/claude-review.yml/runs" \ - -f event=pull_request -f branch="$HEAD_REF" -f per_page=50 \ - --jq '.workflow_runs[] - | select(.status != "completed" and (.id | tostring) != env.RUN_ID - and (((.pull_requests // []) | length) == 0 - or any((.pull_requests // [])[]; (.number | tostring) == env.PR))) - | .status'; then - pass=full; reason="the runs of this workflow could not be read ($LOOKUP_ERR)" - elif [ -n "$LOOKUP_OUT" ]; then - IFS= read -r run_status <<<"$LOOKUP_OUT" - pass=full; reason="the newest verdict is already a follow-up, but another run of this workflow for this pull request has not finished ($run_status): it may be a full review of an older head, whose verdict would have no check left to read it, so this push gets a full review, which replaces it" - else - pass=skip; reason="the newest verdict is already a follow-up" - fi - else - pass=follow-up; base=$reviewed - reason="the newest verdict (${reviewed:0:12}) was a full review, so the commits since it get one follow-up" - fi - fi - fi - fi - fi - - # A push that a newer push has already replaced decides nothing. The newer push has a run of - # its own, and that run decides; a review job from this run could join the concurrency group - # after the newer one and cancel it. This is the step's last read, so the head it compares is - # as fresh as the step can get. Pushes only: a newer push may answer with no review, and the - # full review that opening, reopening, ready for review and the label asked for would be - # lost with it. A read that fails keeps the decision. - if [ "$ACTION" = "synchronize" ] && [ "$pass" != skip ]; then - sha_re='^[0-9a-f]{40}$' - if lookup "repos/$R/pulls/$PR" --jq '.head.sha'; then - if [[ "$LOOKUP_OUT" =~ $sha_re ]] && [ "$LOOKUP_OUT" != "$HEAD_SHA" ]; then - pass=skip; base='' - reason="a newer push replaced this head (${HEAD_SHA:0:12}), and the run for ${LOOKUP_OUT:0:12} decides" - fi - else - reason="$reason (the pull request's head could not be read again: $LOOKUP_ERR)" - fi - fi - - # The follow-up's two SHAs become the `git diff` forms the review job allows (its claude_args), - # so they are checked here, in the step that produces them: both must be full 40-character hex - # SHAs. The marker's head passed that test when it was read; the verdict's commit_id and this - # run's head are checked now. A value that fails makes this a full review, which takes no range - # and is allowed no `git diff`: the same fail-toward-thorough direction every read above takes. - head_sha='' - if [ "$pass" = follow-up ]; then - sha_re='^[0-9a-f]{40}$' - if [[ "$base" =~ $sha_re && "$HEAD_SHA" =~ $sha_re ]]; then - head_sha=$HEAD_SHA - else - reason="the follow-up range (${base:0:12}..${HEAD_SHA:0:12}) is not two 40-character hex SHAs, so this push gets a full review" - pass=full; base='' - fi - fi - - echo "review pass: $pass -- $reason" - if [ "$pass" = skip ]; then - echo "::notice title=No review - skipped on purpose::$reason." - else - echo "::notice title=Review pass - $pass::$reason." - fi - summary "### Review pass: $pass" - summary "- $reason" - if [ "$pass" = follow-up ]; then summary "- covers \`${base:0:12}..${HEAD_SHA:0:12}\`"; fi - { - echo "pass=$pass" - echo "base=$base" - echo "head=$head_sha" - } >> "$GITHUB_OUTPUT" - - review: - needs: decide - # Only a full or a follow-up pass reviews. A skipped decide job (a draft, another label, no - # secret) leaves its outputs empty, so this job skips with it. - if: needs.decide.outputs.pass == 'full' || needs.decide.outputs.pass == 'follow-up' - # CONCURRENCY LIVES HERE, not on the workflow, so that only a run that reviews can cancel one: - # a push cancels the review of the commits it supersedes. The group is unique for any other - # pass, so even if a skipped job were to evaluate its group, it could not touch a running - # review. The unique suffix is `cond && value || ''`, never `cond && '' || value`: the empty - # string is falsy in an expression, and that form would hand every run the unique group. - concurrency: - group: claude-review-${{ github.event.pull_request.number }}${{ needs.decide.outputs.pass != 'full' && needs.decide.outputs.pass != 'follow-up' && format('-skip-{0}', github.run_id) || '' }} - cancel-in-progress: true - runs-on: ubuntu-latest - # Reviews are advisory and typically finish in minutes; a wedged run should release - # its runner rather than sit on the default 6h timeout. - timeout-minutes: 30 - permissions: - contents: read - pull-requests: write - # The Claude step needs this scope. With no `github_token` input, claude-code-action asks the - # runner for this run's OIDC token and exchanges it for the app token the review posts with - # (claude[bot]); it stops when the token cannot be fetched (src/github/token.ts at v1). - # Permissions are set per job, not per step, and this is the only job that has the scope. - id-token: write - env: - # Only a flag reaches this job's environment. The step guards below use it to skip cleanly when - # the repository has no token (a fork pull request gets none either), and the comparison is - # made here, so the token's value is in no job-level environment. It reaches only the Claude - # step, through that step's `claude_code_oauth_token` input. - HAS_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }} - steps: - # Both checkouts keep no credentials in .git/config (`persist-credentials: false`). Each fetches - # with its own token input, and every later step that talks to GitHub carries a token of its own. - - uses: actions/checkout@v7 - if: needs.decide.outputs.pass != 'follow-up' - with: - fetch-depth: 1 - persist-credentials: false - - # A follow-up diffs two commits and lists the ones between them, which a one-commit clone - # cannot do. The whole history of the merge ref (the PR head is its second parent) holds both. - - name: Check out the history a follow-up reads - uses: actions/checkout@v7 - if: needs.decide.outputs.pass == 'follow-up' - with: - fetch-depth: 0 - persist-credentials: false - - # #3650: the verdict check at the bottom counts the bot's formal reviews submitted AFTER this - # instant, so a verdict left by an earlier run on the same PR cannot vouch for this one. Read - # once, here, before the action installs anything -- GitHub stamps submitted_at in the same - # ISO-8601 UTC shape (2026-09-18T22:00:00Z), so the comparison below is a plain string one. - - name: Open the verdict window - id: window - if: ${{ env.HAS_OAUTH_TOKEN == 'true' }} - run: echo "start=$(date -u +%FT%TZ)" >> "$GITHUB_OUTPUT" - - # #3710: which tier reviews this PR. The FULL set is where the traps file's defects live -- - # the shared libraries every product compiles against, the Darling store/analysis/service - # trees (Compose and Mcp are under Service), Lite's service, MCP and database layers, the - # install scripts, and every .sql file wherever it sits (upgrades/, tools/, fixtures) -- and - # a change there gets Opus with the 1M context window, which the callers rule needs on a - # large Storage diff. Everything else is the LIGHT tier. Same version-tag pinning as the - # rest of this repo's actions (build.yml uses dorny/paths-filter@v4; nothing here is - # SHA-pinned, so neither is this). - # - # Every pattern is `dir/**/!(*.md)`, the shape build.yml and darling-paths-filter.yml - # already use and explain: dorny v4 evaluates each pattern as an INDEPENDENT predicate under - # the default predicate-quantifier 'some', so a bare `!**/*.md` exclude would be its own rule - # meaning "any non-markdown file anywhere" (PR #1714, run 30219202642: every area filter true - # on a .gitignore edit). The markdown carve-out stays INSIDE the include, where quantifier - # semantics cannot detach it. A README under Analysis/ is a light-tier change on purpose. - # - # continue-on-error, and the decision step below reads the OUTCOME: a filter that dies (an - # API 503 listing the PR's files) must not silently become the light tier -- that is the - # direction that under-reviews a Storage change. It degrades to FULL, with a warning naming - # why. `all` is the counter the decision notice reports against; it can only ever be true. - # - # Only a FULL pass classifies: a follow-up always runs the light tier's model and turns (see - # the tier step), because it reads a handful of new commits, not the pull request. - - name: Classify the changed paths - id: paths - if: ${{ env.HAS_OAUTH_TOKEN == 'true' && needs.decide.outputs.pass == 'full' }} - continue-on-error: true - uses: dorny/paths-filter@v4 - with: - list-files: json - filters: | - full: - - 'PerformanceMonitor.Analysis/**/!(*.md)' - - 'PerformanceMonitor.Collectors/**/!(*.md)' - - 'PerformanceMonitor.Common/**/!(*.md)' - # #3710 review: six of the traps file's own citations (#3612 #3618 #3625 #3628 #3636 #3644) live - # in these two projects; a tier that reads them lightly would re-learn each one. - - 'PerformanceMonitor.Alerting/**/!(*.md)' - - 'PerformanceMonitor.Notifications/**/!(*.md)' - - 'Darling/PerformanceMonitor.Darling.Storage/**/!(*.md)' - - 'Darling/PerformanceMonitor.Darling.Analysis/**/!(*.md)' - - 'Darling/PerformanceMonitor.Darling.Service/**/!(*.md)' - - 'Lite/Services/**/!(*.md)' - - 'Lite/Mcp/**/!(*.md)' - - 'Lite/Database/**/!(*.md)' - - 'install/**/!(*.md)' - - '**/*.sql' - all: - - '**' - - # The ONE place the tier decision is made. The review step reads model, max_turns and tier - # from here rather than repeating the expression three times, so the ledger line the prompt - # asks for names the model that actually ran. Lists reach the notice through env:, never - # through a ${{ }} interpolation into script text -- a PR author controls file names, and an - # interpolated name is the pull_request script-injection footgun build.yml describes. - # - # The model ids are Claude Code's own catalog ids: `claude-opus-5[1m]` (the [1m] selector - # opts into the 1M context window; claude-code-action's docs name Opus 5 and Sonnet 5 as - # the models that carry it) and `claude-sonnet-5`, the Sonnet family's current id, spelled - # out rather than left to the default so the ledger names it. No effort flag exists in - # claude_args (#3710's ruling); the turn ceiling is the cost lever. - - name: Decide the review tier - id: tier - if: ${{ env.HAS_OAUTH_TOKEN == 'true' }} - env: - PASS: ${{ needs.decide.outputs.pass }} - PATHS_OUTCOME: ${{ steps.paths.outcome }} - FULL: ${{ steps.paths.outputs.full }} - FULL_COUNT: ${{ steps.paths.outputs.full_count }} - FULL_FILES: ${{ steps.paths.outputs.full_files }} - ALL_COUNT: ${{ steps.paths.outputs.all_count }} - run: | - set -euo pipefail - if [ "$PASS" = "follow-up" ]; then - # The paths step did not run (only a full pass classifies), so its outcome says nothing - # here. A follow-up reads a few new commits, and the cheap tier is what it is for. - echo "::notice title=Follow-up review::A follow-up pass always runs the light tier's model and turns." - tier=light - elif [ "$PATHS_OUTCOME" != "success" ]; then - echo "::warning title=Path classification failed - reviewing at the full tier::The paths-filter"\ - "step ended '$PATHS_OUTCOME', so this PR's files could not be classified. The review runs"\ - "at the FULL tier rather than guess light: under-reviewing a Storage change is the"\ - "expensive direction to be wrong in." - tier=full - elif [ "${FULL:-}" = "true" ]; then - echo "::notice title=Full-tier review::${FULL_COUNT:-0} of ${ALL_COUNT:-0} changed files are in the full-tier set." - echo "Changed files in the full-tier set: ${FULL_FILES:-[]}" - tier=full - else - echo "::notice title=Light-tier review::None of the ${ALL_COUNT:-0} changed files are in the full-tier set (shared libraries, Darling Storage/Analysis/Service, Lite Services/Mcp/Database, install/, *.sql)." - tier=light - fi - if [ "$tier" = full ]; then - model='claude-opus-5[1m]'; max_turns=120 - else - model='claude-sonnet-5'; max_turns=30 - fi - echo "tier=$tier -- model=$model -- max_turns=$max_turns" - { - echo "tier=$tier" - echo "model=$model" - echo "max_turns=$max_turns" - } >> "$GITHUB_OUTPUT" - - # The step NAME is read by claude-review-guard.yml (REVIEW_STEP) to tell a clean no-op from a - # run that said nothing; renaming it blinds the guard. The id is for the steps below. - # - # The `|| 'full'` / `|| 'claude-opus-5[1m]'` / `|| '60'` fallbacks: if the tier step somehow - # left an output empty, the review still runs, at the full tier -- the same fail-toward- - # thorough direction the tier step takes -- instead of handing Claude Code `--model ""`. - - name: Claude review - id: review - if: ${{ env.HAS_OAUTH_TOKEN == 'true' }} - uses: anthropics/claude-code-action@v1 - # The action prints every message, tool results included, when ACTIONS_STEP_DEBUG is "true" - # (base-action/src/parse-sdk-options.ts at v1). Pinned "false" here, the log keeps only the - # result fields even on a run with debug logging turned on. show_full_output is left at its - # default, "false". - env: - ACTIONS_STEP_DEBUG: 'false' - with: - # The one place the token's value is read: no other step, and no job-level environment, - # holds it. - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - # The prompt interpolates the repo, the PR number, the tier and the model -- and NOT the - # PR body or title. That is what makes the blind read blind: the body arrives only when - # the reviewer runs `gh pr view` in Phase 2. Do not add the body here (#3710). - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - REVIEW PASS: ${{ needs.decide.outputs.pass }} - REVIEW TIER: ${{ steps.tier.outputs.tier || 'full' }} - MODEL: ${{ steps.tier.outputs.model || 'claude-opus-5[1m]' }} - FOLLOW-UP RANGE: ${{ needs.decide.outputs.base && format('{0}..{1}', needs.decide.outputs.base, github.event.pull_request.head.sha) || 'none (this is a full review)' }} - - You are reviewing a pull request in a repository whose defects recur in known shapes and - whose PR bodies are persuasive by design. The review runs in two phases, in this order, - and the order is the point: you form your own account of the change BEFORE you read the - author's, and the finding is where the two diverge. - - FOLLOW-UP PASS. This paragraph applies ONLY when REVIEW PASS above is `follow-up`; on a - `full` pass skip it entirely. An earlier review already covered every commit up to the - first SHA on the FOLLOW-UP RANGE line (BASE..HEAD, both full SHAs). The commits in that - range are your ENTIRE scope: `git diff BASE HEAD` is the change (for one file's part of - it, `git diff BASE HEAD -- `; those two forms, with both SHAs written out in full - and nothing else before the `--`, are the only `git diff` this run may use) and - `gh pr view ${{ github.event.pull_request.number }} --json commits` lists the pull request's - commits and their messages (find BASE in it by SHA; the commits after it are this range). The - checkout holds the whole history, but its own HEAD is a merge of the pull request into - its base branch, so always pass the two SHAs explicitly. Report defects in those - commits, or defects they expose in code that did not change. Do NOT review unchanged - code again. Do NOT repeat an earlier finding unless a new commit fails to fix it or makes - it worse. Do NOT re-grade the PR body's claims about earlier commits. If the range - includes a merge of the base branch, what that merge brought in is not the author's - change: leave it alone unless a new commit interacts with it. This paragraph REPLACES - PHASE 1 and PHASE 2 below: read the new diff and the files it changes in context, apply - the CALLERS RULE and the traps file to what the new commits changed, and run - `gh pr view ${{ github.event.pull_request.number }}` only for notes that explain them (and - `gh pr view ${{ github.event.pull_request.number }} --comments` for the earlier reviews). - Everything from POSTING on applies unchanged, and the verdict keeps the same shape, with - its sections scoped to the new commits. - - PHASE 1 — BLIND READ. Do NOT run `gh pr view` in this phase: the PR body and the commit - messages are the author's account, and you must form your own first. Allowed here: `gh pr diff ${{ github.event.pull_request.number }}` (and - `--name-only`), Read, Grep, Glob. A full review runs no `git diff`: the checkout holds - one commit, and `gh pr diff` is the diff. The PR branch is already checked out. - 1. Read the diff, then open every changed file and read each changed region in its - surrounding context — the whole method, class member or SQL statement, not the - hunk. - 2. CALLERS RULE. For every changed public signature, constant, SQL statement or - behaviour, Grep the repository for its callers and readers — both SKUs (Lite and - Darling) and both test projects — and, for each caller, decide whether it still - holds under the change. A caller you did not re-read is UNPINNED and is reported - as such. - 3. Read `.github/REVIEW-TRAPS.md`. For every trap whose layer or shape this diff is - near, decide whether it applies HERE and why. A trap that does not apply is stated - as not applying, with the file:line that proves it; a trap that applies is a - finding. Quote the trap in its own words. - 4. Write down, for yourself, before Phase 2: what the change does, what it could - break, the three riskiest changed lines, and which test (by name, found with Grep) - pins each of them — or that none does. - Conventions to hold the code to: CONTRIBUTING.md, including its T-SQL style section (AS - on table aliases, column_name = expression aliasing, OPTION(RECOMPILE) on collector - queries); Lite/Darling parity — this repository ships two apps that must stay in - parity, so a change to one without its counterpart is a finding; correctness at system - boundaries (bugs, edge cases, null/error handling); security (input handling, SQL - injection, secrets, file/network/process use); performance regressions. Do NOT suggest - SQL Server missing-index DMV recommendations — they are folklore in this codebase and - explicitly unwanted. - - PHASE 2 — THE AUTHOR'S ACCOUNT. Now run `gh pr view ${{ github.event.pull_request.number }}` - for the title and body (and `gh pr view ${{ github.event.pull_request.number }} --json commits` - for the commit messages if you want them). - Extract every factual claim the body makes about the code — what changed, what did not, - what was measured, what was tested, which test pins what — and grade each against what - you found in Phase 1: - - VERIFIED — you confirmed it from the tree; say how (file:line, test name, or the - hunk). - - UNVERIFIED — it could not be checked from a read-only tree (a live measurement, a - rig result, a build); say what would have been needed. - - REFUTED — the tree contradicts it; give the evidence. - Then report DIVERGENCE: where the author's account and your blind read disagree, in - either direction — a mechanism the body names that the diff does not implement, a - change the diff makes that the body does not mention, a test the body cites that does - not assert what the body says it does. Divergence is the finding; agreement between the - body and the diff is not evidence of correctness. - - POSTING. Use `gh pr comment ${{ github.event.pull_request.number }} --body-file - <<'EOF'` (the note, then a - line with EOF) for top-level notes and - `mcp__github_inline_comment__create_inline_comment` (with confirmed: true) for - line-level findings. Only post GitHub comments and reviews — do not emit review text - as plain messages. - - GH COMMANDS. Every `gh` command this run may use is one of these, exactly, where N is - ${{ github.event.pull_request.number }}: `gh pr diff N`, `gh pr diff N --name-only`, `gh pr view N`, - `gh pr view N --json commits`, `gh pr view N --comments`, and the three posts - `gh pr comment N --body-file -`, `gh pr review N --comment --body-file -` and - `gh pr review N --request-changes --body-file -`, each followed by a quoted heredoc - (`<<'EOF'`). Any other flag, a body given with `--body`, or an unquoted heredoc is - refused. - - VERDICT PROTOCOL — ALWAYS POST a verdict, in the STRUCTURED SHAPE below. After every - comment is posted, end the review by submitting exactly ONE formal GitHub review whose - body is the block below, filled in. A bare "LGTM" is not a verdict: the job checks the - newest review for the `## Verdict:` header and the ledger line and fails without them. - - Any substantive finding — a correctness bug, a security hole, a data-integrity risk, - a REFUTED claim, a trap that applies, or a violation of a contract the code is - supposed to keep — means: - gh pr review ${{ github.event.pull_request.number }} --request-changes --body-file - <<'EOF' - ...the block, with `## Verdict: CHANGES REQUESTED` and `verdict=CHANGES_REQUESTED`... - EOF - - Otherwise: - gh pr review ${{ github.event.pull_request.number }} --comment --body-file - <<'EOF' - ...the block, with `## Verdict: LGTM` and `verdict=LGTM`... - EOF - Substantive explicitly does NOT include style nits, observations you yourself label - non-blocking, or tradeoffs the code or the PR body already documents as deliberate: - those stay comments, and the verdict is the clean one. Never use --approve — this token - cannot approve without an org setting this repo does not enable, and no gate needs it. - The guard's rule is newest-verdict-wins: on a re-review after new commits, review the - NEW diff and submit a fresh verdict, and a clean fresh verdict clears an earlier - changes-requested by itself. - - THE REVIEW BODY, exactly this shape. The `## Verdict:` line is the first line; the - `### ` headings are read by a machine and appear in this order; the ledger is the LAST - line and is ONE line, and the review-pass line sits directly above it. Replace every - <...>; keep every heading even when its section is empty, and say so under it. - - ## Verdict: LGTM - ### Claims - - VERIFIED — — - - UNVERIFIED — — - - REFUTED — — - ### Riskiest lines - 1. — — pinned by | UNPINNED - 2. — — pinned by | UNPINNED - 3. — — pinned by | UNPINNED - ### Traps near this diff - - — applies | does not apply — - ### Divergence (blind read vs PR body) - - ### Reviewer checklist - - [ ] - - - - Ledger arithmetic: verified / unverified / refuted are the bullet counts under Claims - in each state; traps is the bullet count under Traps near this diff; verdict matches - the header (`LGTM`, or `CHANGES_REQUESTED` for a changes-requested review); tier and - model are the REVIEW TIER and MODEL lines at the top of this prompt, verbatim. The - review-pass line is the REVIEW PASS value from the top of this prompt and the head SHA - this review started on, written exactly as the shape above writes it, `head=` included: - the next push reads it to decide what it gets, and a follow-up starts after that SHA, - so a wrong or missing one costs a review or hides one, and the verdict check fails the - job when the line differs. - # #3650: the prompt above says the branch is checked out and asks for a correctness, - # parity and security review -- an invitation to read code -- while the allowlist used to - # permit only the four gh verbs and the inline-comment tool. Every Read, Grep, Glob and - # git call the reviewer reached for was a permission denial, and the swallowed runs' own - # result blocks put the ratio on record: 50 turns / 18 denials, 39 / 21, 18 / 17, each - # ending subtype=success with NOTHING posted. After enough denials the session ends - # without ever reaching the verdict protocol, so a paid run leaves no trace (three PRs, - # about thirteen runs, one night). Read-only tools are enough: the reviewer needs to open - # the files the diff touches, find a symbol's other callers and read a parity twin, and - # nothing about reviewing needs a write. Nothing here can edit, commit, push or post - # outside the gh forms listed (see the gh rules below); the one git verb is `git diff`, and - # it takes only a follow-up's range (see the git rules below). - # - # #3710 adds no tool. The callers rule is Grep; the traps file is Read; the blind read is - # an ORDERING of the gh verbs already here, enforced by instruction and by the prompt - # withholding the body. The structured verdict is posted through `gh pr review - # --body-file -` with a quoted heredoc -- one of the exact `gh pr review` forms the - # allowlist grants, and no Write tool is needed to stage a body file. --model and - # --max-turns are the tier's; both are plain claude_args per claude-code-action's - # docs/configuration.md ("Custom Model", "Limiting Conversation Turns"). - # - # The gh rules are exact commands with this pull request's number, the forms the prompt - # names: the diff, with or without --name-only; the view, its commits and its comments; and - # the three posts, which read their body from standard input (`--body-file -` with a quoted - # heredoc). A rule with no `*` matches only that command, so no other flag or argument - # reaches gh. The number is the event's own integer. - # - # The git rules take only the forms the review runs. A full review needs no `git diff` (it - # reads `gh pr diff` and works from a one-commit checkout), so it gets none. A follow-up gets - # two forms, with its range written into them by the expression in --allowedTools: - # `git diff BASE HEAD`, which takes no argument after it, and `git diff BASE HEAD -- *`, - # where every argument after `--` is a pathspec. The two SHAs fill the first two arguments. - # Both come from the decide job, which sets them only when each is a 40-character hex SHA. - # The allowlist has no `git log` or `git show` rule: `gh pr view --json commits` carries the - # commit messages, and Read reads the checked-out files. The other two values interpolated - # here, the model and the turn limit, are constants the tier step writes. - # - # --disallowedTools: Claude Code's permissions page ("Read and Edit") documents `Read` deny - # rules with a path: `Read(./secrets/**)`, and `//path` for an absolute one. They apply to - # the Read tool and, as a best effort, to Grep and Glob, and a deny rule wins over an allow - # rule. The CLI reference and the settings reference say `--disallowedTools` carries such - # rules, and the action passes the flag through (base-action/src/parse-sdk-options.ts at - # v1 comma-splits the values and merges them into the SDK's disallowedTools). `./` is the - # directory Claude runs in: the action's run step sets no working directory, so that is the - # workspace root, where actions/checkout puts the tree. So the file tools do not read the - # checkout's .git directory or /proc. - # - # The file tools read only the checkout. The allowlist grants `Read(./**)`, the tree under - # that same directory, and no rule grants a path outside it, so Read, Grep and Glob are - # refused a file elsewhere on the runner, by absolute path or by `../`. A Bash read (`cat`, - # an input redirect) is held to the directory Claude runs in as well. The review reads - # nothing outside the tree: the traps file is .github/REVIEW-TRAPS.md. - # - # --disallowedTools also denies `git remote`, `git config`, `git ls-remote` and `git var`, with - # any arguments. The review runs none of them, and each reads the repository's git - # configuration, which the Read rule above already keeps from the file tools. Claude Code runs - # some read-only git commands with no allow rule, so these four need deny rules of their own. - claude_args: | - --model "${{ steps.tier.outputs.model || 'claude-opus-5[1m]' }}" - --max-turns ${{ steps.tier.outputs.max_turns || '120' }} - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff ${{ github.event.pull_request.number }}),Bash(gh pr diff ${{ github.event.pull_request.number }} --name-only),Bash(gh pr view ${{ github.event.pull_request.number }}),Bash(gh pr view ${{ github.event.pull_request.number }} --json commits),Bash(gh pr view ${{ github.event.pull_request.number }} --comments),Bash(gh pr comment ${{ github.event.pull_request.number }} --body-file -),Bash(gh pr review ${{ github.event.pull_request.number }} --comment --body-file -),Bash(gh pr review ${{ github.event.pull_request.number }} --request-changes --body-file -),Read(./**)${{ needs.decide.outputs.base && needs.decide.outputs.head && format(',Bash(git diff {0} {1}),Bash(git diff {0} {1} -- *)', needs.decide.outputs.base, needs.decide.outputs.head) || '' }}" - --disallowedTools "Read(./.git/**),Read(//proc/**),Bash(git remote *),Bash(git config *),Bash(git ls-remote *),Bash(git var *)" - - # #3650: a review that posted nothing used to finish GREEN. The prompt mandates one formal - # review per run -- changes-requested on a substantive finding, a clean comment review - # otherwise -- so on this repo a run that submitted no verdict has broken its contract every - # time; a clean review is never silent, and a zero here is never legitimate. This step turns - # that into the job's own colour, with the diagnosis in the log, so the guard (#2229/#3492, the - # check that enforces newest-verdict-wins and the drift arm) becomes the second line rather - # than the first. Counted: reviews by the bot, submitted inside this run's - # window, with a NON-EMPTY body. The body test is load-bearing: the inline-comment tool files - # each comment inside a review of its own with an empty body (#3647 carried four bot reviews, - # two of them bodiless carriers), and both verdict shapes must carry one (gh and the API both - # refuse a bodiless comment/changes-requested review) -- so "posted inline notes, never a - # verdict" is the #3470 shape and fails here, as it should. - # Two runs end with zero verdicts and both are defects on this repo, told apart by whether - # Claude ran at all: the action exits SUCCESS in seconds without running when this file - # differs from the default branch's copy (cause 1 in the guard's header), and then it sets no - # execution_file and writes no transcript. Expected on a PR that edits this file; a repo-wide - # outage otherwise -- the guard grades which, because it can read the PR's file list from a - # workflow that is free to change. The step's own outcome is left to speak for itself when it - # is not success (a failed step already reds the job; a cancelled one is a superseded push). - # A gh lookup failure is NOT a verdict on the review (#2309): it warns and stands down rather - # than forcing a paid re-run of the whole job to clear a transient API error -- the guard's - # own tally, a separate and free-to-rerun workflow, stays the enforcement. - # - # #3710: a verdict that EXISTS is no longer enough; it has to have the structured shape. The - # bare "LGTM — " this prompt used to ask for was the whole problem the issue names: - # a verdict with no record of what was checked, indistinguishable from one that checked - # nothing, and useless to a later pass computing the miss rate. So the newest verdict in the - # window (last line of the stream -- the API returns oldest-first and --paginate runs --jq per - # page, so the newest is the last LINE, as the guard's scan already relies on) is fetched by - # id and its body must carry (a) the `## Verdict: LGTM | CHANGES REQUESTED` header, (b) the - # `` line, and (c) a ledger verdict that AGREES with the review's - # API state -- `verdict=CHANGES_REQUESTED` on a CHANGES_REQUESTED review, `verdict=LGTM` on a - # COMMENTED one. (c) is the one that protects the gate: the guard reads the STATE, so a body - # that says "CHANGES REQUESTED" inside a review submitted with --comment is a substantive - # finding the guard would wave through. A review lacking any of the three fails this job - # with the missing piece named; the guard's own verdict arm is untouched and still keys on - # the state, so the two checks stay compatible by construction -- this one is stricter about - # the body, the guard is authoritative about the state. The section headings are listed in - # the log for the record and not graded: a mark that fails on a heading's wording is one - # people learn to ignore, and the ledger is what the later pass computes from. - - name: Verify the review posted a verdict - if: ${{ always() && env.HAS_OAUTH_TOKEN == 'true' }} - env: - GH_TOKEN: ${{ github.token }} - R: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - SINCE: ${{ steps.window.outputs.start }} - REVIEW_OUTCOME: ${{ steps.review.outcome }} - # Set by the action only after Claude actually ran; empty when it refused at validation. - EXECUTION_FILE: ${{ steps.review.outputs.execution_file }} - # Author of every artifact the review leaves behind (the action's bot_name default). - REVIEW_BOT: claude[bot] - # What the tier step decided, to cross-check the ledger's own tier claim (a warning, - # not a failure: a mislabelled tier does not compromise the verdict, only the ledger). - TIER: ${{ steps.tier.outputs.tier }} - # The pass the decide job chose and the head this run started on; the verdict's review-pass - # line must say both, because the next push reads that line to decide what it gets and where - # its follow-up starts. - PASS: ${{ needs.decide.outputs.pass }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - summary() { echo "$*" >> "$GITHUB_STEP_SUMMARY"; } - - if [ "$REVIEW_OUTCOME" != "success" ]; then - echo "::notice title=Verdict check skipped::The review step ended '$REVIEW_OUTCOME'; its own"\ - "outcome carries the story, so this step has nothing to add." - exit 0 - fi - - transcript="${EXECUTION_FILE:-$RUNNER_TEMP/claude-execution-output.json}" - - errfile=$(mktemp 2>/dev/null || echo /dev/null) - if ! matched=$(gh api --paginate "repos/$R/pulls/$PR/reviews?per_page=100" \ - --jq ".[] | select(.user.login == env.REVIEW_BOT - and .submitted_at != null - and .submitted_at >= env.SINCE - and ((.body // \"\") | length) > 0) - | \"\\(.state)\\t\\(.submitted_at)\\t\\(.id)\\t\\(.html_url)\"" 2>"$errfile"); then - err=$(cat "$errfile" 2>/dev/null || true) - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - echo "::warning title=Verdict check could not read the PR's reviews::gh api"\ - "repos/$R/pulls/$PR/reviews failed: ${err:-no stderr}. A lookup failure is not a"\ - "review verdict (#2309), so the review is UNCONFIRMED here; the guard's own tally"\ - "decides, or read the PR by eye." - summary "- Verdict check: lookup failed; review UNCONFIRMED (#2309)." - exit 0 - fi - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - - count=$(printf '%s' "$matched" | grep -c . || true) - echo "verdict reviews by $REVIEW_BOT on PR #$PR since $SINCE: $count" - if [ -n "$matched" ]; then printf '%s\n' "$matched"; fi - - if [ "$count" -gt 0 ]; then - summary "### Claude review posted $count verdict review(s) since $SINCE" - - # #3710: THE SHAPE CHECK, on the newest verdict in the window. A single-review fetch - # rather than carrying bodies through the TSV above: bodies hold tabs and newlines. A - # failure of THIS lookup is the #2309 shape too -- warn and stand down, the verdict - # exists and the guard has the state. - newest=$(printf '%s\n' "$matched" | tail -n 1) - IFS=$'\t' read -r verdict_state _ verdict_id verdict_url <<<"$newest" - errfile=$(mktemp 2>/dev/null || echo /dev/null) - if ! body=$(gh api "repos/$R/pulls/$PR/reviews/$verdict_id" --jq '.body // ""' 2>"$errfile"); then - err=$(cat "$errfile" 2>/dev/null || true) - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - echo "::warning title=Verdict shape check could not read the verdict::gh api"\ - "repos/$R/pulls/$PR/reviews/$verdict_id failed: ${err:-no stderr}. The verdict"\ - "exists ($verdict_url) and the guard reads its state; its SHAPE is UNCONFIRMED here (#2309)." - summary "- Verdict shape: lookup failed; UNCONFIRMED (#2309). $verdict_url" - exit 0 - fi - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true - - # CRs stripped: a body typed through a Windows client can carry them, and the anchors - # below are line-anchored. - body=$(printf '%s' "$body" | tr -d '\r') - header=$(printf '%s\n' "$body" | grep -E -m1 '^## Verdict: (LGTM|CHANGES REQUESTED)[[:space:]]*$' || true) - ledger=$(printf '%s\n' "$body" | grep -E -m1 '^[[:space:]]*$' || true) - sections=$(printf '%s\n' "$body" | grep -E '^### ' | tr '\n' ';' || true) - marker=$(printf '%s\n' "$body" | grep -E '^[[:space:]]*$' | tail -n 1 | sed -E 's/[[:space:]]+$//' || true) - expected_marker="" - echo "newest verdict: $verdict_state $verdict_url" - echo " header: ${header:-}" - echo " ledger: ${ledger:-}" - echo " pass marker: ${marker:-} (expected: $expected_marker)" - echo " sections: ${sections:-}" - - missing='' - [ -z "$header" ] && missing="$missing the '## Verdict: LGTM | CHANGES REQUESTED' header;" - [ -z "$ledger" ] && missing="$missing the '' line;" - [ "$marker" != "$expected_marker" ] && missing="$missing the '$expected_marker' line (found: ${marker:-none});" - if [ -n "$header" ] && [ -n "$ledger" ]; then - ledger_verdict=$(printf '%s' "$ledger" | sed -E 's/.*verdict=([A-Z_]+).*/\1/') - case "$verdict_state:$ledger_verdict" in - CHANGES_REQUESTED:CHANGES_REQUESTED|COMMENTED:LGTM) ;; - *) missing="$missing a ledger verdict ($ledger_verdict) that agrees with the review's state ($verdict_state);" ;; - esac - ledger_tier=$(printf '%s' "$ledger" | sed -E 's/.*tier=([a-z]+).*/\1/') - if [ -n "$TIER" ] && [ "$ledger_tier" != "$TIER" ]; then - echo "::warning title=Ledger tier disagrees with the tier step::The ledger says"\ - "tier=$ledger_tier; this run decided tier=$TIER. The verdict stands; the ledger's"\ - "tier is wrong for the later pass that reads it." - fi - fi - - if [ -z "$missing" ]; then - echo "::notice title=Structured verdict present::$verdict_state -- $verdict_url" - summary "- newest verdict: $verdict_state, structured (header + ledger agree with the state) -- $verdict_url" - exit 0 - fi - - echo "::error title=Review posted an unstructured verdict::The newest verdict by"\ - "$REVIEW_BOT on PR #$PR ($verdict_url) lacks:$missing The prompt promises a"\ - "structured verdict every run -- claims graded VERIFIED/UNVERIFIED/REFUTED, the"\ - "riskiest lines and their pins, the traps considered, the divergence from the PR body,"\ - "and a one-line ledger -- because a bare LGTM is what this review posted on several"\ - "hundred PRs while every defect it missed was found by behaviour (#3710). An"\ - "unstructured verdict is a contract breach, not a clean review: it records nothing"\ - "about what was checked and cannot be told from a review that checked nothing. Do"\ - "NOT read this PR as reviewed; re-run the review workflow. The guard reads the"\ - "verdict's STATE and is not affected by this failure." - summary "- newest verdict is UNSTRUCTURED: missing$missing Not reviewed (#3710). $verdict_url" - exit 1 - fi - - summary '### Claude review posted NO verdict' - - if [ -z "$EXECUTION_FILE" ] && [ ! -s "$transcript" ]; then - echo "::error title=Claude never ran::claude-code-action exited success without running"\ - "Claude -- no execution file, no transcript. That is what it does when this branch's"\ - ".github/workflows/claude-review.yml differs from the default branch's copy (#2229,"\ - "cause 1). Expected on a PR that edits this file; on any other PR it means the file"\ - "has drifted and EVERY PR in the repo is going unreviewed -- see the guard's verdict"\ - "on this PR. Either way, do not read this PR as reviewed." - summary '- Claude never ran (workflow validation refused). Not reviewed.' - exit 1 - fi - - echo "::error title=Review ran and posted no verdict::The review ran and posted no"\ - "verdict: $REVIEW_BOT submitted no formal review on PR #$PR since $SINCE, and the"\ - "prompt promises one every run even when clean. Real money was spent and the output"\ - "vanished (#3650; the #2229 failure shape). Do NOT read this PR as reviewed. The"\ - "review keeps no transcript; the counts from its result block follow." - summary '- The review ran and submitted no formal review. Not reviewed (#3650).' - if [ -s "$transcript" ]; then - # Result fields only (type, subtype, counts, the names of the denied tools): no message - # text and no tool input is printed, and nothing is uploaded; the file holds the whole - # conversation and stays on the runner. The file's result message carries the denials as a - # list (the action's own log derives the count from it), so the count is taken from it here. - echo "--- result block of $transcript (fields only) ---" - jq -c '.[-1] | {type, subtype, is_error, num_turns, - permission_denials_count: ((.permission_denials // []) | length), - total_cost_usd, duration_ms, - denied: [.permission_denials[]? | .tool_name]}' "$transcript" \ - 2>/dev/null || true - else - echo "(no transcript at $transcript)" - fi - exit 1 - - # The `re-review` label is a request, and a request that has been answered goes away: a stuck - # label cannot be added again, so it could never ask twice. Removed only after a FULL review - # whose verdict passed the shape check above (success() covers every step before this one), and - # only when the label is on the pull request. The job's existing pull-requests: write covers - # DELETE /issues/{n}/labels/{name} (GitHub lists it under the Pull requests permission), so no - # permission is added. A label that is already gone is a 404 and not a failure. - - name: Clear the re-review label - if: ${{ success() && needs.decide.outputs.pass == 'full' && (github.event.label.name == 're-review' || contains(github.event.pull_request.labels.*.name, 're-review')) }} - env: - GH_TOKEN: ${{ github.token }} - R: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - errfile=$(mktemp 2>/dev/null || echo /dev/null) - if gh api -X DELETE "repos/$R/issues/$PR/labels/re-review" --silent 2>"$errfile"; then - echo "::notice title=re-review label cleared::The full review is done, so the request is answered." - else - err=$(cat "$errfile" 2>/dev/null || true) - echo "::warning title=re-review label not cleared::gh api DELETE labels/re-review failed: ${err:-no stderr}. Remove the label by hand if it is still on the pull request." - fi - [ "$errfile" != /dev/null ] && rm -f "$errfile" || true diff --git a/.github/workflows/description-drift.yml b/.github/workflows/description-drift.yml deleted file mode 100644 index 65b002c47f..0000000000 --- a/.github/workflows/description-drift.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Description drift - -# #2978: every other check in CI reads the code and none reads the prose beside it, so a -# description that has drifted from its own diff reaches every reviewer unchallenged. This -# compares the two and annotates. It is ADVISORY -- see the exit-0 note at the bottom -- and -# must never be added to the required-checks list. -on: - pull_request: - # `edited` is load-bearing and is not in the default type set: the description is half the - # input, so a body rewritten after the last push has to re-run the comparison or the - # annotation describes a description nobody can still read. - types: [opened, synchronize, reopened, edited] - branches: [dev, main] - # Inert until a queue ruleset is enabled, and harmless if one ever is: a merge_group payload - # carries no pull request, so the two PR-scoped steps skip and the job reports success off the - # self-test alone. That is deliberate insurance -- if this check were ever marked required by - # mistake, a queued PR would still get a result instead of stalling on one that never reports. - merge_group: - -concurrency: - group: description-drift-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -# Read-only, and no `issues:` or write scope anywhere: this check annotates its own run and -# posts nothing to the pull request. A body edit is one of its triggers, so a check that -# commented would answer every description fix with another comment. -permissions: - contents: read - pull-requests: read - -jobs: - description-drift: - runs-on: ubuntu-latest - # Two API pages and a regex pass over one body. Anything approaching this is wedged. - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - # The extractor is the whole check, and its filtering is tuned against a measured flag - # rate rather than derived from anything. Running its own assertions first means a change - # that quietly widens what counts as a symbol reports here instead of in the flag rate - # weeks later. This is the ONLY step that can fail the job: the two below cannot, by the - # lookup handling and the always-zero exit documented on each. - - name: Self-test the extractor - run: python3 .github/scripts/ci_description_symbol_drift.py --self-test - - - name: Fetch the changed files - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - run: | - # Deliberately NOT `set -e`: a lookup failure is not a verdict (#2309, same repo). This - # check is advisory, so a rate limit, a 5xx or a network blip must abstain loudly rather - # than turn the mark red for a reason that has nothing to do with the description. - set -uo pipefail - # `--jq '.[]'` then `jq -s .` rather than `--slurp`: this endpoint returns an array per - # page, and --slurp would hand back an array OF pages that reads as one file entry per - # page. Emitting elements and re-slurping flattens to one array at any page count. - if ! gh api "repos/$REPO/pulls/$PR/files?per_page=100" --paginate --jq '.[]' \ - 2>diffdesc-lookup-error.txt | jq -s . > diffdesc-files.json; then - # Flatten before slicing. A workflow command is single-line -- GitHub honours the - # `::warning ...::` prefix only up to the first newline -- and gh's error text is - # routinely multi-line (rate-limit bodies, pretty-printed JSON). Embedding it raw - # would split the annotation and render the useful half as untitled log text. Same - # invariant the symbol extractor holds on its side by rejecting CR and LF outright. - detail=$(tr '\r\n' ' ' < diffdesc-lookup-error.txt | head -c 300) - echo "::warning title=Description drift lookup failed::gh api (pulls/$PR/files) failed:"\ - "${detail}. The description was NOT compared against"\ - "the diff on this run -- re-run this check to get a verdict." - # An empty list is what the script reads as "no diff to compare", which it reports as - # an abstention rather than as a finding. - echo '[]' > diffdesc-files.json - fi - echo "changed files: $(jq 'length' diffdesc-files.json 2>/dev/null || echo unknown)" - - - name: Compare the description against the diff - if: github.event_name == 'pull_request' - env: - # Through the environment, never through a workflow expression inside `run`. On a - # public repo the body is author-controlled text, and `${{ }}` interpolation into a - # shell would make a backticked "symbol" a command-substitution seam. - PR_BODY: ${{ github.event.pull_request.body }} - # No `continue-on-error` and no `|| true` needed: the script always exits 0 by design. - # A description is not a build failure, and a mark that goes red on prose is one people - # learn to ignore -- which costs more than it catches (the #2229 lesson, same repo). - run: python3 .github/scripts/ci_description_symbol_drift.py --files diffdesc-files.json diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index 768df58a54..0000000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,1595 +0,0 @@ -name: Nightly Build - -on: - schedule: - # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT) - - cron: '0 6 * * *' - workflow_dispatch: # manual trigger — and the vehicle the scheduled re-dispatch below rides - inputs: - from_schedule: - description: 'Set true by the scheduled re-dispatch so the 24h new-commit check applies. Leave false for manual runs, which always build.' - type: boolean - required: false - default: false - publish: - description: 'Publish the release artifacts (gh release, the ghcr image push) once build, linux and the Darling PostgreSQL tests all succeed. Set false for a diagnostic re-run that should build and test without publishing.' - type: boolean - required: false - default: true - test_map: - description: 'Build the class-to-file test map (#5459): the whole Darling.Tests and Lite.Tests suites under AltCover, sharded, joined into the test-map artifact. On by default, including in the scheduled re-dispatch; set false to skip it for one dispatch. It publishes nothing, gates nothing, and a failure in it does not fail the nightly.' - type: boolean - required: false - default: true - altcover_probe: - description: 'Run the AltCover per-test coverage probe job (#5459). Off by default and never set by the scheduled re-dispatch; it only measures, publishes nothing and gates nothing.' - type: boolean - required: false - default: false - -# Read-only by default; the release-publishing steps (gh release, the ghcr push, cosign, the -# provenance attestation) run only in the publish job below, which declares its own write -# permissions scoped to itself. No other job needs to write anything. -permissions: - contents: read - -jobs: - # Scheduled workflows always execute the DEFAULT branch's copy of this file, while nightly - # artifacts deliberately build from dev's tree. That skew is how the 2026-07-26 nightly - # failed (run 30194606068): main's stale copy still read Dashboard/Dashboard.csproj, a path - # #1612 moved to deprecated/ on dev, so 'Set nightly version' died on a file missing from - # the tree it had just checked out — and the same trap bit before (#1550/#1551). The cure - # is structural, not another sync: on schedule this workflow does NOTHING but re-dispatch - # itself onto the dev REF, because a workflow_dispatch run executes the dispatched ref's - # copy of this file — dev's, current by definition. Once main carries this shape, its copy - # has exactly one job that must keep working, and that job references no tree paths at - # all; every future change to the real nightly logic lands on dev and takes effect the - # night it merges, no promotion to main needed. GITHUB_TOKEN can create workflow_dispatch - # runs (the Actions recursion guard exempts workflow_dispatch and repository_dispatch), - # and the dispatched run cannot loop back here because it arrives as workflow_dispatch, - # not schedule. Until main is synced once, the scheduled run still executes main's OLD - # copy and keeps failing nightly — the one-time sync is in the PR that introduced this. - redispatch: - if: github.event_name == 'schedule' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: write - steps: - - name: Re-dispatch this workflow onto the dev ref - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run nightly.yml --repo ${{ github.repository }} --ref dev -f from_schedule=true - - # Everything below runs only in a workflow_dispatch run — the scheduled re-dispatch or a - # manual one — which executes from the dispatched ref (dev for the scheduled path). - # - # Each checkout resolves that ref EXPLICITLY rather than pinning `ref: dev`. Three of these - # were pinned, so a manual dispatch onto any other branch published dev's artifacts while - # reporting the dispatched ref as the run's head SHA — the run, the API and the release page - # all named a branch that was not what got built. That is worse than simply ignoring the - # input, because the mismatch is invisible: it surfaced as a release-candidate build stamping - # the PREVIOUS version, which is only noticeable if you happen to read the artifact name. - check: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - has_changes: ${{ steps.check.outputs.has_changes }} - stamp: ${{ steps.stamp.outputs.stamp }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} - fetch-depth: 0 - - # The nightly prerelease stamp, derived ONCE for the whole run and consumed by every job - # that names an artifact (#3390). Two properties matter and a per-job `date` has neither. - # - # Stable across jobs: `linux` needs `build`, which takes ~8.5 minutes, so the two jobs read - # their clocks ~8.5 minutes apart. Each deriving its own date means a run whose two stamp - # steps straddle midnight UTC ships a Windows zip dated one day and a Linux tarball dated - # the next, from one commit. - # - # Unique per build: the date alone is not. Multiple dispatches a day are normal here - over - # 2026-08-24..09-12, 13 of 20 days published two or more distinct source trees under one - # version string, peaking at five - and nothing in the artifact then distinguishes them. - # `InformationalVersion` — and the Win32 ProductVersion built from it — and the asset names - # all carry this string (#3455 is what actually threads it into the binaries; FileVersion - # stays the numeric prefix), and the apphost exe is byte-identical across builds, so with a - # colliding stamp there is no read that answers "which nightly is this". - # - # run_number is monotonic and numeric, so SemVer 2.0 compares these prereleases in build - # order; the date stays leftmost and human-readable. A short sha would identify the tree - # but sorts as an alphanumeric identifier, which outranks every numeric one. - - name: Derive the nightly stamp - id: stamp - shell: bash - run: | - set -euo pipefail - stamp="$(date -u +%Y%m%d).${{ github.run_number }}" - # Shape, not emptiness. A `date` that exited 0 having printed nothing yields - # "." - non-empty, so it passes every downstream `-n` / - # IsNullOrWhiteSpace guard and stamps both artifacts with a malformed version instead - # of failing here. Asserting the shape at the one place the stamp is produced makes - # those guards a backstop against broken output wiring rather than the only check. - shape='^[0-9]{8}\.[0-9]+$' - [[ "${stamp}" =~ ${shape} ]] || { echo "::error::Derived a malformed nightly stamp: '${stamp}'"; exit 1; } - echo "stamp=${stamp}" >> "$GITHUB_OUTPUT" - echo "Nightly stamp: ${stamp}" - - - name: Check for new commits in last 24 hours - id: check - run: | - RECENT=$(git log --since="24 hours ago" --oneline | head -1) - if [ -n "$RECENT" ]; then - echo "has_changes=true" >> $GITHUB_OUTPUT - echo "New commits found — building nightly" - else - echo "has_changes=false" >> $GITHUB_OUTPUT - echo "No new commits — skipping nightly build" - fi - - build: - needs: check - # Manual dispatches always build (from_schedule defaults false); the scheduled - # re-dispatch sets from_schedule=true and builds only when dev changed in the last - # 24h — the same policy the schedule applied when it ran these jobs directly. - if: needs.check.outputs.has_changes == 'true' || inputs.from_schedule != true - runs-on: windows-latest - # Full pipeline (restore, tests, four publishes, cold pg-runtime fetch, vpk pack, - # release upload) is well under an hour; 90 minutes means hung-not-slow. Nightly ships - # unsigned, so unlike build.yml's release path there is no manual signing gate to wait on. - # - # Raised from 90 to 150 (#5208). The unsharded "Run tests" step alone went 45 -> 64 -> over 89 - # minutes on hosted runners and hit this cap, so no nightly published. The one-log design is - # kept on purpose. This is a guard, not a fix: the slowdown is tracked in #5208, and this value - # should come back down to 90 when it is fixed. - timeout-minutes: 150 - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - # The stamp must reach the BINARIES, not just the names around them. This step's output feeds - # every artifact name and the vpk pack below, but a name is not a stamp: each assembly records - # whatever the msbuild version property resolves to at build time, and that is the clean - # release string Directory.Build.props declares unless the build overrides it. Until #3455 - # nothing did, so a zip named 3.7.0-nightly.20260915.438 shipped a service whose informational - # version read bare 3.7.0 — and the informational version is the one surface a RUNNING service - # answers build questions from: DarlingCliCommands.ProductVersion() reads that attribute for - # --version, and #3453's get_collection_health service block deliberately serves the same - # method's result. The night #3453 shipped, its first live reading came back bare on a .438 - # nightly — the question "which nightly is this" answered with silence at the read built to - # answer it. - # - # So every dotnet publish below passes -p:Version with this value. The SDK derives the - # informational version from it verbatim, while AssemblyVersion and FileVersion derive as the - # numeric prefix (3.7.0.0) — identical to a release build, so nothing assembly-identity-shaped - # differs between the two. No +sha metadata rides along: Directory.Build.props suppresses the - # source-revision append tree-wide (#3222), the stamp's run_number already names the build - # uniquely, and ProductVersion() strips any +suffix from display regardless. Release builds - # keep their clean string by CONSTRUCTION rather than by restraint — build.yml passes no - # version property — and NightlyVersionInjectionTests pins both halves, so the next copied-in - # publish step cannot quietly ship a bare-versioned nightly again. - - name: Set nightly version - id: version - shell: pwsh - env: - NIGHTLY_STAMP: ${{ needs.check.outputs.stamp }} - run: | - $base = ([xml](Get-Content Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ } - if ([string]::IsNullOrWhiteSpace($base)) { throw "Read no version from Directory.Build.props." } - if ([string]::IsNullOrWhiteSpace($env:NIGHTLY_STAMP)) { throw "Read no stamp from the check job." } - $nightly = "$base-nightly.$env:NIGHTLY_STAMP" - echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT - echo "Nightly version: $nightly" - - # `shell: bash` with errexit for the reason build.yml's restore step carries it (#3143): a multi-line - # run block executes under pwsh on Windows, which reports the block's exit code as the LAST command's, - # so the two restores ahead of the last could print NU1004 and leave this step green. The project list - # is what this workflow publishes rather than the whole tree — build.yml's is the one that gates a - # merge, and LockedModeRestoreCoverageTests requires whole-tree lock-file coverage there and only there. - - name: Restore dependencies - shell: bash - run: | - set -euo pipefail - - dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode - dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - dotnet restore Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj --locked-mode - - # #5587: the hang report build.yml's shards carry (PR 5591). `-longRunning 300` makes the runner print a test that - # has run for 5 minutes while the step is still alive, so a hang names its test. The runner output is teed to - # TestResults/lite-runner-nightly.log and uploaded by the always() step below. The Tee-Object pipe leaves - # $LASTEXITCODE as the runner's, and the explicit check keeps the step failing when the runner fails. - # The step timeout is from numbers: the last 5 completed nightly runs on dev (2026-10-06 to 2026-10-08) ran this - # step for 32.4, 39.8, 40.6, 41.4 and 44.2 minutes, and the job ran 4.0 to 4.9 minutes longer than the step - # (setup before it, publish and packaging after it). The job's timeout is 150, so the step's is - # 150 - 4.9 - 5 = 140 (rounded down). The longest step, 44.2 minutes, is 32% of that, under the 70% bar. A job - # timeout would cancel the job and leave the uploads pending; a step timeout fails only this step. - - name: Run tests - timeout-minutes: 140 - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path TestResults | Out-Null - dotnet run --project Lite.Tests/Lite.Tests.csproj -c Release -- -xml TestResults/lite-timing-nightly.xml -longRunning 300 | Tee-Object -FilePath TestResults/lite-runner-nightly.log - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - # #5208: where the time goes. Informational; a parse problem must never turn the job red. - - name: Lite test timing summary - if: always() - continue-on-error: true - run: python .github/scripts/lite-timing-top.py TestResults - - - name: Upload Lite test timings - if: always() - continue-on-error: true - uses: actions/upload-artifact@v6 - with: - name: lite-tests-timing-nightly - path: | - TestResults/lite-timing-*.xml - TestResults/lite-timing-*.xml.cpu.json - retention-days: 7 - if-no-files-found: ignore - - # #5587: the runner output, uploaded whatever the test step did (failed, cancelled or timed out). - - name: Upload Lite runner output - if: always() - continue-on-error: true - timeout-minutes: 5 - uses: actions/upload-artifact@v6 - with: - name: lite-runner-log-nightly - path: TestResults/lite-runner-*.log - retention-days: 7 - if-no-files-found: ignore - - # #2501: -r win-x64 --self-contained, and it makes the portable ZIP SMALLER, not bigger. - # The RID-agnostic framework-dependent publish this replaced copied every platform its - # packages ship - 537 MB of runtimes\ on a 565 MB tree, of which only the 52 MB win-x64 - # folder can ever load on Windows (DuckDB.NET.Bindings.Full is most of it, SkiaSharp and - # SqlClient behind it). Pinning the RID drops ~485 MB of unloadable native payload, which - # is far more than the bundled .NET/WPF/ASP.NET runtime adds back. Measured on the same - # commit and SDK: 565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB. - # - # The size was the secondary reason. The primary one is #2489: a framework-dependent ZIP - # unzipped onto a stock Windows Server dies on the .NET host's own "You must install .NET - # to run this application" before a line of our code runs, so Lite cannot report it, and - # #2499 could only ship a READ-ME-FIRST.txt next to the exe. Self-contained there is - # nothing to install and nothing to fail. That matters most for the nightly ZIP, which is - # the UAT download and is not offered as a Setup.exe at all. - # - # Lite/PerformanceMonitorLite.csproj declares RuntimeIdentifiers so the committed lock - # file covers this RID; see the comment there before removing either half. - - name: Publish Lite - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite -p:Version=${{ steps.version.outputs.VERSION }} - - - name: Publish Darling Service - run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService -p:Version=${{ steps.version.outputs.VERSION }} - - - name: Publish Darling Viewer - run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer -p:Version=${{ steps.version.outputs.VERSION }} - - # Self-contained viewer publish that feeds the remote-seat Velopack Setup.exe (#1555), the same - # publish shape build.yml uses for the Dashboard/Lite Velopack packs. This is IN ADDITION to the - # framework-dependent "Publish Darling Viewer" above, which still feeds the co-located viewer\ - # folder inside PerformanceMonitorDarling-*.zip — that zip is unchanged. - - name: Publish Darling Viewer (self-contained for Velopack) - run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack -p:Version=${{ steps.version.outputs.VERSION }} - - # Same cache key as build.yml's release path: the fetch script's content hash (the SHA256 - # pins live inside it). The ~340MB EDB/TimescaleDB fetch runs at most once per pin-set per - # branch; nightly and release runs share the assembled zip whenever the cache is visible. - - name: Cache Darling pg-runtime.zip - id: cache-pg-runtime - uses: actions/cache@v6 - with: - path: Darling/artifacts/pg-runtime.zip - key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} - - - name: Build Darling pg-runtime.zip - if: steps.cache-pg-runtime.outputs.cache-hit != 'true' - shell: pwsh - run: ./Darling/tools/fetch-pg-runtime.ps1 - - - name: Package artifacts - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - New-Item -ItemType Directory -Force -Path releases - - Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - - - # Same layout as the release zip (build.yml "Package Darling (signed)"): service at the - # archive root with darling.sample.json alongside, viewer under viewer\, pg-runtime.zip - # beside the service exe where DarlingManagedPostgres extracts it on first run. Nightly - # zips are unsigned across the board, so this stages from publish/ instead of signed/. - $darlingDir = 'publish/Darling' - New-Item -ItemType Directory -Force -Path "$darlingDir/viewer" | Out-Null - Copy-Item 'publish/DarlingService/*' $darlingDir -Recurse - Copy-Item 'publish/DarlingViewer/*' "$darlingDir/viewer" -Recurse - Copy-Item 'Darling/artifacts/pg-runtime.zip' $darlingDir - - Compress-Archive -Path 'publish/Darling/*' -DestinationPath "releases/PerformanceMonitorDarling-$version.zip" -Force - - # Darling viewer remote-seat installer (#1555). Nightly ships it UNSIGNED like every other nightly - # artifact. Mirrors build.yml's release vpk pack (same pack id / exe / channel) but packs from the - # raw self-contained publish (no SignPath), and deliberately does NOT touch the Velopack update - # feed: the nightly GitHub release is deleted + recreated each night, so there is no persistent - # delta chain to `vpk download`/`vpk upload` from — we ship a standalone full Setup.exe as a plain - # release asset. Copied to a deterministic name so the checksum + upload steps below pick it up. - # Purely additive: the co-located viewer inside PerformanceMonitorDarling-*.zip is untouched. - - name: Create Darling Viewer Setup.exe (Velopack, unsigned) - shell: pwsh - run: | - $version = "${{ steps.version.outputs.VERSION }}" - dotnet tool install -g vpk --version 1.2.158 - New-Item -ItemType Directory -Force -Path releases/velopack-darlingviewer - vpk pack -u PerformanceMonitorDarlingViewer -v $version -p publish/DarlingViewer-velopack -e PerformanceMonitor.Darling.Viewer.exe -o releases/velopack-darlingviewer --channel darlingviewer - $setup = Get-ChildItem releases/velopack-darlingviewer/*Setup.exe | Select-Object -First 1 - Copy-Item $setup.FullName "releases/PerformanceMonitorDarlingViewer-$version-Setup.exe" - - - name: Generate checksums - shell: pwsh - run: | - $checksums = Get-ChildItem releases/*.zip, releases/*.exe | ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() - "$hash $($_.Name)" - } - # LF and no BOM, written explicitly (#2383). Out-File on Windows emits CRLF whatever - # the encoding, and `shasum -c` on macOS/Linux then takes the trailing \r as part of - # the FILENAME - every entry reports "No such file or directory / FAILED open or - # read", which is precisely what a tampered download looks like. The hashes were - # always right; the file just could not be used by the tool it exists for, on the two - # platforms where that tool is the default. A BOM breaks the first line the same way. - # - # This is the SECOND copy of that step. #2384 fixed the release workflow and left this - # one, so every nightly since has published a checksum file nobody outside Windows can - # verify - and the nightly is the build most likely to be checked by hand. - [IO.File]::WriteAllText( - "$PWD/releases/SHA256SUMS.txt", - (($checksums -join "`n") + "`n"), - [Text.UTF8Encoding]::new($false)) - Write-Host "Checksums:" - $checksums | ForEach-Object { Write-Host $_ } - - # The release-publishing steps (gh release create, the linux upload, the ghcr push) moved to - # the publish job below, which waits on build, linux AND darling-pg. This job hands off its - # windows release assets (the zips, the Setup.exe, SHA256SUMS.txt) and the version string as a - # workflow artifact instead of publishing them itself, so a nightly can no longer publish - # while the Darling PostgreSQL tests are red: publish downloads exactly what this job built - # and cannot run until darling-pg reports success. - - name: Upload windows release assets - uses: actions/upload-artifact@v6 - with: - name: windows-release-assets - path: | - releases/*.zip - releases/*.exe - releases/SHA256SUMS.txt - if-no-files-found: error - retention-days: 3 - - - name: Save the nightly version string - shell: bash - run: echo "${{ steps.version.outputs.VERSION }}" > windows-version.txt - - # The commit build actually checked out, so publish can name the release after the tree it - # built rather than dev's tip at publish time (20-60 minutes later, once darling-pg is done). - - name: Save the built commit SHA - shell: bash - run: | - set -euo pipefail - sha="$(git rev-parse HEAD)" - echo "${sha}" > windows-commit-sha.txt - echo "Built commit: ${sha}" - - - name: Upload the nightly version string - uses: actions/upload-artifact@v6 - with: - name: windows-version - path: | - windows-version.txt - windows-commit-sha.txt - if-no-files-found: error - retention-days: 3 - - # Darling.Tests has live-PostgreSQL tests gated on env vars that the per-PR/push build never - # sets, so regular CI runs only the ungated subset. This job stands up a throwaway PostgreSQL - # from the bundled pg-runtime and runs the full Darling suite against it nightly, so the - # Postgres + managed-bootstrap paths get real coverage. The one live-SQL-Server E2E stays - # skipped (no SQL Server on the runner) — expected. Gated like the build job so it only runs - # when dev actually changed (or on manual dispatch). - darling-pg: - name: Darling PostgreSQL tests - needs: check - # Same gating as the build job: manual dispatches always run, the scheduled - # re-dispatch (from_schedule=true) runs only when dev changed in the last 24h. - if: needs.check.outputs.has_changes == 'true' || inputs.from_schedule != true - runs-on: windows-latest - # Cold pg-runtime fetch + build + the full live-PG suite fits well inside an hour; a - # cluster that never comes up (pg_ctl -w) is the hang this bounds. - timeout-minutes: 60 - - steps: - # Scheduled runs always test dev (schedules execute from the default branch, so ref_name - # would be main). A manual dispatch tests the DISPATCHED ref — the only way to validate a - # branch's gated-pg test changes before merge; the build job stays pinned to dev either - # way, so a branch dispatch can never ship branch binaries. - - uses: actions/checkout@v7 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - # Darling bundles a PostgreSQL 18 + TimescaleDB runtime (pg-runtime.zip). The fetch script - # pulls ~340MB of pinned EDB/TimescaleDB archives, so cache the assembled zip on the SAME - # key build.yml's release job uses (the fetch script's own content hash) — nightly and - # release share one cache entry, so a warm cache means no download here. - - name: Cache Darling pg-runtime.zip - id: cache-pg-runtime - uses: actions/cache@v6 - with: - path: Darling/artifacts/pg-runtime.zip - key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} - - - name: Build Darling pg-runtime.zip (cache miss only) - if: steps.cache-pg-runtime.outputs.cache-hit != 'true' - shell: pwsh - run: ./Darling/tools/fetch-pg-runtime.ps1 - - # One uniform path for hit and miss: we always have the zip (restored or freshly built), - # so always extract it — simpler than branching on the script's -KeepWork assembled tree. - # ExtractToDirectory mirrors the fetch script's own API and reads any zip it writes. - - name: Extract pg-runtime - shell: pwsh - run: | - $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" - $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) - if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } - - # The PREVIOUS-major runtime, for the upgraded-in-place fixture (#1706). Without it the gated - # store-upgrade E2E skips, and with it skips the ONLY behavioral coverage of the in-place - # 17-to-18 path: the runtime rescue, the TimescaleDB bridge, the data-directory swap, the - # revert, and the loopback override that stops pg_upgrade dialing ::1 against our IPv4-only - # listen_addresses. String assertions catch that constant being deleted; nothing but this - # catches the override ceasing to TAKE EFFECT, and the failure mode is a fleet-wide hang. - # Only the DOWNLOADS are cached (~680MB of pinned archives, both fixture runtimes): the script - # re-verifies them by SHA256 and re-assembles in seconds, so a warm cache costs no network. - - name: Cache upgrade-fixture downloads (previous-major runtime) - uses: actions/cache@v6 - with: - path: Darling/artifacts/upgrade-fixture/work/downloads - key: upgrade-fixture-${{ runner.os }}-${{ hashFiles('Darling/tools/new-upgraded-store-fixture.ps1') }} - - # -SkipNew: the CURRENT runtime is already built/cached above as pg-runtime.zip, which is what - # DARLING_TEST_PGRUNTIME_NEWZIP points at. This step only needs the old sides: the previous major, - # and (#3906) the previous release on the current major. - - name: Build previous-major runtime for the upgrade fixture - shell: pwsh - run: ./Darling/tools/new-upgraded-store-fixture.ps1 -SkipNew - - - name: Restore Darling.Tests - run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - - - name: Build Darling.Tests - run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - - # Stand up a throwaway cluster from the bundled runtime. initdb TRUST auth is acceptable - # ONLY here: an ephemeral CI runner, loopback-only, throwaway data. The PRODUCT default is - # the opposite (scram-sha-256 + a generated credential; see DarlingManagedPostgres). The - # appended settings mirror DarlingManagedPostgres.BuildConfAppend (timescaledb preload — - # the Timescale-gated tests detect and use it — plus the port and loopback bind) and - # BuildWorkerSizingConfAppend (the two worker settings). Port 5541 is fixed and distinct; the - # managed-bootstrap E2E starts its OWN postgres on a random free port, so there is no collision. - # - # #1888: the worker settings are load-bearing, not garnish. PostgreSQL's default - # max_worker_processes = 8 cannot launch TimescaleDB's per-hypertable compression, retention - # and continuous-aggregate policy jobs, so without them this job tested a configuration no - # customer runs and made scheduler-racing failures luck-of-the-slot instead of reproducible. - # Values are the product's own derivation from the live hypertable count - # (TimescaleSupport.HypertableCount = the 62-collector catalog + collection_log = 63): - # timescaledb.max_background_workers = HypertableCount + 2 = 74 - # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 85 - # Kept honest by CiClusterWorkerSizingTests (parses this file against the formula) and - # CiClusterWorkerSizingLiveTests (asserts the running cluster serves them). Must configure - # the cluster identically to build.yml's darling-pg job: that guard parses the appended - # settings out of BOTH files and requires the two sets to be equal, so a fix applied to one - # workflow and not the other — the ordinary way these hand-maintained copies drift — fails. - - name: Initialize and start throwaway PostgreSQL - shell: pwsh - run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - $dataDir = "$env:RUNNER_TEMP\darling-pgdata" - $logFile = "$env:RUNNER_TEMP\darling-pg.log" - # The bootstrap superuser is named "darling", not "postgres": the V8 schema-split - # migration runs CREATE SCHEMA ... AUTHORIZATION darling (PgSchemaGenerator.OwnerRole), - # so a cluster without that role fails every fresh-store migration. Matching managed - # mode's shape (DarlingManagedPostgres also initdbs its owner as "darling"). - & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 - if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } - Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb,pg_stat_statements'" - Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" - Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" - Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74" - Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85" - # #5549: the session id (%c) on every line. A PID alone cannot say whether two lines came from one - # session (a pooled connection reused by the next test) or from two (Windows reuses a PID quickly). - Add-Content -Path "$dataDir\postgresql.conf" -Value "log_line_prefix = '%m [%p] %c '" - & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start - if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } - & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling - if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } - - # DARLING_TEST_PG lights up the [Collection("live-postgres")] classes; DARLING_TEST_PGRUNTIME - # lights up the managed-bootstrap E2E. DARLING_TEST_SQL is intentionally unset — the one - # live-SQL-Server E2E stays skipped (no SQL Server on the runner). - # #5587: the hang report build.yml's shards carry (PR 5591): `-longRunning 300` names a test that has run for - # 5 minutes, and the runner output is teed to TestResults/darling-runner-nightly.log and uploaded by the - # always() step further down. The Tee-Object pipe leaves $LASTEXITCODE as the runner's; the explicit check keeps - # the step failing when the runner fails. - # NO step timeout, from numbers: the last 5 completed nightly runs on dev (2026-10-06 to 2026-10-08) ran this step - # for 32.2, 32.3, 36.8, 41.1 and 41.2 minutes, and the job ran 4.2 to 5.1 minutes longer than the step. The job's - # timeout is 60, so a step timeout would be 60 - 5.1 - 5 = 49 (rounded down). The longest step, 41.2 minutes, is - # 84% of that, over the 70% bar, so a step timeout would fail a slow but healthy run. The job's own timeout stays - # the bound; if it cancels the job, the uploads below stay pending, which is the gap the timeout would have closed. - - name: Run Darling PG tests - shell: pwsh - env: - DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" - DARLING_TEST_PGRUNTIME: ${{ github.workspace }}\Darling\artifacts\pg-runtime - # #1706: the pair that lights up the upgraded-in-place store-upgrade E2E — a real - # previous-major store (hypertable, TOAST-sized plan XML, continuous aggregate, - # compressed chunk) upgraded through the production bootstrap and compared by ordered - # row checksum before and after. This is the [#1705] CI gap: every other job only ever - # sees a FRESH store, so nothing else can catch an upgrade path that breaks. - DARLING_TEST_PGRUNTIME_OLD: ${{ github.workspace }}\Darling\artifacts\upgrade-fixture\old\pg-runtime - DARLING_TEST_PGRUNTIME_NEWZIP: ${{ github.workspace }}\Darling\artifacts\pg-runtime.zip - # #3906: the previous release's runtime on the bundle's OWN major, for the same-major E2E. That - # swap (no pg_upgrade, same data directory) is what nearly every field host takes on a release - # that moves PostgreSQL's minor, and nothing else runs it with real data, stamped or not. - DARLING_TEST_PGRUNTIME_PREVIOUS: ${{ github.workspace }}\Darling\artifacts\upgrade-fixture\previous\pg-runtime - run: | - New-Item -ItemType Directory -Force -Path TestResults | Out-Null - dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- -trx TestResults/darling-nightly.trx -longRunning 300 | Tee-Object -FilePath TestResults/darling-runner-nightly.log - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - # PR #5480: a TimescaleDB job worker died with 0xC0000005 in two CI runs and both runs read "success", - # because the postmaster restarts and the tests retry. The crash shows only in the PostgreSQL log, as - # "terminated by exception", so this step turns it into a warning on the run. It only warns: it never - # fails the job, and it runs when the tests failed too. - - name: Warn if a PostgreSQL worker crashed - if: always() - shell: pwsh - run: | - $log = "$env:RUNNER_TEMP\darling-pg.log" - if (Test-Path $log) { - $hits = @(Select-String -Path $log -Pattern 'terminated by exception' -SimpleMatch) - foreach ($hit in ($hits | Select-Object -First 5)) { - $line = $hit.Line - if ($line.Length -gt 300) { $line = $line.Substring(0, 300) } - Write-Host "::warning title=PostgreSQL worker terminated by exception::$line" - } - if ($hits.Count -gt 0) { Write-Host "$($hits.Count) 'terminated by exception' line(s) in darling-pg.log" } - } - exit 0 - - - name: Stop PostgreSQL - if: always() - shell: pwsh - run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - $dataDir = "$env:RUNNER_TEMP\darling-pgdata" - if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } - exit 0 - - # The gated upgrade-path tests stand up their OWN throwaway clusters under the runner's temp - # directory (Directory.CreateTempSubdirectory, prefixes like "darling-pg17boot-" and - # "darling-managedconf-"), separate from the fixed darling-pgdata cluster this job starts - # above. When one of those tests fails, its cluster's own server log (pg_ctl's -l file, - # named "logfile" inside each temp root, plus any "log\\" ring directory a managed-conf boot - # writes) is the only record of what postgres itself said, and it wasn't captured before. - # Those clusters land under %TEMP% (the Windows per-user temp directory), NOT - # ${{ runner.temp }} (RUNNER_TEMP) - the two are different directories on this runner - so - # this step copies them into RUNNER_TEMP first, preserving relative paths, before the - # upload step below (which can only reach RUNNER_TEMP). A test's own "finally" block may - # already have deleted its temp root by the time this runs, so this only catches clusters - # that survive; the test's own failure message (the in-place upgrade test appends the - # upgraded server's own log to its assertion) remains the primary record. - - name: Collect the gated tests' own cluster logs - if: failure() - shell: pwsh - run: | - $dest = "$env:RUNNER_TEMP\darling-cluster-logs" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - $maxBytes = 50MB - $copied = 0 - $roots = Get-ChildItem -Path $env:TEMP -Directory -Filter "darling-*" -ErrorAction SilentlyContinue - foreach ($root in $roots) { - $files = Get-ChildItem -Path $root.FullName -Recurse -File -ErrorAction SilentlyContinue | Where-Object { - $_.Name -eq "logfile" -or $_.Name -like "*.log" -or - $_.FullName -match "\\log\\" -or $_.FullName -match "\\pg_log\\" - } - foreach ($file in $files) { - if ($file.Length -gt $maxBytes) { continue } - $relative = $file.FullName.Substring($env:TEMP.Length).TrimStart("\") - $target = Join-Path $dest $relative - New-Item -ItemType Directory -Force -Path (Split-Path $target -Parent) | Out-Null - Copy-Item -Path $file.FullName -Destination $target -ErrorAction SilentlyContinue - $copied++ - } - } - Write-Host "Collected $copied gated-test cluster log file(s) into $dest" - - # #5587: the runner output, uploaded whatever the test step did (failed, cancelled or timed out). - - name: Upload Darling runner output - if: always() - continue-on-error: true - timeout-minutes: 5 - uses: actions/upload-artifact@v6 - with: - name: darling-runner-log-nightly - path: TestResults/darling-runner-*.log - retention-days: 7 - if-no-files-found: ignore - - - name: Upload PG log and test results on failure - if: failure() || cancelled() - uses: actions/upload-artifact@v6 - with: - name: darling-pg-failure - path: | - ${{ runner.temp }}/darling-pg.log - ${{ runner.temp }}/darling-cluster-logs/** - TestResults/ - if-no-files-found: ignore - overwrite: true - - # ── AltCover per-test coverage probe (#5459, change 4 slice 2) ─────────────────────────────────── - # Dispatch-only and off by default: the scheduled re-dispatch never sets `altcover_probe`, so the - # nightly never runs this. It answers one question for the class-to-file map the PR test selection - # would read: can AltCover (--callContext on [Fact]/[Theory]) give a per-test-class list of product - # source files for the Darling xUnit v3 project on .NET 10, at what overhead, and with what holes? - # It runs about 100 Darling classes (plain, TestHost/web, live PostgreSQL) and about 60 Lite classes - # (plain, DuckDB-backed, collector-starting) once plain and once instrumented, and uploads the raw - # OpenCover reports plus a summary and the schema-1 `classes` map for each suite. Nothing here - # publishes or gates, and no job lists it in `needs`. - # Dispatch on a branch with: - # gh workflow run nightly.yml --ref -f altcover_probe=true -f publish=false - altcover-probe: - name: AltCover per-test coverage probe - # Waits for build and darling-pg so it does not take their runners (#5459); always() keeps it running after a failed one. - needs: [check, build, darling-pg] - if: always() && needs.check.result == 'success' && github.event_name == 'workflow_dispatch' && inputs.altcover_probe == true - runs-on: windows-latest - timeout-minutes: 150 - env: - # Pinned: a probe whose tool floats cannot be compared run to run. - ALTCOVER_VERSION: '9.0.145' - PROBE_BIN: ${{ github.workspace }}\Darling\Darling.Tests\bin\Release\net10.0-windows - PROBE_BIN_LITE: ${{ github.workspace }}\Lite.Tests\bin\Release\net10.0-windows - # PROBE_OUT (the scratch folder under the runner's temp directory) is exported by the install step: - # the `runner` context is not available in a job-level env block. - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Cache Darling pg-runtime.zip - id: cache-pg-runtime - uses: actions/cache@v6 - with: - path: Darling/artifacts/pg-runtime.zip - key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} - - - name: Build Darling pg-runtime.zip (cache miss only) - if: steps.cache-pg-runtime.outputs.cache-hit != 'true' - shell: pwsh - run: ./Darling/tools/fetch-pg-runtime.ps1 - - - name: Extract pg-runtime - shell: pwsh - run: | - $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" - $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) - if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } - - - name: Restore Darling.Tests - run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - - - name: Build Darling.Tests - run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - - # Lite.Tests is built up front too, but probed after the Darling pass (its own bin folder, so the in-place - # instrumentation of the two suites never touches the same files). - - name: Restore Lite.Tests - run: dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - - - name: Build Lite.Tests - run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - # The same throwaway cluster the darling-pg job stands up (CiClusterWorkerSizingTests compares the - # appended settings of both jobs), so the live classes in the sample behave as they do there. - - name: Initialize and start throwaway PostgreSQL - shell: pwsh - run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - $dataDir = "$env:RUNNER_TEMP\darling-pgdata" - $logFile = "$env:RUNNER_TEMP\darling-pg.log" - & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 - if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } - Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb,pg_stat_statements'" - Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" - Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" - Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74" - Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85" - # #5549: the session id (%c) on every line. A PID alone cannot say whether two lines came from one - # session (a pooled connection reused by the next test) or from two (Windows reuses a PID quickly). - Add-Content -Path "$dataDir\postgresql.conf" -Value "log_line_prefix = '%m [%p] %c '" - & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start - if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } - & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling - if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } - - - name: Install AltCover (pinned) - shell: pwsh - run: | - dotnet tool install altcover.global --version $env:ALTCOVER_VERSION --tool-path "$env:RUNNER_TEMP\altcover-tool" - if ($LASTEXITCODE -ne 0) { throw "altcover install failed ($LASTEXITCODE)" } - $out = "$env:RUNNER_TEMP\altcover-probe" - New-Item -ItemType Directory -Force -Path $out | Out-Null - "PROBE_OUT=$out" | Add-Content -Path $env:GITHUB_ENV - - # About 100 classes, spread over plain unit classes, TestHost/web classes and live PostgreSQL - # classes, taken from the built assembly's own class listing (Stage=Guard classes read source text - # and never run product code, so they are left out). - - name: Choose the sample classes - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $lines = @(& "$env:PROBE_BIN\Darling.Tests.exe" -list classes/json -trait- Stage=Guard) - if ($LASTEXITCODE -ne 0) { throw "class listing failed with exit code $LASTEXITCODE" } - $json = @($lines | Where-Object { $_ -match '^\s*\[' }) - if ($json.Count -ne 1) { throw "expected one JSON array line from the class listing, got $($json.Count)" } - Set-Content -Path "$env:PROBE_OUT\all-classes.json" -Value $json[0] - python .github/scripts/altcover-probe.py select --list "$env:PROBE_OUT\all-classes.json" --tests-dir Darling/Darling.Tests --root . --total 100 --want web=25 --want live=12 --out "$env:PROBE_OUT\classes.txt" --manifest "$env:PROBE_OUT\manifest.json" - if ($LASTEXITCODE -ne 0) { throw "class selection failed ($LASTEXITCODE)" } - - # Baseline: the same classes, un-instrumented. Failures here are recorded, not fatal: this is a - # measurement, and a class that fails plain is a hole in the comparison, not in the tool. - - name: Run the sample classes (plain) - shell: pwsh - env: - DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" - run: | - $PSNativeCommandUseErrorActionPreference = $false - $classes = @(Get-Content "$env:PROBE_OUT\classes.txt" | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & "$env:PROBE_BIN\Darling.Tests.exe" @classArgs -xml "$env:PROBE_OUT\plain.xml" - $code = $LASTEXITCODE - $watch.Stop() - Write-Host "plain run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s for $($classes.Count) classes" - @{ plain_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); plain_exit = $code; classes = $classes.Count } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-plain.json" - exit 0 - - # Instrument IN PLACE (originals saved aside): the tests locate the repository by walking up from - # their own directory, so an instrumented copy outside the checkout would read nothing. Product, - # test and Lite assemblies only; every NuGet dependency stays un-instrumented. --methodpoint keeps - # one visit record per method, which is all a file-level map needs. - - name: Instrument the assemblies (AltCover, callContext on Fact and Theory) - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" --inplace --inputDirectory $env:PROBE_BIN --outputDirectory "$env:PROBE_OUT\saved" ` - --report "$env:PROBE_OUT\coverage.xml" --reportFormat OpenCover ` - --callContext "[Fact]" --callContext "[Theory]" --methodpoint ` - --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Tee-Object -FilePath "$env:PROBE_OUT\altcover-instrument.log" | Select-Object -Last 15 - $code = $LASTEXITCODE - $watch.Stop() - Write-Host "instrument: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" - @{ instrument_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrument_exit = $code } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-instrument.json" - if ($code -ne 0) { throw "altcover instrumentation failed ($code)" } - - - name: Run the sample classes (instrumented, AltCover runner) - shell: pwsh - env: - DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" - run: | - $PSNativeCommandUseErrorActionPreference = $false - $classes = @(Get-Content "$env:PROBE_OUT\classes.txt" | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" Runner --recorderDirectory $env:PROBE_BIN --workingDirectory $env:PROBE_BIN --executable "$env:PROBE_BIN\Darling.Tests.exe" -- @classArgs -xml "$env:PROBE_OUT\instrumented.xml" 2>&1 | Tee-Object -FilePath "$env:PROBE_OUT\altcover-run.log" | Select-Object -Last 25 - $code = $LASTEXITCODE - $watch.Stop() - Write-Host "instrumented run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" - @{ instrumented_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrumented_exit = $code } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-instrumented.json" - exit 0 - - # Overhead comparison only: put the original assemblies back, instrument again with AltCover's default - # (sequence-point and branch) recording, run the same classes, and keep only the timings and the report - # size. The method-level pass above is what the summary and the map read. - - name: Overhead comparison (default line and branch recording) - continue-on-error: true - shell: pwsh - env: - DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" - run: | - $PSNativeCommandUseErrorActionPreference = $false - Copy-Item -Path "$env:PROBE_OUT\saved\*" -Destination $env:PROBE_BIN -Recurse -Force - $altcover = "$env:RUNNER_TEMP\altcover-tool\altcover.exe" - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & $altcover --inplace --inputDirectory $env:PROBE_BIN --outputDirectory "$env:PROBE_OUT\saved-line" ` - --report "$env:PROBE_OUT\coverage-line.xml" --reportFormat OpenCover ` - --callContext "[Fact]" --callContext "[Theory]" ` - --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Out-Null - $instrument = $LASTEXITCODE - $instrumentSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) - if ($instrument -ne 0) { Write-Host "line instrumentation failed ($instrument)"; exit 0 } - $classes = @(Get-Content "$env:PROBE_OUT\classes.txt" | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - $watch.Restart() - & $altcover Runner --recorderDirectory $env:PROBE_BIN --workingDirectory $env:PROBE_BIN --executable "$env:PROBE_BIN\Darling.Tests.exe" -- @classArgs -xml "$env:PROBE_OUT\instrumented-line.xml" 2>&1 | Out-Null - $code = $LASTEXITCODE - $runSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) - $bytes = if (Test-Path "$env:PROBE_OUT\coverage-line.xml") { (Get-Item "$env:PROBE_OUT\coverage-line.xml").Length } else { 0 } - Write-Host "line and branch recording: instrument $instrumentSeconds s, run $runSeconds s (exit $code), report $bytes bytes" - @{ line_instrument_seconds = $instrumentSeconds; line_run_seconds = $runSeconds; line_run_exit = $code; line_report_bytes = $bytes } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-line.json" - Remove-Item "$env:PROBE_OUT\coverage-line.xml" -Force -ErrorAction SilentlyContinue - exit 0 - - - name: Summarize the coverage (map for the sampled classes) - if: always() - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $timing = @{} - foreach ($name in 'plain', 'instrument', 'instrumented', 'line') { - $file = "$env:PROBE_OUT\timing-$name.json" - if (Test-Path $file) { (Get-Content $file -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $timing[$_.Name] = $_.Value } } - } - $timing | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing.json" - # AltCover makes an instrumented async test wait on its own task, so a dynamic skip arrives wrapped in an - # AggregateException and xunit reports a failure. Put those back to skips, but only where the plain run - # skipped the same test. - if ((Test-Path "$env:PROBE_OUT\plain.xml") -and (Test-Path "$env:PROBE_OUT\instrumented.xml")) { - python .github/scripts/altcover-probe.py unwrap-skips --plain "$env:PROBE_OUT\plain.xml" --instr "$env:PROBE_OUT\instrumented.xml" | Select-Object -First 30 - } - $report = "$env:PROBE_OUT\coverage.xml" - if (-not (Test-Path $report)) { Write-Host "::warning::no coverage report was produced"; exit 0 } - $item = Get-Item $report - Write-Host "coverage.xml: $($item.Length) bytes" - python .github/scripts/altcover-probe.py summarize --report $report --root $env:GITHUB_WORKSPACE --manifest "$env:PROBE_OUT\manifest.json" --plain-xml "$env:PROBE_OUT\plain.xml" --instr-xml "$env:PROBE_OUT\instrumented.xml" --timing "$env:PROBE_OUT\timing.json" --tool "altcover $env:ALTCOVER_VERSION" --summary-out "$env:PROBE_OUT\summary.json" --map-out "$env:PROBE_OUT\test-map-probe.json" | Select-Object -First 80 - if (Test-Path "$env:PROBE_OUT\summary.json") { - "### AltCover probe" | Add-Content $env:GITHUB_STEP_SUMMARY - '```json' | Add-Content $env:GITHUB_STEP_SUMMARY - Get-Content "$env:PROBE_OUT\summary.json" -TotalCount 120 | Add-Content $env:GITHUB_STEP_SUMMARY - '```' | Add-Content $env:GITHUB_STEP_SUMMARY - } - exit 0 - - # ── Lite.Tests: the same measurement, in its own scratch folder ────────────────────────────────── - # About 60 classes: collector-starting (they mention RemoteCollectorService), DuckDB-backed, and plain. - - name: Choose the Lite sample classes - if: always() - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $out = "$env:PROBE_OUT\lite" - New-Item -ItemType Directory -Force -Path $out | Out-Null - $lines = @(& "$env:PROBE_BIN_LITE\Lite.Tests.exe" -list classes/json -trait- Stage=Guard) - if ($LASTEXITCODE -ne 0) { throw "Lite class listing failed with exit code $LASTEXITCODE" } - $json = @($lines | Where-Object { $_ -match '^\s*\[' }) - if ($json.Count -ne 1) { throw "expected one JSON array line from the Lite class listing, got $($json.Count)" } - Set-Content -Path "$out\all-classes.json" -Value $json[0] - python .github/scripts/altcover-probe.py select --profile lite --list "$out\all-classes.json" --tests-dir Lite.Tests --root . --total 60 --want collector=8 --want duckdb=20 --out "$out\classes.txt" --manifest "$out\manifest.json" - if ($LASTEXITCODE -ne 0) { throw "Lite class selection failed ($LASTEXITCODE)" } - - - name: Run the Lite sample classes (plain) - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $out = "$env:PROBE_OUT\lite" - $classes = @(Get-Content "$out\classes.txt" | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & "$env:PROBE_BIN_LITE\Lite.Tests.exe" @classArgs -xml "$out\plain.xml" - $code = $LASTEXITCODE - $watch.Stop() - Write-Host "Lite plain run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s for $($classes.Count) classes" - @{ plain_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); plain_exit = $code; classes = $classes.Count } | ConvertTo-Json | Set-Content "$out\timing-plain.json" - exit 0 - - - name: Instrument the Lite assemblies (AltCover, callContext on Fact and Theory) - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $out = "$env:PROBE_OUT\lite" - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" --inplace --inputDirectory $env:PROBE_BIN_LITE --outputDirectory "$out\saved" ` - --report "$out\coverage.xml" --reportFormat OpenCover ` - --callContext "[Fact]" --callContext "[Theory]" --methodpoint ` - --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Tee-Object -FilePath "$out\altcover-instrument.log" | Select-Object -Last 15 - $code = $LASTEXITCODE - $watch.Stop() - Write-Host "Lite instrument: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" - @{ instrument_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrument_exit = $code } | ConvertTo-Json | Set-Content "$out\timing-instrument.json" - if ($code -ne 0) { throw "Lite altcover instrumentation failed ($code)" } - - - name: Run the Lite sample classes (instrumented, AltCover runner) - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $out = "$env:PROBE_OUT\lite" - $classes = @(Get-Content "$out\classes.txt" | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" Runner --recorderDirectory $env:PROBE_BIN_LITE --workingDirectory $env:PROBE_BIN_LITE --executable "$env:PROBE_BIN_LITE\Lite.Tests.exe" -- @classArgs -xml "$out\instrumented.xml" 2>&1 | Tee-Object -FilePath "$out\altcover-run.log" | Select-Object -Last 25 - $code = $LASTEXITCODE - $watch.Stop() - Write-Host "Lite instrumented run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" - @{ instrumented_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrumented_exit = $code } | ConvertTo-Json | Set-Content "$out\timing-instrumented.json" - exit 0 - - - name: Lite overhead comparison (default line and branch recording) - continue-on-error: true - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $out = "$env:PROBE_OUT\lite" - Copy-Item -Path "$out\saved\*" -Destination $env:PROBE_BIN_LITE -Recurse -Force - $altcover = "$env:RUNNER_TEMP\altcover-tool\altcover.exe" - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & $altcover --inplace --inputDirectory $env:PROBE_BIN_LITE --outputDirectory "$out\saved-line" ` - --report "$out\coverage-line.xml" --reportFormat OpenCover ` - --callContext "[Fact]" --callContext "[Theory]" ` - --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Out-Null - $instrument = $LASTEXITCODE - $instrumentSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) - if ($instrument -ne 0) { Write-Host "Lite line instrumentation failed ($instrument)"; exit 0 } - $classes = @(Get-Content "$out\classes.txt" | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - $watch.Restart() - & $altcover Runner --recorderDirectory $env:PROBE_BIN_LITE --workingDirectory $env:PROBE_BIN_LITE --executable "$env:PROBE_BIN_LITE\Lite.Tests.exe" -- @classArgs -xml "$out\instrumented-line.xml" 2>&1 | Out-Null - $code = $LASTEXITCODE - $runSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) - $bytes = if (Test-Path "$out\coverage-line.xml") { (Get-Item "$out\coverage-line.xml").Length } else { 0 } - Write-Host "Lite line and branch recording: instrument $instrumentSeconds s, run $runSeconds s (exit $code), report $bytes bytes" - @{ line_instrument_seconds = $instrumentSeconds; line_run_seconds = $runSeconds; line_run_exit = $code; line_report_bytes = $bytes } | ConvertTo-Json | Set-Content "$out\timing-line.json" - Remove-Item "$out\coverage-line.xml" -Force -ErrorAction SilentlyContinue - exit 0 - - - name: Summarize the Lite coverage - if: always() - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - $out = "$env:PROBE_OUT\lite" - $timing = @{} - foreach ($name in 'plain', 'instrument', 'instrumented', 'line') { - $file = "$out\timing-$name.json" - if (Test-Path $file) { (Get-Content $file -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $timing[$_.Name] = $_.Value } } - } - $timing | ConvertTo-Json | Set-Content "$out\timing.json" - if ((Test-Path "$out\plain.xml") -and (Test-Path "$out\instrumented.xml")) { - python .github/scripts/altcover-probe.py unwrap-skips --plain "$out\plain.xml" --instr "$out\instrumented.xml" | Select-Object -First 30 - } - $report = "$out\coverage.xml" - if (-not (Test-Path $report)) { Write-Host "::warning::no Lite coverage report was produced"; exit 0 } - Write-Host "Lite coverage.xml: $((Get-Item $report).Length) bytes" - python .github/scripts/altcover-probe.py summarize --suite Lite.Tests --report $report --root $env:GITHUB_WORKSPACE --manifest "$out\manifest.json" --plain-xml "$out\plain.xml" --instr-xml "$out\instrumented.xml" --timing "$out\timing.json" --tool "altcover $env:ALTCOVER_VERSION" --summary-out "$out\summary.json" --map-out "$out\test-map-probe.json" | Select-Object -First 80 - if (Test-Path "$out\summary.json") { - "### AltCover probe (Lite.Tests)" | Add-Content $env:GITHUB_STEP_SUMMARY - '```json' | Add-Content $env:GITHUB_STEP_SUMMARY - Get-Content "$out\summary.json" -TotalCount 120 | Add-Content $env:GITHUB_STEP_SUMMARY - '```' | Add-Content $env:GITHUB_STEP_SUMMARY - } - exit 0 - - - name: Stop PostgreSQL - if: always() - shell: pwsh - run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - $dataDir = "$env:RUNNER_TEMP\darling-pgdata" - if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } - exit 0 - - - name: Upload the raw coverage and the summary - if: always() - uses: actions/upload-artifact@v6 - with: - name: altcover-probe - path: | - ${{ runner.temp }}/altcover-probe/coverage.xml - ${{ runner.temp }}/altcover-probe/summary.json - ${{ runner.temp }}/altcover-probe/test-map-probe.json - ${{ runner.temp }}/altcover-probe/manifest.json - ${{ runner.temp }}/altcover-probe/classes.txt - ${{ runner.temp }}/altcover-probe/timing.json - ${{ runner.temp }}/altcover-probe/plain.xml - ${{ runner.temp }}/altcover-probe/instrumented.xml - ${{ runner.temp }}/altcover-probe/instrumented.raw.xml - ${{ runner.temp }}/altcover-probe/altcover-*.log - ${{ runner.temp }}/altcover-probe/lite/coverage.xml - ${{ runner.temp }}/altcover-probe/lite/summary.json - ${{ runner.temp }}/altcover-probe/lite/test-map-probe.json - ${{ runner.temp }}/altcover-probe/lite/manifest.json - ${{ runner.temp }}/altcover-probe/lite/classes.txt - ${{ runner.temp }}/altcover-probe/lite/timing.json - ${{ runner.temp }}/altcover-probe/lite/plain.xml - ${{ runner.temp }}/altcover-probe/lite/instrumented.xml - ${{ runner.temp }}/altcover-probe/lite/instrumented.raw.xml - ${{ runner.temp }}/altcover-probe/lite/altcover-*.log - retention-days: 7 - if-no-files-found: ignore - overwrite: true - - # ── Test map: which test classes run which source files (#5459, change 4 slice 3) ──────────────── - # The producer for the class-to-file map that ci-select.py's map_select() reads. It runs the WHOLE - # Darling.Tests suite (6 shards, with a live PostgreSQL store and the runtime variable) and the WHOLE - # Lite.Tests suite (4 shards) under AltCover (--callContext on [Fact]/[Theory], the version the probe - # job pins), cut by class-name hash exactly as build.yml's Darling PostgreSQL shards are, then joins the - # shards in `test-map` with what the test sources say (path and tool-name literals, the type-name rule, - # whole-tree readers) and uploads `test-map.json.gz` for 14 days. - # It changes no other job's result: nothing lists it in `needs`, both jobs are continue-on-error, and a - # failed test inside a shard only costs that class its coverage. It runs in every re-dispatch that builds - # (the same gate as darling-pg) and in manual dispatches; `-f test_map=false` turns it off for one dispatch. - # Nothing reads the map yet (slice 4 does, in shadow mode). On a branch: - # gh workflow run nightly.yml --ref -f publish=false - test-map-shard: - name: Test map shard (${{ matrix.suite }} ${{ matrix.shard }}) - # Waits for build and darling-pg so the 10 windows legs do not take their runners (#5459); always() keeps the map - # running after a failed one. - needs: [check, build, darling-pg] - if: always() && needs.check.result == 'success' && (needs.check.outputs.has_changes == 'true' || inputs.from_schedule != true) && inputs.test_map != false - continue-on-error: true - runs-on: windows-latest - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - - { suite: darling, shard: 0, shards: 6 } - - { suite: darling, shard: 1, shards: 6 } - - { suite: darling, shard: 2, shards: 6 } - - { suite: darling, shard: 3, shards: 6 } - - { suite: darling, shard: 4, shards: 6 } - - { suite: darling, shard: 5, shards: 6 } - - { suite: lite, shard: 0, shards: 4 } - - { suite: lite, shard: 1, shards: 4 } - - { suite: lite, shard: 2, shards: 4 } - - { suite: lite, shard: 3, shards: 4 } - env: - # The version the probe job pins: a map whose tool floats cannot be compared run to run. - ALTCOVER_VERSION: '9.0.145' - MAP_BIN: ${{ github.workspace }}\${{ matrix.suite == 'darling' && 'Darling\Darling.Tests' || 'Lite.Tests' }}\bin\Release\net10.0-windows - MAP_EXE: ${{ matrix.suite == 'darling' && 'Darling.Tests.exe' || 'Lite.Tests.exe' }} - # MAP_OUT (the scratch folder under the runner's temp directory) is exported by the install step: - # the `runner` context is not available in a job-level env block. - steps: - # The dispatched commit, not the ref name: every shard and the join job must read the same tree even - # if dev moves while the run is queued. - - uses: actions/checkout@v7 - with: - ref: ${{ github.sha }} - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Cache Darling pg-runtime.zip - if: matrix.suite == 'darling' - id: cache-pg-runtime - uses: actions/cache@v6 - with: - path: Darling/artifacts/pg-runtime.zip - key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} - - - name: Build Darling pg-runtime.zip (cache miss only) - if: matrix.suite == 'darling' && steps.cache-pg-runtime.outputs.cache-hit != 'true' - shell: pwsh - run: ./Darling/tools/fetch-pg-runtime.ps1 - - - name: Extract pg-runtime - if: matrix.suite == 'darling' - shell: pwsh - run: | - $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" - $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - Add-Type -AssemblyName System.IO.Compression.FileSystem - [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) - if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } - - - name: Restore Darling.Tests - if: matrix.suite == 'darling' - run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode - - - name: Build Darling.Tests - if: matrix.suite == 'darling' - run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore - - - name: Restore Lite.Tests - if: matrix.suite == 'lite' - run: dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode - - - name: Build Lite.Tests - if: matrix.suite == 'lite' - run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - - # The same throwaway cluster the darling-pg job stands up (CiClusterWorkerSizingTests compares the - # appended settings of every job), so the live classes behave as they do there. - - name: Initialize and start throwaway PostgreSQL - if: matrix.suite == 'darling' - shell: pwsh - run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - $dataDir = "$env:RUNNER_TEMP\darling-pgdata" - $logFile = "$env:RUNNER_TEMP\darling-pg.log" - & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 - if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } - Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb,pg_stat_statements'" - Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" - Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" - Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74" - Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85" - # #5549: the session id (%c) on every line. A PID alone cannot say whether two lines came from one - # session (a pooled connection reused by the next test) or from two (Windows reuses a PID quickly). - Add-Content -Path "$dataDir\postgresql.conf" -Value "log_line_prefix = '%m [%p] %c '" - & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start - if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } - & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling - if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } - - - name: Install AltCover (pinned) - shell: pwsh - run: | - dotnet tool install altcover.global --version $env:ALTCOVER_VERSION --tool-path "$env:RUNNER_TEMP\altcover-tool" - if ($LASTEXITCODE -ne 0) { throw "altcover install failed ($LASTEXITCODE)" } - $out = "$env:RUNNER_TEMP\test-map" - New-Item -ItemType Directory -Force -Path $out | Out-Null - "MAP_OUT=$out" | Add-Content -Path $env:GITHUB_ENV - - # This shard's classes, cut by the hash build.yml's Darling PostgreSQL shards use, into chunks that - # stay under the Windows command-line cap (#5100). Every class is listed, Stage=Guard ones included: a - # class the map never saw would run as a "new class" on every pull request. - - name: Cut this shard's classes - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $lines = @(& "$env:MAP_BIN\$env:MAP_EXE" -list classes/json) - if ($LASTEXITCODE -ne 0) { throw "class listing failed with exit code $LASTEXITCODE" } - $json = @($lines | Where-Object { $_ -match '^\s*\[' }) - if ($json.Count -ne 1) { throw "expected one JSON array line from the class listing, got $($json.Count)" } - Set-Content -Path "$env:MAP_OUT\all-classes.json" -Value $json[0] - python .github/scripts/test-map.py cut --list "$env:MAP_OUT\all-classes.json" --shards ${{ matrix.shards }} --shard ${{ matrix.shard }} --out "$env:MAP_OUT\cut" - if ($LASTEXITCODE -ne 0) { throw "the cut failed ($LASTEXITCODE)" } - - # Instrument IN PLACE: the tests locate the repository by walking up from their own directory. Product, - # test and Lite assemblies only; every NuGet dependency stays un-instrumented. --methodpoint keeps one - # visit record per method, which is all a file-level map needs (the probe measured it at 0.93x to 1.15x). - - name: Instrument the assemblies (AltCover, callContext on Fact and Theory) - shell: pwsh - run: | - $PSNativeCommandUseErrorActionPreference = $false - & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" --inplace --inputDirectory $env:MAP_BIN --outputDirectory "$env:MAP_OUT\saved" ` - --report "$env:MAP_OUT\coverage.xml" --reportFormat OpenCover ` - --callContext "[Fact]" --callContext "[Theory]" --methodpoint ` - --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Tee-Object -FilePath "$env:MAP_OUT\altcover-instrument.log" | Select-Object -Last 15 - if ($LASTEXITCODE -ne 0) { throw "altcover instrumentation failed ($LASTEXITCODE)" } - - # One runner invocation per chunk. A failing test is a fact about the tests, not about the map: the - # step never fails on a test exit code, and each chunk's report is kept (a later chunk's report may - # already hold the earlier visits; the join takes the union). DARLING_TEST_PGRUNTIME lights up the - # classes gated on the bundled runtime; the store-upgrade fixtures (~680 MB of archives) are not built - # here, so those classes skip and get text patterns instead of coverage. - - name: Run this shard's classes (instrumented, AltCover runner) - shell: pwsh - env: - DARLING_TEST_PG: ${{ matrix.suite == 'darling' && 'Host=127.0.0.1;Port=5541;Username=darling;Database=darling' || '' }} - DARLING_TEST_PGRUNTIME: ${{ matrix.suite == 'darling' && format('{0}\Darling\artifacts\pg-runtime', github.workspace) || '' }} - run: | - $PSNativeCommandUseErrorActionPreference = $false - $watch = [System.Diagnostics.Stopwatch]::StartNew() - $worst = 0 - $chunks = @(Get-ChildItem "$env:MAP_OUT\cut" -Filter 'chunk-*.txt' | Sort-Object Name) - $number = 0 - foreach ($chunk in $chunks) { - $number++ - $classes = @(Get-Content $chunk.FullName | Where-Object { $_ }) - $classArgs = foreach ($class in $classes) { '-class'; $class } - & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" Runner --recorderDirectory $env:MAP_BIN --workingDirectory $env:MAP_BIN --executable "$env:MAP_BIN\$env:MAP_EXE" -- @classArgs -xml "$env:MAP_OUT\chunk-$number.xml" 2>&1 | Tee-Object -FilePath "$env:MAP_OUT\altcover-run-$number.log" | Select-Object -Last 8 - $code = $LASTEXITCODE - if ($code -gt $worst) { $worst = $code } - Copy-Item "$env:MAP_OUT\coverage.xml" "$env:MAP_OUT\coverage-$number.xml" - Write-Host "chunk ${number}: $($classes.Count) classes, exit $code" - } - $watch.Stop() - Write-Host "instrumented run: $($chunks.Count) chunk(s) in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s, worst exit $worst" - "$worst" | Set-Content "$env:MAP_OUT\tests-exit.txt" - @{ run_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); chunks = $chunks.Count; worst_exit = $worst } | ConvertTo-Json | Set-Content "$env:MAP_OUT\timing.json" - exit 0 - - # AltCover makes an instrumented async test wait on its own task, so a dynamic skip arrives wrapped in an - # AggregateException and xunit reports a failure (see altcover-probe.py). There is no plain run to - # compare with here, so the skip token alone puts the skip back. - - name: Put wrapped skips back - shell: pwsh - run: | - foreach ($xml in @(Get-ChildItem "$env:MAP_OUT" -Filter 'chunk-*.xml')) { - python .github/scripts/altcover-probe.py unwrap-skips --instr $xml.FullName | Select-Object -First 3 - } - exit 0 - - - name: Write this shard's report - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $exit = if (Test-Path "$env:MAP_OUT\tests-exit.txt") { [int](Get-Content "$env:MAP_OUT\tests-exit.txt" -TotalCount 1) } else { -1 } - $reports = @(Get-ChildItem "$env:MAP_OUT" -Filter 'coverage-*.xml' | ForEach-Object { '--report'; $_.FullName }) - $xmls = @(Get-ChildItem "$env:MAP_OUT" -Filter 'chunk-*.xml' | ForEach-Object { '--xml'; $_.FullName }) - if ($reports.Count -eq 0) { throw 'no coverage report was produced' } - python .github/scripts/test-map.py shard-report --suite ${{ matrix.suite }} --shard ${{ matrix.shard }} --root $env:GITHUB_WORKSPACE --listed "$env:MAP_OUT\cut\listed.txt" @reports @xmls --tests-exit $exit --out "$env:MAP_OUT\shard-${{ matrix.suite }}-${{ matrix.shard }}.json" - if ($LASTEXITCODE -ne 0) { throw "shard-report failed ($LASTEXITCODE)" } - - - name: Stop PostgreSQL - if: always() && matrix.suite == 'darling' - shell: pwsh - run: | - $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" - $dataDir = "$env:RUNNER_TEMP\darling-pgdata" - if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } - exit 0 - - - name: Upload this shard's report - uses: actions/upload-artifact@v6 - with: - name: test-map-shard-${{ matrix.suite }}-${{ matrix.shard }} - path: ${{ runner.temp }}/test-map/shard-${{ matrix.suite }}-${{ matrix.shard }}.json - retention-days: 3 - if-no-files-found: error - - # Joins the shards into the map. Linux: it only reads JSON and the checkout (git ls-files, the test - # sources). It refuses to write a map when a shard is missing, because a map without a shard's classes - # would run those classes as "new" on every pull request and hide what they cover. - test-map: - name: Test map (join the shards) - needs: test-map-shard - if: always() && needs.test-map-shard.result != 'skipped' && needs.test-map-shard.result != 'cancelled' - continue-on-error: true - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.sha }} - - - name: Download the shard reports - uses: actions/download-artifact@v6 - with: - pattern: test-map-shard-* - path: shards - - - name: Build test-map.json.gz - run: | - python .github/scripts/test-map.py build --root . --shards shards --sha "${{ github.sha }}" \ - --expect darling=6 --expect lite=4 --out test-map.json.gz --summary test-map-summary.md - cat test-map-summary.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload the map - uses: actions/upload-artifact@v6 - with: - name: test-map - path: | - test-map.json.gz - test-map-summary.md - retention-days: 14 - if-no-files-found: error - - # ── Linux artifact + container image (#1804) ───────────────────────────────────────────────────── - # Runs in parallel with the windows build job — it shares only the check job's nightly stamp, - # not any windows artifact. Publishes the linux-x64 service tar.gz with its own checksum file - # (the windows job owns SHA256SUMS.txt; a cross-job rewrite of one file is a race), and builds - # the service container image, saving it to a tar for the publish job below to push — this job - # talks to neither the release nor ghcr. The bundled pg-runtime is deliberately absent from the - # linux artifact — the compose distribution pairs the service with the official - # timescale/timescaledb image, and managed mode stays Windows. - linux: - # Only check: this job reads no output of build's beyond the shared stamp (#3390), and - # nothing here consumes a windows artifact, so it runs in parallel with build rather than - # waiting on it. - needs: check - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - cache: true - cache-dependency-path: '**/packages.lock.json' - - - name: Set nightly version - id: version - shell: bash - env: - NIGHTLY_STAMP: ${{ needs.check.outputs.stamp }} - run: | - set -euo pipefail - base=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) - [ -n "${base}" ] || { echo "::error::Read no version from Directory.Build.props."; exit 1; } - [ -n "${NIGHTLY_STAMP}" ] || { echo "::error::Read no stamp from the check job."; exit 1; } - echo "VERSION=${base}-nightly.${NIGHTLY_STAMP}" >> "$GITHUB_OUTPUT" - echo "Nightly version: ${base}-nightly.${NIGHTLY_STAMP}" - - # -p:Version for the reason the windows build job's version step documents (#3455): the name - # on the tarball is not the stamp in the service, and the informational version is what - # --version prints and what the get_collection_health service block serves. - - name: Publish service (linux-x64) - run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux -p:Version=${{ steps.version.outputs.VERSION }} - - - name: Package linux artifact + checksum - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - mkdir -p releases - tar -C publish/DarlingService-linux -czf "releases/PerformanceMonitorDarling-linux-x64-${version}.tar.gz" . - (cd releases && sha256sum "PerformanceMonitorDarling-linux-x64-${version}.tar.gz" > SHA256SUMS-linux.txt && cat SHA256SUMS-linux.txt) - echo "${version}" > releases/linux-version.txt - - # The container image build needs THIS checkout (docker build's context is the repo root, - # #1804), so it stays here rather than moving to publish, which has no source tree. Only the - # PUSH, the signing and the release upload are release-publishing steps, and those move to - # publish below: this job saves the built image to a tar (docker save) and hands it, the - # linux tarball and its checksum file to publish as workflow artifacts. Nothing here talks to - # ghcr or the nightly release. - - name: Build container image (ghcr tags, not pushed) - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" - # VERSION reaches the publish that runs INSIDE the image build (#3455): an image tagged - # with the nightly string whose service answers the clean release string is the zip - # defect again, one artifact over. The Dockerfile defaults the arg to empty, so - # build.yml's release image and any hand-run docker build keep publishing against the - # clean declared version. - docker build -f Darling/Dockerfile --build-arg VERSION="${version}" -t "${image}:nightly" -t "${image}:${version}" . - mkdir -p releases - docker save "${image}:nightly" "${image}:${version}" -o releases/darling-container-image.tar - - - name: Upload linux release assets - uses: actions/upload-artifact@v6 - with: - name: linux-release-assets - path: | - releases/PerformanceMonitorDarling-linux-x64-*.tar.gz - releases/SHA256SUMS-linux.txt - releases/linux-version.txt - if-no-files-found: error - retention-days: 3 - - - name: Upload container image - uses: actions/upload-artifact@v6 - with: - name: darling-container-image - path: releases/darling-container-image.tar - if-no-files-found: error - retention-days: 3 - - # ── Publish (#4336 follow-up) ───────────────────────────────────────────────────────────────── - # The one job that talks to the outside world: the GitHub release, the ghcr push, the signature, - # the provenance attestation. It downloads exactly what build and linux built above — nothing - # here rebuilds or republishes — and it needs all three of build, linux and darling-pg to have - # SUCCEEDED, so a red Darling PostgreSQL run can no longer ship a release built on top of it. The - # workflow_dispatch `publish` input is a second, independent gate for a diagnostic re-run that - # wants the build and test coverage without touching the release. - publish: - needs: [build, linux, darling-pg] - if: | - needs.build.result == 'success' && - needs.linux.result == 'success' && - needs.darling-pg.result == 'success' && - (github.event_name != 'workflow_dispatch' || format('{0}', inputs.publish) != 'false') - # A scheduled re-dispatch and a manual dispatch both land the same tag (`nightly`); two - # close dispatches otherwise race on this job's delete-then-create of the release, so only - # one publish runs at a time and a queued second one waits rather than colliding. - concurrency: - group: nightly-publish - cancel-in-progress: false - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: write - # #1804: pushes the nightly container image to ghcr. - packages: write - # Sigstore keyless signing + GitHub provenance for the linux artifact: id-token lets this job - # obtain its OIDC identity (Fulcio issues the short-lived signing cert against it), - # attestations lets attest-build-provenance store the tarball's provenance. - id-token: write - attestations: write - - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} - - - name: Download windows release assets - uses: actions/download-artifact@v6 - with: - name: windows-release-assets - path: releases - - - name: Download the nightly version string - uses: actions/download-artifact@v6 - with: - name: windows-version - path: . - - - name: Download linux release assets - uses: actions/download-artifact@v6 - with: - name: linux-release-assets - path: releases - - - name: Download container image - uses: actions/download-artifact@v6 - with: - name: darling-container-image - path: releases - - - name: Read the nightly version string - id: version - shell: bash - run: | - set -euo pipefail - version="$(cat windows-version.txt)" - [ -n "${version}" ] || { echo "::error::windows-version.txt was empty."; exit 1; } - echo "VERSION=${version}" >> "$GITHUB_OUTPUT" - echo "Nightly version: ${version}" - - # The full SHA build actually checked out (written by the build job's own `git rev-parse - # HEAD`), so the release below names the tree that was built rather than dev's tip at - # publish time — 20-60 minutes later, once darling-pg finishes. - - name: Read the built commit SHA - id: commit - shell: bash - run: | - set -euo pipefail - sha="$(cat windows-commit-sha.txt)" - [ -n "${sha}" ] || { echo "::error::windows-commit-sha.txt was empty."; exit 1; } - echo "SHA=${sha}" >> "$GITHUB_OUTPUT" - echo "Built commit: ${sha}" - - # publish takes its release version from the windows handoff but ships linux's tag and - # container image too; a mismatch would mean the two build jobs disagreed about which - # nightly stamp they were building, and shipping under one label anyway is the wrong call. - - name: Verify windows and linux versions match - shell: bash - run: | - set -euo pipefail - windows_version="${{ steps.version.outputs.VERSION }}" - linux_version="$(cat releases/linux-version.txt)" - if [ "${windows_version}" != "${linux_version}" ]; then - echo "::error::Windows and Linux nightly versions do not match. windows=${windows_version} linux=${linux_version}" - exit 1 - fi - echo "Windows and Linux versions match: ${windows_version}" - - - name: Delete previous nightly release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release delete nightly --yes --cleanup-tag || true - - - name: Create nightly release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - sha="${{ steps.commit.outputs.SHA }}" - short_sha="${sha:0:7}" - echo "Naming the release after the built commit: ${sha}" - body=$(cat < These builds include the latest changes and may be unstable. - > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest). - EOF - ) - - gh release create nightly \ - --target "${sha}" \ - --title "Nightly Build (${version})" \ - --notes "${body}" \ - --prerelease \ - releases/*.zip releases/*.exe releases/SHA256SUMS.txt - - - name: Upload linux artifact to the nightly release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload nightly releases/PerformanceMonitorDarling-linux-x64-*.tar.gz releases/SHA256SUMS-linux.txt --clobber - - - name: Push container image (ghcr, :nightly) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - version="${{ steps.version.outputs.VERSION }}" - image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" - echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - docker load -i releases/darling-container-image.tar - docker push "${image}:nightly" - docker push "${image}:${version}" - - # Keyless Sigstore signing: Fulcio issues a short-lived certificate against this job's OIDC - # identity and the signature lands in ghcr next to the image, logged in Rekor. No keys exist - # anywhere to manage or leak — the signature attests "built by this repository's workflow", - # which is the claim a container consumer actually wants verified. Verify: - # cosign verify ghcr.io/erikdarlingdata/performancemonitor-darling:nightly \ - # --certificate-identity-regexp 'github.com/erikdarlingdata/PerformanceMonitor' \ - # --certificate-oidc-issuer https://token.actions.githubusercontent.com - - name: Install cosign - uses: sigstore/cosign-installer@v3 - - - name: Sign container image (keyless, Sigstore) - shell: bash - run: | - set -euo pipefail - image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" - digest="$(docker inspect --format='{{index .RepoDigests 0}}' "${image}:nightly")" - cosign sign --yes "${digest}" - - # GitHub-native provenance for the tarball (SLSA): verified with - # gh attestation verify PerformanceMonitorDarling-linux-x64-.tar.gz -R erikdarlingdata/PerformanceMonitor - - name: Attest the linux tarball (GitHub provenance) - uses: actions/attest-build-provenance@v4 - with: - subject-path: releases/PerformanceMonitorDarling-linux-x64-*.tar.gz diff --git a/.github/workflows/probe-vpk-signing.yml b/.github/workflows/probe-vpk-signing.yml deleted file mode 100644 index 263abb0f35..0000000000 --- a/.github/workflows/probe-vpk-signing.yml +++ /dev/null @@ -1,141 +0,0 @@ -name: Probe vpk signing invocations - -# #3288 groundwork. Manual only, publishes nothing, touches no release, uploads nothing to -# SignPath. -# -# THE QUESTION BELOW IS SETTLED, AND NOT IN FAVOUR OF THE DESIGN IT MEASURES. `vpk pack -# --signTemplate` cannot sign anything on this SignPath account: the open-source signing policy -# requires origin verification through a trusted build system, so a request is accepted only -# through `signpath/github-action-submit-signing-request`, and that is a workflow step which -# cannot be invoked from inside a running `vpk pack`. A real 3.7.1 release build submitted two -# batches by raw API token and got 403 "the selected signing policy requires that the signing -# request is submitted via a trusted build system" on both. Signing therefore happens after -# packing -- see `Sign the executables Velopack generates` in build.yml and -# .github/scripts/postpack_signing.py. -# -# What this probe still measures honestly is `vpk pack`'s own behaviour: which files it offers a -# signing template, and how it batches them. Everything below about approval cost describes an -# approach that cannot run. -# -# The design question this answers: `vpk pack --signTemplate` is the only way to sign the -# executables Velopack GENERATES (the portable stub, Update.exe, Setup.exe), because those are -# created after packing and re-signing them afterwards invalidates the SHA256 and Size that -# releases.*.json records for each .nupkg -- which the updater verifies, and which delta packages -# patch against. -# -# But on the SignPath Foundation tier every signing request needs a MANUAL approval, and there -# are already four per release. Each --signTemplate invocation would be one more. So the cost of -# the fix is exactly "how many times does vpk invoke the template, and with which files", and -# nothing in Velopack's documentation states it: --signParallel is documented as "10 files per -# signtool.exe call" without saying whether the payload is batched separately from the generated -# binaries, and --signDisableDeep is documented as "requires you to pre-sign your binaries" -# without saying what it then still signs. -# -# So this measures it. The template points at a script that LOGS its arguments and exits 0 -- -# never signing anything -- and the job prints the invocation count and the file list per -# invocation. That is the number that decides whether the fix costs one extra approval per -# product or six. -on: - # A pull_request trigger, not only workflow_dispatch, and the reason is load-bearing: a - # workflow that has NEVER run is not registered, and `gh workflow run` answers - # "not found on the default branch" for it. Since this repo merges to dev and defaults to - # main, a dispatch-only workflow added here could never be dispatched at all -- it would - # have to reach main first, which only happens on a release. Running once on its own PR - # both answers the question and registers it for later dispatch. - pull_request: - paths: - - '.github/workflows/probe-vpk-signing.yml' - branches: [dev, main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - probe: - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - # --signDisableDeep is NOT a Windows option: vpk 1.2.0 answers "Unrecognized command - # or argument" for it, and the string in the assembly belongs to the macOS variant. - # The axis that matters is batch size: vpk invokes the template once per batch and - # each invocation is one SignPath approval. Default is 10, and 2000 is rejected, so - # this brackets the accepted range. 0 means "omit the flag" (the default). - sign_parallel: [0, 35, 50, 100] - steps: - - uses: actions/checkout@v5 - - - uses: actions/setup-dotnet@v5 - with: - dotnet-version: '10.0.x' - - # The same publish shape build.yml feeds to `vpk pack`, so the file census is the real one. - - name: Publish Lite (self-contained, as the Velopack path does) - shell: pwsh - run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite-velopack - - - name: Write the probe that stands in for a signing tool - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path probe | Out-Null - @' - # Logs one line per invocation: the count of files and their names. Exits 0 so vpk - # proceeds exactly as it would with a real signing tool that succeeded. - $log = Join-Path $env:GITHUB_WORKSPACE 'probe\invocations.log' - $files = $args - Add-Content -Path $log -Value ("INVOCATION`t{0} file(s)" -f $files.Count) - foreach ($f in $files) { Add-Content -Path $log -Value (" {0}" -f $f) } - exit 0 - '@ | Set-Content -Path probe/sign-probe.ps1 -Encoding utf8 - New-Item -ItemType File -Force -Path probe/invocations.log | Out-Null - - - name: Pack with the probe as the signing tool - shell: pwsh - env: - VPK_VERSION: '1.2.0' - run: | - dotnet tool install -g vpk --version $env:VPK_VERSION - New-Item -ItemType Directory -Force -Path probe/out | Out-Null - $probeCmd = 'pwsh -NoProfile -File "{0}\probe\sign-probe.ps1" {{{{file...}}}}' -f $env:GITHUB_WORKSPACE - $vpkArgs = @( - 'pack', - '-u', 'PerformanceMonitorLite', - '-v', '9.9.9', - '-p', 'publish/Lite-velopack', - '-e', 'PerformanceMonitorLite.exe', - '-o', 'probe/out', - '--channel', 'probe', - '--signTemplate', $probeCmd - ) - if ('${{ matrix.sign_parallel }}' -ne '0') { $vpkArgs += @('--signParallel', '${{ matrix.sign_parallel }}') } - Write-Host "vpk $($vpkArgs -join ' ')" - & vpk @vpkArgs 2>&1 | Tee-Object -Variable vpkOut - Write-Host "vpk exit: $LASTEXITCODE" - # Never let a rejected argument look like a zero-invocation result. - if ($LASTEXITCODE -ne 0) { - Write-Host '=== vpk FAILED; its output above is the reason ===' - exit 0 - } - - # The answer. Printed rather than uploaded, so reading the job log is the whole workflow. - - name: Report what vpk asked to be signed - if: always() - shell: pwsh - run: | - $log = 'probe/invocations.log' - if (-not (Test-Path $log)) { Write-Host 'NO LOG: the template was never invoked.'; exit 0 } - $lines = Get-Content $log - $invocations = ($lines | Where-Object { $_ -like 'INVOCATION*' }).Count - $files = $lines | Where-Object { $_ -notlike 'INVOCATION*' } | ForEach-Object { $_.Trim() } - Write-Host "=== --signParallel=${{ matrix.sign_parallel }}: $invocations template invocation(s) -> $invocations SignPath approval(s) ===" - Write-Host '' - Get-Content $log - Write-Host '' - Write-Host "=== distinct files vpk wanted signed: $(($files | Select-Object -Unique).Count) ===" - $files | Select-Object -Unique | ForEach-Object { Write-Host " $_" } - Write-Host '' - Write-Host '=== of those, the ones that are Velopack-GENERATED (the #3288 set) ===' - $files | Select-Object -Unique | - Where-Object { $_ -match 'Setup\.exe|Update\.exe|Squirrel\.exe|ExecutionStub' } | - ForEach-Object { Write-Host " $_" } diff --git a/.github/workflows/release-signatures.yml b/.github/workflows/release-signatures.yml deleted file mode 100644 index a53b6eb43e..0000000000 --- a/.github/workflows/release-signatures.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Release signatures - -# #3288: every executable `vpk pack` GENERATES shipped unsigned on v3.6.0 and v3.7.0 while the -# app payload was signed, and nothing noticed for two releases. The reader that found it lives in -# .github/scripts/verify_release_signatures.py and the post-pack signing that fixes it lives in -# .github/scripts/postpack_signing.py; this workflow exists so neither can rot unnoticed, and so -# a published release can be audited on demand without a local checkout. -# -# Neither self-test needs SignPath, a release, or a Windows runner, which matters because the -# release path itself runs only on `release: published` and blocks on manual approvals — these -# self-tests are the only part of #3288 that a pull request can exercise. -# -# The release-time guard is not here. It belongs in the release job, checks local files before -# upload, and is the step that refuses to publish — see the script's own docstring. This one -# answers "what did we actually publish", which needs the release to exist first. -on: - pull_request: - paths: - - '.github/scripts/verify_release_signatures.py' - - '.github/scripts/postpack_signing.py' - - '.github/workflows/release-signatures.yml' - branches: [dev, main] - workflow_dispatch: - inputs: - tag: - description: 'Release tag to audit (e.g. v3.7.0). Leave blank to self-test only.' - required: false - type: string - -concurrency: - group: release-signatures-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - signatures: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - # Covers the PE parse and the release guard's allowlist. The allowlist cases are the ones - # worth naming: the two allowed names also appear as the portable zip's root launcher and - # as Update.exe, so a skip written by filename alone would let an unsigned Setup.exe and an - # unsigned launcher through — which is the defect. Each case pairs a member path with a - # container, and a mutation broadening the match to the basename turns this step red. - - name: Self-test the signature reader - run: python3 .github/scripts/verify_release_signatures.py --self-test - - # Covers the staging rules and the portable-zip rebuild. The rebuild matters because the - # zip is a shipped artifact: the self-test asserts that a re-created archive, which loses - # entry order and the stored `.portable` marker, is refused. - - name: Self-test the post-pack signing script - run: python3 .github/scripts/postpack_signing.py --self-test - - # Manual only, because it requires a published release. Exits 1 while #3288 is open, which - # is the correct answer and the reason this is not wired to anything that gates a merge. - - name: Audit a published release - if: github.event_name == 'workflow_dispatch' && inputs.tag != '' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python3 .github/scripts/verify_release_signatures.py "${{ inputs.tag }}" diff --git a/.github/workflows/runtime-currency.yml b/.github/workflows/runtime-currency.yml deleted file mode 100644 index c17e015d58..0000000000 --- a/.github/workflows/runtime-currency.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Runtime currency - -# #3906: the Darling store runtime (the PostgreSQL + TimescaleDB pinned in Darling/tools/fetch-pg-runtime.ps1) -# is the one shipped dependency nothing else watches. Dependabot covers the NuGet packages, the Actions and, -# through its dotnet-sdk updates, the .NET runtime the self-contained builds pack. But the EDB binaries zip -# and the TimescaleDB release asset are URLs in a script. 3.8.0 shipped PostgreSQL 18.4 a month after 18.6 -# fixed 23 CVEs in it, and TimescaleDB 2.28.1 after 2.29.1 fixed a high-severity advisory. A prospective -# user's security review found both before anyone here did. -# -# Weekly, and red when behind. GitHub notifies whoever last edited this schedule when a scheduled run -# fails, and the run's annotations name the version to move to. Nothing is opened or pushed from here: a -# new runtime is a store upgrade on every field host (DarlingStoreUpgrade), so it goes through a pull -# request and the full live suite like any other change. What counts as "behind" is documented in the -# script, which runs the same way by hand. -# -# Advisory rather than a merge gate: it is not a pull_request check, so an upstream release can never -# turn an unrelated pull request red. Scheduled workflows run from the default branch, so this starts -# checking once it reaches main. -on: - schedule: - # Mondays, after the Dependabot sweep has opened its pull requests. - - cron: '23 14 * * 1' - workflow_dispatch: - -permissions: - contents: read - -jobs: - runtime-currency: - name: Bundled PostgreSQL and TimescaleDB are current - runs-on: ubuntu-latest - # Two small HTTP reads and a comparison. Anything approaching this is wedged. - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - - name: Compare the pins with upstream - shell: pwsh - env: - # Only for the advisory listing's rate limit; the endpoint is public. - GITHUB_TOKEN: ${{ github.token }} - run: ./Darling/tools/check-runtime-currency.ps1 diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml deleted file mode 100644 index b9b88615ea..0000000000 --- a/.github/workflows/sql-validation.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: SQL Validation - -on: - push: - branches: [dev] - paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml'] - pull_request: - branches: [dev] - paths: ['install/**', '.github/sql/**', '.github/workflows/sql-validation.yml'] - -# A re-push to a PR cancels that PR's superseded in-flight matrix, and a dev push cancels -# dev's superseded in-flight matrix — four SQL Server containers per run on the shared -# runner pool, validating code that is no longer the head. Newest-wins costs strictly -# nothing here (same policy and 2026-07-26 baseline as build.yml): this workflow uploads -# no artifacts at all, and it validates the FULL install/ tree at the SHA it checks out -# rather than a diff, so the superseding run's coverage is a superset of whatever the -# cancelled run was midway through. Any event this workflow ever grows beyond push and -# pull_request falls to a unique per-run group and is never cancelled. -concurrency: - group: ${{ github.event_name == 'pull_request' && format('sql-validation-pr-{0}', github.event.pull_request.number) || github.event_name == 'push' && format('sql-validation-push-{0}', github.ref) || format('sql-validation-run-{0}', github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} - -jobs: - validate-sql: - runs-on: ubuntu-latest - # Container pull + install scripts + seeded view execution runs in well under 15 - # minutes per version; a SQL Server container that never reaches healthy is the hang - # this bounds, and there are four of these jobs per run on the shared pool. - timeout-minutes: 30 - strategy: - fail-fast: false - # SQL Server 2016 is the documented minimum but cannot appear here: SQL Server on Linux - # begins at 2017, so no 2016 container image exists for the ubuntu service-container shape. - # 2016 gets the SAME install + seed + view-execution validation manually against the local - # SQL2016 box as part of release testing (last full pass: 2026-07-26, 13.0.6300 - install - # clean, 41 views executed, 0 failed). - matrix: - include: - - version: '2017' - image: mcr.microsoft.com/mssql/server:2017-latest - - version: '2019' - image: mcr.microsoft.com/mssql/server:2019-latest - - version: '2022' - image: mcr.microsoft.com/mssql/server:2022-latest - - version: '2025' - image: mcr.microsoft.com/mssql/server:2025-latest - - name: SQL Server ${{ matrix.version }} - - services: - sqlserver: - image: ${{ matrix.image }} - env: - ACCEPT_EULA: Y - MSSQL_SA_PASSWORD: CI_Test#2026! - ports: - - 1433:1433 - options: >- - --health-cmd "grep -q 'SQL Server is now ready for client connections' /var/opt/mssql/log/errorlog || exit 1" - --health-interval 10s - --health-timeout 5s - --health-retries 15 - - steps: - - uses: actions/checkout@v7 - - - name: Install sqlcmd - run: | - # Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts - if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then - curl -sSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg - source /etc/os-release - echo "deb [arch=amd64,signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${VERSION_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list - fi - sudo apt-get update - sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 - - - name: Run install scripts - env: - SA_PASSWORD: CI_Test#2026! - run: | - for script in $(ls install/[0-9]*.sql | sort); do - filename=$(basename "$script") - - # Skip scripts that require SQL Agent or are test/troubleshooting - case "$filename" in - 45_*) echo "Skipping $filename (requires SQL Agent)"; continue;; - 97_*|98_*|99_*) echo "Skipping $filename (test/troubleshooting)"; continue;; - esac - - echo "Running $filename..." - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "$script" - echo " OK" - done - - - name: Validate installation - env: - SA_PASSWORD: CI_Test#2026! - run: | - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_validate_installation.sql - - # Existence alone let two view bugs ship (#1635, #1666): a view can compile yet throw only - # when rows flow through its projection. Seed one representative row per source table - # (history tables get a correlated pair so LAG/toggle paths emit rows), then materialize - # every report.* view with SELECT * INTO - COUNT(*) would prune the projected columns and - # skip exactly the per-row conversions this exists to catch (#1669). - - name: Seed report view sources - env: - SA_PASSWORD: CI_Test#2026! - run: | - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_seed_report_sources.sql - - - name: Execute report views against seeded rows - env: - SA_PASSWORD: CI_Test#2026! - run: | - /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i .github/sql/ci_execute_report_views.sql From ed20652a494c930f4cb321e4f700c153e00243c8 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:58:09 -0400 Subject: [PATCH 3/3] Proof run for #5603: keep nightly.yml (the test project copies it); it has no pull_request trigger --- .github/workflows/nightly.yml | 1595 +++++++++++++++++++++++++++++++++ 1 file changed, 1595 insertions(+) create mode 100644 .github/workflows/nightly.yml diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000000..768df58a54 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,1595 @@ +name: Nightly Build + +on: + schedule: + # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT) + - cron: '0 6 * * *' + workflow_dispatch: # manual trigger — and the vehicle the scheduled re-dispatch below rides + inputs: + from_schedule: + description: 'Set true by the scheduled re-dispatch so the 24h new-commit check applies. Leave false for manual runs, which always build.' + type: boolean + required: false + default: false + publish: + description: 'Publish the release artifacts (gh release, the ghcr image push) once build, linux and the Darling PostgreSQL tests all succeed. Set false for a diagnostic re-run that should build and test without publishing.' + type: boolean + required: false + default: true + test_map: + description: 'Build the class-to-file test map (#5459): the whole Darling.Tests and Lite.Tests suites under AltCover, sharded, joined into the test-map artifact. On by default, including in the scheduled re-dispatch; set false to skip it for one dispatch. It publishes nothing, gates nothing, and a failure in it does not fail the nightly.' + type: boolean + required: false + default: true + altcover_probe: + description: 'Run the AltCover per-test coverage probe job (#5459). Off by default and never set by the scheduled re-dispatch; it only measures, publishes nothing and gates nothing.' + type: boolean + required: false + default: false + +# Read-only by default; the release-publishing steps (gh release, the ghcr push, cosign, the +# provenance attestation) run only in the publish job below, which declares its own write +# permissions scoped to itself. No other job needs to write anything. +permissions: + contents: read + +jobs: + # Scheduled workflows always execute the DEFAULT branch's copy of this file, while nightly + # artifacts deliberately build from dev's tree. That skew is how the 2026-07-26 nightly + # failed (run 30194606068): main's stale copy still read Dashboard/Dashboard.csproj, a path + # #1612 moved to deprecated/ on dev, so 'Set nightly version' died on a file missing from + # the tree it had just checked out — and the same trap bit before (#1550/#1551). The cure + # is structural, not another sync: on schedule this workflow does NOTHING but re-dispatch + # itself onto the dev REF, because a workflow_dispatch run executes the dispatched ref's + # copy of this file — dev's, current by definition. Once main carries this shape, its copy + # has exactly one job that must keep working, and that job references no tree paths at + # all; every future change to the real nightly logic lands on dev and takes effect the + # night it merges, no promotion to main needed. GITHUB_TOKEN can create workflow_dispatch + # runs (the Actions recursion guard exempts workflow_dispatch and repository_dispatch), + # and the dispatched run cannot loop back here because it arrives as workflow_dispatch, + # not schedule. Until main is synced once, the scheduled run still executes main's OLD + # copy and keeps failing nightly — the one-time sync is in the PR that introduced this. + redispatch: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - name: Re-dispatch this workflow onto the dev ref + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run nightly.yml --repo ${{ github.repository }} --ref dev -f from_schedule=true + + # Everything below runs only in a workflow_dispatch run — the scheduled re-dispatch or a + # manual one — which executes from the dispatched ref (dev for the scheduled path). + # + # Each checkout resolves that ref EXPLICITLY rather than pinning `ref: dev`. Three of these + # were pinned, so a manual dispatch onto any other branch published dev's artifacts while + # reporting the dispatched ref as the run's head SHA — the run, the API and the release page + # all named a branch that was not what got built. That is worse than simply ignoring the + # input, because the mismatch is invisible: it surfaced as a release-candidate build stamping + # the PREVIOUS version, which is only noticeable if you happen to read the artifact name. + check: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + stamp: ${{ steps.stamp.outputs.stamp }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + fetch-depth: 0 + + # The nightly prerelease stamp, derived ONCE for the whole run and consumed by every job + # that names an artifact (#3390). Two properties matter and a per-job `date` has neither. + # + # Stable across jobs: `linux` needs `build`, which takes ~8.5 minutes, so the two jobs read + # their clocks ~8.5 minutes apart. Each deriving its own date means a run whose two stamp + # steps straddle midnight UTC ships a Windows zip dated one day and a Linux tarball dated + # the next, from one commit. + # + # Unique per build: the date alone is not. Multiple dispatches a day are normal here - over + # 2026-08-24..09-12, 13 of 20 days published two or more distinct source trees under one + # version string, peaking at five - and nothing in the artifact then distinguishes them. + # `InformationalVersion` — and the Win32 ProductVersion built from it — and the asset names + # all carry this string (#3455 is what actually threads it into the binaries; FileVersion + # stays the numeric prefix), and the apphost exe is byte-identical across builds, so with a + # colliding stamp there is no read that answers "which nightly is this". + # + # run_number is monotonic and numeric, so SemVer 2.0 compares these prereleases in build + # order; the date stays leftmost and human-readable. A short sha would identify the tree + # but sorts as an alphanumeric identifier, which outranks every numeric one. + - name: Derive the nightly stamp + id: stamp + shell: bash + run: | + set -euo pipefail + stamp="$(date -u +%Y%m%d).${{ github.run_number }}" + # Shape, not emptiness. A `date` that exited 0 having printed nothing yields + # "." - non-empty, so it passes every downstream `-n` / + # IsNullOrWhiteSpace guard and stamps both artifacts with a malformed version instead + # of failing here. Asserting the shape at the one place the stamp is produced makes + # those guards a backstop against broken output wiring rather than the only check. + shape='^[0-9]{8}\.[0-9]+$' + [[ "${stamp}" =~ ${shape} ]] || { echo "::error::Derived a malformed nightly stamp: '${stamp}'"; exit 1; } + echo "stamp=${stamp}" >> "$GITHUB_OUTPUT" + echo "Nightly stamp: ${stamp}" + + - name: Check for new commits in last 24 hours + id: check + run: | + RECENT=$(git log --since="24 hours ago" --oneline | head -1) + if [ -n "$RECENT" ]; then + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "New commits found — building nightly" + else + echo "has_changes=false" >> $GITHUB_OUTPUT + echo "No new commits — skipping nightly build" + fi + + build: + needs: check + # Manual dispatches always build (from_schedule defaults false); the scheduled + # re-dispatch sets from_schedule=true and builds only when dev changed in the last + # 24h — the same policy the schedule applied when it ran these jobs directly. + if: needs.check.outputs.has_changes == 'true' || inputs.from_schedule != true + runs-on: windows-latest + # Full pipeline (restore, tests, four publishes, cold pg-runtime fetch, vpk pack, + # release upload) is well under an hour; 90 minutes means hung-not-slow. Nightly ships + # unsigned, so unlike build.yml's release path there is no manual signing gate to wait on. + # + # Raised from 90 to 150 (#5208). The unsharded "Run tests" step alone went 45 -> 64 -> over 89 + # minutes on hosted runners and hit this cap, so no nightly published. The one-log design is + # kept on purpose. This is a guard, not a fix: the slowdown is tracked in #5208, and this value + # should come back down to 90 when it is fixed. + timeout-minutes: 150 + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + # The stamp must reach the BINARIES, not just the names around them. This step's output feeds + # every artifact name and the vpk pack below, but a name is not a stamp: each assembly records + # whatever the msbuild version property resolves to at build time, and that is the clean + # release string Directory.Build.props declares unless the build overrides it. Until #3455 + # nothing did, so a zip named 3.7.0-nightly.20260915.438 shipped a service whose informational + # version read bare 3.7.0 — and the informational version is the one surface a RUNNING service + # answers build questions from: DarlingCliCommands.ProductVersion() reads that attribute for + # --version, and #3453's get_collection_health service block deliberately serves the same + # method's result. The night #3453 shipped, its first live reading came back bare on a .438 + # nightly — the question "which nightly is this" answered with silence at the read built to + # answer it. + # + # So every dotnet publish below passes -p:Version with this value. The SDK derives the + # informational version from it verbatim, while AssemblyVersion and FileVersion derive as the + # numeric prefix (3.7.0.0) — identical to a release build, so nothing assembly-identity-shaped + # differs between the two. No +sha metadata rides along: Directory.Build.props suppresses the + # source-revision append tree-wide (#3222), the stamp's run_number already names the build + # uniquely, and ProductVersion() strips any +suffix from display regardless. Release builds + # keep their clean string by CONSTRUCTION rather than by restraint — build.yml passes no + # version property — and NightlyVersionInjectionTests pins both halves, so the next copied-in + # publish step cannot quietly ship a bare-versioned nightly again. + - name: Set nightly version + id: version + shell: pwsh + env: + NIGHTLY_STAMP: ${{ needs.check.outputs.stamp }} + run: | + $base = ([xml](Get-Content Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ } + if ([string]::IsNullOrWhiteSpace($base)) { throw "Read no version from Directory.Build.props." } + if ([string]::IsNullOrWhiteSpace($env:NIGHTLY_STAMP)) { throw "Read no stamp from the check job." } + $nightly = "$base-nightly.$env:NIGHTLY_STAMP" + echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT + echo "Nightly version: $nightly" + + # `shell: bash` with errexit for the reason build.yml's restore step carries it (#3143): a multi-line + # run block executes under pwsh on Windows, which reports the block's exit code as the LAST command's, + # so the two restores ahead of the last could print NU1004 and leave this step green. The project list + # is what this workflow publishes rather than the whole tree — build.yml's is the one that gates a + # merge, and LockedModeRestoreCoverageTests requires whole-tree lock-file coverage there and only there. + - name: Restore dependencies + shell: bash + run: | + set -euo pipefail + + dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode + dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + dotnet restore Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj --locked-mode + + # #5587: the hang report build.yml's shards carry (PR 5591). `-longRunning 300` makes the runner print a test that + # has run for 5 minutes while the step is still alive, so a hang names its test. The runner output is teed to + # TestResults/lite-runner-nightly.log and uploaded by the always() step below. The Tee-Object pipe leaves + # $LASTEXITCODE as the runner's, and the explicit check keeps the step failing when the runner fails. + # The step timeout is from numbers: the last 5 completed nightly runs on dev (2026-10-06 to 2026-10-08) ran this + # step for 32.4, 39.8, 40.6, 41.4 and 44.2 minutes, and the job ran 4.0 to 4.9 minutes longer than the step + # (setup before it, publish and packaging after it). The job's timeout is 150, so the step's is + # 150 - 4.9 - 5 = 140 (rounded down). The longest step, 44.2 minutes, is 32% of that, under the 70% bar. A job + # timeout would cancel the job and leave the uploads pending; a step timeout fails only this step. + - name: Run tests + timeout-minutes: 140 + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path TestResults | Out-Null + dotnet run --project Lite.Tests/Lite.Tests.csproj -c Release -- -xml TestResults/lite-timing-nightly.xml -longRunning 300 | Tee-Object -FilePath TestResults/lite-runner-nightly.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # #5208: where the time goes. Informational; a parse problem must never turn the job red. + - name: Lite test timing summary + if: always() + continue-on-error: true + run: python .github/scripts/lite-timing-top.py TestResults + + - name: Upload Lite test timings + if: always() + continue-on-error: true + uses: actions/upload-artifact@v6 + with: + name: lite-tests-timing-nightly + path: | + TestResults/lite-timing-*.xml + TestResults/lite-timing-*.xml.cpu.json + retention-days: 7 + if-no-files-found: ignore + + # #5587: the runner output, uploaded whatever the test step did (failed, cancelled or timed out). + - name: Upload Lite runner output + if: always() + continue-on-error: true + timeout-minutes: 5 + uses: actions/upload-artifact@v6 + with: + name: lite-runner-log-nightly + path: TestResults/lite-runner-*.log + retention-days: 7 + if-no-files-found: ignore + + # #2501: -r win-x64 --self-contained, and it makes the portable ZIP SMALLER, not bigger. + # The RID-agnostic framework-dependent publish this replaced copied every platform its + # packages ship - 537 MB of runtimes\ on a 565 MB tree, of which only the 52 MB win-x64 + # folder can ever load on Windows (DuckDB.NET.Bindings.Full is most of it, SkiaSharp and + # SqlClient behind it). Pinning the RID drops ~485 MB of unloadable native payload, which + # is far more than the bundled .NET/WPF/ASP.NET runtime adds back. Measured on the same + # commit and SDK: 565 MB tree / 212.7 MB zipped becomes 277 MB / 114.2 MB. + # + # The size was the secondary reason. The primary one is #2489: a framework-dependent ZIP + # unzipped onto a stock Windows Server dies on the .NET host's own "You must install .NET + # to run this application" before a line of our code runs, so Lite cannot report it, and + # #2499 could only ship a READ-ME-FIRST.txt next to the exe. Self-contained there is + # nothing to install and nothing to fail. That matters most for the nightly ZIP, which is + # the UAT download and is not offered as a Setup.exe at all. + # + # Lite/PerformanceMonitorLite.csproj declares RuntimeIdentifiers so the committed lock + # file covers this RID; see the comment there before removing either half. + - name: Publish Lite + run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -r win-x64 --self-contained -o publish/Lite -p:Version=${{ steps.version.outputs.VERSION }} + + - name: Publish Darling Service + run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -o publish/DarlingService -p:Version=${{ steps.version.outputs.VERSION }} + + - name: Publish Darling Viewer + run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -o publish/DarlingViewer -p:Version=${{ steps.version.outputs.VERSION }} + + # Self-contained viewer publish that feeds the remote-seat Velopack Setup.exe (#1555), the same + # publish shape build.yml uses for the Dashboard/Lite Velopack packs. This is IN ADDITION to the + # framework-dependent "Publish Darling Viewer" above, which still feeds the co-located viewer\ + # folder inside PerformanceMonitorDarling-*.zip — that zip is unchanged. + - name: Publish Darling Viewer (self-contained for Velopack) + run: dotnet publish Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj -c Release -r win-x64 --self-contained -o publish/DarlingViewer-velopack -p:Version=${{ steps.version.outputs.VERSION }} + + # Same cache key as build.yml's release path: the fetch script's content hash (the SHA256 + # pins live inside it). The ~340MB EDB/TimescaleDB fetch runs at most once per pin-set per + # branch; nightly and release runs share the assembled zip whenever the cache is visible. + - name: Cache Darling pg-runtime.zip + id: cache-pg-runtime + uses: actions/cache@v6 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip + if: steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + + - name: Package artifacts + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + New-Item -ItemType Directory -Force -Path releases + + Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force + + + # Same layout as the release zip (build.yml "Package Darling (signed)"): service at the + # archive root with darling.sample.json alongside, viewer under viewer\, pg-runtime.zip + # beside the service exe where DarlingManagedPostgres extracts it on first run. Nightly + # zips are unsigned across the board, so this stages from publish/ instead of signed/. + $darlingDir = 'publish/Darling' + New-Item -ItemType Directory -Force -Path "$darlingDir/viewer" | Out-Null + Copy-Item 'publish/DarlingService/*' $darlingDir -Recurse + Copy-Item 'publish/DarlingViewer/*' "$darlingDir/viewer" -Recurse + Copy-Item 'Darling/artifacts/pg-runtime.zip' $darlingDir + + Compress-Archive -Path 'publish/Darling/*' -DestinationPath "releases/PerformanceMonitorDarling-$version.zip" -Force + + # Darling viewer remote-seat installer (#1555). Nightly ships it UNSIGNED like every other nightly + # artifact. Mirrors build.yml's release vpk pack (same pack id / exe / channel) but packs from the + # raw self-contained publish (no SignPath), and deliberately does NOT touch the Velopack update + # feed: the nightly GitHub release is deleted + recreated each night, so there is no persistent + # delta chain to `vpk download`/`vpk upload` from — we ship a standalone full Setup.exe as a plain + # release asset. Copied to a deterministic name so the checksum + upload steps below pick it up. + # Purely additive: the co-located viewer inside PerformanceMonitorDarling-*.zip is untouched. + - name: Create Darling Viewer Setup.exe (Velopack, unsigned) + shell: pwsh + run: | + $version = "${{ steps.version.outputs.VERSION }}" + dotnet tool install -g vpk --version 1.2.158 + New-Item -ItemType Directory -Force -Path releases/velopack-darlingviewer + vpk pack -u PerformanceMonitorDarlingViewer -v $version -p publish/DarlingViewer-velopack -e PerformanceMonitor.Darling.Viewer.exe -o releases/velopack-darlingviewer --channel darlingviewer + $setup = Get-ChildItem releases/velopack-darlingviewer/*Setup.exe | Select-Object -First 1 + Copy-Item $setup.FullName "releases/PerformanceMonitorDarlingViewer-$version-Setup.exe" + + - name: Generate checksums + shell: pwsh + run: | + $checksums = Get-ChildItem releases/*.zip, releases/*.exe | ForEach-Object { + $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() + "$hash $($_.Name)" + } + # LF and no BOM, written explicitly (#2383). Out-File on Windows emits CRLF whatever + # the encoding, and `shasum -c` on macOS/Linux then takes the trailing \r as part of + # the FILENAME - every entry reports "No such file or directory / FAILED open or + # read", which is precisely what a tampered download looks like. The hashes were + # always right; the file just could not be used by the tool it exists for, on the two + # platforms where that tool is the default. A BOM breaks the first line the same way. + # + # This is the SECOND copy of that step. #2384 fixed the release workflow and left this + # one, so every nightly since has published a checksum file nobody outside Windows can + # verify - and the nightly is the build most likely to be checked by hand. + [IO.File]::WriteAllText( + "$PWD/releases/SHA256SUMS.txt", + (($checksums -join "`n") + "`n"), + [Text.UTF8Encoding]::new($false)) + Write-Host "Checksums:" + $checksums | ForEach-Object { Write-Host $_ } + + # The release-publishing steps (gh release create, the linux upload, the ghcr push) moved to + # the publish job below, which waits on build, linux AND darling-pg. This job hands off its + # windows release assets (the zips, the Setup.exe, SHA256SUMS.txt) and the version string as a + # workflow artifact instead of publishing them itself, so a nightly can no longer publish + # while the Darling PostgreSQL tests are red: publish downloads exactly what this job built + # and cannot run until darling-pg reports success. + - name: Upload windows release assets + uses: actions/upload-artifact@v6 + with: + name: windows-release-assets + path: | + releases/*.zip + releases/*.exe + releases/SHA256SUMS.txt + if-no-files-found: error + retention-days: 3 + + - name: Save the nightly version string + shell: bash + run: echo "${{ steps.version.outputs.VERSION }}" > windows-version.txt + + # The commit build actually checked out, so publish can name the release after the tree it + # built rather than dev's tip at publish time (20-60 minutes later, once darling-pg is done). + - name: Save the built commit SHA + shell: bash + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "${sha}" > windows-commit-sha.txt + echo "Built commit: ${sha}" + + - name: Upload the nightly version string + uses: actions/upload-artifact@v6 + with: + name: windows-version + path: | + windows-version.txt + windows-commit-sha.txt + if-no-files-found: error + retention-days: 3 + + # Darling.Tests has live-PostgreSQL tests gated on env vars that the per-PR/push build never + # sets, so regular CI runs only the ungated subset. This job stands up a throwaway PostgreSQL + # from the bundled pg-runtime and runs the full Darling suite against it nightly, so the + # Postgres + managed-bootstrap paths get real coverage. The one live-SQL-Server E2E stays + # skipped (no SQL Server on the runner) — expected. Gated like the build job so it only runs + # when dev actually changed (or on manual dispatch). + darling-pg: + name: Darling PostgreSQL tests + needs: check + # Same gating as the build job: manual dispatches always run, the scheduled + # re-dispatch (from_schedule=true) runs only when dev changed in the last 24h. + if: needs.check.outputs.has_changes == 'true' || inputs.from_schedule != true + runs-on: windows-latest + # Cold pg-runtime fetch + build + the full live-PG suite fits well inside an hour; a + # cluster that never comes up (pg_ctl -w) is the hang this bounds. + timeout-minutes: 60 + + steps: + # Scheduled runs always test dev (schedules execute from the default branch, so ref_name + # would be main). A manual dispatch tests the DISPATCHED ref — the only way to validate a + # branch's gated-pg test changes before merge; the build job stays pinned to dev either + # way, so a branch dispatch can never ship branch binaries. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + # Darling bundles a PostgreSQL 18 + TimescaleDB runtime (pg-runtime.zip). The fetch script + # pulls ~340MB of pinned EDB/TimescaleDB archives, so cache the assembled zip on the SAME + # key build.yml's release job uses (the fetch script's own content hash) — nightly and + # release share one cache entry, so a warm cache means no download here. + - name: Cache Darling pg-runtime.zip + id: cache-pg-runtime + uses: actions/cache@v6 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip (cache miss only) + if: steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + + # One uniform path for hit and miss: we always have the zip (restored or freshly built), + # so always extract it — simpler than branching on the script's -KeepWork assembled tree. + # ExtractToDirectory mirrors the fetch script's own API and reads any zip it writes. + - name: Extract pg-runtime + shell: pwsh + run: | + $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" + $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) + if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } + + # The PREVIOUS-major runtime, for the upgraded-in-place fixture (#1706). Without it the gated + # store-upgrade E2E skips, and with it skips the ONLY behavioral coverage of the in-place + # 17-to-18 path: the runtime rescue, the TimescaleDB bridge, the data-directory swap, the + # revert, and the loopback override that stops pg_upgrade dialing ::1 against our IPv4-only + # listen_addresses. String assertions catch that constant being deleted; nothing but this + # catches the override ceasing to TAKE EFFECT, and the failure mode is a fleet-wide hang. + # Only the DOWNLOADS are cached (~680MB of pinned archives, both fixture runtimes): the script + # re-verifies them by SHA256 and re-assembles in seconds, so a warm cache costs no network. + - name: Cache upgrade-fixture downloads (previous-major runtime) + uses: actions/cache@v6 + with: + path: Darling/artifacts/upgrade-fixture/work/downloads + key: upgrade-fixture-${{ runner.os }}-${{ hashFiles('Darling/tools/new-upgraded-store-fixture.ps1') }} + + # -SkipNew: the CURRENT runtime is already built/cached above as pg-runtime.zip, which is what + # DARLING_TEST_PGRUNTIME_NEWZIP points at. This step only needs the old sides: the previous major, + # and (#3906) the previous release on the current major. + - name: Build previous-major runtime for the upgrade fixture + shell: pwsh + run: ./Darling/tools/new-upgraded-store-fixture.ps1 -SkipNew + + - name: Restore Darling.Tests + run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode + + - name: Build Darling.Tests + run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore + + # Stand up a throwaway cluster from the bundled runtime. initdb TRUST auth is acceptable + # ONLY here: an ephemeral CI runner, loopback-only, throwaway data. The PRODUCT default is + # the opposite (scram-sha-256 + a generated credential; see DarlingManagedPostgres). The + # appended settings mirror DarlingManagedPostgres.BuildConfAppend (timescaledb preload — + # the Timescale-gated tests detect and use it — plus the port and loopback bind) and + # BuildWorkerSizingConfAppend (the two worker settings). Port 5541 is fixed and distinct; the + # managed-bootstrap E2E starts its OWN postgres on a random free port, so there is no collision. + # + # #1888: the worker settings are load-bearing, not garnish. PostgreSQL's default + # max_worker_processes = 8 cannot launch TimescaleDB's per-hypertable compression, retention + # and continuous-aggregate policy jobs, so without them this job tested a configuration no + # customer runs and made scheduler-racing failures luck-of-the-slot instead of reproducible. + # Values are the product's own derivation from the live hypertable count + # (TimescaleSupport.HypertableCount = the 62-collector catalog + collection_log = 63): + # timescaledb.max_background_workers = HypertableCount + 2 = 74 + # max_worker_processes = 3 + (HypertableCount + 2) + 8 = 85 + # Kept honest by CiClusterWorkerSizingTests (parses this file against the formula) and + # CiClusterWorkerSizingLiveTests (asserts the running cluster serves them). Must configure + # the cluster identically to build.yml's darling-pg job: that guard parses the appended + # settings out of BOTH files and requires the two sets to be equal, so a fix applied to one + # workflow and not the other — the ordinary way these hand-maintained copies drift — fails. + - name: Initialize and start throwaway PostgreSQL + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + $logFile = "$env:RUNNER_TEMP\darling-pg.log" + # The bootstrap superuser is named "darling", not "postgres": the V8 schema-split + # migration runs CREATE SCHEMA ... AUTHORIZATION darling (PgSchemaGenerator.OwnerRole), + # so a cluster without that role fails every fresh-store migration. Matching managed + # mode's shape (DarlingManagedPostgres also initdbs its owner as "darling"). + & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 + if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } + Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb,pg_stat_statements'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" + Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74" + Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85" + # #5549: the session id (%c) on every line. A PID alone cannot say whether two lines came from one + # session (a pooled connection reused by the next test) or from two (Windows reuses a PID quickly). + Add-Content -Path "$dataDir\postgresql.conf" -Value "log_line_prefix = '%m [%p] %c '" + & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start + if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } + & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling + if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } + + # DARLING_TEST_PG lights up the [Collection("live-postgres")] classes; DARLING_TEST_PGRUNTIME + # lights up the managed-bootstrap E2E. DARLING_TEST_SQL is intentionally unset — the one + # live-SQL-Server E2E stays skipped (no SQL Server on the runner). + # #5587: the hang report build.yml's shards carry (PR 5591): `-longRunning 300` names a test that has run for + # 5 minutes, and the runner output is teed to TestResults/darling-runner-nightly.log and uploaded by the + # always() step further down. The Tee-Object pipe leaves $LASTEXITCODE as the runner's; the explicit check keeps + # the step failing when the runner fails. + # NO step timeout, from numbers: the last 5 completed nightly runs on dev (2026-10-06 to 2026-10-08) ran this step + # for 32.2, 32.3, 36.8, 41.1 and 41.2 minutes, and the job ran 4.2 to 5.1 minutes longer than the step. The job's + # timeout is 60, so a step timeout would be 60 - 5.1 - 5 = 49 (rounded down). The longest step, 41.2 minutes, is + # 84% of that, over the 70% bar, so a step timeout would fail a slow but healthy run. The job's own timeout stays + # the bound; if it cancels the job, the uploads below stay pending, which is the gap the timeout would have closed. + - name: Run Darling PG tests + shell: pwsh + env: + DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" + DARLING_TEST_PGRUNTIME: ${{ github.workspace }}\Darling\artifacts\pg-runtime + # #1706: the pair that lights up the upgraded-in-place store-upgrade E2E — a real + # previous-major store (hypertable, TOAST-sized plan XML, continuous aggregate, + # compressed chunk) upgraded through the production bootstrap and compared by ordered + # row checksum before and after. This is the [#1705] CI gap: every other job only ever + # sees a FRESH store, so nothing else can catch an upgrade path that breaks. + DARLING_TEST_PGRUNTIME_OLD: ${{ github.workspace }}\Darling\artifacts\upgrade-fixture\old\pg-runtime + DARLING_TEST_PGRUNTIME_NEWZIP: ${{ github.workspace }}\Darling\artifacts\pg-runtime.zip + # #3906: the previous release's runtime on the bundle's OWN major, for the same-major E2E. That + # swap (no pg_upgrade, same data directory) is what nearly every field host takes on a release + # that moves PostgreSQL's minor, and nothing else runs it with real data, stamped or not. + DARLING_TEST_PGRUNTIME_PREVIOUS: ${{ github.workspace }}\Darling\artifacts\upgrade-fixture\previous\pg-runtime + run: | + New-Item -ItemType Directory -Force -Path TestResults | Out-Null + dotnet run --project Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-build -- -trx TestResults/darling-nightly.trx -longRunning 300 | Tee-Object -FilePath TestResults/darling-runner-nightly.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # PR #5480: a TimescaleDB job worker died with 0xC0000005 in two CI runs and both runs read "success", + # because the postmaster restarts and the tests retry. The crash shows only in the PostgreSQL log, as + # "terminated by exception", so this step turns it into a warning on the run. It only warns: it never + # fails the job, and it runs when the tests failed too. + - name: Warn if a PostgreSQL worker crashed + if: always() + shell: pwsh + run: | + $log = "$env:RUNNER_TEMP\darling-pg.log" + if (Test-Path $log) { + $hits = @(Select-String -Path $log -Pattern 'terminated by exception' -SimpleMatch) + foreach ($hit in ($hits | Select-Object -First 5)) { + $line = $hit.Line + if ($line.Length -gt 300) { $line = $line.Substring(0, 300) } + Write-Host "::warning title=PostgreSQL worker terminated by exception::$line" + } + if ($hits.Count -gt 0) { Write-Host "$($hits.Count) 'terminated by exception' line(s) in darling-pg.log" } + } + exit 0 + + - name: Stop PostgreSQL + if: always() + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } + exit 0 + + # The gated upgrade-path tests stand up their OWN throwaway clusters under the runner's temp + # directory (Directory.CreateTempSubdirectory, prefixes like "darling-pg17boot-" and + # "darling-managedconf-"), separate from the fixed darling-pgdata cluster this job starts + # above. When one of those tests fails, its cluster's own server log (pg_ctl's -l file, + # named "logfile" inside each temp root, plus any "log\\" ring directory a managed-conf boot + # writes) is the only record of what postgres itself said, and it wasn't captured before. + # Those clusters land under %TEMP% (the Windows per-user temp directory), NOT + # ${{ runner.temp }} (RUNNER_TEMP) - the two are different directories on this runner - so + # this step copies them into RUNNER_TEMP first, preserving relative paths, before the + # upload step below (which can only reach RUNNER_TEMP). A test's own "finally" block may + # already have deleted its temp root by the time this runs, so this only catches clusters + # that survive; the test's own failure message (the in-place upgrade test appends the + # upgraded server's own log to its assertion) remains the primary record. + - name: Collect the gated tests' own cluster logs + if: failure() + shell: pwsh + run: | + $dest = "$env:RUNNER_TEMP\darling-cluster-logs" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + $maxBytes = 50MB + $copied = 0 + $roots = Get-ChildItem -Path $env:TEMP -Directory -Filter "darling-*" -ErrorAction SilentlyContinue + foreach ($root in $roots) { + $files = Get-ChildItem -Path $root.FullName -Recurse -File -ErrorAction SilentlyContinue | Where-Object { + $_.Name -eq "logfile" -or $_.Name -like "*.log" -or + $_.FullName -match "\\log\\" -or $_.FullName -match "\\pg_log\\" + } + foreach ($file in $files) { + if ($file.Length -gt $maxBytes) { continue } + $relative = $file.FullName.Substring($env:TEMP.Length).TrimStart("\") + $target = Join-Path $dest $relative + New-Item -ItemType Directory -Force -Path (Split-Path $target -Parent) | Out-Null + Copy-Item -Path $file.FullName -Destination $target -ErrorAction SilentlyContinue + $copied++ + } + } + Write-Host "Collected $copied gated-test cluster log file(s) into $dest" + + # #5587: the runner output, uploaded whatever the test step did (failed, cancelled or timed out). + - name: Upload Darling runner output + if: always() + continue-on-error: true + timeout-minutes: 5 + uses: actions/upload-artifact@v6 + with: + name: darling-runner-log-nightly + path: TestResults/darling-runner-*.log + retention-days: 7 + if-no-files-found: ignore + + - name: Upload PG log and test results on failure + if: failure() || cancelled() + uses: actions/upload-artifact@v6 + with: + name: darling-pg-failure + path: | + ${{ runner.temp }}/darling-pg.log + ${{ runner.temp }}/darling-cluster-logs/** + TestResults/ + if-no-files-found: ignore + overwrite: true + + # ── AltCover per-test coverage probe (#5459, change 4 slice 2) ─────────────────────────────────── + # Dispatch-only and off by default: the scheduled re-dispatch never sets `altcover_probe`, so the + # nightly never runs this. It answers one question for the class-to-file map the PR test selection + # would read: can AltCover (--callContext on [Fact]/[Theory]) give a per-test-class list of product + # source files for the Darling xUnit v3 project on .NET 10, at what overhead, and with what holes? + # It runs about 100 Darling classes (plain, TestHost/web, live PostgreSQL) and about 60 Lite classes + # (plain, DuckDB-backed, collector-starting) once plain and once instrumented, and uploads the raw + # OpenCover reports plus a summary and the schema-1 `classes` map for each suite. Nothing here + # publishes or gates, and no job lists it in `needs`. + # Dispatch on a branch with: + # gh workflow run nightly.yml --ref -f altcover_probe=true -f publish=false + altcover-probe: + name: AltCover per-test coverage probe + # Waits for build and darling-pg so it does not take their runners (#5459); always() keeps it running after a failed one. + needs: [check, build, darling-pg] + if: always() && needs.check.result == 'success' && github.event_name == 'workflow_dispatch' && inputs.altcover_probe == true + runs-on: windows-latest + timeout-minutes: 150 + env: + # Pinned: a probe whose tool floats cannot be compared run to run. + ALTCOVER_VERSION: '9.0.145' + PROBE_BIN: ${{ github.workspace }}\Darling\Darling.Tests\bin\Release\net10.0-windows + PROBE_BIN_LITE: ${{ github.workspace }}\Lite.Tests\bin\Release\net10.0-windows + # PROBE_OUT (the scratch folder under the runner's temp directory) is exported by the install step: + # the `runner` context is not available in a job-level env block. + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Cache Darling pg-runtime.zip + id: cache-pg-runtime + uses: actions/cache@v6 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip (cache miss only) + if: steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + + - name: Extract pg-runtime + shell: pwsh + run: | + $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" + $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) + if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } + + - name: Restore Darling.Tests + run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode + + - name: Build Darling.Tests + run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore + + # Lite.Tests is built up front too, but probed after the Darling pass (its own bin folder, so the in-place + # instrumentation of the two suites never touches the same files). + - name: Restore Lite.Tests + run: dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + + - name: Build Lite.Tests + run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore + + # The same throwaway cluster the darling-pg job stands up (CiClusterWorkerSizingTests compares the + # appended settings of both jobs), so the live classes in the sample behave as they do there. + - name: Initialize and start throwaway PostgreSQL + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + $logFile = "$env:RUNNER_TEMP\darling-pg.log" + & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 + if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } + Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb,pg_stat_statements'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" + Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74" + Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85" + # #5549: the session id (%c) on every line. A PID alone cannot say whether two lines came from one + # session (a pooled connection reused by the next test) or from two (Windows reuses a PID quickly). + Add-Content -Path "$dataDir\postgresql.conf" -Value "log_line_prefix = '%m [%p] %c '" + & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start + if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } + & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling + if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } + + - name: Install AltCover (pinned) + shell: pwsh + run: | + dotnet tool install altcover.global --version $env:ALTCOVER_VERSION --tool-path "$env:RUNNER_TEMP\altcover-tool" + if ($LASTEXITCODE -ne 0) { throw "altcover install failed ($LASTEXITCODE)" } + $out = "$env:RUNNER_TEMP\altcover-probe" + New-Item -ItemType Directory -Force -Path $out | Out-Null + "PROBE_OUT=$out" | Add-Content -Path $env:GITHUB_ENV + + # About 100 classes, spread over plain unit classes, TestHost/web classes and live PostgreSQL + # classes, taken from the built assembly's own class listing (Stage=Guard classes read source text + # and never run product code, so they are left out). + - name: Choose the sample classes + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $lines = @(& "$env:PROBE_BIN\Darling.Tests.exe" -list classes/json -trait- Stage=Guard) + if ($LASTEXITCODE -ne 0) { throw "class listing failed with exit code $LASTEXITCODE" } + $json = @($lines | Where-Object { $_ -match '^\s*\[' }) + if ($json.Count -ne 1) { throw "expected one JSON array line from the class listing, got $($json.Count)" } + Set-Content -Path "$env:PROBE_OUT\all-classes.json" -Value $json[0] + python .github/scripts/altcover-probe.py select --list "$env:PROBE_OUT\all-classes.json" --tests-dir Darling/Darling.Tests --root . --total 100 --want web=25 --want live=12 --out "$env:PROBE_OUT\classes.txt" --manifest "$env:PROBE_OUT\manifest.json" + if ($LASTEXITCODE -ne 0) { throw "class selection failed ($LASTEXITCODE)" } + + # Baseline: the same classes, un-instrumented. Failures here are recorded, not fatal: this is a + # measurement, and a class that fails plain is a hole in the comparison, not in the tool. + - name: Run the sample classes (plain) + shell: pwsh + env: + DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" + run: | + $PSNativeCommandUseErrorActionPreference = $false + $classes = @(Get-Content "$env:PROBE_OUT\classes.txt" | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & "$env:PROBE_BIN\Darling.Tests.exe" @classArgs -xml "$env:PROBE_OUT\plain.xml" + $code = $LASTEXITCODE + $watch.Stop() + Write-Host "plain run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s for $($classes.Count) classes" + @{ plain_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); plain_exit = $code; classes = $classes.Count } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-plain.json" + exit 0 + + # Instrument IN PLACE (originals saved aside): the tests locate the repository by walking up from + # their own directory, so an instrumented copy outside the checkout would read nothing. Product, + # test and Lite assemblies only; every NuGet dependency stays un-instrumented. --methodpoint keeps + # one visit record per method, which is all a file-level map needs. + - name: Instrument the assemblies (AltCover, callContext on Fact and Theory) + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" --inplace --inputDirectory $env:PROBE_BIN --outputDirectory "$env:PROBE_OUT\saved" ` + --report "$env:PROBE_OUT\coverage.xml" --reportFormat OpenCover ` + --callContext "[Fact]" --callContext "[Theory]" --methodpoint ` + --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Tee-Object -FilePath "$env:PROBE_OUT\altcover-instrument.log" | Select-Object -Last 15 + $code = $LASTEXITCODE + $watch.Stop() + Write-Host "instrument: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" + @{ instrument_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrument_exit = $code } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-instrument.json" + if ($code -ne 0) { throw "altcover instrumentation failed ($code)" } + + - name: Run the sample classes (instrumented, AltCover runner) + shell: pwsh + env: + DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" + run: | + $PSNativeCommandUseErrorActionPreference = $false + $classes = @(Get-Content "$env:PROBE_OUT\classes.txt" | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" Runner --recorderDirectory $env:PROBE_BIN --workingDirectory $env:PROBE_BIN --executable "$env:PROBE_BIN\Darling.Tests.exe" -- @classArgs -xml "$env:PROBE_OUT\instrumented.xml" 2>&1 | Tee-Object -FilePath "$env:PROBE_OUT\altcover-run.log" | Select-Object -Last 25 + $code = $LASTEXITCODE + $watch.Stop() + Write-Host "instrumented run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" + @{ instrumented_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrumented_exit = $code } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-instrumented.json" + exit 0 + + # Overhead comparison only: put the original assemblies back, instrument again with AltCover's default + # (sequence-point and branch) recording, run the same classes, and keep only the timings and the report + # size. The method-level pass above is what the summary and the map read. + - name: Overhead comparison (default line and branch recording) + continue-on-error: true + shell: pwsh + env: + DARLING_TEST_PG: "Host=127.0.0.1;Port=5541;Username=darling;Database=darling" + run: | + $PSNativeCommandUseErrorActionPreference = $false + Copy-Item -Path "$env:PROBE_OUT\saved\*" -Destination $env:PROBE_BIN -Recurse -Force + $altcover = "$env:RUNNER_TEMP\altcover-tool\altcover.exe" + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & $altcover --inplace --inputDirectory $env:PROBE_BIN --outputDirectory "$env:PROBE_OUT\saved-line" ` + --report "$env:PROBE_OUT\coverage-line.xml" --reportFormat OpenCover ` + --callContext "[Fact]" --callContext "[Theory]" ` + --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Out-Null + $instrument = $LASTEXITCODE + $instrumentSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) + if ($instrument -ne 0) { Write-Host "line instrumentation failed ($instrument)"; exit 0 } + $classes = @(Get-Content "$env:PROBE_OUT\classes.txt" | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + $watch.Restart() + & $altcover Runner --recorderDirectory $env:PROBE_BIN --workingDirectory $env:PROBE_BIN --executable "$env:PROBE_BIN\Darling.Tests.exe" -- @classArgs -xml "$env:PROBE_OUT\instrumented-line.xml" 2>&1 | Out-Null + $code = $LASTEXITCODE + $runSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) + $bytes = if (Test-Path "$env:PROBE_OUT\coverage-line.xml") { (Get-Item "$env:PROBE_OUT\coverage-line.xml").Length } else { 0 } + Write-Host "line and branch recording: instrument $instrumentSeconds s, run $runSeconds s (exit $code), report $bytes bytes" + @{ line_instrument_seconds = $instrumentSeconds; line_run_seconds = $runSeconds; line_run_exit = $code; line_report_bytes = $bytes } | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing-line.json" + Remove-Item "$env:PROBE_OUT\coverage-line.xml" -Force -ErrorAction SilentlyContinue + exit 0 + + - name: Summarize the coverage (map for the sampled classes) + if: always() + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $timing = @{} + foreach ($name in 'plain', 'instrument', 'instrumented', 'line') { + $file = "$env:PROBE_OUT\timing-$name.json" + if (Test-Path $file) { (Get-Content $file -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $timing[$_.Name] = $_.Value } } + } + $timing | ConvertTo-Json | Set-Content "$env:PROBE_OUT\timing.json" + # AltCover makes an instrumented async test wait on its own task, so a dynamic skip arrives wrapped in an + # AggregateException and xunit reports a failure. Put those back to skips, but only where the plain run + # skipped the same test. + if ((Test-Path "$env:PROBE_OUT\plain.xml") -and (Test-Path "$env:PROBE_OUT\instrumented.xml")) { + python .github/scripts/altcover-probe.py unwrap-skips --plain "$env:PROBE_OUT\plain.xml" --instr "$env:PROBE_OUT\instrumented.xml" | Select-Object -First 30 + } + $report = "$env:PROBE_OUT\coverage.xml" + if (-not (Test-Path $report)) { Write-Host "::warning::no coverage report was produced"; exit 0 } + $item = Get-Item $report + Write-Host "coverage.xml: $($item.Length) bytes" + python .github/scripts/altcover-probe.py summarize --report $report --root $env:GITHUB_WORKSPACE --manifest "$env:PROBE_OUT\manifest.json" --plain-xml "$env:PROBE_OUT\plain.xml" --instr-xml "$env:PROBE_OUT\instrumented.xml" --timing "$env:PROBE_OUT\timing.json" --tool "altcover $env:ALTCOVER_VERSION" --summary-out "$env:PROBE_OUT\summary.json" --map-out "$env:PROBE_OUT\test-map-probe.json" | Select-Object -First 80 + if (Test-Path "$env:PROBE_OUT\summary.json") { + "### AltCover probe" | Add-Content $env:GITHUB_STEP_SUMMARY + '```json' | Add-Content $env:GITHUB_STEP_SUMMARY + Get-Content "$env:PROBE_OUT\summary.json" -TotalCount 120 | Add-Content $env:GITHUB_STEP_SUMMARY + '```' | Add-Content $env:GITHUB_STEP_SUMMARY + } + exit 0 + + # ── Lite.Tests: the same measurement, in its own scratch folder ────────────────────────────────── + # About 60 classes: collector-starting (they mention RemoteCollectorService), DuckDB-backed, and plain. + - name: Choose the Lite sample classes + if: always() + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $out = "$env:PROBE_OUT\lite" + New-Item -ItemType Directory -Force -Path $out | Out-Null + $lines = @(& "$env:PROBE_BIN_LITE\Lite.Tests.exe" -list classes/json -trait- Stage=Guard) + if ($LASTEXITCODE -ne 0) { throw "Lite class listing failed with exit code $LASTEXITCODE" } + $json = @($lines | Where-Object { $_ -match '^\s*\[' }) + if ($json.Count -ne 1) { throw "expected one JSON array line from the Lite class listing, got $($json.Count)" } + Set-Content -Path "$out\all-classes.json" -Value $json[0] + python .github/scripts/altcover-probe.py select --profile lite --list "$out\all-classes.json" --tests-dir Lite.Tests --root . --total 60 --want collector=8 --want duckdb=20 --out "$out\classes.txt" --manifest "$out\manifest.json" + if ($LASTEXITCODE -ne 0) { throw "Lite class selection failed ($LASTEXITCODE)" } + + - name: Run the Lite sample classes (plain) + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $out = "$env:PROBE_OUT\lite" + $classes = @(Get-Content "$out\classes.txt" | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & "$env:PROBE_BIN_LITE\Lite.Tests.exe" @classArgs -xml "$out\plain.xml" + $code = $LASTEXITCODE + $watch.Stop() + Write-Host "Lite plain run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s for $($classes.Count) classes" + @{ plain_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); plain_exit = $code; classes = $classes.Count } | ConvertTo-Json | Set-Content "$out\timing-plain.json" + exit 0 + + - name: Instrument the Lite assemblies (AltCover, callContext on Fact and Theory) + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $out = "$env:PROBE_OUT\lite" + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" --inplace --inputDirectory $env:PROBE_BIN_LITE --outputDirectory "$out\saved" ` + --report "$out\coverage.xml" --reportFormat OpenCover ` + --callContext "[Fact]" --callContext "[Theory]" --methodpoint ` + --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Tee-Object -FilePath "$out\altcover-instrument.log" | Select-Object -Last 15 + $code = $LASTEXITCODE + $watch.Stop() + Write-Host "Lite instrument: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" + @{ instrument_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrument_exit = $code } | ConvertTo-Json | Set-Content "$out\timing-instrument.json" + if ($code -ne 0) { throw "Lite altcover instrumentation failed ($code)" } + + - name: Run the Lite sample classes (instrumented, AltCover runner) + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $out = "$env:PROBE_OUT\lite" + $classes = @(Get-Content "$out\classes.txt" | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" Runner --recorderDirectory $env:PROBE_BIN_LITE --workingDirectory $env:PROBE_BIN_LITE --executable "$env:PROBE_BIN_LITE\Lite.Tests.exe" -- @classArgs -xml "$out\instrumented.xml" 2>&1 | Tee-Object -FilePath "$out\altcover-run.log" | Select-Object -Last 25 + $code = $LASTEXITCODE + $watch.Stop() + Write-Host "Lite instrumented run: exit $code in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s" + @{ instrumented_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); instrumented_exit = $code } | ConvertTo-Json | Set-Content "$out\timing-instrumented.json" + exit 0 + + - name: Lite overhead comparison (default line and branch recording) + continue-on-error: true + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $out = "$env:PROBE_OUT\lite" + Copy-Item -Path "$out\saved\*" -Destination $env:PROBE_BIN_LITE -Recurse -Force + $altcover = "$env:RUNNER_TEMP\altcover-tool\altcover.exe" + $watch = [System.Diagnostics.Stopwatch]::StartNew() + & $altcover --inplace --inputDirectory $env:PROBE_BIN_LITE --outputDirectory "$out\saved-line" ` + --report "$out\coverage-line.xml" --reportFormat OpenCover ` + --callContext "[Fact]" --callContext "[Theory]" ` + --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Out-Null + $instrument = $LASTEXITCODE + $instrumentSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) + if ($instrument -ne 0) { Write-Host "Lite line instrumentation failed ($instrument)"; exit 0 } + $classes = @(Get-Content "$out\classes.txt" | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + $watch.Restart() + & $altcover Runner --recorderDirectory $env:PROBE_BIN_LITE --workingDirectory $env:PROBE_BIN_LITE --executable "$env:PROBE_BIN_LITE\Lite.Tests.exe" -- @classArgs -xml "$out\instrumented-line.xml" 2>&1 | Out-Null + $code = $LASTEXITCODE + $runSeconds = [math]::Round($watch.Elapsed.TotalSeconds, 1) + $bytes = if (Test-Path "$out\coverage-line.xml") { (Get-Item "$out\coverage-line.xml").Length } else { 0 } + Write-Host "Lite line and branch recording: instrument $instrumentSeconds s, run $runSeconds s (exit $code), report $bytes bytes" + @{ line_instrument_seconds = $instrumentSeconds; line_run_seconds = $runSeconds; line_run_exit = $code; line_report_bytes = $bytes } | ConvertTo-Json | Set-Content "$out\timing-line.json" + Remove-Item "$out\coverage-line.xml" -Force -ErrorAction SilentlyContinue + exit 0 + + - name: Summarize the Lite coverage + if: always() + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + $out = "$env:PROBE_OUT\lite" + $timing = @{} + foreach ($name in 'plain', 'instrument', 'instrumented', 'line') { + $file = "$out\timing-$name.json" + if (Test-Path $file) { (Get-Content $file -Raw | ConvertFrom-Json).PSObject.Properties | ForEach-Object { $timing[$_.Name] = $_.Value } } + } + $timing | ConvertTo-Json | Set-Content "$out\timing.json" + if ((Test-Path "$out\plain.xml") -and (Test-Path "$out\instrumented.xml")) { + python .github/scripts/altcover-probe.py unwrap-skips --plain "$out\plain.xml" --instr "$out\instrumented.xml" | Select-Object -First 30 + } + $report = "$out\coverage.xml" + if (-not (Test-Path $report)) { Write-Host "::warning::no Lite coverage report was produced"; exit 0 } + Write-Host "Lite coverage.xml: $((Get-Item $report).Length) bytes" + python .github/scripts/altcover-probe.py summarize --suite Lite.Tests --report $report --root $env:GITHUB_WORKSPACE --manifest "$out\manifest.json" --plain-xml "$out\plain.xml" --instr-xml "$out\instrumented.xml" --timing "$out\timing.json" --tool "altcover $env:ALTCOVER_VERSION" --summary-out "$out\summary.json" --map-out "$out\test-map-probe.json" | Select-Object -First 80 + if (Test-Path "$out\summary.json") { + "### AltCover probe (Lite.Tests)" | Add-Content $env:GITHUB_STEP_SUMMARY + '```json' | Add-Content $env:GITHUB_STEP_SUMMARY + Get-Content "$out\summary.json" -TotalCount 120 | Add-Content $env:GITHUB_STEP_SUMMARY + '```' | Add-Content $env:GITHUB_STEP_SUMMARY + } + exit 0 + + - name: Stop PostgreSQL + if: always() + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } + exit 0 + + - name: Upload the raw coverage and the summary + if: always() + uses: actions/upload-artifact@v6 + with: + name: altcover-probe + path: | + ${{ runner.temp }}/altcover-probe/coverage.xml + ${{ runner.temp }}/altcover-probe/summary.json + ${{ runner.temp }}/altcover-probe/test-map-probe.json + ${{ runner.temp }}/altcover-probe/manifest.json + ${{ runner.temp }}/altcover-probe/classes.txt + ${{ runner.temp }}/altcover-probe/timing.json + ${{ runner.temp }}/altcover-probe/plain.xml + ${{ runner.temp }}/altcover-probe/instrumented.xml + ${{ runner.temp }}/altcover-probe/instrumented.raw.xml + ${{ runner.temp }}/altcover-probe/altcover-*.log + ${{ runner.temp }}/altcover-probe/lite/coverage.xml + ${{ runner.temp }}/altcover-probe/lite/summary.json + ${{ runner.temp }}/altcover-probe/lite/test-map-probe.json + ${{ runner.temp }}/altcover-probe/lite/manifest.json + ${{ runner.temp }}/altcover-probe/lite/classes.txt + ${{ runner.temp }}/altcover-probe/lite/timing.json + ${{ runner.temp }}/altcover-probe/lite/plain.xml + ${{ runner.temp }}/altcover-probe/lite/instrumented.xml + ${{ runner.temp }}/altcover-probe/lite/instrumented.raw.xml + ${{ runner.temp }}/altcover-probe/lite/altcover-*.log + retention-days: 7 + if-no-files-found: ignore + overwrite: true + + # ── Test map: which test classes run which source files (#5459, change 4 slice 3) ──────────────── + # The producer for the class-to-file map that ci-select.py's map_select() reads. It runs the WHOLE + # Darling.Tests suite (6 shards, with a live PostgreSQL store and the runtime variable) and the WHOLE + # Lite.Tests suite (4 shards) under AltCover (--callContext on [Fact]/[Theory], the version the probe + # job pins), cut by class-name hash exactly as build.yml's Darling PostgreSQL shards are, then joins the + # shards in `test-map` with what the test sources say (path and tool-name literals, the type-name rule, + # whole-tree readers) and uploads `test-map.json.gz` for 14 days. + # It changes no other job's result: nothing lists it in `needs`, both jobs are continue-on-error, and a + # failed test inside a shard only costs that class its coverage. It runs in every re-dispatch that builds + # (the same gate as darling-pg) and in manual dispatches; `-f test_map=false` turns it off for one dispatch. + # Nothing reads the map yet (slice 4 does, in shadow mode). On a branch: + # gh workflow run nightly.yml --ref -f publish=false + test-map-shard: + name: Test map shard (${{ matrix.suite }} ${{ matrix.shard }}) + # Waits for build and darling-pg so the 10 windows legs do not take their runners (#5459); always() keeps the map + # running after a failed one. + needs: [check, build, darling-pg] + if: always() && needs.check.result == 'success' && (needs.check.outputs.has_changes == 'true' || inputs.from_schedule != true) && inputs.test_map != false + continue-on-error: true + runs-on: windows-latest + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - { suite: darling, shard: 0, shards: 6 } + - { suite: darling, shard: 1, shards: 6 } + - { suite: darling, shard: 2, shards: 6 } + - { suite: darling, shard: 3, shards: 6 } + - { suite: darling, shard: 4, shards: 6 } + - { suite: darling, shard: 5, shards: 6 } + - { suite: lite, shard: 0, shards: 4 } + - { suite: lite, shard: 1, shards: 4 } + - { suite: lite, shard: 2, shards: 4 } + - { suite: lite, shard: 3, shards: 4 } + env: + # The version the probe job pins: a map whose tool floats cannot be compared run to run. + ALTCOVER_VERSION: '9.0.145' + MAP_BIN: ${{ github.workspace }}\${{ matrix.suite == 'darling' && 'Darling\Darling.Tests' || 'Lite.Tests' }}\bin\Release\net10.0-windows + MAP_EXE: ${{ matrix.suite == 'darling' && 'Darling.Tests.exe' || 'Lite.Tests.exe' }} + # MAP_OUT (the scratch folder under the runner's temp directory) is exported by the install step: + # the `runner` context is not available in a job-level env block. + steps: + # The dispatched commit, not the ref name: every shard and the join job must read the same tree even + # if dev moves while the run is queued. + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Cache Darling pg-runtime.zip + if: matrix.suite == 'darling' + id: cache-pg-runtime + uses: actions/cache@v6 + with: + path: Darling/artifacts/pg-runtime.zip + key: pg-runtime-${{ runner.os }}-${{ hashFiles('Darling/tools/fetch-pg-runtime.ps1') }} + + - name: Build Darling pg-runtime.zip (cache miss only) + if: matrix.suite == 'darling' && steps.cache-pg-runtime.outputs.cache-hit != 'true' + shell: pwsh + run: ./Darling/tools/fetch-pg-runtime.ps1 + + - name: Extract pg-runtime + if: matrix.suite == 'darling' + shell: pwsh + run: | + $zip = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime.zip" + $dest = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime" + if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $dest) + if (-not (Test-Path "$dest\pgsql\bin\pg_ctl.exe")) { throw "pg-runtime missing pgsql\bin\pg_ctl.exe" } + + - name: Restore Darling.Tests + if: matrix.suite == 'darling' + run: dotnet restore Darling/Darling.Tests/Darling.Tests.csproj --locked-mode + + - name: Build Darling.Tests + if: matrix.suite == 'darling' + run: dotnet build Darling/Darling.Tests/Darling.Tests.csproj -c Release --no-restore + + - name: Restore Lite.Tests + if: matrix.suite == 'lite' + run: dotnet restore Lite.Tests/Lite.Tests.csproj --locked-mode + + - name: Build Lite.Tests + if: matrix.suite == 'lite' + run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore + + # The same throwaway cluster the darling-pg job stands up (CiClusterWorkerSizingTests compares the + # appended settings of every job), so the live classes behave as they do there. + - name: Initialize and start throwaway PostgreSQL + if: matrix.suite == 'darling' + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + $logFile = "$env:RUNNER_TEMP\darling-pg.log" + & "$bin\initdb.exe" -D $dataDir -U darling -A trust --encoding=UTF8 + if ($LASTEXITCODE -ne 0) { throw "initdb failed ($LASTEXITCODE)" } + Add-Content -Path "$dataDir\postgresql.conf" -Value "shared_preload_libraries = 'timescaledb,pg_stat_statements'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "port = 5541" + Add-Content -Path "$dataDir\postgresql.conf" -Value "listen_addresses = '127.0.0.1'" + Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74" + Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85" + # #5549: the session id (%c) on every line. A PID alone cannot say whether two lines came from one + # session (a pooled connection reused by the next test) or from two (Windows reuses a PID quickly). + Add-Content -Path "$dataDir\postgresql.conf" -Value "log_line_prefix = '%m [%p] %c '" + & "$bin\pg_ctl.exe" -D $dataDir -l $logFile -w start + if ($LASTEXITCODE -ne 0) { if (Test-Path $logFile) { Get-Content $logFile -Tail 50 }; throw "pg_ctl start failed ($LASTEXITCODE)" } + & "$bin\createdb.exe" -h 127.0.0.1 -p 5541 -U darling darling + if ($LASTEXITCODE -ne 0) { throw "createdb failed ($LASTEXITCODE)" } + + - name: Install AltCover (pinned) + shell: pwsh + run: | + dotnet tool install altcover.global --version $env:ALTCOVER_VERSION --tool-path "$env:RUNNER_TEMP\altcover-tool" + if ($LASTEXITCODE -ne 0) { throw "altcover install failed ($LASTEXITCODE)" } + $out = "$env:RUNNER_TEMP\test-map" + New-Item -ItemType Directory -Force -Path $out | Out-Null + "MAP_OUT=$out" | Add-Content -Path $env:GITHUB_ENV + + # This shard's classes, cut by the hash build.yml's Darling PostgreSQL shards use, into chunks that + # stay under the Windows command-line cap (#5100). Every class is listed, Stage=Guard ones included: a + # class the map never saw would run as a "new class" on every pull request. + - name: Cut this shard's classes + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $lines = @(& "$env:MAP_BIN\$env:MAP_EXE" -list classes/json) + if ($LASTEXITCODE -ne 0) { throw "class listing failed with exit code $LASTEXITCODE" } + $json = @($lines | Where-Object { $_ -match '^\s*\[' }) + if ($json.Count -ne 1) { throw "expected one JSON array line from the class listing, got $($json.Count)" } + Set-Content -Path "$env:MAP_OUT\all-classes.json" -Value $json[0] + python .github/scripts/test-map.py cut --list "$env:MAP_OUT\all-classes.json" --shards ${{ matrix.shards }} --shard ${{ matrix.shard }} --out "$env:MAP_OUT\cut" + if ($LASTEXITCODE -ne 0) { throw "the cut failed ($LASTEXITCODE)" } + + # Instrument IN PLACE: the tests locate the repository by walking up from their own directory. Product, + # test and Lite assemblies only; every NuGet dependency stays un-instrumented. --methodpoint keeps one + # visit record per method, which is all a file-level map needs (the probe measured it at 0.93x to 1.15x). + - name: Instrument the assemblies (AltCover, callContext on Fact and Theory) + shell: pwsh + run: | + $PSNativeCommandUseErrorActionPreference = $false + & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" --inplace --inputDirectory $env:MAP_BIN --outputDirectory "$env:MAP_OUT\saved" ` + --report "$env:MAP_OUT\coverage.xml" --reportFormat OpenCover ` + --callContext "[Fact]" --callContext "[Theory]" --methodpoint ` + --assemblyFilter "^(?!PerformanceMonitor|Darling|Lite)" 2>&1 | Tee-Object -FilePath "$env:MAP_OUT\altcover-instrument.log" | Select-Object -Last 15 + if ($LASTEXITCODE -ne 0) { throw "altcover instrumentation failed ($LASTEXITCODE)" } + + # One runner invocation per chunk. A failing test is a fact about the tests, not about the map: the + # step never fails on a test exit code, and each chunk's report is kept (a later chunk's report may + # already hold the earlier visits; the join takes the union). DARLING_TEST_PGRUNTIME lights up the + # classes gated on the bundled runtime; the store-upgrade fixtures (~680 MB of archives) are not built + # here, so those classes skip and get text patterns instead of coverage. + - name: Run this shard's classes (instrumented, AltCover runner) + shell: pwsh + env: + DARLING_TEST_PG: ${{ matrix.suite == 'darling' && 'Host=127.0.0.1;Port=5541;Username=darling;Database=darling' || '' }} + DARLING_TEST_PGRUNTIME: ${{ matrix.suite == 'darling' && format('{0}\Darling\artifacts\pg-runtime', github.workspace) || '' }} + run: | + $PSNativeCommandUseErrorActionPreference = $false + $watch = [System.Diagnostics.Stopwatch]::StartNew() + $worst = 0 + $chunks = @(Get-ChildItem "$env:MAP_OUT\cut" -Filter 'chunk-*.txt' | Sort-Object Name) + $number = 0 + foreach ($chunk in $chunks) { + $number++ + $classes = @(Get-Content $chunk.FullName | Where-Object { $_ }) + $classArgs = foreach ($class in $classes) { '-class'; $class } + & "$env:RUNNER_TEMP\altcover-tool\altcover.exe" Runner --recorderDirectory $env:MAP_BIN --workingDirectory $env:MAP_BIN --executable "$env:MAP_BIN\$env:MAP_EXE" -- @classArgs -xml "$env:MAP_OUT\chunk-$number.xml" 2>&1 | Tee-Object -FilePath "$env:MAP_OUT\altcover-run-$number.log" | Select-Object -Last 8 + $code = $LASTEXITCODE + if ($code -gt $worst) { $worst = $code } + Copy-Item "$env:MAP_OUT\coverage.xml" "$env:MAP_OUT\coverage-$number.xml" + Write-Host "chunk ${number}: $($classes.Count) classes, exit $code" + } + $watch.Stop() + Write-Host "instrumented run: $($chunks.Count) chunk(s) in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) s, worst exit $worst" + "$worst" | Set-Content "$env:MAP_OUT\tests-exit.txt" + @{ run_seconds = [math]::Round($watch.Elapsed.TotalSeconds, 1); chunks = $chunks.Count; worst_exit = $worst } | ConvertTo-Json | Set-Content "$env:MAP_OUT\timing.json" + exit 0 + + # AltCover makes an instrumented async test wait on its own task, so a dynamic skip arrives wrapped in an + # AggregateException and xunit reports a failure (see altcover-probe.py). There is no plain run to + # compare with here, so the skip token alone puts the skip back. + - name: Put wrapped skips back + shell: pwsh + run: | + foreach ($xml in @(Get-ChildItem "$env:MAP_OUT" -Filter 'chunk-*.xml')) { + python .github/scripts/altcover-probe.py unwrap-skips --instr $xml.FullName | Select-Object -First 3 + } + exit 0 + + - name: Write this shard's report + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $exit = if (Test-Path "$env:MAP_OUT\tests-exit.txt") { [int](Get-Content "$env:MAP_OUT\tests-exit.txt" -TotalCount 1) } else { -1 } + $reports = @(Get-ChildItem "$env:MAP_OUT" -Filter 'coverage-*.xml' | ForEach-Object { '--report'; $_.FullName }) + $xmls = @(Get-ChildItem "$env:MAP_OUT" -Filter 'chunk-*.xml' | ForEach-Object { '--xml'; $_.FullName }) + if ($reports.Count -eq 0) { throw 'no coverage report was produced' } + python .github/scripts/test-map.py shard-report --suite ${{ matrix.suite }} --shard ${{ matrix.shard }} --root $env:GITHUB_WORKSPACE --listed "$env:MAP_OUT\cut\listed.txt" @reports @xmls --tests-exit $exit --out "$env:MAP_OUT\shard-${{ matrix.suite }}-${{ matrix.shard }}.json" + if ($LASTEXITCODE -ne 0) { throw "shard-report failed ($LASTEXITCODE)" } + + - name: Stop PostgreSQL + if: always() && matrix.suite == 'darling' + shell: pwsh + run: | + $bin = "$env:GITHUB_WORKSPACE\Darling\artifacts\pg-runtime\pgsql\bin" + $dataDir = "$env:RUNNER_TEMP\darling-pgdata" + if (Test-Path "$bin\pg_ctl.exe") { & "$bin\pg_ctl.exe" -D $dataDir -m fast -w stop } + exit 0 + + - name: Upload this shard's report + uses: actions/upload-artifact@v6 + with: + name: test-map-shard-${{ matrix.suite }}-${{ matrix.shard }} + path: ${{ runner.temp }}/test-map/shard-${{ matrix.suite }}-${{ matrix.shard }}.json + retention-days: 3 + if-no-files-found: error + + # Joins the shards into the map. Linux: it only reads JSON and the checkout (git ls-files, the test + # sources). It refuses to write a map when a shard is missing, because a map without a shard's classes + # would run those classes as "new" on every pull request and hide what they cover. + test-map: + name: Test map (join the shards) + needs: test-map-shard + if: always() && needs.test-map-shard.result != 'skipped' && needs.test-map-shard.result != 'cancelled' + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + + - name: Download the shard reports + uses: actions/download-artifact@v6 + with: + pattern: test-map-shard-* + path: shards + + - name: Build test-map.json.gz + run: | + python .github/scripts/test-map.py build --root . --shards shards --sha "${{ github.sha }}" \ + --expect darling=6 --expect lite=4 --out test-map.json.gz --summary test-map-summary.md + cat test-map-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the map + uses: actions/upload-artifact@v6 + with: + name: test-map + path: | + test-map.json.gz + test-map-summary.md + retention-days: 14 + if-no-files-found: error + + # ── Linux artifact + container image (#1804) ───────────────────────────────────────────────────── + # Runs in parallel with the windows build job — it shares only the check job's nightly stamp, + # not any windows artifact. Publishes the linux-x64 service tar.gz with its own checksum file + # (the windows job owns SHA256SUMS.txt; a cross-job rewrite of one file is a race), and builds + # the service container image, saving it to a tar for the publish job below to push — this job + # talks to neither the release nor ghcr. The bundled pg-runtime is deliberately absent from the + # linux artifact — the compose distribution pairs the service with the official + # timescale/timescaledb image, and managed mode stays Windows. + linux: + # Only check: this job reads no output of build's beyond the shared stamp (#3390), and + # nothing here consumes a windows artifact, so it runs in parallel with build rather than + # waiting on it. + needs: check + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: Set nightly version + id: version + shell: bash + env: + NIGHTLY_STAMP: ${{ needs.check.outputs.stamp }} + run: | + set -euo pipefail + base=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) + [ -n "${base}" ] || { echo "::error::Read no version from Directory.Build.props."; exit 1; } + [ -n "${NIGHTLY_STAMP}" ] || { echo "::error::Read no stamp from the check job."; exit 1; } + echo "VERSION=${base}-nightly.${NIGHTLY_STAMP}" >> "$GITHUB_OUTPUT" + echo "Nightly version: ${base}-nightly.${NIGHTLY_STAMP}" + + # -p:Version for the reason the windows build job's version step documents (#3455): the name + # on the tarball is not the stamp in the service, and the informational version is what + # --version prints and what the get_collection_health service block serves. + - name: Publish service (linux-x64) + run: dotnet publish Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj -c Release -r linux-x64 --self-contained false -o publish/DarlingService-linux -p:Version=${{ steps.version.outputs.VERSION }} + + - name: Package linux artifact + checksum + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.VERSION }}" + mkdir -p releases + tar -C publish/DarlingService-linux -czf "releases/PerformanceMonitorDarling-linux-x64-${version}.tar.gz" . + (cd releases && sha256sum "PerformanceMonitorDarling-linux-x64-${version}.tar.gz" > SHA256SUMS-linux.txt && cat SHA256SUMS-linux.txt) + echo "${version}" > releases/linux-version.txt + + # The container image build needs THIS checkout (docker build's context is the repo root, + # #1804), so it stays here rather than moving to publish, which has no source tree. Only the + # PUSH, the signing and the release upload are release-publishing steps, and those move to + # publish below: this job saves the built image to a tar (docker save) and hands it, the + # linux tarball and its checksum file to publish as workflow artifacts. Nothing here talks to + # ghcr or the nightly release. + - name: Build container image (ghcr tags, not pushed) + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.VERSION }}" + image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" + # VERSION reaches the publish that runs INSIDE the image build (#3455): an image tagged + # with the nightly string whose service answers the clean release string is the zip + # defect again, one artifact over. The Dockerfile defaults the arg to empty, so + # build.yml's release image and any hand-run docker build keep publishing against the + # clean declared version. + docker build -f Darling/Dockerfile --build-arg VERSION="${version}" -t "${image}:nightly" -t "${image}:${version}" . + mkdir -p releases + docker save "${image}:nightly" "${image}:${version}" -o releases/darling-container-image.tar + + - name: Upload linux release assets + uses: actions/upload-artifact@v6 + with: + name: linux-release-assets + path: | + releases/PerformanceMonitorDarling-linux-x64-*.tar.gz + releases/SHA256SUMS-linux.txt + releases/linux-version.txt + if-no-files-found: error + retention-days: 3 + + - name: Upload container image + uses: actions/upload-artifact@v6 + with: + name: darling-container-image + path: releases/darling-container-image.tar + if-no-files-found: error + retention-days: 3 + + # ── Publish (#4336 follow-up) ───────────────────────────────────────────────────────────────── + # The one job that talks to the outside world: the GitHub release, the ghcr push, the signature, + # the provenance attestation. It downloads exactly what build and linux built above — nothing + # here rebuilds or republishes — and it needs all three of build, linux and darling-pg to have + # SUCCEEDED, so a red Darling PostgreSQL run can no longer ship a release built on top of it. The + # workflow_dispatch `publish` input is a second, independent gate for a diagnostic re-run that + # wants the build and test coverage without touching the release. + publish: + needs: [build, linux, darling-pg] + if: | + needs.build.result == 'success' && + needs.linux.result == 'success' && + needs.darling-pg.result == 'success' && + (github.event_name != 'workflow_dispatch' || format('{0}', inputs.publish) != 'false') + # A scheduled re-dispatch and a manual dispatch both land the same tag (`nightly`); two + # close dispatches otherwise race on this job's delete-then-create of the release, so only + # one publish runs at a time and a queued second one waits rather than colliding. + concurrency: + group: nightly-publish + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + # #1804: pushes the nightly container image to ghcr. + packages: write + # Sigstore keyless signing + GitHub provenance for the linux artifact: id-token lets this job + # obtain its OIDC identity (Fulcio issues the short-lived signing cert against it), + # attestations lets attest-build-provenance store the tarball's provenance. + id-token: write + attestations: write + + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.ref_name || 'dev' }} + + - name: Download windows release assets + uses: actions/download-artifact@v6 + with: + name: windows-release-assets + path: releases + + - name: Download the nightly version string + uses: actions/download-artifact@v6 + with: + name: windows-version + path: . + + - name: Download linux release assets + uses: actions/download-artifact@v6 + with: + name: linux-release-assets + path: releases + + - name: Download container image + uses: actions/download-artifact@v6 + with: + name: darling-container-image + path: releases + + - name: Read the nightly version string + id: version + shell: bash + run: | + set -euo pipefail + version="$(cat windows-version.txt)" + [ -n "${version}" ] || { echo "::error::windows-version.txt was empty."; exit 1; } + echo "VERSION=${version}" >> "$GITHUB_OUTPUT" + echo "Nightly version: ${version}" + + # The full SHA build actually checked out (written by the build job's own `git rev-parse + # HEAD`), so the release below names the tree that was built rather than dev's tip at + # publish time — 20-60 minutes later, once darling-pg finishes. + - name: Read the built commit SHA + id: commit + shell: bash + run: | + set -euo pipefail + sha="$(cat windows-commit-sha.txt)" + [ -n "${sha}" ] || { echo "::error::windows-commit-sha.txt was empty."; exit 1; } + echo "SHA=${sha}" >> "$GITHUB_OUTPUT" + echo "Built commit: ${sha}" + + # publish takes its release version from the windows handoff but ships linux's tag and + # container image too; a mismatch would mean the two build jobs disagreed about which + # nightly stamp they were building, and shipping under one label anyway is the wrong call. + - name: Verify windows and linux versions match + shell: bash + run: | + set -euo pipefail + windows_version="${{ steps.version.outputs.VERSION }}" + linux_version="$(cat releases/linux-version.txt)" + if [ "${windows_version}" != "${linux_version}" ]; then + echo "::error::Windows and Linux nightly versions do not match. windows=${windows_version} linux=${linux_version}" + exit 1 + fi + echo "Windows and Linux versions match: ${windows_version}" + + - name: Delete previous nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release delete nightly --yes --cleanup-tag || true + + - name: Create nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.VERSION }}" + sha="${{ steps.commit.outputs.SHA }}" + short_sha="${sha:0:7}" + echo "Naming the release after the built commit: ${sha}" + body=$(cat < These builds include the latest changes and may be unstable. + > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest). + EOF + ) + + gh release create nightly \ + --target "${sha}" \ + --title "Nightly Build (${version})" \ + --notes "${body}" \ + --prerelease \ + releases/*.zip releases/*.exe releases/SHA256SUMS.txt + + - name: Upload linux artifact to the nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload nightly releases/PerformanceMonitorDarling-linux-x64-*.tar.gz releases/SHA256SUMS-linux.txt --clobber + + - name: Push container image (ghcr, :nightly) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + version="${{ steps.version.outputs.VERSION }}" + image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" + echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + docker load -i releases/darling-container-image.tar + docker push "${image}:nightly" + docker push "${image}:${version}" + + # Keyless Sigstore signing: Fulcio issues a short-lived certificate against this job's OIDC + # identity and the signature lands in ghcr next to the image, logged in Rekor. No keys exist + # anywhere to manage or leak — the signature attests "built by this repository's workflow", + # which is the claim a container consumer actually wants verified. Verify: + # cosign verify ghcr.io/erikdarlingdata/performancemonitor-darling:nightly \ + # --certificate-identity-regexp 'github.com/erikdarlingdata/PerformanceMonitor' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign container image (keyless, Sigstore) + shell: bash + run: | + set -euo pipefail + image="ghcr.io/${{ github.repository_owner }}/performancemonitor-darling" + digest="$(docker inspect --format='{{index .RepoDigests 0}}' "${image}:nightly")" + cosign sign --yes "${digest}" + + # GitHub-native provenance for the tarball (SLSA): verified with + # gh attestation verify PerformanceMonitorDarling-linux-x64-.tar.gz -R erikdarlingdata/PerformanceMonitor + - name: Attest the linux tarball (GitHub provenance) + uses: actions/attest-build-provenance@v4 + with: + subject-path: releases/PerformanceMonitorDarling-linux-x64-*.tar.gz