Skip to content

feat(build-policy): enforced remote builds, registry push, deploy-by-digest, queue coalescing, required checks - #209

Merged
AminDhouib merged 18 commits into
canaryfrom
feat/build-policy
Sep 11, 2026
Merged

AminDhouib merged 18 commits into
canaryfrom
feat/build-policy

Conversation

@AminDhouib

@AminDhouib AminDhouib commented Sep 10, 2026

Copy link
Copy Markdown
Member

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_settings row 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.md sections 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; a build_policy_exclusion list; and a "build locally once" grant recorded in build_policy_audit with actor, reason and timestamp, spent by the next deploy of that unit.

  • decideBuildPolicy > when enforcement is off > leaves the unit alone with reason not_enforced
  • decideBuildPolicy > exclusions > prefers the exclusion over a break-glass grant so the grant is not burned
  • break glass > builds locally once and spends the grant
  • break glass > is enforced again on the next deploy once the grant is spent
  • units that keep a local build > does not spend a break-glass grant on an excluded unit

2. Enforced remote build, no silent local fallback. Every application whose source is github, and every git source 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 host
  • enforced remote build for a GitHub-sourced application > enforces a git source hosted on github.com
  • decideBuildPolicy > overriding the per-unit field > replaces a per-unit build server with the org default
  • no silent local fallback > fails the deploy with a named error when no build server is configured
  • no silent local fallback > never starts a build when the build server is missing
  • no silent local fallback > fails when no registry is configured
  • isGithubHostUrl > rejects https://github.com.evil.example/acme/thing.git

3. 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 command
  • enforced remote build > deploys the image by digest rather than by the mutable tag
  • enforced remote build > stores the image tag and digest on the deployment record
  • enforced remote build > gives the deploy host registry credentials so it can pull the digest
  • enforced remote build > fails the deploy when the build published no digest
  • parseImageDigestFromLog > takes the last marker when a log carries several
  • buildDigestRef > handles a reference with a port in the host

4. 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 cleanQueuesByApplication and cleanQueuesByCompose.

  • enqueue-time gate > drops the older queued deploy and reports how many
  • coalesceQueuedDeploy > collapses several queued deploys into one audit entry
  • coalesceQueuedDeploy > never lets a queue failure block the deploy that is being enqueued

5. Default watch paths and the skip marker. A unit with no watchPaths gets them derived from buildPath, 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 none
  • enqueue-time gate > deploys when a changed file is inside the derived watch paths
  • enqueue-time gate > leaves a unit with its own watch paths to upstream's own check
  • enqueue-time gate > skips the deploy on a [skip deploy] commit and records why
  • hasSkipDeployMarker > does not match the unrelated [skip ci] marker
  • deriveDefaultWatchPaths > ignores an unset docker context path rather than collapsing to the whole repo

6. 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 succeeded
  • required checks > checks the commit the image was built from
  • required checks > fails the deploy before the deploy step when a required check fails
  • required checks > fails the deploy when the required checks never conclude
  • required checks > does not call GitHub at all when the list is empty
  • evaluateRequiredChecks > uses the newest run when a check was re-run
  • waitForRequiredChecks > fails fast when a required check concludes in failure

7. 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 builds
  • deploy-hook body with an image > records the image on the deployment
  • deploy-hook body with an image > still honours required checks
  • parseDeployHookImage > rejects an image on a registry the org did not configure
  • parseDeployHookImage > rejects a body with an image but no digest, because deploys are by digest
  • parseDeployHookImage > rejects shell metacharacters in the image reference

8. Integration test, the upstream-merge tripwire. apps/dokploy/__test__/build-policy/deploy-path.integration.test.ts drives the real deployApplication and rebuildApplication with 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.

