fix(ci): sync observability configs to host before docker compose up #599
Reference in New Issue
Block a user
Delete Branch "fix/issue-598-obs-dood-bind-mounts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Fixes #598 — observability stack fails to start in the DooD CI runner because relative bind mounts resolve to paths the host daemon cannot see.
docker-compose.observability.yml: All 5 config bind mounts now use${OBS_CONFIG_DIR:-./infra/observability}as the base path — defaults to the relative path for localdocker compose up, configurable via env var in CI.nightly.yml/release.yml: New "Sync observability configs to host" step copies the config tree from the job container's overlay2 filesystem (visible in the host mount namespace) to a stable host path using the existingnsenter/Alpine pattern.OBS_CONFIG_DIRis injected into the env file.Root cause
runner-config.yamlonly mounts/var/run/docker.sock— no workspace directory is shared with the host. When Compose resolves./infra/observability/prometheus/prometheus.ymlas a bind mount source, it passes the absolute path to the host daemon. The host has no such path, auto-creates an empty directory there, and the container fails with:Sync mechanism
The overlay2
MergedDiris the job container's complete filesystem as seen from the host mount namespace — no Docker socket tricks, no custom images. Same Alpine digest already pinned in the Caddy reload step.Test plan
docker compose -f docker-compose.observability.yml upstill works (noOBS_CONFIG_DIRneeded)🤖 Generated with Claude Code
DooD runner only shares /var/run/docker.sock — no workspace directory is mapped to the host daemon. Relative bind mounts in docker-compose.observability.yml resolved to paths that didn't exist on the host; Docker auto-created directories in their place, causing 'not a directory' mount failures for all five config files. Fix: - docker-compose.observability.yml: replace hardcoded ./infra/observability/ prefix with ${OBS_CONFIG_DIR:-./infra/observability} so the path is configurable while remaining backwards-compatible for local use. - nightly.yml / release.yml: add a 'Sync observability configs to host' step that finds the job container's overlay2 MergedDir (the container's full filesystem as seen from the host mount namespace), then uses the existing nsenter/alpine pattern to cp the config tree into a stable host path (/srv/familienarchiv-{staging,production}/obs-configs). OBS_CONFIG_DIR is injected into the env file so Compose picks it up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>🛠️ Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
This is a pragmatic and well-explained fix for a real DooD limitation. The overlay2 trick is non-obvious but the inline comments explain the why clearly, which is exactly what infrastructure code should do. Here's my full assessment.
What's correct ✅
Sync observability configs to hostis inserted beforeStart observability stackin bothnightly.ymlandrelease.yml. The env file getsOBS_CONFIG_DIRset before the main stack starts, and the configs land on the host before they're bind-mounted. The sequence is sound.alpine:3.21@sha256:48b0309…is the same pinned digest already used in the Caddy reload step — good consistency, Renovate will pick it up.docker-compose.observability.ymlfallback default is correct.${OBS_CONFIG_DIR:-./infra/observability}means localdocker compose uprequires zero config changes. The CI path is injected via env file; local development is unchanged.Concerns
Suggestion — Overlay2 driver assumption (non-blocker for a single known runner)
The script assumes the runner's Docker daemon uses the
overlay2storage driver:If the runner is ever migrated to a VFS or devicemapper-backed daemon,
MergedDirwill be empty or absent andSRCwill silently resolve to a garbage path — configs won't sync, observability stack will fail with the original "not a directory" error.Since this is a single known self-hosted runner on a fixed VPS, this is acceptable as-is. But consider adding a guard:
Suggestion — Stale config files accumulate on the host (minor)
cp -r "${SRC}/." /srv/…/obs-configs/copies INTO the existing directory. If a config file is renamed or removed from the repo, the old filename survives on the host forever. The bind mount will then serve the new file and retain the orphaned old file (which observability services ignore, but it's untidy).A
rsync --deletewould be cleaner here, but that requiresrsyncin the Alpine image. Alternatively:Suggestion — No post-sync verification before observability stack starts
The sync step has no exit-code check beyond the
cpcommand itself. Adding a quick sanity check (e.g., verify Prometheus config landed) gives a faster, more descriptive failure if the overlay path is wrong:Observation — Hardcoded paths duplicated between workflow and env file
/srv/familienarchiv-staging/obs-configsappears twice innightly.yml: once in theOBS_CONFIG_DIR=...line in the env file write step, and once inside thensentercommand. These must stay in sync manually. Not a blocker, but if the path ever changes, it's easy to update one and miss the other. Consider a YAML anchor or a local shell variable at the top of the step to DRY it up — but I recognize Gitea Actions YAML is limited here.Summary
The fix is correct and the comments are good. The overlay2 guard and a post-sync verification are worth adding for robustness before this sees a live production run. The stale-file and path-duplication issues are minor polish items. Approving — but I'd feel better with the verification check before merging to main.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
This PR introduces a privileged Docker container operation as part of the CI pipeline. It's solving a real DooD bind-mount problem, but the privilege model deserves explicit acknowledgment. Here's the security read.
What's good ✅
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07dprevents a supply-chain substitution attack — you can't pull a malicious image by swapping the tag. This is the right approach.OVERLAYcomes fromdocker inspect "$(hostname)", and$(pwd)is the checked-out workspace. Neither is user-controlled input. No injection risk in the current context.cp -rtargets a specificobs-configsdirectory. The intent is file copy, not arbitrary code execution.Concern —
--privileged --pid=hostis a significant privilege level (blocker-class observation, accepted risk)This container has:
--privileged: full capability set, includingCAP_SYS_ADMIN,CAP_SYS_PTRACE,CAP_NET_ADMIN, device access, etc.--pid=host: cankill -9any process on the host, attach debuggers, read/proc/*/memnsenter -t 1 -m: enters the host's mount namespace — can read/write anywhere on the host filesystemThis is essentially root shell access on the host machine. The current
cpcommand is benign. But if the CI pipeline were compromised (e.g., via a malicious PR, a compromised secret, or a supply-chain attack on an earlier step), this step could be repurposed to:/root/.ssh/authorized_keys/etc/cron.d/for persistence.env.productionfileThis is an accepted architectural risk of DooD — the socket is already shared, and anyone who can run
docker runalready has effective root on the host. I'm not flagging this as a new vulnerability introduced by this PR. I'm flagging it for explicit acknowledgment: this CI runner is a high-value target and the security posture depends entirely on the strength of the Gitea secrets and the runner's network isolation.Recommendation: Add a comment in the workflow file (similar to the existing bind-mount comment in
docker-compose.observability.yml) documenting the threat model explicitly:Concern —
docker inspect "$(hostname)"relies on container namingThe
"$(hostname)"expansion assumes the job container's hostname matches its Docker container name. This is the Gitea Actions default but is not guaranteed if the runner configuration changes (e.g., custom hostname, network alias). Ifdocker inspectfails silently,OVERLAYwill be empty,SRCbecomes$(pwd)/infra/observability(relative — a bare path without the overlay prefix), and thensentercommand will try to copy from a path that doesn't exist in the host mount namespace.A failed copy would not be caught until
Start observability stackfails. Adding the exit guard Tobias suggested ([ -d "$OVERLAY" ] || exit 1) provides a faster, clearer failure mode.Minor — No
set -eorset -o pipefailin the shell scriptGitea Actions runs each
run:step withbash -eby default, so the first failing command will abort the step. This is fine as-is, but thedocker runexit code depends on thesh -c '...'inside — ifnsenterexits non-zero (e.g., permission denied in a different environment), bash will surface it. No action required, just confirming the default behavior is adequate here.Summary
The privileged container is the dominant risk in this PR — not a new vulnerability, but an explicit escalation of the CI attack surface that deserves documented acknowledgment. The Alpine image is properly pinned, the variables are not user-controlled, and the operation is scoped to file copy. Approving as a known-and-accepted risk with the recommendation to add the threat-model comment.
👨💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer
Verdict: ✅ Approved
No application code changed. This is pure CI infrastructure — my lens here is code quality within the workflow files and the compose change.
What's good ✅
docker-compose.observability.ymlchange is minimal and correct. Five bind mounts updated to${OBS_CONFIG_DIR:-./infra/observability}/.... The fallback default means localdocker compose upworks without any new env var. The change is mechanical and scoped.release.ymlcomment cross-referencesnightly.ymlrather than duplicating the full rationale. That's DRY at the documentation level: one authoritative explanation, one pointer.Minor observation — Shell variable interpolation in the
sh -cstringThe escaped
\"${SRC}/.\"expands${SRC}in the outer shell (the runner) before passing it to thedocker runcommand. This is intentional —SRCis set on the runner and needs to be embedded as a literal path inside thensentercommand. It works correctly. The nested quoting is ugly but there's no simpler way given the escaping requirements across two shell contexts.No change needed — just confirming I read it and it's correct, not a bug.
Summary
Nothing here touches application code, services, or domain logic. The workflow changes are well-commented, minimal, and correctly ordered. LGTM from my side.
🏗️ Markus Keller (@mkeller) — Application Architect
Verdict: ✅ Approved
This is a CI pipeline fix with no architectural surface area. Let me run the doc-update checklist and confirm there's nothing missed.
Doc update checklist
docs/architecture/db/diagramsCLAUDE.mdpackage table + C4 diagramCLAUDE.mdroute table + C4 diagramdocs/architecture/c4/l2-containers.puml+docs/DEPLOYMENT.mddocs/architecture/c4/l1-context.pumlErrorCodeorPermissionvalueCLAUDE.md+docs/ARCHITECTURE.mddocs/GLOSSARY.mddocs/adr/Docker service / infrastructure component
No new Docker service is added. The observability stack (
prometheus,loki,promtail,tempo,grafana) already exists. The change is to how config files reach those containers in CI — a deployment mechanism change, not a topology change.l2-containers.pumldoes not need to change.However,
docs/DEPLOYMENT.mdlikely documents how the observability stack is deployed. If it describes the bind mount paths or the assumption that relative paths work, that section should be updated to reflect theOBS_CONFIG_DIRmechanism. This is a minor concern — ifDEPLOYMENT.mdhas no such detail, no update is needed.ADR consideration
The DooD overlay2 pattern is a non-obvious architectural workaround with lasting consequences: any future CI workflow that uses bind mounts in an observability stack must follow the same pattern. This is a candidate for a lightweight ADR:
This is a suggestion, not a blocker. The inline comment already captures the rationale. An ADR would just make it discoverable for future infrastructure work.
Summary
No blocking doc gaps. The change is correctly scoped to CI plumbing. The optional ADR and a
DEPLOYMENT.mdspot-check are the only follow-up items worth considering.🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
No application tests were added or changed, which is expected for a CI pipeline fix. My concerns are about observability and failure-detection within the workflow itself.
What's covered ✅
docker compose upstill works${OBS_CONFIG_DIR:-./infra/observability}preserves the local dev testing path — no breakage of developer-side verification.Concern — No verification step between sync and observability stack start (suggestion)
The "Sync observability configs to host" step runs a
cp -rinside annsentershell. If the overlay2 path is wrong,SRCwill be an invalid path and thecpwill either silently copy nothing or fail with a non-zero exit code.If
cpexits non-zero, the step fails and CI stops. Good. But ifSRCresolves to an existing but wrong directory (edge case), configs could be missing without a clear error message — and the failure would surface later as a cryptic "not a directory" or missing config error inside the observability container startup, which is harder to diagnose.Suggestion: Add an explicit verification step (or a check at the end of the sync step) before
Start observability stack:This turns a late, cryptic failure (container startup error) into an early, explicit failure (sync verification error). Five minutes saved per failed CI run.
Concern — No smoke test that observability stack is actually healthy after the deploy
The existing
Start observability stackstep starts the containers. But there's no assertion that Prometheus, Grafana, and Loki become healthy before the workflow continues. If a config file has a syntax error (e.g., from a future edit), the containers would start and then crash in the background.Consider adding a health check step after starting the observability stack:
This is a suggestion, not a blocker for this PR — the PR is fixing a regression, not improving the test harness. But worth tracking.
Observation — Test plan item 3 is manual, not automated
This is verified by running the workflow, which requires a release trigger. There's no automated way to verify this in a PR CI run. Acceptable limitation given the DooD constraint — just noting it as a coverage gap.
Summary
The sync step will fail the workflow if
cpexits non-zero, which is the right behavior. The missing piece is an explicit verification that the right files landed at the right paths before the observability stack attempts to mount them. That's a low-cost improvement that would make future failures dramatically easier to diagnose. Approving the fix as-is, with the verification suggestion as a follow-up.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
Reviewing this PR from a requirements completeness and traceability perspective.
Issue traceability ✅
fix(ci)and body referencesFixes #598. Issue linkage is present./var/run/docker.sock, not the workspace directory, so relative bind mount paths fail on the host daemon.Requirements coverage assessment
FR-1 (implicit): The CI pipeline must start the observability stack without errors when running under a DooD runner.
→ The PR addresses this via the overlay2 sync mechanism. Covered.
FR-2 (implicit): Local
docker compose -f docker-compose.observability.yml upmust continue to work withoutOBS_CONFIG_DIRset.→ The
${OBS_CONFIG_DIR:-./infra/observability}fallback indocker-compose.observability.ymlpreserves this. Covered.FR-3 (implicit): Both staging (nightly) and production (release) workflows must be fixed.
→ Both
nightly.ymlandrelease.ymlreceive the sync step and theOBS_CONFIG_DIRenv injection. Covered.Test plan quality ✅
The three test plan items are correctly scoped and testable:
docker compose up(manually verifiable by the developer)Items are specific enough to verify — no "ensure it works" vagueness.
Open question — Non-overlay2 runner behavior
The fix assumes
overlay2storage driver. There is no fallback and no documented behavior if the driver changes. This is an implicit assumption that is not captured as a constraint in either the issue or the PR description. Worth adding to the issue or as a code comment:Summary
Requirements are fully covered. Traceability from issue to fix is clear. The test plan is actionable. The only gap is the undocumented
overlay2assumption, which is a minor documentation concern, not a functional gap.🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead & Accessibility Strategist
Verdict: ✅ Approved
This PR makes no changes to the frontend, UI components, Svelte files, or any user-facing behavior. It is entirely contained within CI workflow files and the Docker Compose observability configuration.
I checked:
.sveltefiles modifiedThe observability stack (Prometheus, Grafana, Loki, Tempo, Promtail) is internal infrastructure — it has no direct user interface surface in the Familienarchiv application itself.
LGTM from a UI/UX and accessibility perspective. Nothing to review here.
Implementation update — replaced overlay2 sync with workspace bind mount
After reviewer discussion, the initial overlay2/nsenter sync approach was replaced with a simpler fix that removes the
--privileged --pid=hostcontainer entirely.Root insight
The bind-mount resolution problem requires that
$(pwd)inside a job container is a real host path. The overlay2 sync worked around this after the fact; the workspace bind mount prevents the problem at the source.Changes (3 commits,
15ef079e)fix(ci): configure workspace bind mount for DooD bind-mount resolution—runner-config.yamlworkdir_parent: /srv/gitea-workspace— act_runner now stores job workspaces at a real host path/srv/gitea-workspaceadded tovalid_volumesoptionsupdated to mount/srv/gitea-workspace:/srv/gitea-workspace(identical path both sides — this is the key constraint)fix(ci): replace overlay2 sync with workspace bind mount for DooD—docker-compose.observability.yml,nightly.yml,release.yml./infra/observability/…—OBS_CONFIG_DIRvariable removedOBS_CONFIG_DIRenv injection from both env-file write stepsdocs(adr): add ADR-015 for DooD workspace bind-mount approach—docs/adr/015-dood-workspace-bind-mount.mdOut-of-band changes applied to the VPS
/srv/gitea-workspaceonraddatz.cloud- /srv/gitea-workspace:/srv/gitea-workspaceto the runner service in~/docker/gitea/compose.yaml~/docker/gitea/runner-config.yamlto matchdocker compose restart runner)Reviewer concerns addressed
--privileged --pid=hostthreat model commentdocker inspect "$(hostname)"reliability🏛️ Markus Keller (@mkeller) — Senior Application Architect
Verdict: ⚠️ Approved with concerns
Blockers
PR description is stale — describes v1, diff implements v2.
The PR body still describes the
OBS_CONFIG_DIRenv-var approach and thensenter/Alpine sync step (the "v1" approach). The actual diff contains none of that — it's the workspace bind-mount approach. This mismatch means the PR body actively misleads a reviewer about what is being merged. The body should be rewritten to describe the workspace bind-mount approach before merge.docs/DEPLOYMENT.mdlikely needs an update.runner-config.yamlis infrastructure. The doc table in my review checklist maps "New Docker service or infrastructure component" →docs/DEPLOYMENT.md. The workspace path/srv/gitea-workspaceand the runnercompose.yamlvolume requirement are new operational facts for whoever manages the VPS. Ifdocs/DEPLOYMENT.mdordocs/infrastructure/production-compose.mdalready document the runner setup, this section needs a note. If not, consider whether this is the right moment to add it.Suggestions
Out-of-band operational prerequisite is documented, but fragile.
The ADR correctly notes:
This is an acceptable constraint for a single-person operation. Good that it's in the ADR. Consider whether a comment in
runner-config.yaml(pointing to the ADR) is also warranted for the person who next edits the file.ADR quality is excellent. The four alternatives table with explicit rejection reasons is exactly the format I want. The "identical host ↔ container path" constraint is correctly identified as the load-bearing invariant and explained with enough depth that a future developer won't accidentally break it by mapping to a different container path. This is a permanent architectural asset.
👨💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Blockers
PR description describes the wrong implementation.
The body still refers to
OBS_CONFIG_DIR,nightly.yml/release.ymlworkflow steps, and thensenter/Alpine pattern — none of which appear in the diff. A reviewer who reads the body and then the diff will be confused. Please rewrite the PR body to match the actual changes before merge.Observations (no code issues)
There's no application code in this PR — just YAML config and a Markdown ADR. Nothing to flag on naming, function size, or TDD evidence. The changed lines are infrastructure config, not business logic.
Comments in
runner-config.yamlfollow the right pattern.The new block at
runner-config.yaml:8–19explains whyworkdir_parentmust equal the host path and names the failure mode that motivated it. That's exactly the kind of comment that belongs in a config file. The# SECURITY:note (carried forward from before) is correct and should stay.ADR references the previous approach honestly.
The alternatives table calls out "the previous approach, see PR #599 v1" with a clear rejection reason. This is the kind of traceability I want: future readers can understand why the simpler-looking fix was tried and abandoned without digging through PR history.
Minor
Nothing else to flag here. The YAML is minimal and the ADR is the right vehicle for this level of explanation.
🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ⚠️ Approved with concerns
Blockers
PR description is completely wrong for what's in the diff.
The body describes the v1 sync approach (
OBS_CONFIG_DIR, nsenter Alpine step,nightly.yml/release.ymlchanges). The actual diff is two files: a new ADR and arunner-config.yamlchange. These must match before merge — anyone triaging later will be misled.Two manual host-side steps are prerequisites for this config to work, and they're easy to miss.
From the ADR:
mkdir -p /srv/gitea-workspaceon the VPS- /srv/gitea-workspace:/srv/gitea-workspaceto the runner'scompose.yaml(outside this repo)The
runner-config.yamlcomment documents item 2. But if either prerequisite is missing, the runner silently continues running with the old workspace layout — jobs won't fail at startup, they'll fail whendocker composetries to resolve bind mounts. This is a confusing failure mode. The prerequisite comment is good; I'd also suggest verifying these steps are done before merging, since the test plan checkboxes are all still unchecked.Suggestions
Workspace persistence under
/srv/gitea-workspaceneeds a cleanup strategy.The ADR says:
On a CX32 with ~40GB disk, this is survivable — but for a long-running instance, a simple cron like
find /srv/gitea-workspace -maxdepth 3 -name "*.lock" -mtime +7 -delete(or a full cleanup of orphaned run dirs) is worth adding to the ops runbook before it becomes an incident. Not a blocker, but worth tracking.The
valid_volumes+optionspattern is correct.valid_volumeswhitelists paths that workflow steps may reference ✓optionsmounts them into the job container at startup ✓/srv/gitea-workspaceis correctly enforced ✓The config change itself is clean. The main risk is the operational bootstrapping.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
Blockers
The PR's stated requirements and test plan do not match the implementation in the diff.
The PR body defines acceptance criteria anchored to the v1 approach:
The second item explicitly mentions
OBS_CONFIG_DIR— a concept that no longer exists in this implementation. The diff contains no changes todocker-compose.observability.yml,nightly.yml, orrelease.yml, which the body implies were changed.Revised acceptance criteria for the actual implementation (workspace bind-mount approach):
Suggestions
The operational prerequisites are implicit requirements. They are documented in the ADR but they are not in the test plan. Before closing issue #598, confirm:
/srv/gitea-workspacecreated on VPS ✓/✗compose.yamlupdated ✓/✗These items represent the Definition of Done for issue #598 and should be checked before the PR is merged.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
What I checked
Docker socket exposure — unchanged, acceptable for this trust model.
The
optionsline adds-v /srv/gitea-workspace:/srv/gitea-workspaceto the existing-v /var/run/docker.sock:/var/run/docker.sock. The socket mount is the dominant privilege grant here; the workspace volume adds no meaningful new attack surface. The# SECURITY:comment correctly scopes the trust model to "only trusted code from this private repo" — this is the right annotation for a future auditor.Workspace persistence — minor risk, documented.
Job workspaces under
/srv/gitea-workspacepersist across runs. act_runner creates per-run subdirectories, but orphaned directories from interrupted runs could contain:This is not a new attack surface relative to the v1 approach (overlay2 MergedDir was also persistent). The ADR acknowledges it. For a private single-tenant runner it's acceptable. If the runner is ever opened to untrusted contributors, this persistence model would need re-evaluation — but that's explicitly out of scope per ADR-011.
Path traversal concern — not applicable.
The bind-mount source is a fixed path (
/srv/gitea-workspace) not derived from user input. No injection vector here.Privilege escalation via
--privileged— removed.The ADR notes that the v1 approach required
--privileged --pid=host(effective root on the host). The v2 approach eliminates this. This is a meaningful security improvement — the blast radius of a compromised job container is now limited to the Docker socket (which was already present) rather than full host filesystem access via nsenter.Summary
v2 is measurably more secure than v1. The socket mount is the accepted risk for this deployment model. No new findings.
🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
What I checked
Test plan is stale and partially invalid.
The PR test plan references
OBS_CONFIG_DIR("Localdocker compose -f docker-compose.observability.yml upstill works (noOBS_CONFIG_DIRneeded)") — a concept that belongs to v1, which was abandoned. With the v2 workspace bind-mount approach,OBS_CONFIG_DIRwas never added todocker-compose.observability.yml, so this criterion no longer makes sense. The test plan needs rewriting to match the actual implementation.All three test plan checkboxes remain unchecked.
This indicates the fix has not been verified in any environment. The core fix depends on two out-of-band prerequisites on the VPS (the directory creation and runner compose.yaml volume). Without those being applied and verified, this PR can't be considered done.
No automated test coverage possible — expected.
CI config changes and runner YAML are not unit-testable. The test strategy here is necessarily manual verification + CI observation. That's fine. But the manual verification steps must actually be completed and the boxes checked before merge.
Suggestion
Before merging, I'd want to see at minimum:
OBS_CONFIG_DIRreferenceThe config change itself looks correct — the risk is that it's been theoretically correct but not yet empirically validated.
🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead
Verdict: ✅ Approved
This PR contains no user-facing changes — it's a CI runner configuration change and an Architecture Decision Record document. There are no Svelte components, no route changes, no CSS, and no UI interactions affected.
I reviewed the two changed files to confirm:
docs/adr/015-dood-workspace-bind-mount.md— Technical infrastructure documentation, no UX implicationsrunner-config.yaml— CI runner YAML configuration, no UX implicationsNothing for me to flag here. LGTM from a UI/UX perspective.