CI/CD Pipeline — PR to Running Pod¶
What this covers: the end-to-end delivery path for the Platform stack (NestJS services on
AKS) in the Acme monorepo — how a pull request is validated, which checks are required versus
informational, how Nx affected-only computation and the remote build cache keep wall-clock down,
where the work physically runs (Actions Runner Controller pods on Kubernetes, a shared BuildKit
daemon, an RWX workspace cache, plus a GitHub-hosted fallback), how images are built, scanned and
sealed, and how the GitOps writeback → ArgoCD sync → smoke gate → auto-rollback loop closes. The
legacy stack (legacy-api and legacy-web on Azure App Service; only domain-api is containerised) runs a separate, older workflow
graph; it is referenced only where the two share runners, cache or branch protection.
Deciding ADRs: ADR-0021 Platform CI Runner Strategy, ADR-0039 Actions Runner Controller on
AKS for Self-Hosted CI Runners, ADR-0040 Shared Rootless BuildKit Daemon for Docker Matrix
Builds, ADR-0041 Azure Files RWX + Azure Disk Premium Cache Layering for Ephemeral ARC
Runners, ADR-0042 Testcontainers / Docker-Daemon Class Stays on ubuntu-latest. Deploy-side
context: ADR-0032 Single-Branch GitOps + AppOfApps, ADR-0033 BC-Aligned Bundle Deploy
Units, ADR-0034 Argo Rollouts SLO Analysis, ADR-0035 Workload Identity End-to-End.
1. Workflow file layout¶
The pipeline is a single dispatcher calling three reusable workflows. This shape exists to kill
a specific failure class: the previous workflow_run-coupled chain re-resolved
workflow_run.head_sha at every stage, so CI, build and deploy could each act on a different
commit (symptom: Trivy MANIFEST_UNKNOWN against a tag that was never pushed). The dispatcher
resolves the SHA once and passes it as an explicit workflow_call input; every reusable checks
out inputs.sha.
.github/
├── workflows/
│ ├── platform-pipeline.yml ← THE dispatcher (push:main | pull_request | dispatch)
│ │ ├── resolve ← resolves sha, run_build_push, run_deploy,
│ │ │ run_integration, has_platform (once, at the top)
│ │ ├── ci → uses ./.github/workflows/_ci.yml
│ │ ├── build-push → uses ./.github/workflows/_build-push.yml
│ │ ├── deploy → uses ./.github/workflows/_deploy.yml
│ │ └── platform-ci-gate ← aggregates the per-service test map into ONE check
│ ├── _ci.yml ← workflow_call only: lint/build/typecheck/test + guards
│ ├── _build-push.yml ← workflow_call only: 14-leg matrix + release-manifest seal
│ ├── _deploy.yml ← workflow_call only: gate → writeback → sync → smoke → rollback
│ ├── ci.yml ← legacy stack CI (required check: `CI / ci-gate`)
│ ├── ci-v2.yml ← ARC-parallelised legacy CI, shadow mode (`ci-v2-gate`)
│ ├── platform-rollback.yml ← BREAK-GLASS bundle rollback (workflow_dispatch only)
│ ├── platform-e2e-gate.yml ← DORMANT pre-merge e2e scaffold (see §7)
│ └── terraform-validate.yml ← required check: `Validate Terraform`
└── actions/ ← composites
├── setup-node-npm ← node toolchain + npm cache + `npm ci`
├── nx-cache-check ← probes the remote cache, emits URL+token or blanks
├── arc-setup-workspace ← ARC-only: verify /cache mounts, npm ci --prefer-offline
├── arc-compute-affected ← ARC-only: affected set + fork-PR guard
├── azure-login-oidc ← federated Azure auth (no stored credentials)
└── mint-github-app-token ← GitOps push identity via GitHub App
_ci.yml, _build-push.yml and _deploy.yml carry on: workflow_call only — they cannot be
triggered by push or PR. The invariant: there is exactly one entry point, and the commit under
test is an explicit parameter, never an ambient context value.
2. Pipeline flowchart¶
flowchart TD
PR["Pull request opened / updated"] --> RES["resolve: pin SHA, compute flags"]
RES --> HV{"has_platform?<br/>PR touches apps/platform, libs/platform,<br/>charts, scripts/ci, nx.json, lockfile?"}
HV -->|no| GREEN["platform-ci-gate: trivially GREEN"]
HV -->|yes| ENS["ensure-runner: az vm start,<br/>best-effort, never hard-fails"]
ENS --> AVAIL{"runner online?"}
AVAIL -->|yes| CISH["ci job — self-hosted 8 vCPU<br/>NX_PARALLEL=6"]
AVAIL -->|no| CIFB["ci-fallback — ubuntu-latest 2 vCPU<br/>NX_PARALLEL=2, heap capped"]
CISH --> HARD["Hard gates: lint, build, typecheck,<br/>shared-lib test"]
CIFB --> HARD
HARD --> MAP["Per-service test:unit accumulator<br/>emits map service to outcome"]
MAP --> CANARY["Canary docker build + Trivy<br/>gateway and trading only, INFORMATIONAL"]
RES --> GUARDS["Parallel ubuntu guards:<br/>helm-template-strict, helm-unittest,<br/>sleep-guard, api-prefix, legacy-domain,<br/>config-chart-contract, grafana-rules, pact"]
CANARY --> GATE["platform-ci-gate<br/>REQUIRED check"]
GUARDS --> GATE
GREEN --> GATE
GATE -->|red| BLOCK["PR blocked"]
GATE -->|green| MERGE["Squash-merge to main"]
MERGE --> PUSH["push:main re-enters the dispatcher"]
PUSH --> DET["detect-affected: ONE npm ci,<br/>nx affected over SHA~1..SHA"]
DET --> MTX["build-push matrix, 14 legs, fail-fast:false<br/>build iff affected AND test:unit success"]
MTX --> TRIVY["Trivy on the pushed DIGEST<br/>HARD gate, blocks the release"]
TRIVY --> SEAL["release-manifest-seal:<br/>every service,tag pair verified in ACR"]
SEAL -->|any missing| STOP["Seal fails — deploy never starts"]
SEAL -->|all present| DGATE["deployment-gate:<br/>freeze / weekend / business-hours"]
DGATE --> WB["Image-tag writeback as a bump PR<br/>auto-merge, 20 min deadline"]
WB --> SYNC["Poll ArgoCD Applications<br/>Healthy AND Synced, 600s"]
SYNC --> VER["Verify live workload image equals tag"]
VER --> SMOKE["Post-deploy smoke gate<br/>Playwright via port-forward"]
SMOKE -->|pass| E2E["Post-deploy e2e, informational"]
SMOKE -->|fail| RB["auto-rollback: open incident issue,<br/>dispatch platform-rollback per bundle"]
Takeaways
- Two independent skip paths keep non-Platform PRs fast.
has_platform=falseskips the whole heavycireusable and the gate still reports green — this is what allows the gate to be a required check without wedging docs-only or legacy-only PRs. A path-filtered required check would sit permanently in "Expected" and block every non-matching PR; the PR trigger therefore carries onlypaths-ignore(docs, markdown,.gitignore,LICENSE) and the filtering happens inside the job graph. - The runner is best-effort, the pipeline is not.
ensure-runnernever hard-fails; a dead VM routes to a byte-identicalci-fallbackonubuntu-latestwith parallelism and heap retuned for 2 cores. Exactly one ofci/ci-fallbackruns, and the reusable workflow's output is coalesced from whichever did. - A red service holds only its own image. A per-service failure does not fail the
cijob — the JSON map is the signal, and it must survive theworkflow_callboundary (GitHub drops the outputs of a failed reusable workflow). The gate job turns a red map entry back into one red check. - Trivy is informational pre-merge and a hard gate at release. A CVE-of-the-day in a transitive dependency must not red a required check on a PR that did not introduce it; the same scan, run against the pushed digest, does block the deploy.
- Invariant encoded: nothing reaches the cluster that was not linted, typechecked, unit-tested, image-scanned and proven present in the registry — and the artefact identity is a digest, not a tag.
3. Nx affected, the remote cache, and the per-service gate map¶
Every heavy step is nx affected, never run-many. Scope exclusion is by tag
(--exclude='tag:scope:backend,tag:scope:frontend,tag:scope:domain,tag:scope:shared,tag:scope:infra')
so Platform CI never runs legacy projects and vice versa.
flowchart LR
subgraph base["Affected base selection"]
PRB["pull_request:<br/>nx-set-shas merge-base"]
PSH["push / dispatch:<br/>pinned SHA~1..SHA"]
end
base --> NXA["nx show projects --affected --type=app --json"]
NXA -->|array| SET["affected set"]
NXA -->|"non-array / error"| FAIL["FAIL-SAFE: treat ALL as affected"]
SET --> LOOP["for each of 14 services:<br/>nx test:unit <service>"]
FAIL --> LOOP
LOOP --> MAPO["test_results JSON map"]
MAPO --> BGATE{"per-leg build decision"}
BGATE -->|"success"| BUILD["build + push image"]
BGATE -->|"failure"| HOLD["HELD — keep last-good image"]
BGATE -->|"absent AND detection reliable"| LOUD["LOUD FAIL — refuse to guess"]
BGATE -->|"absent AND detection degraded"| SKIP["skip — defer to the CI affected set"]
subgraph cache["Nx remote cache"]
PROBE["nx-cache-check:<br/>GET /health/live, 5s timeout"]
PROBE -->|"200"| ON["export server URL + token"]
PROBE -->|"timeout / unset"| OFF["warn, fall back to local cache"]
end
cache -.->|"env on every nx step"| LOOP
Takeaways
- The affected base is pinned differently on the two paths, deliberately.
nx-set-shasresolves the base to the last green run, which on a re-dispatch of an already-green SHA collapses to the SHA itself → empty affected set → empty map → the build gate would false-red a legitimate rebuild. On push/dispatch the base is hard-pinned toSHA~1..SHA, which on a squash-merge branch is exactly the merged PR's change set — and, critically, is the same basedetect-affecteduses, so the CI map is provably a superset of what build-push attempts. - The two fail-safes point in opposite directions, on purpose. Affected detection failing means "build everything" (a wrong skip is a stale-image deploy — the worst outcome). Test outcome being unconfirmed means "do not build" (stale-but-working beats new-but-broken).
- Nx cannot see the Docker build context.
.dockerignoreand the sharedapps/platform/Dockerfileare not Nx inputs and belong to no project, so a change to either is routed into the same build-everything fail-safe, and both are listed on the pipeline's pushpaths:filter..github/ci-cache-version.txtis the sanctioned Docker-layer cache-bust knob, read on the runner and passed as--build-arg(it cannot beCOPYed —.githubis excluded from the build context). - The remote cache is never load-bearing. A health probe with a 5-second timeout decides
whether to export
NX_SELF_HOSTED_REMOTE_CACHE_*; a miss degrades to the local cache with a warning rather than failing the job. Read tokens are available to all branches, write tokens only on main pushes — cache poisoning from a PR is structurally impossible. - Invariant encoded: the set of services whose tests ran is always a superset of the set of services whose images are rebuilt.
4. Where the work runs — ARC on Kubernetes¶
ADR-0021 originally chose a single always-warm self-hosted VM with no fallback, on the grounds that the GitHub-hosted fallback could not run Docker (no Testcontainers, no BuildKit cache) and so gave false confidence. ADR-0039 supersedes the capacity half of that decision: measured CI wall-clock was 8 min on a clean queue but 55–57 min when the single runner was busy, and a declared 12-way build matrix serialised to ~75 min because every leg wanted the same label.
flowchart TB
GH["GitHub Actions<br/>pending-jobs API"] -->|KEDA scales on queue depth| CTRL
subgraph cluster["development-acme-aks — namespace arc-system"]
CTRL["gha-runner-scale-set-controller<br/>chart pinned 0.10.0"]
CTRL --> RS1["RunnerScaleSet arc-linux-x64<br/>min 1 / max 12"]
CTRL --> RS2["RunnerScaleSet arc-linux-x64-large<br/>min 1 / max 6"]
CTRL --> RS3["RunnerScaleSet arc-linux-x64-builder<br/>min 0 / max 12"]
RS1 --> POD["Runner pods — ROOTLESS<br/>runAsNonRoot, no docker socket,<br/>capabilities drop ALL"]
RS2 --> POD
RS3 --> POD
POD -->|"docker buildx --driver remote"| BK["buildkitd StatefulSet<br/>rootless, single replica<br/>nodeAffinity to baseline node, PDB minAvailable 1"]
BK --> DISK["Azure Disk Premium 100GB RWO<br/>/var/lib/buildkit — 6-hourly snapshots"]
POD --> RWX["Azure Files Premium RWX 1TB<br/>/cache/npm and /cache/nx subPaths"]
POD --> EMPTY["emptyDir per pod<br/>node_modules — NEVER on RWX"]
end
POD -->|"Workload Identity<br/>arc-runner-sa"| KV["Key Vault + ACR pull"]
POD -->|"Workload Identity<br/>arc-builder-sa"| ACR["ACR push"]
BK -->|"cache-from / cache-to type=registry"| BC["developmentacmeacr.azurecr.io/buildcache/<service>:<branch>"]
subgraph hosted["GitHub-hosted — ubuntu-latest"]
TC["Testcontainers / bootstrap-harness<br/>GHA services blocks, raw docker pull-push"]
AGG["Aggregator + gate jobs"]
FORK["Fork-PR jobs"]
end
Takeaways
- Rootless pods force the build daemon out of the pod. ARC pods drop all capabilities and
mount no Docker socket, so
docker/build-push-actionhas nothing to talk to. ADR-0040 chooses a sharedbuildkitdover per-pod Docker-in-Docker precisely because the 14 services share one base image: DinD would re-pull ~250 MB ofnode:22-slim+ distroless layers once per leg. Cache is namespaced per branch (buildcache/<service>:<branch>), so a poisoned PR layer can never be consumed by amainbuild. - Three storage primitives, matched to three access patterns (ADR-0041). npm tarball cache and
Nx cache are read-heavy and write-once-per-hash → RWX Azure Files.
node_modulesis thousands of concurrent small writes duringnpm ci→ guaranteed RWX corruption, so it is per-podemptyDir. The BuildKit layer cache is single-writer, latency-sensitive → RWO Premium Disk.arc-setup-workspacerefuses to proceed unless/cache/npmand/cache/nxare real, writable mountpoints — a silent fallback to pod-local storage would look like a cache that simply never warms. - Cache integrity is enforced by tooling, not trust.
npm ci --prefer-offlinevalidates every tarball against the lockfile integrity field, so an RWX poisoning attempt fails at verification. Fork PRs never mount/cacheat all — they fall through toubuntu-latestsiblings, and every ARC job carries ahead.repo.fork == falseguard. - ADR-0042 draws a hard line: anything needing a Docker daemon stays on
ubuntu-latest. Testcontainers requires the runner itself to spawn arbitrary containers; remote BuildKit does not provide that.bootstrap-harnessand thetest:integrationclass are therefore permanently hosted, and the ARC contract'sused_forlist was reduced to[ci-build, ci-test-backend]to remove a silent contradiction between two spec documents. - Known SPOF, consciously accepted. Single-replica
buildkitdmeans a node drain or OOM fails all matrix legs at once; mitigations are node affinity to the always-warm baseline node, a PodDisruptionBudget, 6-hourly disk snapshots and a documented ≤15 min recovery. A 2-replica sharded-cache design was rejected as +£25/mo and +20% operational surface for a short window.
Verified caveat — dev sizing does not match the ADR. ADR-0040 Amendment 1 records that the
development subscription has zero quota in two VM families and a regional 10 vCPU cap, so
arc-pool runs 1 × 2-vCPU node. --max-parallelism=12 is configured but effective concurrency
is ~2. The 12-service matrix benchmark that gates ADR-0039/0040 out of Proposed status has not
been executed in any environment I could verify from the repository.
5. PR to running pod¶
sequenceDiagram
autonumber
participant Dev as Developer
participant GH as GitHub Actions
participant ARC as Runner pod / self-hosted VM
participant BK as buildkitd
participant ACR as developmentacmeacr
participant Git as main branch
participant Argo as ArgoCD
participant K8s as AKS workloads
Dev->>GH: push branch, open PR
GH->>GH: resolve — pin SHA, compute has_platform
GH->>ARC: dispatch ci (or ci-fallback)
ARC->>ARC: nx affected lint / build / typecheck
ARC->>ARC: per-service test:unit, accumulate map
ARC-->>GH: test_results {service: success|failure}
GH->>GH: platform-ci-gate aggregates to one check
Dev->>Git: squash-merge (branch must be up to date)
Git->>GH: push:main re-enters dispatcher
GH->>GH: detect-affected — one npm ci, SHA~1..SHA
GH->>ARC: build-push matrix leg per service
ARC->>ARC: OIDC login as image-build identity (main-only FIC)
ARC->>BK: buildx build, cache-from/to branch namespace
BK->>ACR: push sha-XXXXXXX (+ latest on main)
ARC->>ACR: Trivy scan by DIGEST — HIGH/CRITICAL blocks
GH->>ACR: seal — assert every (service, tag) manifest exists
Note over GH,ACR: unbuilt services inherit their tag from<br/>main's chart values, a missing manifest fails the seal
GH->>GH: deployment-gate — freeze / weekend / hours
GH->>Git: open bump PR: chart values image.tag = sha-XXXXXXX
Note over Git: direct push to main is rejected (GH006),<br/>auto-merge lands it after required checks, 20 min deadline
Git->>Argo: ApplicationSet detects the commit
Argo->>K8s: sync bundle Application, Argo Rollouts canary
Note over K8s: setWeight 10 → pause 2m → setWeight 50 → pause 2m → 100
GH->>Argo: poll Healthy AND Synced, 600s budget
GH->>K8s: assert live container image ends with :sha-XXXXXXX
GH->>K8s: port-forward gateway, run smoke suite
alt smoke passes
GH->>GH: record deployment, run informational e2e
else smoke fails
GH->>GH: open incident issue with prior tags
GH->>GH: dispatch platform-rollback per affected bundle
end
Takeaways
- The CI→CD handoff is a git commit, not a
kubectl apply. CI's terminal act is bumpingimage.tagin the chart values that ArgoCD reads; the cluster is never touched imperatively on the happy path (ADR-0032). CI holds no kubeconfig secret — it federates via OIDC only to read Application status and verify the rolled-out image. - Writeback is a PR, not a push. Branch protection has no per-actor bypass under classic
rules, so a direct
git push origin mainreturns GH006. The deploy job opens a one-commit bump branch, enables auto-merge, and polls:BEHINDtriggersupdate-branch,DIRTYfails fast,BLOCKEDis the normal waiting state and only fails once a required check actually reports failure. The push identity is a GitHub App with three-tier degradation (repo secrets → in-cluster secret →GITHUB_TOKEN), and the App token is scrubbed from the git remote in analways()step. - The write target differs by deploy unit, and guessing is refused. Standalone services
(gateway, ai, frontend) carry
.image.tagincharts/values/<svc>.yaml; bundle services carry.["<svc>"].image.taginside their bundle chart'svalues.yaml(ADR-0033). A shadowcharts/values/<svc>.yamlexists for bundle services, so a wrong target is a silent no-op for ArgoCD — the resolver therefore hard-fails on any service present in neither map, and refuses to create a missing key. - The sync poll must outlast the canary. The base rollout spends 240 s in mandatory pauses
before reaching 100%; the poll budget is 600 s to cover that plus image pull, DB wait and
migration init. Bundle services share one Application, so the distinct Application set is polled
rather than one poll per service — an earlier revision polled
platform-<service>, which does not exist for bundled services. - Invariant encoded: the running container image is asserted equal to the sealed tag before
the deploy is recorded — with the tag anchored at end-of-string so
sha-abc123cannot validate against a pod runningsha-abc1234.
6. Required versus informational checks¶
| Check (context name) | Source workflow | Status | Notes |
|---|---|---|---|
Validate Terraform |
terraform-validate.yml |
Required | Always reports; the model the other gates copy |
CI / ci-gate |
ci.yml |
Required | Legacy-stack aggregator |
platform-ci-gate |
platform-pipeline.yml |
Required | Green trivially when has_platform=false |
ci-v2-gate |
ci-v2.yml |
Informational | ARC shadow of legacy CI; name deliberately overridden so it does not collide with ci-gate |
| Trivy (PR-time canary) | _ci.yml |
Informational | continue-on-error, findings still surfaced |
| Trivy (post-push, by digest) | _build-push.yml |
Blocking | Not a PR check — it blocks the release |
helm-template-strict |
_ci.yml |
Blocking within CI | Dev tuples + missing values file hard-fail; production tuples advisory |
| Storybook build | _ci.yml |
Informational | continue-on-error, self-hosted only |
| Post-deploy e2e | _deploy.yml |
Informational | continue-on-error until ≥10 consecutive green runs |
| Post-deploy smoke | _deploy.yml |
Blocking | Hard gate; overridable only by force-deploy with a documented incident |
Two structural rules govern this table:
- Branch protection matches check runs by name, across all workflows. Two jobs named
ci-gatein different files collide and both become required. This is why the shadow gate is renamed at the job level (name: ci-v2-gate) while keeping its job id, and why the Platform gate isplatform-ci-gaterather thanci-gate. - A required check must report on every PR. Hence no
paths:allowlist on the PR trigger and an internalhas_platformguard that exits green. A path-filtered required check reports nothing on non-matching PRs and leaves them permanently "Expected — Waiting".
Additional CI-time guards that run on hosted runners in parallel with the main job:
helm-unittest, a sleep-guard lint (no fixed sleeps in Platform source), an API-prefix guard, a
legacy-domain guard, a config⊆chart contract checker, a Grafana alert-rules guard, and Pact
contract verification (self-hosted with hosted twin). Changes to scripts/ci/** are on the
pipeline's path filter for a reason: a broken checker that lands green turns every subsequent PR
red.
Verified drift: the repository's own CLAUDE.md states the required set is main (CI) and
Validate Terraform. The workflow files show ci.yml's aggregator job is ci-gate (added at the
runner cutover) and that platform-ci-gate was built explicitly to become required. Branch
protection settings are not in the repository, so the live required-check list could not be
verified from source — treat the table above as the intent expressed in the workflows.
7. Release lifecycle and rollback¶
stateDiagram-v2
[*] --> Affected: nx affected on merged commit
Affected --> Built: image pushed to ACR
Affected --> Held: test:unit failed — last-good image retained
Built --> Scanned: Trivy by digest
Scanned --> Sealed: manifest verified in ACR
Scanned --> Blocked: HIGH or CRITICAL finding
Sealed --> Gated: freeze / weekend / hours check
Gated --> WrittenBack: bump PR auto-merged to main
Gated --> Deferred: gate says no — no-deploy job
WrittenBack --> Syncing: ArgoCD reconciles
Syncing --> Verified: Healthy + Synced + image match
Syncing --> DeployFailed: 600s timeout or mismatch
Verified --> Live: smoke gate passed
Verified --> RollingBack: smoke gate failed
RollingBack --> Live: prior tag restored
RollingBack --> ManualIncident: prior tag unknown or dispatch failed
DeployFailed --> ManualIncident
Blocked --> [*]
Held --> [*]
Deferred --> [*]
Live --> [*]
ManualIncident --> [*]
Takeaways
- Prior tags are captured before mutation. The deploy job reads each service's existing
image.taginto ajq-built JSON object beforeyqrewrites the file — that object is the rollback target. Values are shell-escaped throughjqrather thanprintf, because a stray quote silently produced invalid JSON in an earlier revision. - Auto-rollback is bundle-granular, not service-granular. Deployed services are mapped to their bundle Application (7 of them per ADR-0033), deduplicated, and one rollback dispatch is issued per bundle with that bundle's representative service's prior tag. Drift between this map and the live ApplicationSet is asserted by a dedicated workflow.
- Rollback validates aggressively before acting. The target tag must match
sha-<hex>or semver; the confirmation input must be the literalROLLBACK; the incident link must resolve to an open issue; production rollbacks are refused from any ref other thanmain. The incident issue is opened first, so the rollback workflow always has something to validate against and comment on. - The declared primary rollback path is
git reverton chart values — ArgoCD reconciles automatically.platform-rollback.ymlis break-glass, for when revert is too slow or the values file is not the bug (e.g. the cluster is on a different SHA than git claims). - Invariant encoded: every automated cluster mutation leaves an auditable trail — a deployment record, an incident issue, or a merged PR. Nothing changes the cluster anonymously.
Dormant, not missing: platform-e2e-gate.yml is the intended pre-merge e2e gate. It is
deliberately inert (no auto-trigger, guarded by a repo variable) because an ARC pod has no ambient
kubectl access to the dev cluster: the runner ServiceAccount has no in-cluster RBAC, the ARC
NetworkPolicy is default-deny for the apiserver CIDR, and the runner image ships neither kubectl
nor kubelogin. Activation is blocked on those three platform prerequisites plus ≥10 consecutive
green runs of the post-deploy twin — the current file keeps the working OIDC + az aks
get-credentials + port-forward path rather than assuming in-cluster access it does not have.
8. What could not be verified from source¶
- Live branch-protection configuration — not in the repository (see §6).
- The 12-service matrix benchmark gating ADR-0039/0040 out of Proposed: dev capacity (one 2-vCPU node) cannot execute it, and no production ARC environment exists in the repo.
- Cache hit rates and P50 PR feedback. ADR-0039 predicts 3–12 min (from 8–55 min); ADR-0041
predicts
npm ciat ~30 s warm (from ~3 min). Design targets, not measurements. - Whether the ARC cutover has completed.
_ci.yml's main job still targets a self-hosted VM label with anubuntu-latesttwin andci-v2.ymlstill calls itself a shadow — so ARC labels are live for the legacy shadow graph, but the Platform pipeline had not been flipped onto them at the commit read.