File Hook
packages/server/src/services/application.ts 4 in deployApplication, 4 in rebuildApplication: plan (also where a missing build server throws), append the push shell, gate and pin, deploy the pinned target
packages/server/src/utils/builders/index.ts ApplicationNested.buildPolicyImage, returned first by getImageName, the deploy-by-digest seam
apps/dokploy/pages/api/deploy/github.ts the enqueue gate in the push to applications and push to composes loops, after upstream's own shouldDeploy so upstream lines are untouched
apps/dokploy/pages/api/deploy/gitlab.ts the same two gate blocks in the Push Hook handler, plus a helper that reads the head commit message out of the payload
apps/dokploy/pages/api/deploy/[refreshToken].ts the optional image body, then the gate
apps/dokploy/pages/api/deploy/compose/[refreshToken].ts an image body is rejected with a 400, then the gate
packages/server/src/services/compose.ts the required-checks gate inside runComposeBuild, between the clone and the build
apps/dokploy/server/queues/queueSetup.ts the two clean-queue helpers now return how many they dropped (both existing callers ignore it)
apps/dokploy/server/queues/queue-types.ts optional pinnedImage on the application job arm
apps/dokploy/server/queues/deployments-queue.ts one branch routing a pinned image to the no-build deploy

Plus 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, and DO $$ ... EXCEPTION WHEN duplicate_object for 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_audit and the buildPolicyAuditAction enum. Adds application.requiredChecks and compose.requiredChecks (text[]), and deployment.imageTag and deployment.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 an image: 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 no image: keys today. So the decision returns compose_build_not_relocatable for 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], derived watchPaths and requiredChecks. 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.addExclusion and buildPolicy.allowLocalBuildOnce refuse a composeId with a 400 rather than writing a row nothing reads. (An earlier revision of this paragraph claimed all six applied in full; three did not, and compose.requiredChecks was a writable API field that silently did nothing. Round-2 finding A: requiredChecks is now wired for compose, the other two claims are withdrawn.)

Other risks:

  • Deploys now depend on the build server and the registry being reachable. That is the point (spec section 7); break-glass is the manual fallback and it is audited.
  • The digest crosses hosts through a marker line in the deployment log, because the build runs as a detached shell whose only channel back is that log. If the line is absent the deploy fails with DIGEST_NOT_PUBLISHED rather than deploying a mutable tag.
  • Required-checks gating fails closed. A unit with required checks set but no GitHub App provider fails rather than deploying unverified.
  • Upstream merges are the standing risk (spec section 11); the integration test is the tripwire and every hook carries a marker comment.
  • Not deployed anywhere. This is code only; the live Dokploy instance was not touched.

Verification

Tests Passing Failing Files failing
Before (canary) 1992 1968 15 6
After (review round 1) 2219 2195 15 6

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 --noEmit is clean in both packages/server and apps/dokploy, and biome check reports no issues.


Review round 1

The spec-compliance review of 39f6134 returned 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.

# Severity Finding Commit
1 Blocker The enqueue gate ran with the policy off, so derived watch paths silently stopped deploys 09a8942 — plus 84f8251 for the compose deploy-hook residue found while re-checking
2 Blocker Every deploy gained an SSH round trip and two settings reads while the policy was off 09a8942
3 Blocker NO_BUILD_SERVER / NO_REGISTRY threw before the deployment row existed, so the refusal was invisible 09a8942
4 Blocker The deploy-hook image path was live with the policy off, and its allowlist was host-only 09a8942
5 Major Coalescing dropped queued preview deployments for the same application 09a8942
6 Major The digest read-back trusted the repository name printed in the build log 09a8942
7 Minor Rollback by stored digest was not implemented b2157f9
8 Minor Preview deployments bypassed enforcement entirely fd406b3
9 Minor Spec 5.2.1's read-only build-server field was missing; the module directory diverged from the spec 09a8942 (UI), 9dce3d5 (directory recorded as a decision with its cost)
10 Minor The router did not verify the target unit belongs to the active organization 09a8942
11 Minor Required checks matched check-run names only, never commit status contexts 09a8942

What changed, by fix

1, 2 — the policy off is upstream, and costs nothing. buildPolicyDeployGate
and resolveDeployHookImage both open with a cached "does anybody enforce at
all" 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 prepareBuildPolicyDeploy returns the
application 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.ts asserts the absence of each call
rather than only the outcome.

