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:
- 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
author_associationgate forclaude.yml, and the label-bound policy job forclaude-code-review.yml(which triggers onpull_request, notpull_request_target, because the Claude GitHub App token exchange rejectspull_request_targetOIDC 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 exactclaude-reviewlabel event from an actor whose GitHubrole_nameismaintainoradmin, 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. - Least-privilege
permissions:, non-persisted checkout credentials where the job's own contract requires them (claude.ymlis a documented exception and deliberately persists them — follow the per-job bullet, not this summary), and — forclaude-code-review.yml—track_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) 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) also run onjuniper-ci, matching every other repository in the pool. Theclaude-code-review.ymlorchestration jobsstart-required-checkandpolicyuse that same route: neither executes pull request code (start-required-checkhas no checkout and only calls the GitHub API;policychecks out only the protected default branch withpersist-credentials: false) and neither references a secret, while the workflow's ninepull_request_targetaction types delivered ~120 times a day were costing two whole billable hosted minutes per delivery for ~20 seconds of work.finalizeandfailure-finalizerstay onubuntu-latestso a terminal policy status survives a self-hosted failure; all four runner pins live in.github/scripts/test-claude-review-policy.shand both new routes are listed inEXPECTED_GLOBAL_SELF_HOSTED_ROUTES. They hold an OAuth token and write authority, so their triggers must stay gated to trusted sources.claude.ymlretains its exactly pinned per-eventauthor_associationpredicate.claude-code-review.ymlis opt-in only: a protected-default-branch policy job permits the review job only for a currentclaude-reviewlabel whose latest label-timeline actor has GitHubrole_namemaintain/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 requestsid-token: writeand must not overridegithub_tokenon theanthropics/claude-code-action@v1step: this lets the action perform its own Claude GitHub App OIDC exchange and post review comments under the Claude App identity, rather than the genericgithub-actions[bot]identity a job-scoped token produces. The exchange is trigger-sensitive: it rejects OIDC tokens minted forpull_request_targetruns (App token exchange failed: 401 Unauthorized - Invalid OIDC tokenon every run from the 2026-09-01 rewrite onward — runs33477820821,33611937760,33614658203, one of them carrying a workflow file byte-identical tomain), and accepts them frompull_requestruns and fromclaude.yml'sissue_commentruns (run33615411297posted asclaude[bot]). The workflow therefore triggers onpull_request; the Console connection was never the cause. Underpull_requestthe 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.ymland.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 noclaude-review/policystatus until they mergemain.claude-review/policyis 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-actionexits 0 and logsSkipping action due to workflow validationwhen the App token exchange refuses the run's workflow identity, so the review job fails closed when the action produced noexecution_fileoutput (observed on run33618935430, which publishedclaude-review/policy= success with no review posted). A successful action is not proof of a delivered review either: run33623961896obtained the App token and reachedsubtype: successinnum_turns: 2(real reviews take 12-13 turns) while posting no inline comments and no summary, so the job additionally fails closed when noclaude[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 separateclaude-review/request-ownerstatus 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 theclaude-review/policystatus context and conversation resolution after the workflow exists onmain, 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 disabledtrack_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.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.