Repo topology and release pipeline for multi-target delivery (technical)¶
Decision record. Status: accepted · Date: 2026-08-05 · Ticket: WING-334
The decision is accepted; the pipeline is not built. Nothing in §4–§7 exists yet — there is no release workflow, and every target still builds shared code independently (§3). Read this as the target state plus the work to reach it (§8), not as a description of current CI.
Scope: delivery only. How the apps work — Cloud Run fronting the GKE Playwright runner, sticky egress IPs, mobile streaming its screen, the desktop editions — is settled and is not revisited here. This record answers one question: how a single change reaches every shipped version without being rebuilt per target.
Related: plain-English version · architecture · packaging · cloud-run-server-edition · editions-matrix
1. Decision¶
One repository, trunk-based, one release pipeline. Targets are packaging jobs over shared artifacts, never separate branches or repos.
Environment branches and multi-repo are both rejected for the same reason: they make the unit of change per-target when almost every change is shared. Merge and backport cost grows O(targets × changes), the repo already runs many concurrent worktrees off one master with fast-forward landings, and drift becomes the steady state. Neither reduces variance — the runtime already varies along six-plus axes (editions-matrix §2) that no git topology can encode.
2. Delivery surfaces¶
| Surface | Artifact | Runs on |
|---|---|---|
| Cloud app | container | Cloud Run |
| Playwright runner | container | GKE, sticky egress IPs — designed, not yet a separate artifact |
| Marketing site | container (Dockerfile.site, cloudbuild.site.yaml) |
Cloud Run, separate service |
| Desktop Windows | NSIS installer + portable | update feed |
| Desktop macOS | signed/notarized .app |
not built yet |
| Desktop Linux | package | not built yet |
| Mobile shell | store build over the web dist | not built yet |
| Browser extension | per-browser zips | store upload |
Eight surfaces, four of which are not built yet. They fall into four tracks by what they consume:
- Frontend + API — cloud app, desktop Windows/macOS/Linux. Embed both. Four surfaces; two exist today.
- Frontend only — the mobile shell. Ships the SPA and talks to the cloud API over the network; it embeds no API binary.
- API only, headless — the Playwright runner. Takes the API publish output, no frontend.
- Independent — the marketing site and the browser extension. The site publishes
WorkWingman.Sitewith neither the Angular frontend norWorkWingman.Api; the extension shares no product frontend. Both ride their own packaging tracks and are not what this record is about.
On the runner, be accurate about today: Playwright currently runs in-process inside the Api image — Dockerfile:52-55 installs Chromium into it and the entrypoint is WorkWingman.Api.dll. A separate GKE runner is the decided direction, but there is no distinct runner artifact in this repo yet and no remote-runner contract. The pipeline below must therefore treat it as a surface to be created, not one to be wired up.
3. The problem: every target rebuilds shared code¶
Verified 2026-08-05:
| Where | What it rebuilds |
|---|---|
Dockerfile:9 |
RUN npm run build — the Angular dist |
electron/build-installer.ps1:45 |
npm run build — the same dist, again |
Dockerfile:22 |
dotnet publish src/WorkWingman.Api |
electron/build-installer.ps1:50 |
dotnet publish src/WorkWingman.Api -r win-x64 --self-contained |
Two live targets, two independent rebuilds of the same source. Five surfaces will ship the SPA (cloud, three desktops, mobile) — so on the current pattern that becomes five independent rebuilds of one frontend, plus separate API publishes per RID. Each is a chance to build from a different tree, a different node_modules, or a different commit, with nothing forcing two targets to ship the same bits.
There is also no release workflow. ci.yml, mutation.yml, inspect.yml, and extension.yml are test and lint workflows; the site is the only surface with a cloudbuild; cloud deploys run from a local pinned worktree through tools/WorkWingman.CloudDeploy. Consistency today rests on a person running the right script from the right tree.
4. The pipeline: build once, consume many¶
One commit SHA produces one set of shared artifacts. Every packaging job consumes them.
The invariant is: no packaging job rebuilds product source. No npm run build, no dotnet publish of our code, below stage 1. Packaging jobs may run shell-native toolchains that do not touch shared sources — Xcode and Gradle compiling the mobile shell, electron-builder rebuilding a native module, an installer compiler. Those wrap prebuilt artifacts; they do not reproduce them. Stated as an absolute "no compilers" the rule would either block mobile outright or get quietly reinterpreted, and a rule that gets reinterpreted is not a rule.
Stage 1 — build shared code, once per SHA
- frontend-dist-<sha> — one npm run build
- api-publish-<rid>-<sha> — one dotnet publish per runtime (framework-dependent for containers, self-contained for desktop)
Stage 2 — package, N jobs, all consuming stage 1
- cloud app image ← base + api-publish-linux-x64 + frontend-dist
- GKE Playwright runner image ← its base + the same publish output
- desktop Windows ← api-publish-win-x64 + frontend-dist → electron-builder
- desktop macOS / Linux ← same shape, different RID
- mobile shell ← the same frontend-dist
- site image ← independent (shares no frontend)
- extension ← independent (shares no frontend)
Stage 3 — release without rebuilding. The mechanism differs by surface, and saying "promote the image" everywhere would be wrong:
- Containers (cloud app, runner, site) — promote by digest.
prodandqaare already the same image with different configuration (ops/cloud-run/env-ladder.json); that pattern stands. - Desktop and mobile — one signed artifact per SHA per RID, built once. Environment differences arrive through the update-feed channel, first-run configuration, or remote settings — never a second compile, and never baked into the package. This is exactly why the hardcoded loopback origin (§9.4) is a delivery bug: it bakes an environment into an artifact that is supposed to be environment-agnostic.
The shared rule is build once, never rebuild to ship. The delivery mechanism is per-surface.
Artifact transport. Stage 2 jobs run on different machines and operating systems — Windows self-hosted, Apple Silicon, Linux containers — and share no filesystem with stage 1. Stage 1 therefore uploads its outputs to CI artifact storage keyed by SHA, and each stage 2 job downloads the immutable artifacts it needs before packaging. Without that step the pipeline is a diagram, not a pipeline.
Why this holds without discipline: a packaging job that cannot rebuild product source cannot drift in the product. It either finds the artifact for that SHA or it fails.
5. Release manifest¶
Every release records one row per surface: SHA · artifact digest · RID/platform · builder · deploy target · timestamp.
That makes "did this fix reach the GKE runner / the Mac build / the phone" a lookup instead of a belief, and it makes a partial release visible — a surface missing from the manifest did not ship.
6. What this buys QA¶
Uniformity is the goal, not bug prevention. A bug that ships identically to every surface is one bug: QA finds it once on the qa rung, it is fixed once, it is gone everywhere. Bugs that differ per surface are N bugs, each needing its own discovery on its own device — that scales QA linearly with target count, which is the cost no pipeline and no hire can absorb.
So the pipeline's job is to make a QA result transferable at the shared layer. "Passed on SHA abc on the qa rung" is a statement about the macOS build's shared behavior only if the macOS build provably contains the artifacts built from abc. That is what stage 1 guarantees and what the manifest (§5) proves.
Be precise about the limit, because a QA lab gets budgeted against it. Identical artifacts do not make a browser, an Electron renderer, and a mobile WebView the same host, and a framework-dependent Linux API publish is not a self-contained win-x64 one. Host rendering, native bridges, and RID-specific runtime behavior still produce host-specific bugs and are not covered by the qa-rung pass. What transfers is shared-layer behavior; what does not is the matrix below.
The per-surface delta — this is the QA lab's matrix. Everything shared is covered once on the qa rung. What genuinely differs per surface, and therefore must be re-tested per surface:
- installer and update path (per desktop OS)
- signing / notarization behavior on a clean machine (Gatekeeper, SmartScreen)
- the shell's own bridge — API origin, share sheet, screen streaming
- OS integration: file paths, permission prompts, deep links
Plus a functional smoke pass per platform. A matching SHA does not make functional QA transferable, because the product genuinely has OS-dependent code paths — src/WorkWingman.Api/LlmHarnessRegistration.cs and the Windows SAPI branch in src/WorkWingman.Infrastructure/Audio/LocalVoiceSynthesizer.cs are two — and each RID gets its own API publish. Every platform therefore needs a short functional smoke and regression run over the OS-sensitive surface area, not only the installation and integration checks above. Limiting the lab to install/trust/bridge/OS-integration would miss exactly the class of bug that only appears on one runtime.
That list is short by construction, and it is meant to stay short. If it grows, something shared has leaked into a target — the drift this pipeline exists to prevent, showing up as QA cost rather than as a bug report. Treat growth in the per-surface matrix as the alarm.
The release manifest also drives the lab directly: it says which SHA to install on which device, and which surfaces a given QA pass does not yet cover.
7. CI shape¶
Every PR — shared build and unit tests, Angular unit and lint, SAST/secrets as today, and one representative dotnet publish (framework-dependent) to prove the code compiles. The full RID matrix runs nightly and at tag. Without that split the PR gate grows with every platform added and the constant-time claim is false.
Not a full container build per PR — that is the most expensive possible way to prove code compiles. Build images when Dockerfile* or dependencies change, and on nightly/tag.
Nightly — stage 1 + stage 2 across every surface, so a broken packaging job is found before a release needs it. macOS notarization is an external Apple round-trip with variable latency: nightly produces an ad-hoc-signed build, and notarization runs on tag only. A notarization failure is release-blocking and never nightly-blocking.
Tag — the full matrix, signed and notarized, manifest published, artifacts promoted.
Secret custody — explicit matrix. PR gate: no signing secrets of any kind. Nightly: ad-hoc or development certificates only. Release/tag: production certificates, notarization, registry push. Never on fork PRs under any circumstance.
Production signing certificates and notarization credentials are scoped to tag/release runs only — never the PR gate (which runs unreviewed diffs) and never nightly, which packages every surface from unreviewed trunk. Nightly desktop builds are ad-hoc-signed or unsigned; they exist to prove packaging works, not to be installed by users. Registry push credentials are likewise release-scoped. A nightly job holding a production signing key is a nightly job that can sign anything that lands on trunk.
8. Work to do¶
| # | Change | Ticket |
|---|---|---|
| 1 | Add the release workflow (stages 1→2→3, keyed on the commit SHA) | WING-347 |
| 2 | Packaging scripts stop building shared code — build-installer.ps1 takes a -FrontendDist path; the single Dockerfile gains a build arg / stage target so it can COPY a prebuilt dist instead of running node. Not a second Dockerfile — §9.5 exists because two recipes for one artifact always diverge. Local dev keeps building for itself; CI never does |
WING-347 |
| 3 | Stamp the SHA into every artifact; publish the release manifest | WING-347 |
| 4 | Bring the GKE Playwright runner image into the same pipeline | WING-347 |
| 5 | Electron mac/linux targets; remove the hardcoded loopback endpoint | WING-345 |
| 6 | Mobile shell packaging over the same dist | WING-349 |
| 7 | Delete the duplicate Site Dockerfile | WING-350 |
9. Delivery problems in the current layout¶
- Shared code is rebuilt per target (§3) — the root cause of cross-version drift.
- No release workflow — releases depend on a person and a local tree.
electron/is win-x64 only (electron/package.json:64-91,electron/build-installer.ps1:49-51) — mac and Linux have no packaging path.- Hardcoded
127.0.0.1:5211(electron/preload.js:23) — this port was hijacked once by a shared dev launch configuration, silently pointing an installed app at a sandbox API. - Duplicate Site Dockerfiles —
Dockerfile.siteandsrc/WorkWingman.Site/Dockerfilebuild the same service with different stage names. Two files, one artifact. - The GKE runner is outside the repo's build story — a shipped surface with no pipeline is a surface that drifts silently.
10. Provenance¶
Topology decided by council 2026-08-05 at opus tier: Cedric, Jenny, and Gronktayvius each answered independently, unanimous for one repo with no dissent; the Clahadore seat failed and the synthesis was chaired by hand. This document was then reviewed at the /council-code-review gate, where Gronktayvius verified 30 factual claims against the source (2 false, 7 imprecise, all corrected) and Cedric caught two contradictions with live behavior.
An earlier draft also prescribed runtime architecture — host composition roots, capability contracts, a mobile framework choice. That was out of scope and has been removed: how each app works was already decided and is not reopened here. Delivery mechanics only.
Full council transcript lives in the sibling llm-council repo (fleet-internal): reports/2026-08-05_163111_architecture-decision--workwingman-repo-topology.md.