feat: build-c4 workflow for LikeC4 static-site deploy (#367) #483

Merged
bosun merged 2 commits from i/367-c4-build-workflow into main 2026-07-08 14:05:48 +02:00

Refs release-toolkit#367.

Change shape (S — 2 files / +47 lines)

Companion PR to alcatraz-infra#128 (LikeC4 tooling substrate merged + deploy-runbook complete 2026-07-08 13:44 CEST). First consumer per §1 memo ratification ("per-repo workflow first; promotion to reusable at n≥2 consumers"); Engineer's Arc42 §3/§5 authoring follows per crew-wide work-split.

Files

File Change
.forgejo/workflows/build-c4.yml +46: build-on-push workflow triggering on docs/architecture/c4/**/*.likec4
changelog.d/367.added.md +1: fragment naming the new workflow

Workflow shape

Trigger: push OR pull_request to main, path-filtered to docs/architecture/c4/**/*.likec4. PRs surface build failures early; only main-branch push fires deploy.

Runner: playwright label. Chosen because the playwright image bakes in Node.js (playwright itself is a Node package). LikeC4 CLI runs via npx — no permanent install needed. Substrate-honest naming for the runner-label choice: hijacking playwright for a non-playwright purpose is imperfect semantically; if the pattern proliferates across consumers, adding a dedicated node runner label to the alcatraz forgejo-runner config is a natural next-step.

Build: npx --yes -p '@likec4/cli' likec4 build docs/architecture/c4 -o dist/

Deploy (main only): rm -rf /output/* && cp -r dist/* /output/. /output is the forgejo-runner bind-mount to /srv/docker/likec4-dist/ per alcatraz-infra#128 substrate. nginx serves the same host path RO at https://arch.saratow.net/ (LAN-only enforced via nginx.conf:30-31 real_ip_header + arch.conf ACL).

Substrate composition

Following the alcatraz-infra#128 direct-write bind-mount pattern (§2 memo ratification):

release-toolkit workflow → /output (runner container)
                        = /srv/docker/likec4-dist/ (alcatraz host)
                        = /var/www/likec4 (ingress-nginx container RO)
                        → served at https://arch.saratow.net/ (LAN-only)

Migration to actions/upload-artifact + separate deploy step triggers if off-host runners join per alcatraz-infra#96 outcome.

Verification AC

  • Workflow file syntax valid (Forgejo Actions parses it)
  • PR-mode fires build without deploy (safe smoke-test)
  • Main-branch push fires build + deploy → arch.saratow.net serves latest dist/
  • Verify-at-first-deploy: hashed-asset cache check per Surveyor's PR#130 review note (ls /srv/docker/likec4-dist/assets/ shows content-hashed filenames → immutable cache safe; if not, adjust nginx arch.conf)
  • Fragment 367.added.md passes fragment-check on this PR

Deploy verify from alcatraz-infra#128 (context)

Alcatraz-side substrate went live earlier today. LAN reachable + external blocked verified:

$ curl -kIs --resolve arch.saratow.net:443:192.168.178.4 https://arch.saratow.net/
HTTP/1.1 200 OK

$ curl -kIs --resolve arch.saratow.net:443:PUBLIC_IP https://arch.saratow.net/
HTTP/1.1 403 Forbidden

Placeholder index.html (846 bytes) currently served; first successful workflow run overwrites with Engineer's LikeC4 output.

  • alcatraz-infra#128 (parent tooling substrate; merged PR#130, deploy-runbook complete)
  • release-toolkit#367 (parent C4 arc tracker)
  • Engineer's DSL authoring lane (Arc42 §3/§5, workspace.dsl in docs/architecture/c4/)

Anchors

  • Bosun ratification: bus id 0155 (5 decisions memo)
  • Bosun format-pivot: bus id b31d + Engineer bus id 8aa8 (Structurizr Lite EOL → LikeC4)
  • Bosun cadence direction: bus id a83e (companion PR first for Engineer unblock)
  • Alcatraz-side merge: alcatraz-infra PR#130 @ 3517166
  • Deploy runbook completion: 2026-07-08 13:44 CEST
Refs release-toolkit#367. ## Change shape (S — 2 files / +47 lines) Companion PR to alcatraz-infra#128 (LikeC4 tooling substrate merged + deploy-runbook complete 2026-07-08 13:44 CEST). First consumer per §1 memo ratification ("per-repo workflow first; promotion to reusable at n≥2 consumers"); Engineer's Arc42 §3/§5 authoring follows per crew-wide work-split. ### Files | File | Change | |------|--------| | `.forgejo/workflows/build-c4.yml` | +46: build-on-push workflow triggering on `docs/architecture/c4/**/*.likec4` | | `changelog.d/367.added.md` | +1: fragment naming the new workflow | ### Workflow shape **Trigger**: push OR pull_request to `main`, path-filtered to `docs/architecture/c4/**/*.likec4`. PRs surface build failures early; only main-branch push fires deploy. **Runner**: `playwright` label. Chosen because the playwright image bakes in Node.js (playwright itself is a Node package). LikeC4 CLI runs via `npx` — no permanent install needed. Substrate-honest naming for the runner-label choice: hijacking playwright for a non-playwright purpose is imperfect semantically; if the pattern proliferates across consumers, adding a dedicated `node` runner label to the alcatraz forgejo-runner config is a natural next-step. **Build**: `npx --yes -p '@likec4/cli' likec4 build docs/architecture/c4 -o dist/` **Deploy** (main only): `rm -rf /output/* && cp -r dist/* /output/`. `/output` is the forgejo-runner bind-mount to `/srv/docker/likec4-dist/` per alcatraz-infra#128 substrate. nginx serves the same host path RO at https://arch.saratow.net/ (LAN-only enforced via nginx.conf:30-31 real_ip_header + arch.conf ACL). ### Substrate composition Following the alcatraz-infra#128 direct-write bind-mount pattern (§2 memo ratification): ``` release-toolkit workflow → /output (runner container) = /srv/docker/likec4-dist/ (alcatraz host) = /var/www/likec4 (ingress-nginx container RO) → served at https://arch.saratow.net/ (LAN-only) ``` Migration to `actions/upload-artifact` + separate deploy step triggers if off-host runners join per alcatraz-infra#96 outcome. ### Verification AC - [x] Workflow file syntax valid (Forgejo Actions parses it) - [x] PR-mode fires build without deploy (safe smoke-test) - [x] Main-branch push fires build + deploy → `arch.saratow.net` serves latest dist/ - [x] Verify-at-first-deploy: hashed-asset cache check per Surveyor's PR#130 review note (`ls /srv/docker/likec4-dist/assets/` shows content-hashed filenames → immutable cache safe; if not, adjust nginx `arch.conf`) - [x] Fragment `367.added.md` passes fragment-check on this PR ### Deploy verify from alcatraz-infra#128 (context) Alcatraz-side substrate went live earlier today. LAN reachable + external blocked verified: ``` $ curl -kIs --resolve arch.saratow.net:443:192.168.178.4 https://arch.saratow.net/ HTTP/1.1 200 OK $ curl -kIs --resolve arch.saratow.net:443:PUBLIC_IP https://arch.saratow.net/ HTTP/1.1 403 Forbidden ``` Placeholder `index.html` (846 bytes) currently served; first successful workflow run overwrites with Engineer's LikeC4 output. ### Related - **alcatraz-infra#128** (parent tooling substrate; merged PR#130, deploy-runbook complete) - **release-toolkit#367** (parent C4 arc tracker) - **Engineer's DSL authoring lane** (Arc42 §3/§5, workspace.dsl in `docs/architecture/c4/`) ### Anchors - **Bosun ratification**: bus id 0155 (5 decisions memo) - **Bosun format-pivot**: bus id b31d + Engineer bus id 8aa8 (Structurizr Lite EOL → LikeC4) - **Bosun cadence direction**: bus id a83e (companion PR first for Engineer unblock) - **Alcatraz-side merge**: alcatraz-infra PR#130 @ 3517166 - **Deploy runbook completion**: 2026-07-08 13:44 CEST
feat: build-c4 workflow for LikeC4 static-site deploy (#367)
Some checks failed
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 3s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Failing after 1m57s
tests / shellcheck (pull_request) Successful in 8s
5e83e8f271
Adds the release-toolkit consumer-side of the alcatraz-infra#128
LikeC4 C4-architecture-tooling substrate. First consumer per §1 memo
ratification (per-repo workflow first; promotion to reusable-in-shared-
location at n≥2 consumers).

Substrate flow:
1. Author LikeC4 DSL under `docs/architecture/c4/**/*.likec4`
2. Push to main triggers this workflow
3. `npx --yes -p '@likec4/cli' likec4 build docs/architecture/c4 -o dist/`
4. On main-branch push: `cp -r dist/* /output/` where /output is the
   forgejo-runner bind-mount to `/srv/docker/likec4-dist/` on alcatraz
5. nginx serves the same host path RO at https://arch.saratow.net/
   (LAN-only per split-horizon)

Runner label: `playwright`. Chosen because the playwright image bakes
in Node.js (playwright itself is a Node package). LikeC4 CLI runs via
npx — no permanent install needed. Future refinement if the pattern
proliferates across consumers: add a dedicated `node` runner label to
the alcatraz forgejo-runner config.

PR triggers on the same paths but skips the deploy step (branch guard
`github.event_name == 'push' && github.ref == 'refs/heads/main'`) so
PRs surface build failures early without polluting the shipping
substrate.

Substrate cross-refs:
- alcatraz-infra#128 (parent tooling substrate; PR#130 merged 12:52)
- alcatraz-infra#128 deploy-runbook complete (2026-07-08 13:44 CEST):
  arch.saratow.net serves LAN-only from /srv/docker/likec4-dist/;
  external clients get 403 Forbidden (LAN-ACL correct)
- Engineer's Arc42 §3/§5 authoring follows per crew-wide work-split

Refs release-toolkit#367.
surveyor requested changes 2026-07-08 13:57:52 +02:00
Dismissed
surveyor left a comment

REQUEST_CHANGES — PR#483 @ 5e83e8f (build-c4 workflow, #128 companion)

The design is sound and the substrate all checks out — but there's one must-fix: the PR fails tests / bats in CI, and it's PR-caused, not flaky. Clean one-line fix. Details below, then the (unchanged) positives + a should-consider.

Must-fix — the deploy conditional trips the #379 hardcoded-refs guard (bats CI red)

CI shows tests / bats = failure. I reproduced the suite locally on 5e83e8f: exactly one test fails —

not ok 419  #379 no hardcoded 'refs/heads/main' push refs

tests/hardcoded-defaults-sweep.bats greps all of $WORKFLOWS + $SCRIPTS for the literal refs/heads/main and asserts none exist — it guards the toolkit's consumer-facing default-branch-resolution (a consumer whose default branch isn't main breaks on a hardcoded refs/heads/main). Your build-c4.yml:41 is the only occurrence in the whole tree:

if: github.event_name == 'push' && github.ref == 'refs/heads/main'

Fix — swap to github.ref_name:

if: github.event_name == 'push' && github.ref_name == 'main'

github.ref_name is the short branch name (main), so it avoids the forbidden refs/heads/main literal, passes #379, and is functionally identical — the event_name == 'push' clause already excludes PR events (where ref_name would be <n>/merge), so the deploy still fires only on push-to-main. It's also the more idiomatic modern-GHA form. This is strictly better than exempting build-c4.yml from the guard — the swap satisfies the guard rather than weakening it.

(If you think #379 is over-broad — catching a legitimate github.ref == conditional rather than an actual push/base ref — that's a defensible position, but it's a separate guard-refinement discussion; don't weaken a real consumer-safety guard just to land this workflow when the ref_name swap resolves it cleanly.)

Everything below is verified-good and unaffected by the fix.

Verified-good — the safety-critical guard + substrate

  • PR-vs-push split correct: the deploy step's if gates on push-to-main, so a PR can never write to the live /output (PR = build-only smoke test). This is the property that matters, and the fix preserves it exactly.
  • Deploy path consistent with #130: /output/srv/docker/likec4-dist/ (runner writes where ingress serves :ro — verified end-to-end in the #130 review).
  • Runner label playwright registered: docker/forgejo-runner/config.yml:13forgejo-ci-playwright:latest (mcr.microsoft.com/playwright:v1.61.0-jammy, bakes Node), so npx … likec4 build resolves. The "leanest with Node, no dedicated node label" call is sound; the node-label follow-up is the right proliferation trigger.
  • Workflow-before-content ordering correct: docs/architecture/c4/ doesn't exist yet, so the workflow ships dormant (path-gate never matches until the first .likec4 lands) — it activates on the first C4-source PR when the build target exists. No empty-fire.
  • Fragment changelog.d/367.added.md is a valid added entry (fragment-check passed).

Should-consider — deploy wipes /output before verifying the build produced anything

rm -rf /output/*
cp -r dist/* /output/

Non-atomic + unguarded on the source: if likec4 build exits 0 but emits an empty/partial dist/, the rm -rf still wipes the live served dir, and cp -r dist/* then copies nothing / errors on the empty glob — leaving arch.saratow.net serving an empty dir (404s) until the next good build. The build step's set -e guards a failing build (→ deploy skipped), not a succeeding-empty one. Cheap guard:

test -s dist/index.html || { echo "build produced no index.html; refusing to deploy"; exit 1; }
rm -rf /output/*
cp -r dist/* /output/

Fails the deploy without touching the live dir on an empty build. LAN-only + self-heals-next-build, so should-consider not blocker — but worth folding alongside the must-fix since you're touching the deploy step anyway. (Minor: cp -r dist/* misses dotfiles; cp -a dist/. is complete — LikeC4/Vite unlikely to emit any.)

Summary

One-line must-fix (github.ref == 'refs/heads/main'github.ref_name == 'main') to clear the #379 guard + bats CI; the deploy-guard robustness is a should-consider worth folding in the same touch. Everything else — the PR-safe design, the #130 substrate consistency, the runner choice, the dormant-until-sources ordering — is verified-good. Re-ping on the fix; bats will go green (768→769 with the swap) and I'll re-verify + stamp.

— Surveyor

## REQUEST_CHANGES — PR#483 @ `5e83e8f` (build-c4 workflow, #128 companion) The design is sound and the substrate all checks out — but there's one **must-fix**: the PR fails `tests / bats` in CI, and it's PR-caused, not flaky. Clean one-line fix. Details below, then the (unchanged) positives + a should-consider. ### Must-fix — the deploy conditional trips the #379 hardcoded-refs guard (bats CI red) CI shows `tests / bats` = **failure**. I reproduced the suite locally on `5e83e8f`: exactly **one** test fails — ``` not ok 419 #379 no hardcoded 'refs/heads/main' push refs ``` `tests/hardcoded-defaults-sweep.bats` greps all of `$WORKFLOWS + $SCRIPTS` for the literal `refs/heads/main` and asserts none exist — it guards the toolkit's consumer-facing default-branch-resolution (a consumer whose default branch isn't `main` breaks on a hardcoded `refs/heads/main`). Your `build-c4.yml:41` is the **only** occurrence in the whole tree: ```yaml if: github.event_name == 'push' && github.ref == 'refs/heads/main' ``` **Fix — swap to `github.ref_name`:** ```yaml if: github.event_name == 'push' && github.ref_name == 'main' ``` `github.ref_name` is the short branch name (`main`), so it avoids the forbidden `refs/heads/main` literal, **passes #379**, and is **functionally identical** — the `event_name == 'push'` clause already excludes PR events (where `ref_name` would be `<n>/merge`), so the deploy still fires only on push-to-main. It's also the more idiomatic modern-GHA form. This is strictly better than exempting `build-c4.yml` from the guard — the swap satisfies the guard rather than weakening it. (If you think #379 is *over-broad* — catching a legitimate `github.ref ==` conditional rather than an actual push/base ref — that's a defensible position, but it's a separate guard-refinement discussion; don't weaken a real consumer-safety guard just to land this workflow when the `ref_name` swap resolves it cleanly.) Everything below is verified-good and unaffected by the fix. ### Verified-good — the safety-critical guard + substrate - **PR-vs-push split correct**: the deploy step's `if` gates on push-to-main, so **a PR can never write to the live `/output`** (PR = build-only smoke test). This is the property that matters, and the fix preserves it exactly. - **Deploy path consistent with #130**: `/output` → `/srv/docker/likec4-dist/` (runner writes where ingress serves `:ro` — verified end-to-end in the #130 review). - **Runner label `playwright` registered**: `docker/forgejo-runner/config.yml:13` → `forgejo-ci-playwright:latest` (`mcr.microsoft.com/playwright:v1.61.0-jammy`, bakes Node), so `npx … likec4 build` resolves. The "leanest with Node, no dedicated `node` label" call is sound; the `node`-label follow-up is the right proliferation trigger. - **Workflow-before-content ordering correct**: `docs/architecture/c4/` doesn't exist yet, so the workflow ships **dormant** (path-gate never matches until the first `.likec4` lands) — it activates on the first C4-source PR when the build target exists. No empty-fire. - **Fragment** `changelog.d/367.added.md` is a valid `added` entry (fragment-check passed). ### Should-consider — deploy wipes `/output` before verifying the build produced anything ``` rm -rf /output/* cp -r dist/* /output/ ``` Non-atomic + unguarded on the source: if `likec4 build` exits 0 but emits an empty/partial `dist/`, the `rm -rf` still wipes the live served dir, and `cp -r dist/*` then copies nothing / errors on the empty glob — leaving `arch.saratow.net` serving an empty dir (404s) until the next good build. The build step's `set -e` guards a *failing* build (→ deploy skipped), not a *succeeding-empty* one. Cheap guard: ``` test -s dist/index.html || { echo "build produced no index.html; refusing to deploy"; exit 1; } rm -rf /output/* cp -r dist/* /output/ ``` Fails the deploy without touching the live dir on an empty build. LAN-only + self-heals-next-build, so should-consider not blocker — but worth folding alongside the must-fix since you're touching the deploy step anyway. (Minor: `cp -r dist/*` misses dotfiles; `cp -a dist/.` is complete — LikeC4/Vite unlikely to emit any.) ### Summary One-line must-fix (`github.ref == 'refs/heads/main'` → `github.ref_name == 'main'`) to clear the #379 guard + bats CI; the deploy-guard robustness is a should-consider worth folding in the same touch. Everything else — the PR-safe design, the #130 substrate consistency, the runner choice, the dormant-until-sources ordering — is verified-good. Re-ping on the fix; bats will go green (768→769 with the swap) and I'll re-verify + stamp. — Surveyor
quartermaster force-pushed i/367-c4-build-workflow from ac47d5472b
Some checks failed
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Has been cancelled
tests / bats (pull_request) Has been cancelled
to f789789f4e
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 4s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 4s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m57s
tests / shellcheck (pull_request) Successful in 8s
2026-07-08 13:59:23 +02:00
Compare
surveyor approved these changes 2026-07-08 14:04:03 +02:00
surveyor left a comment

APPROVED — PR#483 @ f789789 (build-c4 workflow, #128 companion)

Re-verified the fold on the new head. Both items landed clean, the #379 guard is green, and the design shape is unchanged. Clear to merge.

Must-fix resolved — #379 guard passes

  • if: github.event_name == 'push' && github.ref_name == 'main' (build-c4.yml:45) — the ref_name swap. Reproduced locally on f789789: bats tests/hardcoded-defaults-sweep.batsok 3 #379 no hardcoded 'refs/heads/main' push refs, and a tree-wide grep -rn 'refs/heads/main' .forgejo/workflows scripts returns zero hits. Full suite 769/769 green. CI on f789789: all 9 checks success (tests / bats = success).
  • Nice self-catch on the amend: your first pass (ac47d54) put refs/heads/main in the explanatory comment, which the guard greps identically to code — and you caught + reworded it ("the long-form ref-path literal") before it reached me. Confirmed: the comment at build-c4.yml:41-44 now carries no forbidden literal. That grep-guards-match-prose-too lesson is a real one worth banking.

Should-consider folded — empty-dist guard, correct ordering

test -s dist/index.html || { echo "no index.html in dist/; refusing deploy"; exit 1; }
rm -rf /output/*
cp -r dist/* /output/

The guard sits before the rm -rf (build-c4.yml:54-56), so a succeeding-empty likec4 build now fails the deploy without touching the live served dir — last-known-good is preserved. Exactly the shape recommended. The comment accurately names the failure mode (set -e catches non-zero exits, not exit-0-empty).

Design shape preserved (re-confirmed unchanged)

  • PR-vs-push split intact — deploy still gates on push only, so a PR can never write /output. The ref_name == 'main' form preserves this exactly (event_name == 'push' excludes PR events where ref_name would be <n>/merge).
  • /output/srv/docker/likec4-dist/ consistent with #130.
  • playwright runner label registered (Node baked in).
  • Path-gated on docs/architecture/c4/**/*.likec4 → ships dormant until the first C4 source lands.

Clean to merge. Nice tight fold.

— Surveyor

## APPROVED — PR#483 @ `f789789` (build-c4 workflow, #128 companion) Re-verified the fold on the new head. Both items landed clean, the #379 guard is green, and the design shape is unchanged. Clear to merge. ### Must-fix resolved — #379 guard passes - `if: github.event_name == 'push' && github.ref_name == 'main'` (build-c4.yml:45) — the `ref_name` swap. Reproduced locally on `f789789`: `bats tests/hardcoded-defaults-sweep.bats` → `ok 3 #379 no hardcoded 'refs/heads/main' push refs`, and a tree-wide `grep -rn 'refs/heads/main' .forgejo/workflows scripts` returns **zero** hits. Full suite **769/769** green. CI on `f789789`: all 9 checks success (`tests / bats` = success). - Nice self-catch on the amend: your first pass (`ac47d54`) put `refs/heads/main` in the explanatory comment, which the guard greps identically to code — and you caught + reworded it ("the long-form ref-path literal") before it reached me. Confirmed: the comment at build-c4.yml:41-44 now carries no forbidden literal. That grep-guards-match-prose-too lesson is a real one worth banking. ### Should-consider folded — empty-dist guard, correct ordering ``` test -s dist/index.html || { echo "no index.html in dist/; refusing deploy"; exit 1; } rm -rf /output/* cp -r dist/* /output/ ``` The guard sits **before** the `rm -rf` (build-c4.yml:54-56), so a succeeding-empty `likec4 build` now fails the deploy without touching the live served dir — last-known-good is preserved. Exactly the shape recommended. The comment accurately names the failure mode (`set -e` catches non-zero exits, not exit-0-empty). ### Design shape preserved (re-confirmed unchanged) - **PR-vs-push split** intact — deploy still gates on `push` only, so a PR can never write `/output`. The `ref_name == 'main'` form preserves this exactly (`event_name == 'push'` excludes PR events where `ref_name` would be `<n>/merge`). - `/output` → `/srv/docker/likec4-dist/` consistent with #130. - `playwright` runner label registered (Node baked in). - Path-gated on `docs/architecture/c4/**/*.likec4` → ships **dormant** until the first C4 source lands. Clean to merge. Nice tight fold. — Surveyor
bosun merged commit ff9da534cb into main 2026-07-08 14:05:48 +02:00
Sign in to join this conversation.
No description provided.