3 — a refused plan is visible. The plan now runs after createDeployment
and its error is raised inside the deploy's own try, so a refusal produces a
deployment row in error, the application marked error, the reason in the log
and 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 than
merely 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 PR
preview 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. readPublishedImage
rejects any marker whose tag does not start with the repository this deploy
publishes, and runs it through assertSafeImageReference before pinning. A
forged marker in a Dockerfile's own build output no longer decides where the
image comes from.

7 — rollback by digest. buildPolicy.rollbackToDigest reads imageTag and
imageDigest off any past deployment and redeploys that exact image with no
build. It needs no rollbackRegistry, so a unit with enforcement on and no
rollback 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. addExclusion and
allowLocalBuildOnce assert the unit belongs to the active organization, and the
zod schema requires exactly one of the two ids; audit is now an
adminProcedure, matching the mutations that write it. Required checks match
commit 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 carrying
    an image, with the policy off included. Upstream ignores that body, so this
    would 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 through application.environment.project
    for the organization id. findApplicationById always loads that relation, so
    it 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

  • Finding 9's directory move. The module stays at services/build-policy/
    rather than moving to community/build-policy/. The reviewer called it a
    judgement 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.
  • Finding 9's create/update-time forcing. Deploy-time forcing is kept and the
    spec should be amended, as the reviewer suggested: persisting buildServerId
    onto the unit row would make the stored value wrong the moment an exclusion or
    a break-glass grant changed the answer.

Verification, round 1

Check Result
tsc --noEmit in apps/dokploy exit 0
biome check on every changed file clean, except one pre-existing useOptionalChain warning also present on canary
build-policy tests 226 passing, 0 failing, across 11 files (was 143 across 6)
Full suite 2195 passing, 15 failing — the same 15, in the same 6 files, as the review's own baseline run

The 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) and
wss/readValidDirectory.test.ts (1). No test that passed before this round fails
after 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 9dce3d5 returned FIX. It confirmed all eleven round-1
findings 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.

# Severity Finding Commit
A Major (the blocker) README, this body and a policy.ts comment all claimed exclusions, break-glass and requiredChecks applied to compose in full; none did, and compose.requiredChecks was a writable field that silently did nothing 12fd698
B Minor A derived watch-path skip left no audit row, and there was no "before you turn it on" section 6095a34
C Minor Coalescing ran before the deploy-hook body was validated, so a refused body emptied the queue and enqueued nothing 6095a34
D Minor A unit with its own registryId got the wrong pull credentials when pinned, and the hook allowlist could diverge from the publish target 6095a34
E Major The required-checks wait ran after the build, needed no policy switch, and held the instance's only deployment slot for 30 minutes e1dc9c7
F Minor requiredChecks could never pass on a sourceType: "git" unit with no GitHub App, the case the owner/repo fallback was written for ccf89f5
G Minor The GitLab webhook route had no gate, so [skip deploy] was ignored and GitLab deploys were never coalesced 1cb880d

Nits 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.

requiredChecks is now wired for compose. There is a clean hook:
runComposeBuild already ran the deploy as discrete steps — clone, patches,
optional down --volumes, build — so the gate is one inserted line between the
clone and the build, the compose equivalent of the application path's new hook
2a/4. It sits ahead of the down --volumes step so a refused check never leaves
the stack torn down. New build-policy/compose-checks.ts, and compose.update
gained 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 — the
only reader of exclusions and grants — is never called on the compose path at
all. Both procedures now refuse a composeId with 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.ts comment 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.

  • The wait is no longer after the build. New 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.
  • It is policy-gated. It does nothing unless the organization has
    enforceRemoteBuilds on, so a per-unit field 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.
  • The wait still occupies its deployment slot. Mitigated, not removed: the
    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 buildsConcurrency
    should 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

  • N2 — a Docker Hub registry with an empty registryUrl makes every
    deploy-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.
  • N4rollbackToDeploymentDigest takes an organizationId it does not
    check; 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.
  • N6 — the build-server UI's isPolicyEnforced flag over-restricts for an
    excluded unit. Cosmetic, and it errs toward showing the field as locked rather
    than editable.
  • N7 — the required-checks poll does not paginate past 100 check runs.
    Fails closed. The shorter default timeout cuts the call volume it was raised
    about; pagination is follow-up.

