feat(build-policy): enforced remote builds, registry push, deploy-by-digest, queue coalescing, required checks - #209
Merged
Merged
Conversation
Adds the decision layer of the fork build-policy module plus its schema:
- decideBuildPolicy: enforcement, github-source gating, exclusions,
audited break-glass, and a hard error instead of a silent local build
when no build server or registry is configured
- github.com source detection for both `sourceType: github` and a
`sourceType: git` unit whose customGitUrl points at github.com
- `[skip deploy]` commit-message marker
- default watchPaths derivation from buildPath / Dockerfile / compose path
- image helpers: `<app>:<sha>` tagging, digest marker parsing, deploy-by-digest
- deploy-hook `{image, tag, digest}` body validation against the org registries
- requiredChecks evaluation and a polling wait with an injectable clock
- queue coalescing, best-effort so it can never block an enqueue
110 unit tests, no database or network.
Schema: build_policy_settings / _exclusion / _audit tables, a per-unit
`requiredChecks` array on applications and composes, and `imageTag` /
`imageDigest` on deployments.
Services: organization settings, exclusions, audit trail with break-glass
grants, the database-backed policy resolver, the remote tag+push+digest
build shell, deploy-by-digest, GitHub check-run gating, the enqueue-time
gate (skip marker, derived watchPaths, queue coalescing) and the
no-build deploy of an image supplied in a deploy-hook body.
tRPC: a `buildPolicy` router, organization-scoped from the session only.
Upstream hook points, all marked `build-policy hook` and documented in
services/build-policy/README.md:
- services/application.ts: 4 hooks each in deployApplication and
rebuildApplication (plan, push command, pin, deploy target)
- utils/builders/index.ts: deploy-by-digest override in getImageName
- pages/api/deploy/{github,[refreshToken],compose/[refreshToken]}.ts:
the enqueue gate and the optional {image, tag, digest} body
- server/queues/{queueSetup,queue-types,deployments-queue}.ts: the
coalescing counts and the pinned-image job arm
287 added lines across 20 upstream files. Test suite: 2102 tests, 2078
passing, same 15 failures in the same 6 environment-dependent files as
before the change (real docker, swarm and filesystem).
Migration 0200_handy_lifeguard, written idempotently so it satisfies the fork's own migration-idempotency test and re-runs cleanly on a partially migrated database. Integration test (33 cases) drives the real deployApplication with docker, ssh and git mocked, covering: enforced GitHub source builds remotely and deploys by digest; excluded unit builds locally; break-glass builds locally once then enforcement returns; a missing build server or registry is a hard error with no build started; required checks pass, fail and time out; a deploy-hook image deploys with no build; queue coalescing; [skip deploy]; derived watch paths. This is the tripwire for upstream merges (spec 11). README documents all eleven hook points in eight upstream files, the migration, how the digest crosses hosts, and the compose gap. UI: a Build Policy settings card (enforcement toggle, default build server and registry, required-checks timeout, a warning when enforcement is on but unconfigured), an exclusions card, an audit-log card, and a per-application requiredChecks card next to the existing build-server card. Also switches the generated build shell to posix.join so the path it hands the Linux build host is correct regardless of the host Dokploy runs on.
Review of PR #209 returned FIX. This commit covers the four blockers, the two majors and the three minors that were straightforward, each with a test written to fail first. 1. The enqueue gate and the deploy-hook image body now short-circuit while no organization enforces remote builds, behind a 5s cached probe, so an unenforced instance behaves exactly as upstream does. 2. The gate makes no SSH round trip and no settings read on that path. 3. A refused plan (NO_BUILD_SERVER / NO_REGISTRY) now creates the deployment, marks it and the application errored and sends the build error notification before the error is rethrown. The log append is best-effort so an unreachable host cannot cost the notification. 4. A deploy-hook image must equal the unit's own repository, not merely sit on a host the organization owns. 5. Coalescing drops only the unit's own plain deploys; a queued preview for the same application survives, and the audit names what it dropped. 6. The digest read-back rejects a marker naming any repository other than the one this deploy publishes, and runs it through assertSafeImageReference before pinning. 9. The per-unit build server field is read-only with a reason while the policy enforces. 10. addExclusion and allowLocalBuildOnce assert the unit belongs to the active organization; audit is now an adminProcedure. 11. Required checks match commit status contexts as well as check runs. Also fixes a latent bug: createDeployment created the deployment log on application.buildServerId || serverId, which is the wrong host once the policy relocates the build.
…policy is off Residue of review finding 1. The compose deploy hook returned a 400 for any body carrying an `image`, whether or not the organization enforced anything. Upstream ignores that body entirely, so on the day this merges a CI job that posts one to a compose deploy hook would start failing for a capability the instance does not even have switched on. `rejectComposeDeployHookImage` now applies the same two gates the application side already had: the cached "does anybody enforce" probe first, then the organization's own setting. An enforcing organization still gets the 400 that tells it the compose build is not relocatable; everyone else gets upstream's behaviour, which is to ignore the body.
…lation `toBuildPolicyUnit` and `prepareBuildPolicyDeploy` reached straight through `application.environment.project` for the organization id. `findApplicationById` always loads that relation, so this never fired in production, but it made the fork a new way for somebody else's deploy path to throw a TypeError: any caller holding a leaner row crashed inside a hook that is supposed to be a no-op while the policy is off. Both now read it defensively and stand aside when it is missing, planning as `no_organization` and deploying the application unchanged, with a warning. An enforcement that cannot resolve an organization has nothing to enforce against; refusing the deploy there would turn an unloaded relation into an outage. `prepareBuildPolicyDeploy` also reads the organization after its early return rather than before, so an unenforced deploy with no required checks does not touch it at all.
Review finding 8. `deployPreviewApplication` and `rebuildPreviewApplication` had no hook 1/4, so a PR preview kept building on the deploy host — the exact machine this track exists to unload, and previews are a large share of its build load. Spec 5.2.1 covers any GitHub App sourced unit, and a preview is one. Both preview paths now carry the same four hooks as the plain deploy: forced build server, tag and push by sha, digest read-back, deploy by digest. The image is published under the preview's own appName, so a preview never overwrites the production tag for the same repository. Hook 1/4 is split in the preview paths. The plan has to be computed before `createDeploymentPreview`, because the deployment log file is created on whichever host is going to build, and `createDeploymentPreview` was picking `application.buildServerId || serverId` — the wrong host once the policy relocates the build. A refused plan is rethrown from inside the try, so the preview status, the deployment log and the PR comment all carry the reason. Previews stay ungated in the webhook: no coalescing, no derived watch paths, no [skip deploy]. A preview is created by opening a PR, and a reviewer waiting on one should not have it filtered away by a path rule.
Review finding 7. Every enforced deploy already wrote `imageTag` and `imageDigest` onto its deployment row and nothing ever read them back, so spec 5.2.4's "rollback redeploys a stored digest with no build" was absent. `buildPolicy.rollbackToDigest` reads those two columns off any past deployment and puts that exact image back with a pull and a service update. It needs no `rollbackRegistry` and no `rollbackActive`, so a unit that has enforcement on but neither of those now has a rollback where before it had none. Upstream's rollback is untouched and unrelated: it replays a snapshot of `<appName>:latest` pushed to a dedicated registry, and it keeps working under the policy because the snapshot runs as part of the build. The digest path deliberately does not re-run required checks. The commit being restored already shipped, and a rollback is the one moment a team cannot afford to wait on CI; `deployPinnedApplicationImage` takes a flag for that rather than growing a second copy of itself. A deployment row with no stored digest is refused with DIGEST_NOT_PUBLISHED rather than being quietly turned into a build. The deployment id comes from input, so the router proves the application it belongs to is the active organization's before anything deploys. The MCP scope snapshot gains one line, `buildPolicy-rollbackToDigest` at dokploy:admin, which is the intended permission for a mutation that redeploys.
The README was the reviewer's map of the module and it had gone stale in the one way that matters: it asserted "every code path below short-circuits to the upstream behaviour" while the enqueue gate did not, which is how finding 1 was found and how it would have been missed. Rewritten against what the code now does: - an "Off by default, and it means it" section that names each enqueue-time behaviour and the cached probe that short-circuits them, and says what an unenforced deploy costs; - the hook catalogue recounted: thirty hooks in nine files, with a per-file table, after the preview paths and the deployment-log host line were added; - new sections for rollback, preview deployments, coalescing and required checks, each saying why the choice is what it is rather than only what it is; - `ownership.ts` and `rollback.ts` added to the file table, `hook-body.ts` corrected from "against the org registries" to the unit's own repository; - the router's per-procedure access, and why audit is admin-only; - the directory question from finding 9 recorded as a decision with its cost, rather than left as a silent divergence from the spec.
…ing G) `skip-deploy.ts` claimed the `[skip deploy]` marker was honoured on every provider route. `pages/api/deploy/gitlab.ts` had no build-policy gate at all, so on GitLab the marker was ignored and — the part that matters for a programme whose goal is cutting CI compute — GitLab-triggered deploys were never coalesced, so N pushes still produced N builds. - wire `buildPolicyDeployGate` into the Push Hook handler's applications and composes loops, the same shape and the same position (after upstream's own `shouldDeploy`) as `github.ts`. - add `gitlabHeadCommitMessage`: GitLab's job title is `Push to <branch>`, not the commit message, so the marker has to be read from the payload. Prefer the commit whose id equals `checkout_sha`, fall back to the newest commit, and pass null when the payload carries no commits. - correct the `skip-deploy.ts` comment to name the routes that actually carry the gate, and say plainly that tag pushes and previews do not. - README: hook-point table gains gitlab.ts, counts corrected 30 -> 33 hooks in ten files, and a section explaining why this route is gated. Default-off is unchanged: the gate's first statement is still the cached `isBuildPolicyEnforcedAnywhere()` check, so with no policy row this route does exactly what it did before. Tests: `__test__/build-policy/gitlab-route-gate.test.ts`, 10 new assertions driving the real handler. The 23 pre-existing tests in `__test__/deploy/gitlab.test.ts` still pass unchanged.
…inding F) `source.ts` documents `parseGithubOwnerRepo` as existing so a `sourceType: "git"` unit can still be check-gated, and `resolveOwnerRepo` falls back to it — but `github-checks.ts:116` hard-requires a GitHub App provider, so a unit with no `githubId` resolved its owner/repo and threw on the very next step. By then the image had been built, tagged and pushed to the organization registry, and the message did not say the fix was to connect the App. The fallback is kept, because it works for the unit it was written for: `saveGitProvider` sets `sourceType: "git"` without clearing `githubId`, so a unit moved from the App to a plain git remote keeps a usable installation token. What is added is the refusal at the API boundary. - `describeRequiredChecksSupport` in `source.ts` tests all three things reading a commit's checks needs: a github.com source, a resolvable owner/repo, and a GitHub App installation to read them with. - `assertRequiredChecksSupported` throws a new coded `REQUIRED_CHECKS_UNSUPPORTED`; `assertRequiredChecksSupportedForUpdate` applies it to a partial patch, honouring an explicit null in the patch rather than falling back to the stored value. - `application.update` calls it and converts the error to a 400 naming the unit and the remedy. Setting an EMPTY list is always allowed, so a unit can always be cleared out of an unsupported state. - the deploy-time message now names both remedies, for the row whose App connection is removed after the checks were set. - README gains "What a unit needs before it can be check-gated"; the UI card says a GitHub App connection is required. Default-off is unchanged: this is a validation on a write an operator makes, and it rejects strictly more than before only for configurations that could never have worked. Tests: `__test__/build-policy/required-checks-support.test.ts`, 17 assertions. Build-policy suite 13 files / 253 passing.
…on the policy (finding E) The required-checks wait sat in `prepareBuildPolicyDeploy`, after the build, and it entered on a non-empty `requiredChecks` alone with no policy switch involved. So one mistyped check name on one unit built, tagged and pushed an image, then held the instance's only deployment slot for the full 30-minute timeout and failed. On a self-hosted instance `jobData.serverId` is set only under `IS_CLOUD`, so every deployment job lands in the single `LOCAL_PARTITION` whose concurrency is `buildsConcurrency ?? 1` — every other deploy queues behind it. - new `runBuildPolicyPreBuildGate` (hook 2a/4) runs the gate between the clone and the build, on the sha the clone just fetched. It executes the clone half itself and returns a fresh `set -e;` prefix; when inactive it returns the caller's string unchanged and executes nothing, so the assembled command stays byte-identical to upstream's. Wired into all four deploy paths. - it is policy-gated on `settings.enforceRemoteBuilds`, so a per-unit `requiredChecks` value can no longer change deploy behaviour on an instance where nobody enabled the policy. It is still honoured for a unit the policy left local: an exclusion decides where a unit builds, not whether its team gave up its CI gate. - `prepareBuildPolicyDeploy` loses the wait and its early return simplifies to `if (!plan.enforced)`. - default timeout 30 -> 5 minutes (column default, migration, snapshot, the service fallback and the settings form), because the wait holds a deployment slot. - README documents plainly what is NOT fixed: the wait still occupies its slot, raise `buildsConcurrency` before enabling checks, and taking the wait out of the queue means not enqueueing until the checks pass, which is a queue redesign left as follow-up. Also in this commit, two nits from the same review: - N3: `upsertBuildPolicySettings` cleared the enforcement cache BEFORE the write, so a concurrent read in that window repopulated it with the old value. Cleared after the write now. - N1/N5: README no longer claims "no extra query" for an unenforced deploy (it is one settings SELECT, which the branch's own test asserts) and no longer claims an index on `enforceRemoteBuilds` that the migration does not create. Tests: `__test__/build-policy/required-checks-before-build.test.ts`, 12 assertions. Build-policy suite 14 files / 265 passing. Full suite 2258 tests, 2234 passed, 15 failed — the same fifteen test full-names as `baseline.json` (merge base cf4abf0), zero added and zero fixed, verified with cmp.py.
… the rest (finding A) The README, the PR body and a comment in `policy.ts` all said exclusions, break-glass and `requiredChecks` applied to compose units in full. None of the three did. `compose.requiredChecks` was a real column and a real writable API field that silently did nothing, so an operator who set it — because the README said it worked — believed that compose unit's deploys waited for CI, and they deployed immediately, every time. Claiming a safety control that is not wired is the dangerous direction for a document to be wrong in. Resolution, split by what is worth wiring: **`requiredChecks` is now wired for compose.** `runComposeBuild` already ran the deploy as discrete `runStep` calls, so there is a clean hook between the clone and the build — the compose equivalent of the application path's hook 2a/4. New `build-policy/compose-checks.ts`, one inserted line in `compose.ts`, placed ahead of the `down --volumes` step so a refused check never leaves the stack torn down. Same default-off shape as everything else: an empty list reads nothing at all, a non-empty one costs the cached enforcement boolean first. `compose.update` gained the same API-boundary validation as `application.update`. **Exclusions and break-glass are not wired, and now say so.** Both decide where a unit builds; a compose build is never relocated, so `resolveBuildPolicy` — the only reader of exclusions and grants — is never called on the compose path at all. `addExclusion` and `allowLocalBuildOnce` now refuse a `composeId` with a 400 explaining why, instead of writing an FK-linked row nothing reads: a break-glass grant against a compose unit used to stay pending for ever, with an audit entry for a grant that was never spent. All three documents corrected to a per-behaviour table rather than a blanket claim: README, the `policy.ts` comment, and the PR body (separate edit). Tests: `__test__/build-policy/compose-required-checks.test.ts`, 12 assertions, including the one the review asked for — that the compose gate never consults exclusions or break-glass, and that an excluded compose unit is still check-gated, because exclusion is about the build host and not about CI. Build-policy suite 15 files / 277 passing. Full suite 2270 tests, 2246 passed, 15 failed — the same fifteen test full-names as baseline.json, zero added and zero fixed.
…, follow the publish registry (B, C, D) The three inherited findings the round-2 review left open. B — a derived watch-path skip left no trace. The skip-marker branch audits; the watch-path branch, the one that fires without anybody asking for it, returned a 301 on a webhook delivery nobody reads and wrote nothing. It now records a `deploy_skipped` row carrying the derived paths and the changed-file list. Added a README "Before you turn it on" section: the section on default-off was good and the one on what changes the moment the switch is flipped did not exist, with derived watchPaths on monorepo units named as the highest blast radius. C — coalescing ran before the deploy-hook body was validated, in both routes. A POST with a malformed or foreign-repository image dropped every waiting deploy for the unit and then enqueued nothing; a CI job retrying with a broken body kept the queue empty. Worse on the compose route, which refuses EVERY body carrying an image while enforcing, so a CI job that standardises on posting one would coalesce and 400 on every push, for ever. The two calls are independent, so they are simply swapped. D — a unit with its own `registryId` got the wrong pull credentials when pinned. `getAuthConfig` tests `registry` in an `else if` that precedes the `buildRegistry` branch, and both pin sites only filled `buildRegistry` in when it was empty, so a stale `registryId` from a previous Docker-provider configuration won and the swarm pull failed. New `authForPublishedRegistry` nulls `registry` and puts the registry the digest actually lives on into `buildRegistry`; `pinned-deploy.ts` does the same with its host-resolved registry. Also the related divergence: `resolveDeployHookImage` built its allowlist from the unit's own registry first while an enforced build always publishes to `settings.defaultRegistryId`, so the digest the enforced build had just published would be rejected and one on a repository the enforced path never writes to accepted. The organization default now comes first. Tests: `gate-audit-and-registry.test.ts` (9) and `hook-body-before-coalescing.test.ts` (3), the latter verified to fail against the pre-swap route before the fix was restored. Build-policy suite 17 files / 289 passing. Full suite 2282 tests, 2258 passed, 15 failed — the same fifteen test full-names as baseline.json.
…ng H) `waitForComposeRequiredChecks` had exactly one call site, inside `runComposeBuild`. `rebuildCompose` — the Redeploy button, reached through `compose.redeploy` and the queue's `type: "redeploy"` arm — has its own inlined pipeline and never called it, while the README table, the `policy.ts` comment and the PR body all said compose gets `requiredChecks` unqualified. This was worse than a stale sentence, because the two halves interact into a trap. `runComposeBuild` clones BEFORE it gates, so a refused deploy leaves the unchecked commit in the code directory. `rebuildCompose` does not re-clone; it builds whatever is on disk. So: push a commit that fails the check, the deploy is correctly refused and audited, and then anyone clicking Redeploy ships exactly the commit the gate just rejected, with no check, no audit row and no warning. `rebuildApplication` never had this hole — it calls the gate, skips the clone because its command is still the bare prefix, re-reads the sha from the existing checkout and refuses again. One call in `rebuildCompose`, in the same position as the other one: after the patches step, ahead of the `down --volumes` teardown and the build, so a refused check still cannot leave the stack torn down. Docs corrected rather than left to drift: the README table row now says deploy, redeploy and previews and names both call sites, the `compose-checks.ts` docstring lists both and says a third pipeline would need its own, and the `policy.ts` comment matches. Tests: `__test__/build-policy/compose-redeploy-gate.test.ts`, 5 assertions driving the real `rebuildCompose`, including the end-to-end trap — a redeploy of a commit the push gate refused must refuse — and the ordering property that the gate runs before both the teardown and the build. Four of the five failed against the old code before the fix. Build-policy suite 18 files / 294 passing.
… 3, I and J)
I — round 2's finding D swapped the deploy-hook allowlist precedence from "the
unit's own registry first" to "the organization default first". That fixed the
enforced case and broke the other one. Gate 1 of `resolveDeployHookImage` tests
whether the ORGANIZATION enforces, not whether this unit is enforced, so the
capability is live for units the policy leaves local — excluded, break-glassed,
or not GitHub sourced — and those publish to their own registry. Their own
repository was then rejected. A fixed precedence gets one of the two cases wrong
whichever way it points, so the allowlist now asks the plan.
New `previewBuildPolicyDecision` in `resolve.ts` is the read-only sibling of
`resolveBuildPolicy`: same reads, same decision, but it spends no break-glass
grant and writes no audit row. Both matter here. The grant is one-shot and
belongs to the next deploy, so validating a request body must not consume it,
and an audit row per webhook delivery would be noise. `resolveBuildPolicy` now
calls it and keeps the two side effects, so there is still one source of truth
for the decision.
J — round 2 nit N2, promoted by the review because the D reorder made it
organization-wide. `registryUrl` is `notNull().default("")` and the empty string
is the supported Docker Hub configuration, not a misconfiguration, so
`getRegistryTag` legitimately returns `prefix/app` with no host. `hook-body.ts`
required a host, so once the allowlist resolved through the org default, an
organization whose default registry is Docker Hub rejected EVERY unit's
deploy-hook body. The check bought something when the allowlist was a host
allowlist; under whole-repository equality a hostless reference can only match a
hostless allowed repository, which is the same repository. Dropped, with the
reasoning recorded where the check used to be.
Tests: `__test__/build-policy/hook-allowlist-follows-plan.test.ts`, 11
assertions. Verified negative first: with the old precedence and the old host
check restored, the three finding-I cases and the finding-J case fail, and the
rest still pass. The two round-2 finding D allowlist tests are superseded by
these and were removed from `gate-audit-and-registry.test.ts`, whose header now
points at the new file; its finding-B coverage is untouched.
Build-policy suite 19 files / 301 passing.
…(round 3, K) Round 2's finding G added a build-policy gate to the GitLab webhook route. The gate derives default `watchPaths` when a unit has none, and `deriveDefaultWatchPaths` read `buildPath` — the GitHub column — for every unit. `watch-paths.ts` claimed to match `getBuildAppDirectory` (`utils/filesystem/directory.ts:104-123`), and it matched it for `sourceType: "github"` only: that function selects `gitlabBuildPath`, `bitbucketBuildPath`, `giteaBuildPath`, `dropBuildPath` or `customGitBuildPath` per source type. Mostly benign, because `buildPath` defaults to `"/"`, which derives to `**` and deploys everything. The case that bites is a unit migrated from GitHub to GitLab: `saveGitlabProvider` writes `gitlabBuildPath` and never resets `buildPath`, so the stale GitHub path survived as the derived watch path and pushes touching the real build path were silently skipped. Adding the GitLab gate is what made a pre-existing mismatch reachable on a new route; the shared `[refreshToken]` deploy-hook route carries Bitbucket, Gitea, drop and plain-git units into the same gate. New `buildPathForSource` mirrors `getBuildAppDirectory`'s selection, returning null for a source type that has no build path (docker) and falling back to `buildPath` when no source type is supplied, so every existing caller keeps its behaviour. `BuildPolicyGateUnit` carries `sourceType` and all six columns, and the three application gate call sites — github.ts, gitlab.ts and the shared `[refreshToken]` route — pass them. Default-off is untouched: this only runs inside the gate, which returns before reading anything unless the organization enforces. Tests: `__test__/build-policy/watch-paths-by-source.test.ts`, 19 assertions, including the GitHub-to-GitLab migration case and a column-by-column table for bitbucket, gitea, plain git and drop. Two of my first expectations were wrong about the code rather than the reverse: a Dockerfile directory inside the build path is absorbed by it, and an empty build path means the repository root and correctly derives `**`. Both are asserted as the code behaves. README hook-point table and the "before you turn it on" note updated to say the build path is selected per source type. Build-policy suite 20 files / 320 passing. Full suite 2313 tests, failure set unchanged at the baseline fifteen.
…d file list (round 3, N8 and N9) N8 — the README's hook-point table has now been wrong at two consecutive review heads. It claimed "thirty-eight, in eleven files" while markers actually live in fifteen, with `server/api/routers/application.ts` and `.../compose.ts` having no row at all after the F and A commits. Hand-maintained counts against a moving codebase do not survive, so the table is now generated and carries the two commands that regenerate it. The count is also defined precisely rather than by feel: a marked BLOCK is `>>> build-policy hook` ... `<<< build-policy hook`, and a NOTE is a single-line marker on an upstream line whose meaning changed without growing a block. 31 blocks and 25 notes across 15 files, reproducible with a grep. The application.ts subsection says how its 16 blocks and 8 notes decompose into the five hooks per deploy path it already described, so the two views reconcile. N9 — the finding-B `deploy_skipped` audit row embedded the entire `changedFiles` array. A monorepo-wide push touches thousands of paths and this row is written once per skipped webhook delivery, so the row could be far larger than anything an operator would read. It now stores `changedFilesCount`, a `changedFilesTruncated` flag and the first 50 paths. The count is the part that answers "did my push really only touch shared code"; the sample is enough to recognise the push. Tests: two added to `gate-audit-and-registry.test.ts`, verified failing first — a 500-file push must record the count, the flag and 50 entries, and a one-file push must record it whole with the flag false. Build-policy suite 20 files / 322 passing. Full suite 2315 tests, failure set unchanged at the baseline fifteen.
AminDhouib
added a commit
that referenced
this pull request
Sep 11, 2026
introduced Two additions, both found while opening this PR. Section 2.1 gains a third trap: dokploy.yml triggers on every push to canary with no paths filter, so even a docs-only merge republishes the image and moves the canary, latest and version tags. The digest capture this runbook opens with is only valid if canary is frozen for the rollout window, and this PR is itself such a merge. Section 5 gains G6: pull-request.yml job pr-check (test) fails on every PR targeting canary since #209. Five tests, all in application.real.test.ts, all from db.query.buildPolicySettings being undefined in that file's hand-written db mock. Evidenced against three runs of the same workflow: green before #209 (34048304936), the same five failures at the #209 head (34543739811) and at this docs-only branch (34611157554). Inherited, not caused here, and not a pre-existing fork baseline. The four review rounds could not see it because the same file already failed on the reviewer's Windows host for an unrelated reason.
This was referenced Sep 11, 2026
AminDhouib
added a commit
that referenced
this pull request
Sep 11, 2026
Owner item 18. Operator runbook for rolling the canary head (b0cadcd, PR #209) onto the live Dokploy instance and enabling the build-once policy per unit. Covers: current state and default-off confirmation with file refs; pre-flight backups (pg_dump, volume tars, rollback image tar) with verification steps; the digest-pinned service update and its verification checklist; per-unit candidacy for all 89 application and 42 compose units with a batch order; the required-checks mapping per repo; the known gaps carried out of the four review rounds; and the measured compute the change is expected to recover. Docs only. Nothing here was executed against the live instance.
AminDhouib
added a commit
that referenced
this pull request
Sep 11, 2026
introduced Two additions, both found while opening this PR. Section 2.1 gains a third trap: dokploy.yml triggers on every push to canary with no paths filter, so even a docs-only merge republishes the image and moves the canary, latest and version tags. The digest capture this runbook opens with is only valid if canary is frozen for the rollout window, and this PR is itself such a merge. Section 5 gains G6: pull-request.yml job pr-check (test) fails on every PR targeting canary since #209. Five tests, all in application.real.test.ts, all from db.query.buildPolicySettings being undefined in that file's hand-written db mock. Evidenced against three runs of the same workflow: green before #209 (34048304936), the same five failures at the #209 head (34543739811) and at this docs-only branch (34611157554). Inherited, not caused here, and not a pre-existing fork baseline. The four review rounds could not see it because the same file already failed on the reviewer's Windows host for an unrelated reason.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Dokploy becomes the only builder for GitHub-sourced applications on this instance. When the organization turns enforcement on, a deploy builds on the organization build server, pushes
<repository>:<sha>to the organization registry, and updates the swarm service by digest. CI then waits for that image instead of building its own (spec 5.1 to 5.6).Everything lives in one fork module,
packages/server/src/services/build-policy/. The touch points in upstream files are 11 marked hooks in 8 files, all listed in the module README, so an upstream merge has one page to reconcile.The policy is off by default. With no
build_policy_settingsrow for an organization every code path short-circuits to stock upstream behaviour, and the test suite proves it.Design source:
docs/superpowers/specs/2026-09-10-ci-build-once-and-pool-design.mdsections 5.1 to 5.6, 7, 8 and 11.Behaviours 1-9, with test names
1. Organization setting, exclusions, audited break-glass.
enforceRemoteBuilds(default false),defaultBuildServerId,defaultRegistryId,requiredChecksTimeoutMinutes; abuild_policy_exclusionlist; and a "build locally once" grant recorded inbuild_policy_auditwith actor, reason and timestamp, spent by the next deploy of that unit.decideBuildPolicy > when enforcement is off > leaves the unit alone with reason not_enforceddecideBuildPolicy > exclusions > prefers the exclusion over a break-glass grant so the grant is not burnedbreak glass > builds locally once and spends the grantbreak glass > is enforced again on the next deploy once the grant is spentunits that keep a local build > does not spend a break-glass grant on an excluded unit2. Enforced remote build, no silent local fallback. Every application whose source is
github, and everygitsource hosted on github.com, has its build server replaced by the organization default at deploy time regardless of the per-unit field. A unit with no build server or no registry fails the deploy with a named error; there is no automatic downgrade.enforced remote build for a GitHub-sourced application > runs the build on the organization build server, not the deploy hostenforced remote build for a GitHub-sourced application > enforces a git source hosted on github.comdecideBuildPolicy > overriding the per-unit field > replaces a per-unit build server with the org defaultno silent local fallback > fails the deploy with a named error when no build server is configuredno silent local fallback > never starts a build when the build server is missingno silent local fallback > fails when no registry is configuredisGithubHostUrl > rejects https://github.com.evil.example/acme/thing.git3. Push by sha, deploy by digest, digest on the deployment record.
enforced remote build > appends a tag-and-push of <repository>:<sha> to the build commandenforced remote build > deploys the image by digest rather than by the mutable tagenforced remote build > stores the image tag and digest on the deployment recordenforced remote build > gives the deploy host registry credentials so it can pull the digestenforced remote build > fails the deploy when the build published no digestparseImageDigestFromLog > takes the last marker when a log carries severalbuildDigestRef > handles a reference with a port in the host4. Queue coalescing. Before enqueueing, still-waiting deploys for the same unit are dropped and the count is audited. Running builds are untouched, so N pushes produce one build of the newest commit. This wires the previously unwired
cleanQueuesByApplicationandcleanQueuesByCompose.enqueue-time gate > drops the older queued deploy and reports how manycoalesceQueuedDeploy > collapses several queued deploys into one audit entrycoalesceQueuedDeploy > never lets a queue failure block the deploy that is being enqueued5. Default watch paths and the skip marker. A unit with no
watchPathsgets them derived frombuildPath, the Dockerfile directory and the compose file directory. A[skip deploy]marker (also[deploy skip]and[no deploy]) skips the deploy and records why.enqueue-time gate > applies the derived default watch paths when the unit has noneenqueue-time gate > deploys when a changed file is inside the derived watch pathsenqueue-time gate > leaves a unit with its own watch paths to upstream's own checkenqueue-time gate > skips the deploy on a [skip deploy] commit and records whyhasSkipDeployMarker > does not match the unrelated [skip ci] markerderiveDefaultWatchPaths > ignores an unset docker context path rather than collapsing to the whole repo6. Per-unit required checks. Empty (the default) means ungated, so push-to-deploy latency is unchanged until a team opts in. Non-empty makes the deploy wait for those GitHub check runs on the built commit, polled with the GitHub App installation token the fork already holds, with the organization timeout. Fails closed.
required checks > deploys once every required check has succeededrequired checks > checks the commit the image was built fromrequired checks > fails the deploy before the deploy step when a required check failsrequired checks > fails the deploy when the required checks never concluderequired checks > does not call GitHub at all when the list is emptyevaluateRequiredChecks > uses the newest run when a check was re-runwaitForRequiredChecks > fails fast when a required check concludes in failure7. Deploy-hook body with an image. When the body carries
{image, tag, digest}the deploy skips the build and deploys that image by digest. The image must be fully qualified and on a registry this organization has configured, so a deploy-hook token cannot become "run any image on my swarm".deploy-hook body with an image > deploys the supplied image by digest and never buildsdeploy-hook body with an image > records the image on the deploymentdeploy-hook body with an image > still honours required checksparseDeployHookImage > rejects an image on a registry the org did not configureparseDeployHookImage > rejects a body with an image but no digest, because deploys are by digestparseDeployHookImage > rejects shell metacharacters in the image reference8. Integration test, the upstream-merge tripwire.
apps/dokploy/__test__/build-policy/deploy-path.integration.test.tsdrives the realdeployApplicationandrebuildApplicationwith docker, ssh and git mocked at their module boundaries. If a merge drops a hook point, these fail loudly rather than the policy silently reverting to local builds.9. UI. An org settings panel on Web Server settings (enforcement toggle, default build server, default registry, required-checks timeout, and a warning when enforcement is on but unconfigured), an exclusions card, an audit-log card, and a per-application required-checks card next to the existing build-server card. All follow the existing settings-card patterns; no new UI primitives.
Hook points in upstream code
Grep for
build-policy hook. Eleven hooks in eight files: 287 added lines and 9 changed, plus 14 lines of UI registration.packages/server/src/services/application.tsdeployApplication, 4 inrebuildApplication: plan (also where a missing build server throws), append the push shell, gate and pin, deploy the pinned targetpackages/server/src/utils/builders/index.tsApplicationNested.buildPolicyImage, returned first bygetImageName, the deploy-by-digest seamapps/dokploy/pages/api/deploy/github.tsshouldDeployso upstream lines are untouchedapps/dokploy/pages/api/deploy/gitlab.tsapps/dokploy/pages/api/deploy/[refreshToken].tsapps/dokploy/pages/api/deploy/compose/[refreshToken].tspackages/server/src/services/compose.tsrunComposeBuild, between the clone and the buildapps/dokploy/server/queues/queueSetup.tsapps/dokploy/server/queues/queue-types.tspinnedImageon the application job armapps/dokploy/server/queues/deployments-queue.tsPlus three barrel lines, two zod lines for the new array columns, and the UI registration above.
Migration
apps/dokploy/drizzle/0200_handy_lifeguard.sql, idempotent in the fork house style (IF NOT EXISTS, andDO $$ ... EXCEPTION WHEN duplicate_objectfor types and constraints), so it passes the repo's own migration-idempotency test and re-runs cleanly on a partially migrated database.Creates
build_policy_settings,build_policy_exclusion,build_policy_auditand thebuildPolicyAuditActionenum. Addsapplication.requiredChecksandcompose.requiredChecks(text[]), anddeployment.imageTaganddeployment.imageDigest(text). No data migration: an organization with no settings row has the policy off, which is the pre-change behaviour.Risk
Known gap: compose units do not build remotely. A compose unit builds and runs in one
docker compose up --build, so relocating its build means splitting the deploy in two and requiring every buildable service to declare animage:key pointing at the org registry. That is a large change to upstream's compose path and it would break every compose unit in the fleet that has noimage:keys today. So the decision returnscompose_build_not_relocatablefor compose units, the compose deploy hook rejects a supplied image with a 400, and a test asserts exactly that reason so the gap stays visible.A compose unit gets coalescing,
[skip deploy], derivedwatchPathsandrequiredChecks. It does not get exclusions or break-glass, and cannot: both decide where a unit builds, and a compose build is never relocated, so there is nothing to exclude it from.buildPolicy.addExclusionandbuildPolicy.allowLocalBuildOncerefuse acomposeIdwith a 400 rather than writing a row nothing reads. (An earlier revision of this paragraph claimed all six applied in full; three did not, andcompose.requiredCheckswas a writable API field that silently did nothing. Round-2 finding A:requiredChecksis now wired for compose, the other two claims are withdrawn.)Other risks:
DIGEST_NOT_PUBLISHEDrather than deploying a mutable tag.Verification
canary)The same 15 failures in the same 6 files before and after. All six need real docker, a swarm manager or host filesystem paths and fail on any workstation.
tsc --noEmitis clean in bothpackages/serverandapps/dokploy, andbiome checkreports no issues.Review round 1
The spec-compliance review of
39f6134returned FIX: 4 blockers, 2 majors,5 minors. All eleven are addressed. Every fix has a test written to fail against
the old behaviour first.
Head is now
9dce3d5.09a8942— plus84f8251for the compose deploy-hook residue found while re-checking09a8942NO_BUILD_SERVER/NO_REGISTRYthrew before the deployment row existed, so the refusal was invisible09a894209a894209a894209a8942b2157f9fd406b309a8942(UI),9dce3d5(directory recorded as a decision with its cost)09a894209a8942What changed, by fix
1, 2 — the policy off is upstream, and costs nothing.
buildPolicyDeployGateand
resolveDeployHookImageboth open with a cached "does anybody enforce atall" probe: one indexed lookup, held for five seconds, cleared whenever the
settings are written. With the policy off the gate reads no organization, no
settings and writes no audit row, and
prepareBuildPolicyDeployreturns theapplication before it resolves a commit sha or re-reads the settings. The
settings the plan was decided from now ride on the plan instead of being read a
second time.
policy-off-is-upstream.test.tsasserts the absence of each callrather than only the outcome.
3 — a refused plan is visible. The plan now runs after
createDeploymentand its error is raised inside the deploy's own
try, so a refusal produces adeployment row in
error, the application markederror, the reason in the logand a build-error notification, which spec section 7 requires. The log append is
best-effort so an unreachable host cannot cost the notification.
4 — the deploy-hook image is the unit's own repository. Two gates: the
capability does not exist while the policy is off, and the reference must equal
getRegistryTag(registry, appName)for the unit's own registry rather thanmerely sitting on a host the organization owns. A deploy hook URL is a bearer
token pasted into CI configs across the fleet; it must not gain the power to run
any image on ghcr.io. Tests cover a foreign repository on the correct host, and
a repository that merely starts with the unit's own name.
5 — coalescing drops only plain deploys. New helpers additionally require
applicationType === "application"(and the compose sibling), so a queued PRpreview for the same application survives a push to main. The audit row now
names the titles of what it dropped.
6 — the digest marker is not trusted on its name.
readPublishedImagerejects any marker whose tag does not start with the repository this deploy
publishes, and runs it through
assertSafeImageReferencebefore pinning. Aforged marker in a Dockerfile's own build output no longer decides where the
image comes from.
7 — rollback by digest.
buildPolicy.rollbackToDigestreadsimageTagandimageDigestoff any past deployment and redeploys that exact image with nobuild. It needs no
rollbackRegistry, so a unit with enforcement on and norollback registry now has a rollback where it had none. Upstream's snapshot
rollback is untouched. Required checks are deliberately not re-run: the commit
already shipped, and a rollback is the one moment a team cannot wait on CI.
8 — previews are enforced. Both preview paths carry the same four hooks, so
a preview builds on the build server and deploys by digest, under the preview's
own appName so it never overwrites the production tag. Previews stay ungated in
the webhook, which is a separate and deliberate choice now stated in the README.
9, 10, 11 — UI, ownership, statuses. The per-unit build server field is
disabled with a reason while the policy enforces.
addExclusionandallowLocalBuildOnceassert the unit belongs to the active organization, and thezod schema requires exactly one of the two ids;
auditis now anadminProcedure, matching the mutations that write it. Required checks matchcommit status contexts as well as check runs, with a token missing the statuses
scope falling back rather than failing the deploy.
Two things found while fixing
84f8251— the compose deploy hook returned a 400 for any body carryingan
image, with the policy off included. Upstream ignores that body, so thiswould have broken a CI job that posts one on the day it merged. Same class as
finding 1, found by re-reading every path that finding implicated.
cf6cd23— the hooks reached throughapplication.environment.projectfor the organization id.
findApplicationByIdalways loads that relation, soit never fired in production, but it made the fork a new way for another
deploy path to throw. Both call sites now stand aside when it is missing
rather than crashing, which is what the GitLab preview tests said once
previews were hooked.
Not done, on purpose
services/build-policy/rather than moving to
community/build-policy/. The reviewer called it ajudgement call. Every file in it is new, so an upstream merge conflicts only on
the nine hooked files the README catalogues, and those conflict wherever the
module lives. The trade is now written down in the README instead of being a
silent divergence from the spec.
spec should be amended, as the reviewer suggested: persisting
buildServerIdonto the unit row would make the stored value wrong the moment an exclusion or
a break-glass grant changed the answer.
Verification, round 1
tsc --noEmitinapps/dokploybiome checkon every changed fileuseOptionalChainwarning also present oncanaryThe 15 are docker-, swarm- and host-path-dependent and fail on any workstation:
deploy/application.real.test.ts(5),setup/monitoring-setup.real.test.ts(4),server/server-setup.test.ts(3),backups/restore-use-statement.test.ts(1),compose/compose-project-directory.test.ts(1) andwss/readValidDirectory.test.ts(1). No test that passed before this round failsafter it.
Still not deployed anywhere, and not merged: canary is what the org's production
Dokploy runs from, so that call is the owner's.
Review round 2
The round-2 review of
9dce3d5returned FIX. It confirmed all eleven round-1findings and both extras as genuinely fixed and could not break any of them, and
rested its FIX verdict on finding A alone. Seven findings (A-G) and three
nits are addressed below; head is now
6095a34d.policy.tscomment all claimed exclusions, break-glass andrequiredChecksapplied to compose in full; none did, andcompose.requiredCheckswas a writable field that silently did nothing12fd6986095a346095a34registryIdgot the wrong pull credentials when pinned, and the hook allowlist could diverge from the publish target6095a34e1dc9c7requiredCheckscould never pass on asourceType: "git"unit with no GitHub App, the case the owner/repo fallback was written forccf89f5[skip deploy]was ignored and GitLab deploys were never coalesced1cb880dNits N1, N3 and N5 are folded into
e1dc9c7. N2, N4, N6 and N7 are not done;see below.
A — compose parity: wired where it is worth wiring, withdrawn where it is not
The review asked for the smaller correct fix and for the choice to be stated.
The answer is both, split by behaviour, because the three are not alike.
requiredChecksis now wired for compose. There is a clean hook:runComposeBuildalready ran the deploy as discrete steps — clone, patches,optional
down --volumes, build — so the gate is one inserted line between theclone and the build, the compose equivalent of the application path's new hook
2a/4. It sits ahead of the
down --volumesstep so a refused check never leavesthe stack torn down. New
build-policy/compose-checks.ts, andcompose.updategained the same API-boundary validation as
application.update.Exclusions and break-glass are not wired, and the claim is withdrawn. Wiring
them would be meaningless rather than merely expensive: both decide where a
unit builds, a compose build is never relocated, and
resolveBuildPolicy— theonly reader of exclusions and grants — is never called on the compose path at
all. Both procedures now refuse a
composeIdwith a 400 that says why. Before,a break-glass grant against a compose unit stayed pending for ever and the audit
log showed a grant that was never spent.
All three statements are replaced with a per-behaviour table rather than a
blanket claim, in the README, in the
policy.tscomment and in this body.E — the checks gate moved before the build, and behind the policy switch
Two of the three problems are fixed and the third is documented rather than
fixed, in those words.
runBuildPolicyPreBuildGate(hook 2a/4) runs between the clone and the build, on the sha the clone just
fetched. When it is active it executes the clone half itself and hands the
caller a fresh shell prefix; when inactive it returns the caller's string
unchanged and executes nothing, so the assembled command stays byte-identical
to upstream's. A refused check now costs one clone, not a whole build and a
registry push.
enforceRemoteBuildson, so a per-unit field can no longer change deploybehaviour on an instance where nobody enabled the policy. It is still honoured
for a unit the policy left local: an exclusion decides where a unit builds,
not whether its team gave up its CI gate.
default timeout drops from 30 minutes to 5, and both the README and the
settings form now say the wait holds a slot and that
buildsConcurrencyshould be raised before enabling checks on a busy instance. Taking the wait
out of the queue means not enqueueing until the checks pass, which needs a
waiter that outlives the request and survives a restart. That is a queue
redesign rather than a hook point, and it is named as follow-up rather than
smuggled into this branch.
Not done, round 2
registryUrlmakes everydeploy-hook body fail the "no registry host" check. Real, and narrow: it needs
a registry configured with no URL at all. Left as follow-up.
rollbackToDeploymentDigesttakes anorganizationIdit does notcheck; its only caller does check, correctly, and the review confirmed there
is no time-of-check/time-of-use window. Defence in depth, not a gap.
isPolicyEnforcedflag over-restricts for anexcluded unit. Cosmetic, and it errs toward showing the field as locked rather
than editable.
Fails closed. The shorter default timeout cuts the call volume it was raised
about; pagination is follow-up.
Verification, round 2
tsc --noEmitinapps/dokploybiome checkon every changed fileuseOptionalChainwarning present oncanaryThe 15 failures are the same fifteen test full-names as the pre-branch
baseline at merge-base
cf4abf05, compared set against set rather than bycount: zero added, zero fixed. They are docker-, swarm- and host-path-dependent
and fail on any workstation.
Every fix in this round has a test written to fail against the old behaviour
first. Six new test files:
gitlab-route-gate.test.ts,required-checks-support.test.ts,required-checks-before-build.test.ts,compose-required-checks.test.ts,gate-audit-and-registry.test.tsandhook-body-before-coalescing.test.ts.Default-off is still true at every touch point, and it was re-verified after
each change: nothing in this round alters the live instance's behaviour before
an operator enables the policy. Still not deployed anywhere, and not merged.
Review round 3
The round-3 review of
6095a34dreturned FIX. It reproduced every number theround-2 report claimed, confirmed A, E, F, G, B, C and D all held under attack,
and rested its verdict on finding H alone. Four findings and two nits are
addressed below; head is now
6d27c886.rebuildComposehas its own inlined pipeline and never called the gate, and becauserunComposeBuildclones before it gates, a refused commit stayed in the code directory where the Redeploy button would ship itdbd68b7f1ed8c2registryUrlis the supported Docker Hub configuration, and after the round-2 reorder an organization defaulting to Docker Hub rejected every unit's deploy-hook bodyf1ed8c2buildPath, so a unit migrated from GitHub to GitLab filtered pushes against a stale path5b460d6Nits N8 and N9 are fixed in
6d27c886. N4, N6, N7, N10 and N11 are not done;see below.
H — the Redeploy button now refuses the same commit the push refused
rebuildComposeis whatcompose.redeploycalls, and it is a separate inlinedpipeline rather than a caller of
runComposeBuild. It now callswaitForComposeRequiredChecksin the same position the deploy path does, whichis what
rebuildApplicationalready did for applications: gate, skip the clone,re-read the same sha, refuse again. The interaction is the reason this mattered
more than a documentation gap. A refused compose deploy leaves the unchecked
commit on disk, so before this fix the refusal was one click from being
bypassed. The test asserts exactly that sequence: a push refused by the gate,
then a redeploy that must refuse too.
I and J — the allowlist asks the plan instead of guessing
Round 2's finding D swapped the deploy-hook allowlist from "the unit's own
registry first" to "the organization default first". That is right for an
enforced unit and wrong for every other one, because gate 1 tests whether the
organization enforces, not whether this unit is enforced: the capability
is live for excluded, break-glassed and non-GitHub-sourced units, and those
publish to their own registry. A fixed precedence is wrong in one direction or
the other whichever way it points, so the allowlist now resolves the registry
the plan would.
New
previewBuildPolicyDecisionis the read-only sibling ofresolveBuildPolicy: same reads, same decision, but it spends no break-glassgrant and writes no audit row. Both matter. The grant is one-shot and belongs to
the next deploy, so validating a request body must not consume it.
J follows from the same reorder. The "must have a registry host" check in
hook-body.tsbought something when the allowlist was a host allowlist; underwhole-repository equality a hostless reference can only match a hostless allowed
repository, which is the same repository. Dropped, with the reasoning recorded
where the check was.
K — watch paths read the build-path column the unit actually builds from
deriveDefaultWatchPathsreadbuildPathfor every unit while claiming tomatch
getBuildAppDirectory, which selectsgitlabBuildPath,bitbucketBuildPath,giteaBuildPath,dropBuildPathorcustomGitBuildPathper source type. Mostly benign, because
buildPathdefaults to/and derivesto
**. The case that bites is a unit migrated from GitHub to GitLab:saveGitlabProviderwrites the new column and never resets the old one, so thestale GitHub path became the filter and matching pushes were skipped. New
buildPathForSourcemirrors the upstream selection, and the gate now carriesthe source type and all six columns.
N8 and N9
and carries the commands that regenerate it. It had been wrong at two
consecutive heads. The count is defined precisely: 31 marked blocks and 25
single-line notes across 15 files.
changedFilesarray. It stores the count, a truncation flag and the first 50 paths.
Not done, round 3
reasons still hold.
assertNotComposeUnitsits in the tRPC router rather than insideaddBuildPolicyExclusion/grantBreakGlass. Each has exactly one non-testcaller today, so it holds; moving it is follow-up.
disconnectGitProvider,saveGitProviderandsaveDockerProvidercan move a unit into a state that cannot satisfy
requiredCheckswithoutre-validating. It fails closed at deploy time and, since finding E, that
refusal costs only a clone.
Verification, round 3
tsc --noEmitinapps/dokploybiome checkon every changed fileThe 15 failures are the same fifteen test full-names as the pre-branch
baseline at merge-base
cf4abf05, compared set against set: zero added, zerofixed. Every fix in this round has a test written to fail against the old
behaviour first. Three new test files:
compose-redeploy-gate.test.ts,hook-allowlist-follows-plan.test.tsandwatch-paths-by-source.test.ts.Default-off is still true at every touch point. Still not deployed anywhere, and
not merged.