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.
PR application checks follow product paths and concrete execution/routing contracts. Editing
validate-workflows.sh alone (for example, updating a Claude review contract) runs workflow
validation without selecting .NET, Angular, integration or application scan jobs. Changes to
pr-tests.yml, shared runner cleanup, or a dedicated PR routing contract still select their live
lanes; combining validator edits with product changes preserves the product checks.
.github/scripts/test-pr-change-detection.sh executes the actual detector with fixtures for both
cases. This changes job selection only: required checks and runner security controls are unchanged.
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:
- Fork-originated code. Untrusted PR code must never run on the shared host. The
*-forkhosted equivalents, the same-repository head gates,contents: read, and non-persisted checkout credentials all stay exactly as they are. - Undeclared host-tool dependencies. Check before routing (see the
helmrollback below); install the tool via acamaradesuk/server-configrole 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). - 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:
- 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 carryjuniper-normal-ci,juniper-elastic, andpomegranate-ci; onlypomegranate-01also carries the exclusivepomegranate-e2elabel. Ordinary jobs must therefore requestjuniper-normal-cias well as the group, while each specialized route must request its complete workload-specific label set. Their provisioning and runtime/tool baseline are owned bycamaradesuk/server-config, and they must be online and revalidated before a SyRF route is enabled. That history is recorded as rationale invalidate-workflows.sh(~line 895), but the script cannot assert host controls — it only checks workflow-YAML-observable properties. So this control is verified by thecamaradesuk/server-configroles that provision the listeners, and a credentialed job must not run outside the audited group — which is the shared CAMARADES groupjuniper-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. - Trusted triggers — the unchanged per-event
author_associationgates forclaude.yml, and the/claude-reviewPR-comment gate forclaude-code-review.yml. The latter executes default-branch workflow code and checks maintain/admin access for requester and rerunner plus open, non-draft, same-repository/default-base PR eligibility before checkout or OAuth use. No labels or PR lifecycle events activate reviews. - Least-privilege
permissions:and checkout authentication appropriate to each lane. Ordinary test lanes do not persist credentials. The two Claude lanes retain checkout authentication because the action fetches before configuring its own App token in progress mode (upstream #1340); the review lane usescontents: read, and checkout removes the credential post-job. No custom GitHub token overrides the action’s App OIDC exchange.
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) usejuniper-ciwith[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 inpr-preview.ymluse the same route. The remaining hosted Preview jobs are exactlycheck-label-hosted,check-label, andcleanup-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-deploymentis same-repository-only;/reseed-dbverifies OWNER/MEMBER/COLLABORATOR association and same-repository provenance before any credential-bearing step. - The Preview GitOps jobs
refresh-metadata,write-versions,reseed-database, andwait-for-healthprovisionyq(pinned v4) andkubectl(pinned to the cluster's control-plane minor, ±1 skew accepted) at runtime: anInstall yq/Install kubectlstep downloads a pinned release binary into$RUNNER_TEMP, verifies its published SHA-256, and adds it toGITHUB_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@v4is a Docker action and never putsyqonPATH; GitHub's hosted image preinstalls bothyqandkubectl, 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-foldingandtest-identity-integrationon ordinary Juniper capacity. Their explicit*-forkcounterparts remain onubuntu-latest.test-quartz-integrationuses 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 shareddetect-changesand tokenlesstest-summaryjobs remain hosted because one output/fan-in graph serves both internal and fork routes. sonar-webjoinssonar-api,sonar-pm,sonar-quartz, andsonar-identityon 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 andalways()cleanup. Forks cannot launch Sonar.- The E2E heavy body remains on
pomegranate-e2e; its base-defined, no-checkoutcheck-label,comment, andcleanupjobs use ordinary Juniper capacity.pull_request_targetkeeps fork code out of every self-hosted E2E job. -
.github/scripts/test-self-hosted-migration-routing.sh, the lane contracts, andEXPECTED_GLOBAL_SELF_HOSTED_ROUTESare the executable inventory. CodeQL routing is intentionally unchanged. -
Both
pr-tests.yml.NET unit routes retain the full Release solution build and analyzers but passGenerateOpenApi=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.ymlaretest-dotnet,test-dotnet-integration, andtest-web. All three must use the shared CAMARADES groupjuniper-ciwith labels[self-hosted, linux, x64, juniper-normal-ci], run only onrefs/heads/main, keep permissions at exactlycontents: read, avoid all secrets-context references, and check out with persisted credentials disabled. The workflow root and itsalways()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.ymlvalidation gates (validate-generated-code,validate-openapi-spec,test-charts, andcheck-gsd-state) plustest-webandtest-dotnet; they are an explicitly accepted shared-host risk and are pinned to same-repository PR heads. The lightweightdetect-changesand no-checkouttest-summaryorchestration jobs remain hosted so one output graph can select the Juniper jobs for same-repository heads and explicit GitHub-hosted*-forkcounterparts for fork heads. Fork source must never be checked out or executed on a persistent listener. Every checked-out Juniper route stays at exactlycontents: read, has no secret/OIDC/write authority, uses non-persisted checkout credentials, has a bounded timeout, and delegates itsalways()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 withexec, armstrap 'cancelled=1' TERM INTbefore meaningful work, derives deletion roots internally fromRUNNER_TEMPplus lane-owned basename-only keys, captures each command status, and aggregates final absence and cleanliness proofs into one verdict. Its fixedstandard,toolchain, andworkspaceprofiles 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 (notat most) intest-pr-validation-gates-runner-routing.sh,test-pr-web-runner-routing.shandtest-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-uniquerunner.temppath. 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-uniquerunner.temproot; 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 reviewedsonar-api,sonar-pm,sonar-quartz, andsonar-identityroutes 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 thanalways()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 JuniperACTIONS_RUNNER_HOOK_JOB_COMPLETEDis 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 bycamaradesuk/server-config, not duplicated in SyRF workflow code. - The Preview entry routes
check-label-juniperanddetect-changes-juniperfollow 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-dbhandler. The no-checkout route selectors remain hosted. The workflow has noworkflow_dispatchtrigger: 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 forkpull_requestruns are never granted. It was removed rather than left as a dead arm in thedetect-changesfan-in. Fork previews therefore require a separately authorized promotion path rather than the shared build chain. For the same reason thealways()-guarded publishing tail (retag-unchanged,write-versions,update-pr-status,create-tags) must gate onneeds.detect-changes.result == 'success':always()is broader than!cancelled(), and a skipped detection reaches those jobs with every*_changedoutput empty, which reads as "nothing changed" and copies:latestonto the PR's head SHA before shipping those tags to cluster-gitops..github/scripts/test-preview-entry-gates-runner-routing.shpins 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, andalways()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 onlypull-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_onlydispatch input selects both trusted test lanes —test-webandtest-dotnet, both self-hosted onjuniper-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-11test-dotnetwas canary-allowed while GitHub-hosted). Keep asserting them independently; do not collapse one into the other. Apart fromdetect-changesandworkflow-summaryorchestration, every other job must have a top-level AND deny guard for the canary. - The token-bearing
sonar-api,sonar-pm,sonar-quartz, andsonar-identityPR consumers run on the persistent Pomegranate capacity injuniper-ci, requesting[self-hosted, linux, x64, juniper-normal-ci, pomegranate-ci]. They remain same-repository-only, retain exactlycontents: 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-uniquerunner.temproot, and delegatealways()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 bytest-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-testsjob is the sole E2E consumer of the dedicated Pomegranate lane injuniper-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 usespull_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 onlycontents: readand 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 onpomegranate-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 withoutsudo, whileawscli-localis 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 to127.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-jobalways()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.shpins this routing, trust, prerequisite, cleanup, and reporting contract; fork or untrusted code must never reach this Docker-socket lane. - The
test-dotnetunit lane may start its existing untagged Testcontainers fixtures on Juniper. The individually approved trusted-maintest-dotnet-integrationlane 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 fromrun_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-uniquerunner.temproot, and removes/verifies only its job-owned workspace and temp residue in analways()step. Every integration-reached Testcontainers builder applies an owner label; whenSYRF_TEST_JOB_KEYidentifies 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 andunless-stoppedpolicy 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 infinally. 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 toEXPECTED_GLOBAL_SELF_HOSTED_ROUTESin.github/scripts/validate-workflows.sh. Do not add a SyRF-specific runner group or routing label; use the sharedjuniper-cigroup and org-widejuniper-normal-cilabel, adding the org-widepomegranate-cilabel only for reviewed Pomegranate capacity. test-dotnetwas rolled back toubuntu-lateston 2026-08-07 and restored tojuniper-cion 2026-08-11 once the root cause was fixed:SyRF.Identity.Endpoint.Testsinvokeshelmas a bare process name, which GitHub-hosted images ship and the Juniper hosts did not. Helm v3.21.3 is installed system-wide by thehelmrole incamaradesuk/server-config#156, and the lane recordshelm version --shortbefore restore/build/test work. Before routing any job self-hosted, check for undeclared host-tool dependencies (helm,crane,docker, cloud CLIs): a bareProcess.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) remain onjuniper-ci. The review workflow is one direct comment-driven job with in-job authorization, the existing 60-minute timeout, all eleven specialist review areas and native action progress/inline comments. There are no label controllers, status publishers or cancellation jobs.claude.ymlretains its exact per-event trusted-author predicate and general behavior..github/scripts/test-claude-review-comment.cjsexercises the comment authorization and no-request paths; the timeout contract and global routing allowlist pin the remaining routes. A skipped action still fails the requested job, but review runs publish no synthetic required status. Before rollout, remove only obsoleteclaude-review/policyrequirements, preserving test checks, approvals and conversation resolution. See Claude comment reviews and migration. - 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.shafter 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-versionsmay 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.ymlprovides a manualversion-onlyparity 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.ymldeliberately has four root execution lanes plus a tokenless summary.actionlintstays isolated because reviewdog needspull-requests: write;script-testsandcustom-validationstay in separatecontents: readjobs 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 consolidatedpassive-validationjob. The finalsummaryhaspermissions: {}, 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 usecontinue-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; theworkflow_securityoutput and the summary's Security advisory execution row report whether those checks executed successfully, not whether they found advisory matches. Missing.github/services.jsonis 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. Inscript-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.
E2E test deadline and teardown reserve¶
The dedicated E2E job retains its 45-minute deadline. Immediately before Playwright,
e2e-test-budget.sh subtracts elapsed setup, a 17-minute post-test reserve and a
30-second forced-stop grace. Exhausted setup budgets fail the test step without
launching Playwright; timeout and command failures retain the failed test outcome.
Post-test steps have explicit limits totalling 16 minutes, leaving one minute for
step transitions. Scoped service and Docker cleanup runs before artifact uploads,
so a slow upload cannot consume the teardown allocation. Budget fixtures include
slow setup, the exhaustion boundary, invalid clocks and the post-test timeout sum.
The E2E lane additionally has actions: read to prove that a resource's owning
run attempt has completed before recovering it. Host identity and hook-path
checks still precede checkout; ownership recovery and fixed-port proofs run
after checkout so their implementation can be tested as repository code.
See the CI recovery contract.