Skip to content

Juniper runner routing

This is the per-job routing ledger for the shared self-hosted juniper-ci runner pool: which GitHub Actions jobs run self-hosted, which stay GitHub-hosted, and the controls that make each decision safe. It was relocated verbatim from the root CLAUDE.md.

Read this before changing any runs-on: value, and run .github/scripts/validate-workflows.sh afterwards — every route below is pinned by a focused contract script that fails CI when the pin drifts. The quick-reference summary lives in .claude/rules/ci-workflows.md.

Shared Juniper runner boundary

Default posture: prefer the shared juniper-ci self-hosted pool where possible; GitHub-hosted (ubuntu-latest) is the exception that needs a reason. Hosted minutes are metered and the org Actions spending limit was exhausted twice on 2026-08-17/18, each time blocking every hosted job across every repo (the signature is a 2-3s failure with steps: 0 and no logs — gh api repos/camaradesuk/syrf/check-runs/<job-id>/annotations names the cause). Self-hosted consumes no budget and is generally faster.

The reasons that still justify staying hosted, and the only ones that do by default:

  1. Fork-originated code. Untrusted PR code must never run on the shared host. The *-fork hosted equivalents, the same-repository head gates, contents: read, and non-persisted checkout credentials all stay exactly as they are.
  2. Undeclared host-tool dependencies. Check before routing (see the helm rollback below); install the tool via a camaradesuk/server-config role or stay hosted, or, for a job-scoped CLI binary, a pinned checksum-verified runtime install as the Preview jobs do (see the 2026-08-31 record).
  3. A specific, written reason recorded here — not inherited caution.

Holding a secret is not by itself a permanent disqualifier — the pool already runs the Claude agent jobs with an OAuth token and write authority — but it is only acceptable on top of three controls together, not trigger-gating alone:

  1. Host-level listener isolation — a prerequisite of the host, not something CI can check. Secret-bearing jobs became acceptable on Juniper only once every listener enforced PrivateTmp, MemorySwapMax=0, per-listener CPU/memory/task caps and post-job cleanup; before that the listeners had no isolation whatsoever. The active persistent pool has twelve Pomegranate listeners. All carry juniper-normal-ci, juniper-elastic, and pomegranate-ci; only pomegranate-01 also carries the exclusive pomegranate-e2e label. Ordinary jobs must therefore request juniper-normal-ci as well as the group, while each specialized route must request its complete workload-specific label set. Their provisioning and runtime/tool baseline are owned by camaradesuk/server-config, and they must be online and revalidated before a SyRF route is enabled. That history is recorded as rationale in validate-workflows.sh (~line 895), but the script cannot assert host controls — it only checks workflow-YAML-observable properties. So this control is verified by the camaradesuk/server-config roles that provision the listeners, and a credentialed job must not run outside the audited group — which is the shared CAMARADES group juniper-ci, and only that group. Routing a secret-bearing job to any other self-hosted group satisfies the letter of this rule while violating it.
  2. Trusted triggers — the author_association gate for claude.yml, and the label-bound policy job for claude-code-review.yml (which triggers on pull_request, not pull_request_target, because the Claude GitHub App token exchange rejects pull_request_target OIDC tokens — see the Claude agent jobs bullet). The controller is loaded from the repository's independently resolved protected default-branch tip and only accepts PRs targeting that branch. The review policy requires the exact claude-review label event from an actor whose GitHub role_name is maintain or admin, binds it to the current same-repository non-draft head, and rejects forks/stale requests before checkout. The validator and focused policy fixtures enforce these mechanisms; changing one deliberately fails CI until the pinned contract is updated.
  3. Least-privilege permissions:, non-persisted checkout credentials where the job's own contract requires them (claude.yml is a documented exception and deliberately persists them — follow the per-job bullet, not this summary), and — for claude-code-review.ymltrack_progress: false, which the validator enforces separately because the action's progress/tag mode fetches before configuring Git authentication while direct agent mode authenticates first (see the Claude agent jobs bullet further down this section, and upstream PR #1340).

The per-job contracts below remain binding and take precedence over this preamble: where a specific bullet names a job as hosted, that bullet governs until that job is individually reviewed and its bullet updated. This preamble changes the default for new or unreviewed work; it does not silently re-route anything already pinned below.

2026-08-31 paid-minute migration (superseding routing record)

This record supersedes any older “remain hosted” statement in the dated bullets below for the jobs it names. General same-host or abstract trust-boundary concerns do not block internal same-repository work; the concrete boundary is that fork-authored executable code must not reach the persistent pool with internal credentials.

  • Preview version calculation (_preview-gitversion.yml:version) and reusable image building (_docker-build.yml:build) use juniper-ci with [self-hosted, linux, x64, juniper-normal-ci]. The Angular artifact build, Lambda packaging, image retagging, Git tags, deployment/status/GitOps orchestration, Argo verification, failure reporting, label repair, and authorized reseed jobs in pr-preview.yml use the same route. The remaining hosted Preview jobs are exactly check-label-hosted, check-label, and cleanup-tags: deterministic fork denial, its fork-capable selector, and the not-yet-proven multi-cloud teardown lane. Fork preview builds are denied without checkout; create-deployment is same-repository-only; /reseed-db verifies OWNER/MEMBER/COLLABORATOR association and same-repository provenance before any credential-bearing step.
  • The Preview GitOps jobs refresh-metadata, write-versions, reseed-database, and wait-for-health provision yq (pinned v4) and kubectl (pinned to the cluster's control-plane minor, ±1 skew accepted) at runtime: an Install yq / Install kubectl step downloads a pinned release binary into $RUNNER_TEMP, verifies its published SHA-256, and adds it to GITHUB_PATH. This is the documented exception to "install via server-config or stay hosted" for a job-scoped CLI tool that needs no daemon, no root, and no host state — mikefarah/yq@v4 is a Docker action and never puts yq on PATH; GitHub's hosted image preinstalls both yq and kubectl, Juniper does not. Bumping either pin means updating the version and re-checking the checksum source; tools that need root, a daemon, or host state (Docker, Helm, the gcloud SDK) still go through server-config.
  • Same-repository PR validation now also includes validate-pdf-path-case-folding and test-identity-integration on ordinary Juniper capacity. Their explicit *-fork counterparts remain on ubuntu-latest. test-quartz-integration uses one hosted internal/fork lane: its SQL Server container exited during startup in two clean Pomegranate capability runs on PR #3027, so moving it would weaken required coverage. The shared detect-changes and tokenless test-summary jobs remain hosted because one output/fan-in graph serves both internal and fork routes.
  • sonar-web joins sonar-api, sonar-pm, sonar-quartz, and sonar-identity on the Pomegranate subset, requesting [self-hosted, linux, x64, juniper-normal-ci, pomegranate-ci]. All five are same-repository-only, token-minimal, full-history, non-persisting checkouts with isolated state and always() cleanup. Forks cannot launch Sonar.
  • The E2E heavy body remains on pomegranate-e2e; its base-defined, no-checkout check-label, comment, and cleanup jobs use ordinary Juniper capacity. pull_request_target keeps fork code out of every self-hosted E2E job.
  • .github/scripts/test-self-hosted-migration-routing.sh, the lane contracts, and EXPECTED_GLOBAL_SELF_HOSTED_ROUTES are the executable inventory. CodeQL routing is intentionally unchanged.

  • Both pr-tests.yml .NET unit routes retain the full Release solution build and analyzers but pass GenerateOpenApi=false. The matching dedicated OpenAPI routes own the API build, NSwag execution, and generated-contract diff, so do not restore duplicate build-time generation to the unit lanes.

  • The self-hosted routes in ci-cd.yml are test-dotnet, test-dotnet-integration, and test-web. All three must use the shared CAMARADES group juniper-ci with labels [self-hosted, linux, x64, juniper-normal-ci], run only on refs/heads/main, keep permissions at exactly contents: read, avoid all secrets-context references, and check out with persisted credentials disabled. The workflow root and its always() summary exception must fail closed outside the main ref so the whole graph stays main-only.
  • The automatic PR test routes on Juniper are the read-only pr-tests.yml validation gates (validate-generated-code, validate-openapi-spec, test-charts, and check-gsd-state) plus test-web and test-dotnet; they are an explicitly accepted shared-host risk and are pinned to same-repository PR heads. The lightweight detect-changes and no-checkout test-summary orchestration jobs remain hosted so one output graph can select the Juniper jobs for same-repository heads and explicit GitHub-hosted *-fork counterparts for fork heads. Fork source must never be checked out or executed on a persistent listener. Every checked-out Juniper route stays at exactly contents: read, has no secret/OIDC/write authority, uses non-persisted checkout credentials, has a bounded timeout, and delegates its always() cleanup through .github/scripts/juniper-job-cleanup.sh. The six exact invocations are pinned in the validation-gate, Web and .NET routing contracts; hosted-fork jobs do not invoke it. The helper replaces the Actions step shell with exec, arms trap 'cancelled=1' TERM INT before meaningful work, derives deletion roots internally from RUNNER_TEMP plus lane-owned basename-only keys, captures each command status, and aggregates final absence and cleanliness proofs into one verdict. Its fixed standard, toolchain, and workspace profiles prevent workflow callers from supplying cleanup commands or arbitrary paths. OpenAPI and .NET use the toolchain profile's bounded lingering-writer removal/backoff plus tracked-file restore; generated-code, Web and charts use standard exact-child removal; GSD uses workspace-only cleanup. The direct behavioural contract exercises invalid addressing, every profile, retry bounds, signal-driven retries, persistent failure aggregation and failed-status reporting, so workflow validators only need to prove that each lane delegates to the shared implementation. Juniper budgets are sized from measured shared-host runs plus the mandatory residue cleanup, which must never be the step that gets cancelled: generated-code 15 minutes, OpenAPI 25, web 25 and GSD-state 5; the hosted fork counterparts retain 5, 10, 15 and 2. The .NET lane is sized the same way at 25 minutes, with 15 for its hosted fork counterpart. Every one of these is pinned exactly (not at most) in test-pr-validation-gates-runner-routing.sh, test-pr-web-runner-routing.sh and test-pr-dotnet-runner-routing.sh, each budget declared once in its script so the assertion and its negative fixtures cannot drift, and each carrying fixtures one minute either side, so silently narrowing a budget fails CI rather than quietly reintroducing the cancelled-cleanup failure. The generated-code validator isolates its pnpm store; the OpenAPI validator isolates .NET/NuGet, pnpm, and temporary state; and the chart validator isolates Helm state. The Juniper web job retains the 25-minute/4-GiB-per-process/two-worker caps, disables setup-node's automatic package-manager cache, and isolates pnpm state under a run-unique runner.temp path. The Juniper .NET job uses a 25-minute timeout and a four-processor cap, verifies Helm, and isolates .NET CLI plus NuGet global-package, HTTP, scratch and plugin-cache state under a run-unique runner.temp root; Testcontainers cleanup remains Ryuk plus the host's scoped age sweeper, never job-local or blanket Docker pruning. Workflow/route-contract changes must trigger every affected real lane. The existing twelve-listener pool and its per-listener CPU/memory/task limits are the concurrency boundary; do not add a constant Actions concurrency group because GitHub retains only one pending job per group and replaces older queued work. The reviewed sonar-api, sonar-pm, sonar-quartz, and sonar-identity routes use Pomegranate as documented below; every other Sonar, token, container-integration, publish, registry, GitOps, deploy, production, and privileged PR job remains hosted. Mutually exclusive token-bearing route consumers must use !cancelled() rather than always() so obsolete jobs stop. The shared PR cleanup helper is repository-owned, so checkout is intentionally the first step in all six lanes and the routing contract rejects any command inserted before it. If checkout itself fails after partially modifying the workspace, the helper may not exist; the separately provisioned root-owned Juniper ACTIONS_RUNNER_HOOK_JOB_COMPLETED is the authoritative backstop. It purges the listener _work, temp and cache roots, proves them empty, and retains the fail-closed active-job marker when proof fails. This host control is verified by camaradesuk/server-config, not duplicated in SyRF workflow code.
  • The Preview entry routes check-label-juniper and detect-changes-juniper follow the same accepted same-repository boundary. Fork PRs use the explicit GitHub-hosted label fallback; comment events bypass both label routes and go directly to the independent /reseed-db handler. The no-checkout route selectors remain hosted. The workflow has no workflow_dispatch trigger: a selected older ref can execute that ref's older workflow implementation, so a default-branch no-op guard is not a sufficient safety boundary. There is no hosted detection fallback: it could only have run for fork heads, which must not reach the build chain — every job below it pushes to GHCR and reads repository secrets that fork pull_request runs are never granted. It was removed rather than left as a dead arm in the detect-changes fan-in. Fork previews therefore require a separately authorized promotion path rather than the shared build chain. For the same reason the always()-guarded publishing tail (retag-unchanged, write-versions, update-pr-status, create-tags) must gate on needs.detect-changes.result == 'success': always() is broader than !cancelled(), and a skipped detection reaches those jobs with every *_changed output empty, which reads as "nothing changed" and copies :latest onto the PR's head SHA before shipping those tags to cluster-gitops. .github/scripts/test-preview-entry-gates-runner-routing.sh pins the publishing-tail guards plus the fork exclusions on the secret-bearing metadata refresh and write-token label repair. Both Juniper jobs are read-only, have non-persisted checkout credentials, bounded timeouts, run-unique state, and always() cleanup whose root is derived from job-level keys even when preparation fails. The label gate cannot mutate pull requests; its rare lock-db/use-snapshot repair is handed to a separate no-checkout hosted job with only pull-requests: write. No Preview build, package, registry, tag, GitOps, deployment, reseed, cleanup, or other credentialed job is moved by this entry-gate carve-out.
  • The default-false run_trusted_tests_only dispatch input selects both trusted test lanes — test-web and test-dotnet, both self-hosted on juniper-ci. The canary allowlist and the main-workflow self-hosted allowlist happen to coincide again as of 2026-08-11, but they are separately defined and have diverged before (between 2026-08-07 and 2026-08-11 test-dotnet was canary-allowed while GitHub-hosted). Keep asserting them independently; do not collapse one into the other. Apart from detect-changes and workflow-summary orchestration, every other job must have a top-level AND deny guard for the canary.
  • The token-bearing sonar-api, sonar-pm, sonar-quartz, and sonar-identity PR consumers run on the persistent Pomegranate capacity in juniper-ci, requesting [self-hosted, linux, x64, juniper-normal-ci, pomegranate-ci]. They remain same-repository-only, retain exactly contents: read, check out full history first with persisted credentials disabled, use action-managed JDK 21 and .NET 10 (with .NET 8 additionally retained for Quartz), cap .NET at four processors, isolate .NET CLI/NuGet/temp state under a run-unique runner.temp root, and delegate always() cleanup to the shared toolchain profile. Their 25-minute budgets, exact service-token references, route labels, trigger gates, and first-step/cleanup contracts are pinned by test-pr-pomegranate-sonar-runner-routing.sh; changes to that contract force all four real scans. Optional persistent Sonar cache optimisation is not a routing prerequisite. All remaining Sonar jobs stay GitHub-hosted. Forks cannot launch any token-bearing scan.
  • The heavy e2e-tests.yml/e2e-tests job is the sole E2E consumer of the dedicated Pomegranate lane in juniper-ci, requesting exactly [self-hosted, linux, x64, pomegranate-e2e]; its lightweight label gate, PR comment, and label-cleanup jobs remain GitHub-hosted. Its PR event uses pull_request_target, so the immutable base-branch workflow denies fork heads before explicitly checking out the accepted same-repository head SHA. Manual Docker-lane dispatch is permitted only when the selected workflow SHA is the repository's exact current protected default-branch tip; the job has only contents: read and checks out with persisted credentials disabled. Changes to the E2E workflow or its routing contract force a live PR smoke and a full main run. Before checkout it proves exclusive placement on pomegranate-01, the exact root-owned lifecycle hooks, Docker/Compose/exec, fixed-port and container-name availability, and clean labelled Docker state; Chromium system dependencies are host-provisioned and the job installs the browser without sudo, while awscli-local is isolated beneath run-unique runner temp. The existing per-PR/main concurrency group and 45-minute timeout remain binding. Fixed ports and stack names are exclusive; all Compose publications and host .NET listeners bind to 127.0.0.1; Compose services and LocalStack Lambda executors have no-swap CPU/memory/PID limits aggregating to the listener's four-core allowance. Services run in isolated process groups, and same-job always() teardown removes those groups plus the exact Compose/Lambda objects; the root-owned completion hook is the cancellation backstop for labelled containers, network, volumes, reservations, PID files, checkout, temp, and cache residue. Resource profiling aggregates the runner and job-owned Docker cgroups, measures only exact job container/volume mutable bytes, and separately reports new exact job-referenced image IDs by virtual size; daemon-wide image totals are prohibited. .github/scripts/test-e2e-concurrency.sh pins this routing, trust, prerequisite, cleanup, and reporting contract; fork or untrusted code must never reach this Docker-socket lane.
  • The test-dotnet unit lane may start its existing untagged Testcontainers fixtures on Juniper. The individually approved trusted-main test-dotnet-integration lane uses the same shared-Docker ownership contract: Ryuk stays enabled as the primary cleanup mechanism and the host runs an hourly, two-hour-age, org.testcontainers=true-scoped backstop sweeper; neither job may prune Docker or perform blanket container cleanup. The integration lane is excluded from run_trusted_tests_only, retains its exactly pinned 25-minute timeout and four-processor cap, serializes project execution, verifies Docker before test work, isolates .NET CLI/NuGet/temp state beneath a run-unique runner.temp root, and removes/verifies only its job-owned workspace and temp residue in an always() step. Every integration-reached Testcontainers builder applies an owner label; when SYRF_TEST_JOB_KEY identifies a CI-owned container it also applies 2-CPU/no-swap/256-PID limits and a 2-GiB memory cap (3 GiB and a 1024-PID limit for SQL Server, which spawns a worker thread per host logical processor at boot regardless of the CPU quota; 256 killed it on the Pomegranate pool), while local Testcontainers retain their previous resource behaviour. The legacy raw-Docker Quartz fixture preserves its stable port/name, reuse and unless-stopped policy for local development, but in CI uses a sanitized run-unique name, the same owner/sweeper labels and SQL Server caps, --restart no, an atomically allocated loopback-only port, and removes that exact job-owned container in finally. Deploy, release, promotion, preview, token, publish, registry, GitOps, production, and privileged-PR jobs remain GitHub-hosted except for the four reviewed Pomegranate Sonar routes above. Every further move still requires its own review, the audited-listener isolation controls, all three controls above (audited-listener isolation, trusted triggers, least privilege), an update to the bullet that names it, and an update to EXPECTED_GLOBAL_SELF_HOSTED_ROUTES in .github/scripts/validate-workflows.sh. Do not add a SyRF-specific runner group or routing label; use the shared juniper-ci group and org-wide juniper-normal-ci label, adding the org-wide pomegranate-ci label only for reviewed Pomegranate capacity.
  • test-dotnet was rolled back to ubuntu-latest on 2026-08-07 and restored to juniper-ci on 2026-08-11 once the root cause was fixed: SyRF.Identity.Endpoint.Tests invokes helm as a bare process name, which GitHub-hosted images ship and the Juniper hosts did not. Helm v3.21.3 is installed system-wide by the helm role in camaradesuk/server-config#156, and the lane records helm version --short before restore/build/test work. Before routing any job self-hosted, check for undeclared host-tool dependencies (helm, crane, docker, cloud CLIs): a bare Process.Start/run: invocation of a tool the workflow never installs is exactly this trap. A change to the main workflow must itself trigger the affected lane so routing changes cannot merge without live evidence.
  • The two Claude agent jobs (claude.yml, claude-code-review.yml) also run on juniper-ci, matching every other repository in the pool. The claude-code-review.yml orchestration jobs start-required-check and policy use that same route: neither executes pull request code (start-required-check has no checkout and only calls the GitHub API; policy checks out only the protected default branch with persist-credentials: false) and neither references a secret, while the workflow's nine pull_request_target action types delivered ~120 times a day were costing two whole billable hosted minutes per delivery for ~20 seconds of work. finalize and failure-finalizer stay on ubuntu-latest so a terminal policy status survives a self-hosted failure; all four runner pins live in .github/scripts/test-claude-review-policy.sh and both new routes are listed in EXPECTED_GLOBAL_SELF_HOSTED_ROUTES. They hold an OAuth token and write authority, so their triggers must stay gated to trusted sources. claude.yml retains its exactly pinned per-event author_association predicate. claude-code-review.yml is opt-in only: a protected-default-branch policy job permits the review job only for a current claude-review label whose latest label-timeline actor has GitHub role_name maintain/admin, on an open, non-draft, same-repository PR targeting the default branch. The controller is always checked out from the independently resolved protected default-branch tip. The review job requests id-token: write and must not override github_token on the anthropics/claude-code-action@v1 step: this lets the action perform its own Claude GitHub App OIDC exchange and post review comments under the Claude App identity, rather than the generic github-actions[bot] identity a job-scoped token produces. The exchange is trigger-sensitive: it rejects OIDC tokens minted for pull_request_target runs (App token exchange failed: 401 Unauthorized - Invalid OIDC token on every run from the 2026-09-01 rewrite onward — runs 33477820821, 33611937760, 33614658203, one of them carrying a workflow file byte-identical to main), and accepts them from pull_request runs and from claude.yml's issue_comment runs (run 33615411297 posted as claude[bot]). The workflow therefore triggers on pull_request; the Console connection was never the cause. Under pull_request the workflow file comes from the PR head, but the policy controller is still checked out from the protected default branch, the exchange itself refuses to issue an App token when the workflow file on the run differs from the default-branch version (Workflow validation failed. The workflow file must exist and have identical content to the version on the default branch), and a repository push ruleset ("Restrict file paths", no bypass actors) covers .github/workflows/claude-code-review.yml, .github/workflows/claude.yml and .github/scripts/claude-review-policy.sh, which admins disable temporarily to change those files. The exchange still fails closed on the review step if the App connection is ever lost, rather than silently posting under the wrong identity. Open PRs whose branch predates the trigger change publish no claude-review/policy status until they merge main. claude-review/policy is an exact-head required status context started pending by a no-checkout job before policy setup: ordinary PR events complete it without Claude, while a requested review cannot complete it until policy setup, exact-head review, and finalization all succeed; protected-source failure finalization makes setup failures explicit. A review that never ran must not complete it either: anthropics/claude-code-action exits 0 and logs Skipping action due to workflow validation when the App token exchange refuses the run's workflow identity, so the review job fails closed when the action produced no execution_file output (observed on run 33618935430, which published claude-review/policy = success with no review posted). A successful action is not proof of a delivered review either: run 33623961896 obtained the App token and reached subtype: success in num_turns: 2 (real reviews take 12-13 turns) while posting no inline comments and no summary, so the job additionally fails closed when no claude[bot] verdict comment was posted for this run, read with the job-scoped token from the PR's issue comments and bounded to comments created after the run started. The separate claude-review/request-owner status is internal cancellation ownership, is bound to the latest label event, and must not be required. An authoritative synchronize invalidates a still-labelled request and explicitly cancels every verified nonterminal prior-head run owned by that PR, including runs skipped over by rapid successive pushes, but synchronize deliveries use run-unique concurrency and label-event ordering, with equal second-resolution timestamps treated as ambiguous, so an older delivery cannot pre-cancel a newer request. Synchronize never cancels a current-head run and revalidates label/request ownership immediately before stale status publication. Closed deliveries bypass open-owner status startup so they can cancel the verified owned run; because they publish no status at all, they end successfully once that cancellation is confirmed rather than leaving a red run on every merged PR, while an unconfirmed cancellation still fails closed. Removal cancels only a verified nonterminal owner for the removed label-application ID, revalidates before and after cancellation/publication, and fails closed if cancellation cannot be confirmed; remove/reapply deliberately re-requests; paginated, ID-ordered status ownership plus exact API and label-timeline fencing prevent older lifecycle deliveries, unrelated label events, job reruns, or shared-SHA PRs from overwriting newer requests. Forks and unsafe requests fail before checkout. Main branch protection must require the claude-review/policy status context and conversation resolution after the workflow exists on main, so a completed review with substantive inline threads remains merge-blocking until every thread is addressed and resolved. The focused policy fixture pins all these paths. The review job must retain both non-persisting checkouts (protected controller and exact head), its exactly pinned 60-minute timeout, one non-empty direct prompt and disabled track_progress. Keep that workaround until an action release includes upstream PR #1340; the validator enforces the temporary contract.
  • Active JavaScript actions use Node 24-compatible releases. When updating an action, retain its existing workflow semantics and choose a Node 24-compatible release; immutable pins must identify the release they pin. Archived workflows are historical reference only and are not part of this runtime baseline. Run .github/scripts/validate-workflows.sh after workflow changes; it enforces the checkout contract used on Juniper.
  • Preview version calculation is consolidated through _preview-gitversion.yml: one read-only GitHub-hosted job checks out the exact PR head with full history, runs GitVersion once for each changed service configuration (including Lambda-only S3 Notifier changes outside the Docker matrix), and forwards the existing output contract plus per-service outcomes. Web artifacts, each Docker matrix entry, and S3 Notifier packaging use only their matching successful outcome, so successful siblings may stage images or packages after an unrelated calculation fails. Those staged artifacts are not activated: write-versions, git tags, and deployment progression remain fail-closed on aggregate version success and required build prerequisites; write-versions may accept a skipped version job only when change detection proves that no service changed. Status output must identify per-service failures and a staged-but-blocked Lambda package rather than claiming activation. test-ci-cd.yml provides a manual version-only parity canary that independently recalculates all eight configs, fails on any consumed-output mismatch, and cannot build, publish, deploy, promote, or mutate an environment.
  • validate-workflows.yml deliberately has four root execution lanes plus a tokenless summary. actionlint stays isolated because reviewdog needs pull-requests: write; script-tests and custom-validation stay in separate contents: read jobs because they execute PR-controlled repository scripts and therefore require independent fresh checkouts and environments. Only shellcheck, yamllint, inline workflow-security scans, and inline services-JSON validation share the consolidated passive-validation job. The final summary has permissions: {}, depends on exactly those four root jobs, reports the four passive outputs plus the two executable-job results, and fails unless every dependency succeeds.
  • Passive validation is collect-all but fail-closed. Each passive leaf check uses continue-on-error, and evaluator steps convert every failed or skipped leaf into the corresponding published failure before final passive aggregation exits non-zero. The final aggregation itself must never use continue-on-error. A failed yamllint install/configuration fails yamllint while unrelated shell, security, and JSON validators still execute. The hardcoded-secret and dangerous-command pattern findings preserve their pre-consolidation advisory-only contract: they emit warnings but do not fail CI; the workflow_security output and the summary's Security advisory execution row report whether those checks executed successfully, not whether they found advisory matches. Missing .github/services.json is deliberately fail-closed: a change that removes this required validation input must update the validation design and deterministic fixture rather than passing as "nothing to validate". Do not rejoin a PR-controlled executable validator or fixture to the passive job; the consolidation contract pins the job graph, separate checkouts, least privilege, non-persisted credentials, passive outputs, summary fan-in, and an executable-rejoin negative fixture. In script-tests, yq verification and the consolidation contract must run immediately after checkout, before any mutable repository fixture; the independent custom validator pins that prefix, and the consolidation contract pins both authoritative launcher bodies and rejects disabling conditions.