Verification, round 2

Check Result
tsc --noEmit in apps/dokploy exit 0
biome check on every changed file clean, except the same pre-existing useOptionalChain warning present on canary
build-policy tests 289 passing, 0 failing, across 17 files (was 226 across 11)
Full suite 2282 tests, 2258 passing, 9 skipped, 15 failing

The 15 failures are the same fifteen test full-names as the pre-branch
baseline at merge-base cf4abf05, compared set against set rather than by
count: 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.ts and
hook-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 6095a34d returned FIX. It reproduced every number the
round-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.

# Severity Finding Commit
H Major (the blocker) A compose redeploy was not check-gated. rebuildCompose has its own inlined pipeline and never called the gate, and because runComposeBuild clones before it gates, a refused commit stayed in the code directory where the Redeploy button would ship it dbd68b7
I Minor The deploy-hook allowlist divergence was inverted rather than removed: a unit the policy leaves local had its own repository rejected f1ed8c2
J Minor Round-2 nit N2, promoted. An empty registryUrl is the supported Docker Hub configuration, and after the round-2 reorder an organization defaulting to Docker Hub rejected every unit's deploy-hook body f1ed8c2
K Minor The new GitLab gate derived watch paths from the GitHub-only buildPath, so a unit migrated from GitHub to GitLab filtered pushes against a stale path 5b460d6

Nits 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

rebuildCompose is what compose.redeploy calls, and it is a separate inlined
pipeline rather than a caller of runComposeBuild. It now calls
waitForComposeRequiredChecks in the same position the deploy path does, which
is what rebuildApplication already 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 previewBuildPolicyDecision 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. 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.ts 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 was.

K — watch paths read the build-path column the unit actually builds from

deriveDefaultWatchPaths read buildPath for every unit while claiming to
match getBuildAppDirectory, which selects gitlabBuildPath,
bitbucketBuildPath, giteaBuildPath, dropBuildPath or customGitBuildPath
per source type. Mostly benign, because buildPath defaults to / and derives
to **. The case that bites is a unit migrated from GitHub to GitLab:
saveGitlabProvider writes the new column and never resets the old one, so the
stale GitHub path became the filter and matching pushes were skipped. New
buildPathForSource mirrors the upstream selection, and the gate now carries
the source type and all six columns.

N8 and N9

  • N8 — the hook-point table is now generated rather than hand-maintained,
    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.
  • N9 — the finding-B audit row no longer embeds the whole changedFiles
    array. It stores the count, a truncation flag and the first 50 paths.

Not done, round 3

  • N4, N6, N7 — unchanged from round 2, and the review confirmed the
    reasons still hold.
  • N10assertNotComposeUnit sits in the tRPC router rather than inside
    addBuildPolicyExclusion / grantBreakGlass. Each has exactly one non-test
    caller today, so it holds; moving it is follow-up.
  • N11disconnectGitProvider, saveGitProvider and saveDockerProvider
    can move a unit into a state that cannot satisfy requiredChecks without
    re-validating. It fails closed at deploy time and, since finding E, that
    refusal costs only a clone.

Verification, round 3

Check Result
tsc --noEmit in apps/dokploy exit 0
biome check on every changed file clean
build-policy tests 322 passing, 0 failing, across 20 files (was 289 across 17)
Full suite 2315 tests, 2291 passing, 9 skipped, 15 failing

The 15 failures are the same fifteen test full-names as the pre-branch
baseline at merge-base cf4abf05, compared set against set: zero added, zero
fixed. 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.ts and watch-paths-by-source.test.ts.

Default-off is still true at every touch point. Still not deployed anywhere, and
not merged.

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
AminDhouib merged commit b0cadcd into canary Sep 11, 2026
2 of 3 checks passed
@AminDhouib
AminDhouib deleted the feat/build-policy branch September 11, 2026 00:00
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant