Skip to content

chore(release): sync v1.8.0-rc.3 to main - #1437

Merged
scttbnsn merged 683 commits into
mainfrom
release/v1.8-rc3-main
Oct 20, 2026
Merged

scttbnsn merged 683 commits into
mainfrom
release/v1.8-rc3-main

Conversation

@scttbnsn

@scttbnsn scttbnsn commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Promotes dev/v1.8 at 87d20e9c1 for v1.8.0-rc.3. Head 6909b9bc7 has exactly the same tree as dev (47daa8824), with main and dev as parents. Every content change landed on dev first through reviewed PRs.

What's new since rc.2: the three fold-in batches (#1432 security and auth, #1433 agents, watcher, store and API, #1434 UI, refactors, build and the MIT relicense), the two Renovate minor/patch updates (#1398, #1399), the biome schema match (#1436), test and CI work (#1420, #1424, #1425, #1426, #1430, #1431) and the rc.3 prep (#1435).

The rc.3 prep moves current docs, translated README warnings, website, demo and support policy to rc.3. Feature and deprecation introduction versions stay on rc.1, and rc.1/rc.2 changelog history and archived docs are unchanged. rc.3 is still opt-in testing and starts a new seven-day soak from its publish time. Stable stays v1.7.0.

The local pre-push gate passed on this exact head (coverage, builds, static checks, repo and workflow tests, UI typecheck).

scttbnsn and others added 30 commits September 4, 2026 05:05
* docs(hooks): say the pre-hook abort is the default and name the opt-out (#1001)

(cherry picked from commit d62e2d4)

* chore(release): carry the v1.7.0-rc.10 release identity into 1.8

Same shape as #993 carried rc.8 and rc.9. The CHANGELOG keeps 1.8's own
[Unreleased] on top and gains a [1.7.0-rc.10] section below it, copied
verbatim from dev/v1.7; the once-reservation and cron-deadline bullet
that section carries is removed from [Unreleased], where it landed with
the 1.8-side backport in #997. The comparison-link chain gains the tag
and [Unreleased] now compares from v1.7.0-rc.10.

The README and the six translations gain the rc.10 highlights block, the
updates page gains the matching release notes, and the demo mocks, site
config, quickstart tag table, and the agent/app/portwing API samples move
from 1.7.0-rc.9 to 1.7.0-rc.10. The readme-translations, changelog-links,
release-identity, release-docs-identity and release-precut-check tests
move with them.

The 1.8-only surfaces stay 1.8's: the trigger and action counts, the
Portainer rows, and the v1.8.0 roadmap line in README.md are untouched.
…ot (#1007)

* refactor(triggers): fold the once gate into reserveOnceNotificationSlot

* refactor(triggers): mirror the once gate in releaseOnceNotificationSlot
…#1009)

* fix(rollback): prefer the backup's captured digest when pinning the rollback image

selectPulledRepoDigest disambiguates a local image carrying several
RepoDigests under one repository by preferring container.result.digest,
the manifest the watcher resolved for the image being pulled. That is the
right answer on the update path and the wrong one on a manual rollback,
which pulls the backup's retained image: the candidate digest describes
the update the rollback exists to undo. A republished multi-arch index
leaves both digests on the same local image, which is exactly when the
tie-break runs, so the rollback could pull, gate and recreate from the
candidate's manifest while reporting it had restored the backup.

The identity binder now takes the preferred digest from its caller.
Passing the option at all replaces the candidate digest, including when
the value inside it is undefined, so resolveRollbackImageReference hands
over what its backup record captured and nothing else. With nothing to
match, the tie-break falls through to the registry manifest lookup for
the backup's own tag, which is the manifest the rollback wants. The
update path passes no option and is unchanged.

* test(compose): pin the per-replica hook and prune rate under compose-file-once

DR-66 reported that a compose-file-once replica never runs the deferred
pre-update hook or the prune/backup step, because
buildPerformContainerUpdateOptions passes no post-pull hook once
postPullGateCompleted is set. It does not: createTriggerContext returns
deferPreRuntimeUpdateLifecycle false for exactly that case, so the
lifecycle runs the pre-update hook and runPreRuntimeUpdateLifecycle up
front instead of behind a hook, and both still run for every replica.

What no test covered is the rate. Compose-file-once pulls once per
service and recreates every replica, so the gate, the hook and the
prune/backup step each run at one of two rates and nothing in the
lifecycle names either. This pins them for a two-replica service: one
pull, a gate per replica in the preflight and never again inside a
refresh, and a pre-update hook, prune and backup record per replica.

Verified as a guard by forcing deferPreRuntimeUpdateLifecycle true, which
moves those steps behind a hook the compose-file-once path does not pass
and fails the test.

* fix(rollback): fail closed when the preferred digest cannot be matched

selectPulledRepoDigest never returned undefined once it had two or more
candidates. Past the reference check and the registry manifest lookup it
picked the lexicographically first RepoDigest and only warned, so
handleMissingPulledImageIdentity was unreachable from there. On the
rollback path that is a coin flip: the retained image can carry both the
backup's manifest and the update candidate's, and with the registry
unreachable or the backup's tag gone, the rollback would pull, gate and
recreate from whichever digest sorted first while reporting it had
restored the backup, which contradicts the contract in app/util/backup.ts.

The tie-break now knows whether the caller named the image this pull is
for. When it did and nothing matched, the digest resolves to undefined,
the binder goes through handleMissingPulledImageIdentity, and the policy
decides: refuse the rollback under `required`, warn and fall back to the
mutable tag under the permissive value. The update path passes no options
and is byte-for-byte unchanged, deterministic pick included.

PulledImageIdentityOptions.preferredDigest is now required and nullable
rather than optional, so `{}` can no longer drop the update path's
candidate tie-break while looking like it asked for nothing.
capturePulledImageIdentity normalises null to undefined and passes the
caller-pinned flag alongside it; resolveRollbackImageReference sends
`{ preferredDigest: backup.imageDigest ?? null }`.

- test(rollback): pin the required/permissive outcomes of an unbreakable
  tie, and model the integration test on production — a running image ID
  that differs from the retained one, so the same-image guard actually
  runs, and a container tag distinct from the backup's, so the
  manifest-lookup assertion pins the backup's tag
- test(compose): assert the full per-replica call order under
  compose-file-once instead of bare membership
- docs(changelog): reword the DR-64 entry as hardening, since no path
  was found where a rollback actually reached the old tie-break
…1012)

* feat(config): add a maintenance window scope vocabulary and env alias

Adds DD_WATCHER_{name}_MAINTENANCE_WINDOW_SCOPE, parsed by the same
longest-suffix-first alias table that now also maps _MAINTENANCE_WINDOW and
_MAINTENANCE_WINDOW_TZ, plus the shared install/scan vocabulary the watcher
and the trigger both resolve against. Default is install.

Refs: #946

* feat(triggers): defer automatic installs until the maintenance window opens

Under the install scope a closed window no longer stops discovery or
notification; it only holds the automatic install back. The deferral queues a
maintenance-window catch-up watch on the container's watcher and counts the
deferred install on the existing maintenance skip metric, whose help text now
names both cases.

Batch and digest sends stop recording an update as notified when it was
deferred, so the once=true dedupe cannot swallow the rescan that fires when the
window opens. Manual API updates stay admitted under either scope.

Refs: #946

* feat(watchers): scope the maintenance window to installs by default

The scheduled scan no longer stops at a closed maintenance window unless the
watcher sets maintenancewindowscope=scan. Under the new install default the
cron scan runs on schedule, so new containers appear, container state stays
fresh and update notifications go out on time, and only the automatic install
waits for the window. nextRunAt reports the plain cron time under install and
the next in-window time under scan.

Set DD_WATCHER_{name}_MAINTENANCE_WINDOW_SCOPE=scan to keep the pre-v1.8.0
behaviour byte for byte.

Refs: #946

* fix(triggers): gate command actions on the maintenance window

A `command` action was not window-gated at all. UPDATE_ACTION_TRIGGER_TYPES
deliberately excludes `command` because it does not run the Docker update
lifecycle, and the deferral hung off that set, so the window only reached
docker, dockercompose and portainer. Under the old scan scope the window held a
command action back indirectly: no scan, no report, no command. Under the
install scope the scan always runs, so DD_ACTION_COMMAND_* fired its shell
command the instant an update was detected, at any hour.

The deferral now hangs off the action category instead of the update-action
subset, so every action trigger defers and notification triggers keep sending.
UPDATE_ACTION_TRIGGER_TYPES is untouched: it still means "routes through the
admission queue" and has other consumers that depend on that meaning.

The window pass and the dependency pass move out of runAcceptedUpdateBatch into
collectMaintenanceWindowDeferredIds so the batch and digest paths that call
triggerBatch directly (which is how a command action sends) share exactly the
same decision. A batch with every container deferred sends nothing rather than
an empty batch.

* fix(watcher): stop an ordinary cron tick destroying a queued catch-up

runCronWatch cleared the maintenance-window queue on every tick that got past
the scan gate. Under the scan scope that was safe: the only arming site was the
gate ten lines above, so reaching the clear meant the window was open,
unconfigured, or explicitly ignored. Under the install scope the gate never
fires and the only arming site is the trigger deferral, which runs from paths
outside watch(). A digest flush at 08:00 armed the catch-up, the 12:00 cron scan
cleared it, the digest handler only buffers so nothing re-armed it, and 02:00
arrived with no queue. The install never landed, every day. An arm taken by a
webhook or a manual single-container scan was discarded the same way.

The clear is now conditional on the scan being able to deliver what the queue
holds: the catch-up scan itself, or a scan that runs while the window is open.
That is exactly the set the scan scope could already reach, so its behaviour is
unchanged.

* fix(triggers): apply a digest action's deferred installs when the window opens

The catch-up scan re-runs watch(), which is enough for the simple and batch
paths: their dispatch hangs off the container report the scan emits, so the
deferred install is applied there and then. A digest trigger's report handler
only re-buffers. The install sat in the buffer until the next digest cron, up to
a day after the window that was supposed to apply it and quite possibly after it
closed again, which is not what the CHANGELOG and the docs promise.

Delivery, not a documentation climbdown. A window that opens at 02:00 and a
digest that flushes at 08:00 would otherwise install outside the window on every
cycle, which is the thing the window exists to prevent, and telling operators
"digest installs wait for the next digest cron" would leave the feature
correct only for two of the three modes.

The watcher now announces MaintenanceWindowOpened after its catch-up scan
finishes, so the deferred containers are back in the store with fresh state
before anything reads them. A digest trigger records which watchers held its
installs back during a flush and flushes again when one of those announces, so
a window opening on an unrelated watcher never brings a digest forward.

* fix(triggers): do not arm the window catch-up on a deregistered watcher

deferAutoUpdateForMaintenanceWindow called queueMaintenanceWindowWatch with no
isWatcherDeregistered check, unlike every other arming site. A watcher sets that
flag in its own teardown and clears its queue there, but registry
deregisterComponent only removes the entry from the state map after
component.deregister() resolves, so a trigger evaluating in that window resolves
a torn-down watcher and publishes a fresh refed 60s timer on it.
checkQueuedMaintenanceWindowWatch re-queues itself for as long as the window is
closed, so nothing would ever clear it again. Also, deregisterAll tears watchers
down before triggers, which is exactly the ordering that hits this.

The counter still moves: the install really was held back.

* fix(watcher): count deferred installs on their own metric

dd_watcher_maintenance_skip_total kept its name and labels but silently changed
unit: it meant one per skipped scan cycle per watcher, and the install-scope
deferral made it one per deferred container per trigger evaluation. Fifty
pending updates on a five-minute cron is roughly 14,400 samples a day against
288, so every existing rate alert and the Grafana panel built on it were
measuring something else without anyone touching them.

The skip counter goes back to its original meaning and help text. Under the
install scope no scan is ever skipped, so it stays at zero there, which is
correct rather than a regression.

dd_watcher_maintenance_deferred_updates_total is the new one: same type,name
labels, one sample per container per dispatch evaluation, help text saying so.
A container held back because something it depends on was deferred is counted
too, labelled by its own watcher, and the help text says that as well. It counts
under either scope, because an install held back is an install held back.

No double counting from a batch+digest trigger: it registers a batch handler and
a digest handler, but the digest handler only buffers, so one scan cycle is one
evaluation and one sample. The digest cron flush later is a second evaluation
and a second sample, which is the documented unit. Pinned by a test.

* fix(triggers): key the window deferral set on the business id

An id-less container record put `undefined` in the deferred set, so every
other id-less container in the same batch read as deferred too. The one that
really was sent then kept its retry-buffer entry and went out again on the
next cycle, and under `once=true` a genuinely deferred one could be recorded
as notified.

Key the returned set on the same stable business id the retry and digest
buffers use, and keep a separate id-keyed view for the dependency-graph walk,
which only ever sees ids buildDependencyGraph emitted. The literal "skip
containers with no id" guard from recordNotifiedForResult would have been
worse here: a deferred container with no id would read as not deferred and
install outside the window.

* docs(maintenance-window): say what the window actually gates

The changelog and the watchers page still described the window as holding
back installs, and the changelog claimed `dd_watcher_maintenance_skip_total`
counts them. Both now match the code: every action trigger is gated,
`command` included, notification triggers never are; the catch-up scan
carries a simple or batch trigger and a window-opened announcement makes a
digest trigger flush what it deferred; and deferred work has its own counter,
`dd_watcher_maintenance_deferred_updates_total`, while the skip counter keeps
its one-per-skipped-scan unit and stays at zero under the install scope.

* style(maintenance-window): drop em dashes from the new prose and comments

* fix(watcher): announce the window opening from the scan that consumes the catch-up

The emit had one call site, the once-a-minute poll, but the poll is not the
only thing that consumes an armed catch-up: any scan reaching runCronWatch
while the window is open clears the queue, which also cancels the poll. With
CRON=0 * * * * and a 02:00 window the cron tick fires at 02:00:00, a full
minute before the poll would have, so it cleared the arm in silence, the
digest handler only re-buffered, and the deferred install waited for the next
digest cron. Every day, forever.

runCronWatch now reads the armed flag before the clear and calls
announceMaintenanceWindowOpened once the scan has finished, so the emit
belongs to whichever scan actually consumed the arm. That makes the ordering
claim true by construction rather than by luck: the announce runs inside the
scan that just awaited watch(), so a coalesced caller can no longer be told
the window opened before the catch-up ran. A failed announce is logged and
swallowed, since the scan has already done its work by then.

* fix(triggers): gate a command action's lifecycle dispatch on the window

dispatchContainerForEvent short-circuits update-action types and nothing
else, so a command action reached this.trigger() from the maturity-cleared
path, driven by the maturity gate's own scheduler rather than by the
watcher's scan. A window closed at 3pm did not stop it, which made the docs
claim that all four action triggers are held back false.

Gate the action category there the same way the simple dispatch path does:
defer and arm the catch-up under the install scope. Deliberately not
widening the update-action short-circuit instead, which would stop command
actions firing on lifecycle events at all, with or without a window
configured, and nothing has asked for that.

A deferred lifecycle event is not re-delivered. The update itself still
lands through the ordinary update-available path once the window opens, and
replaying an update-applied hours after the fact would be worse than
skipping it.

* fix(triggers): keep the deferral marker when the digest flush is refused

The window-opened handler deleted the watcher's marker and then called a
flush that returns immediately while another flush is in progress. The
marker is the entire record that this trigger deferred an install for that
watcher, so spending it on a flush that never ran lost the announcement and
left the install for the next digest cron, which is the wait the event
exists to skip.

Check the marker, bail while a flush is in progress, and only delete it on
the path that actually flushes. Nothing is stranded: the in-flight flush
re-arms the watcher's catch-up queue for whatever it defers, and the next
scan to consume that queue announces again about a minute later.

* fix(triggers): contain a failed window-opened flush inside its own handler

emitOrderedHandlers awaits each handler in turn and does not catch, so a
throw from this handler aborted the whole announcement: every trigger
ordered after it was skipped, and the watcher logged a failed announcement
rather than one trigger's failed flush. The pre-dispatch region of the
digest flush sits outside its own try, so this was reachable.

Catch inside the handler, log, and re-arm the marker, which keeps the same
rule the refusal path uses: the marker is spent only by a flush that
delivered. Deliberately not changing emitOrderedHandlers itself. Propagation
is a documented contract there, asserted by two tests, and every handler
registered for this event is this method, so containing it here covers the
event completely.

* docs(maintenance-window): say that agent containers are not deferred yet

The deferral resolves the container's watcher out of the component registry
and needs a live isMaintenanceWindowOpen on it, which only the controller's
own Docker watcher has. Eligibility falls back to the masked
maintenancewindowopen boolean an agent reports, so the dialog shows
`maintenance-window-closed` for an agent-owned container while nothing
actually holds its update back. The docs promised the deferred update is
applied when the window opens, with no qualification.

One sentence in each place that made the promise: the eligibility page, the
getting-started window section, the watchers reference, the changelog and
the release notes.

* fix(triggers): only gate the window on a rule that still has an install ahead

The gate sat below the rule, threshold and must-trigger checks, so it caught
every rule a command trigger handles, not just maturity-cleared. None of the
others is an install: update-applied and update-failed report on an update
that already ran, security-alert on a scan, container-unhealthy on a health
transition, agent-disconnect and agent-reconnect on connectivity. Deferring
one of those destroys the report rather than postponing it, and nothing
re-delivers it. A command action used as a post-update hook, with a 02:00 to
03:59 window and an operator clicking Update anyway at 15:00, applied the
update and then never ran the hook, while the gate armed the catch-up poll
and counted a deferred update for work that had already finished. That also
contradicted the changelog and the watchers page, which both say manual
updates are unaffected.

Gate on an explicit PRE_INSTALL_NOTIFICATION_RULE_IDS set, currently
maturity-cleared alone, checked before the defer call so a reporting rule
touches neither the counter nor the queue. update-available never reaches
this method; its own paths carry the gate. The set is named rather than an
inline comparison so the next rule added has to make the decision on
purpose. The synthetic agent containers are not relied on here: they escape
today only because buildAgentContainer sets watcher: 'agent', which resolves
to no registered watcher.

* fix(prometheus): finish the deferred-updates metric help text
#1010, #1011) (#1014)

* docs: cover registries on agents and what the maintenance window gates (#1010)

* docs: cover registries on agents and what the maintenance window gates

* docs: use TLS in the agent registry example and name the recheck action

(cherry picked from commit 4d1a2f2)

* docs(repo): retire the finished refactor plans and fix the docs README version table (#1011)

* docs(repo): retire the finished refactor plans and fix the docs README version table

queue-ux-refactor-plan.md and update-path-refactor-plan.md described work that has shipped in full (phase-only queue status a7e5263; requestContainerUpdate admission, recovery reconciliation, self-update operation kind). Their history is kept in the planning archive.

docs/README.md still listed current as v1.5 with v1.4 and v1.3 snapshots. It now points at apps/web/scripts/docs-versions.mjs, which is what sync-docs.mjs and next.config.mjs actually read, instead of restating the list.

* docs(readme): realign the roadmap table with the planning roadmap

The v1.7.0 row still promised selective bulk updates, image prune and static image monitoring, all of which the roadmap moved to v1.8 after the GA re-rank. The v1.8.0 row listed the volume browser and upstream-base tracking for local images, both now post-v1.8, and the v2.0+ row promised a native Podman provider the roadmap explicitly does not plan. Rows now mirror the roadmap's version targets in the English README and the six translations, which the readme-translations test holds to the same link set.

* docs(readme): fix the translation terms flagged in review

docs/README.md now says the first docs-versions entry maps the current source directory to the active public slug. Polish: update ordering uses the feature name the comparison table already uses, the SQLite store is a magazyn not a sklep, and the web terminal is a terminal webowy. Portuguese: health gates keeps gate, matching the existing gate de imagem wording.

(cherry picked from commit b0683bb)

* docs(agents): mount the CA file the registries example points at
…ngle-container update (#1013)

* fix(compose): honour the availability policy when a tag moves on a single-container update

The post-create image-ID check from #984 exists so every container of a
compose-file-once service runs the image the preflight's one gate decision
cleared. It also fires on the ordinary compose path, where a local tag that
moves between the pull and the create now refuses the update, removes the new
container and rolls back. That is a new fail-closed mode: before #984 the
update went through on whatever the tag pointed at.

A moved tag on a single container is an availability-versus-certainty call,
which is the one DD_SECURITY_AVAILABILITY_POLICY already settles for the
security gate. The ordinary path now defers to it. Under warn the mismatch is
logged with the expected and actual image IDs and the container stands; under
block, the default, the refusal, the cleanup and the rollback are unchanged.
The policy is read off the security gate the same way the Docker trigger reads
it for an unbindable pulled image, so it applies whether or not a scanner is
configured.

Compose-file-once ignores the policy and still refuses. Its containers are all
created against one preflight decision, so keeping a mismatched one would leave
that container on an image the gate never cleared while its siblings run the
image it did, which is a split service rather than a slower one.

* fix(compose): key the moved-tag refusal on a preflighted identity

The post-create image-ID check keyed its refusal on whether compose-file-once
was enabled, which is the wrong question on both sides.

Without compose-file-once every container of a service pulls and resolves its
own image (skipPull is false), so under DD_SECURITY_AVAILABILITY_POLICY=warn a
tag moving mid-batch left web-1, web-2 and web-3 on different builds while
every operation recorded success. With it enabled, recreateContainer() and a
direct updateContainerWithCompose() reach the same check carrying no preflight
context at all and were refused for a shared decision nothing had made.

The discriminator is now whether the identity being checked against arrived
preflighted: runtimeContext.pulledImageId or runtimeContext.imageIdentity set
at the entry of refreshComposeServiceWithDockerApi, read before the local
capturePulledImageIdentity recovery can fill expectedImageId in for this
container alone. It cannot be derived afterwards, because that recovery
publishes the first replica's ID as the service's shared one (DR-54).
Preflighted refuses whatever the policy says, since those containers all run on
the one gate decision the preflight made. Otherwise the availability policy
decides.

On the unpreflighted path a moved tag mid-batch keeps going container by
container under warn rather than aborting the batch: each container is already
resolving its own image, so refusing the rest would trade a visible
per-container divergence for a half-rolled-back service, and each kept
container names its own actual image in the log.

* test(compose): make the moved-tag cases match reachable security postures

The DR-54 refusal test injected securityGateUnboundWarn: true and relied on
the file-level configuration mock returning { enabled: false } for the
availability policy to read as undefined. That combination cannot occur: the
warning is only ever set under the optional identity binding policy, which
needs a scanner enabled and the policy at warn. The test now spells out the
default no-scanner posture with the policy at block, which is exactly what
produces the preflight context it injects, and drops the impossible flag.

Adds the case nothing covered: a scanner configured, availability policy warn,
signature verification off, gate on, and an image with no matching RepoDigests.
That is the only posture that reaches the skipSecurityGate branch in
production, and the moved tag afterwards is kept rather than rolled back.

The two DR-67 tests are renamed off "single-container": the discriminator is
whether an identity arrived preflighted, not how many containers the service
has.

* feat(compose): audit a container kept on a moved tag

Keeping the container under DD_SECURITY_AVAILABILITY_POLICY=warn leaves it
running an image nothing verified or scanned, and the log line was the only
trace. The path this branch is actually reached from is an install with no
scanner, where the identity binding policy is disabled and never records the
unbound-image skip, so no other audit row exists for it either.

The keep branch now writes a security-scan-skipped row naming both the image
the pull resolved to and the one the container came up on, matching what the
unbound-image skip already records under the same policy.

* fix(compose): guard the availability policy read against a missing config

The read went through securityGate.securityConfig.getSecurityConfiguration()
unguarded, where its sibling in Docker.getPostPullIdentityBindingPolicy uses
optional chaining for the same access. A gate carrying no securityConfig would
raise a TypeError from inside the recreate's own try, and the rollback net
would report it as a failed replacement, so a wiring bug would be
indistinguishable from a tag that actually moved. It now reads the same way,
an unset policy is fail-closed, and the docblock records why the policy is read
without gating on security.enabled.

* docs(compose): scope the moved-tag check to what actually reaches it

The compose trigger page claimed Drydock inspects every container it creates
and compares its image ID against the pull. It only does that when the create
reference fell back to the mutable tag; a container created from a bound digest
never reaches the comparison. The paragraph also sat under Security Gate
(Update Bouncer), where an install with no scanner has no reason to read it,
even though that is the install the behaviour is aimed at.

Reworded and moved to Failure safety, with a one-line cross-reference left in
the gate section. The compose-file-once paragraph now says its unconditional
refusal is unconditional because of the preflight, not in general. The
DD_SECURITY_AVAILABILITY_POLICY row in the security reference says it also
governs the moved-tag case and is read with no scanner configured.

The unreleased #975/#984 changelog bullet said the refusal was not specific to
any policy, which the new bullet contradicts. Since #984 has not shipped, that
bullet is amended to scope the unconditional refusal to preflighted
compose-file-once batches and hand the rest to the bullet below it.

* fix(compose): treat the recovered-ID sink as a preflighted batch marker

The preflighted predicate read only pulledImageId and imageIdentity, which
leaves one replica out. A compose-file-once preflight whose own inspect throws
records neither, so its first replica arrives with both undefined, recovers an
image ID itself, and publishes it to the rest of the batch. Under
DD_SECURITY_AVAILABILITY_POLICY=warn with no scanner that replica read as
unpreflighted and was kept on a moved tag, while every replica after it was
compared against the ID it had just published and refused: replica one on the
moved image, its siblings rolled back, which is the split service the check
exists to prevent.

onPulledImageIdResolved is only ever attached by attachRecoveredPulledImageIdSink,
so its presence is the batch marker that survives a failed preflight inspect.
Adding it to the predicate refuses the recovering replica too.

The covering test pins the exact case, and without the new clause it fails by
refusing nginx-b instead of nginx-a, which is that split spelled out.

* test(compose): pin the preflight marker and kept-audit timing against the pre-fix behavior

Add three regression tests for the moved-tag review round: a
compose-file-once update with no preflighted identity in its runtime
context is kept under availability policy warn rather than refused
by the old isComposeFileOnceEnabled() guard; a runtime context
carrying pulledImageId alone (without imageIdentity or
onPulledImageIdResolved) is refused under warn, pinning that clause
of the preflight marker independently; and a kept decision whose
startContainer call then fails records no security-scan-skipped
audit row, since the container never actually ran.

* fix(compose): record the kept-container audit row after the recreate succeeds

assertReplacementContainerImage recorded the security-scan-skipped
audit row for a kept moved tag before the recreated container had
actually started. If startContainer then threw, the rollback net
removed the candidate and restored the previous container, but the
audit trail still said the container had been kept under
DD_SECURITY_AVAILABILITY_POLICY=warn. The assert now returns a kept
descriptor instead of recording anything, and
refreshComposeServiceWithDockerApi records the row itself once
recreateReplacementContainerWithCleanup has returned successfully,
so a row only exists for a container that actually ran.

Also rewrites three comments that had drifted from what the code
does: the audit-row comment now hedges the way the docs already do
instead of claiming it is the only possible row on that path, the
preflightedServiceIdentity comment no longer claims a read-order
hazard between it and the ID-recovery sink that isn't there, and the
CHANGELOG and docs entries for the moved-tag policy now key on
whether a call was preflighted rather than on compose-file-once
being on, which was backwards for the off case.

* docs(compose): say the kept-container audit entry follows a successful start
… installs (#1017)

* feat(monitoring): add a Grafana panel for deferred maintenance-window installs

* fix(monitoring): place the deferred-updates panel under Maintenance Skips
…te candidate (#1020)

* fix(compose): recreate a rollback from the backup image, not the update candidate

Dockercompose.recreateContainer wrote the image it was handed into the
compose file and then called refreshComposeServiceWithDockerApi with no
runtime context, so the refresh re-derived its own target from the
container's updateKind and created the container from the update
candidate. The compose file said the backup, the container ran the
update, and the rollback reported success. Clearing the update result
does not help: the fast resync after an update has already moved the
container's tag to the candidate by the time a rollback runs.

The recreate now passes runtimeContext.newImage through, and with it the
digest its own reference pins, so the identity binder cannot break a
RepoDigests tie with container.result.digest, which describes the
manifest this recreate is undoing. It deliberately passes none of the
compose-file-once identity fields, so the post-create image check still
treats the path as unpreflighted.

* fix(rollback): hand the health monitor the full container

startHealthMonitor built its container reference from the id and name
alone and passed it to the trigger as the container, so a compose action
threw a TypeError reading container.image.registry.name before it reached
the compose file. Auto-rollback on a failed healthcheck therefore never
ran on a compose stack; the only trace was an auto-rollback audit row
carrying the error. RollbackMonitor already held the whole container and
dropped everything but the name, so it now passes it through and the
monitor overlays the replacement's id on it.

HealthMonitor.test.ts drives a real Dockercompose through the rollback,
which is what the wholesale trigger mock could not catch.

* refactor(compose): mark a rollback caller-pinned without re-parsing its digest

selectPulledRepoDigest already matches a digest-pinned pull off the
reference before it ever reads tieBreak.preferredDigest, so
getPinnedManifestDigest's parsed value was dead code; the options bag
only needed to exist to mark the rollback caller-pinned. Tighten
MonitoredContainer (and the RollbackContainer type feeding it) so a
bare { id, name } stub can't typecheck in place of the real container
shape the compose action reads. Fix the DR-101 changelog entry's tense
mismatch, note the failed auto-rollback also logs an error, and drop
the now-redundant digest-preference sentence.

* fix(rollback): pull the backup image before an automatic rollback recreates the container

The health monitor logged "Auto-rollback: pulling backup image X" and then
never pulled it. Neither recreate path fetches for itself: Docker's goes
straight to createContainer, and the compose one runs its runtime refresh
with skipPull so the manual rollback in app/api/backup.ts, which pulls
before it calls in, is not made to pull twice. That left the caller owning
the pull, and the automatic caller never did one.

A backup image no longer on the host therefore failed at create, and on
both paths the running container was already gone by then. Docker's
performRollback calls stopAndRemoveContainer before recreateContainer. On
compose, stopAndRemoveContainer is a no-op override, but the runtime
refresh does the real stop/remove itself and then, when the create fails,
the rollback net restores the container from currentContainerSpec.Config
.Image, which on an automatic rollback is the failing update the rollback
existed to undo.

DR-110 recorded the compose half as failing safe. It only does under a
`required` identity binding policy, which needs security enabled and either
signature verification or a non-warn availability policy. On a stock
install the policy is `disabled`, so capturePulledImageIdentity swallows
the failed inspect, verifyPulledImageCompatibility swallows its own, and
getCloneRuntimeConfigOptions swallows both of its inspects. Nothing between
the identity capture and the stop/remove throws.

The pull goes in the health monitor rather than in the compose refresh.
recreateContainer is shared with the manual API rollback, so flipping
skipPull would pull twice on that route; putting it in the monitor also
covers the Docker action's rollback, which has the same bug, and it makes
the existing log line true. Docker.pullRollbackImage resolves the registry
manager with the anonymous fallback the compose refresh already uses, takes
its getAuthPull, and pulls whatever buildRollbackImageReference produced, so
a record with a captured digest pulls repo:tag@sha256:... and the daemon
resolves it by digest.

Second, the compose refresh now refuses a missing image before the removal
instead of at create: verifyPulledImageCompatibility takes requireLocalImage
and the refresh passes skipPull, so behind a pull a failed inspect is still
Docker's to report, and without one it is a hard error that leaves the
running container alone.

Refs DR-110
…name (1.8) (#1022)

* fix(agent): decide container ownership by enumerated id, not watcher name

The rc.6 ownership gate (#922) refused an agent's container that had no
store row whenever the controller had a watcher registered under the same
name. That name collides by default: a controller with no DD_WATCHER_*
registers its default watcher as `local`, and the agents quickstart sets
DD_WATCHER_LOCAL_SOCKET on the agent, so both sides are called `local`
while watching different hosts. On a fresh store every agent container was
refused on every ingest path and the API showed no agent rows at all. An
install with the rows already stored lost a container permanently the
first time it was recreated under a new id, because the same gate feeds
the prune keep-set.

A watcher name is not evidence about who owns an id; the ids the
controller's own daemon hands back are. Each controller-local Docker
watcher now publishes the ids its listContainers() call returned into
watchers/controller-local-container-ids.ts, replacing its set on every
enumeration and clearing it on deregister, and the gate refuses a
no-record ingest only when the incoming id is in one of those sets,
naming the watcher that holds it. Recording happens off the raw
enumeration, before the watch filter and the discovery settle window, so
the id is still known before the row exists, which is the race the gate
exists to close. A controller-Docker-transport agent watcher records
nothing, since it enumerates the agent's daemon rather than the
controller's.

(cherry picked from commit b50bb7e)

* fix(agent): seed the controller-local id set at watcher init and document the same-daemon case

Docker.init() now seeds a controller-local watcher's enumerated id set with
one listContainers() call before the startup cron tick is scheduled. Without
it, the set stayed empty from registration until START_WATCHER_DELAY_MS
later, while the agent SSE connection starts immediately after
registry.init() resolves, so an agent could claim a controller-host
container id during that window. A daemon that's unreachable at boot only
logs a warning through the watcher's logger; registration still succeeds.

Also:
- inline the enumerated-id list into the record call in getContainers() and
  bump the Docker.ts structure test's line cap to fit both changes
- replace the stray #1013 PR reference (an unrelated PR) with (DR-106) in
  AgentClient.ts/test.ts and Docker.watch.test.ts
- correct the CHANGELOG workaround wording: renaming the watcher on either
  side also avoided the bug, not just disabling the controller's watcher
- document the same-daemon topology, where an agent watches a daemon a
  controller-local watcher already watches: the agents doc callout now says
  DD_LOCAL_WATCHER=false is required (not just optional) in that case, and
  test/qa-agent-test.yml is updated to match (DD_LOCAL_WATCHER=false,
  DD_AGENT_ALLOW_INSECURE_SECRET=true, drydock:dev instead of the stale
  v1.4.1-fix pin, a pullable redis:7.2-alpine instead of a never-pulled pin)

(cherry picked from commit dd201b9)

* fix(agent): drop the controller's explicit local watcher from the QA agent compose file

DD_LOCAL_WATCHER=false only skips the default watcher; the DD_WATCHER_LOCAL_* keys still registered one, so the controller kept enumerating the shared daemon and refused the agent's containers. The docs callout now says to remove those keys too.

(cherry picked from commit 4432273)

* fix(watchers): correct off-by-one in Docker.ts structure test line cap

split('\n').length on a file ending in a trailing newline counts one
more than wc -l does, so the 1650 cap set while forward-porting the
DR-106 fixes undercounted by one against the actual file.

* docs(watchers): drop em dash from controller-local-container-ids comment

* fix(watchers): keep a deregistered watcher from restoring its container claims

getContainers() can settle after deregisterComponent() already cleared the
controller-local claim set via forgetControllerLocalEnumeration(). Recording
the claim set once the pending listContainers() call finally resolves would
resurrect it for a dead watcher, permanently blocking any agent that reuses
that container id since nothing else ever clears a claim for a watcher
that's gone. Guard the record call with the existing isWatcherDeregistered
flag and add a test that holds getContainers() open across
deregisterComponent(). Bumps Docker.structure.test.ts's line cap per its
own convention.

Also fixes test/qa-agent-test.yml's gitleaksignore entry, which fingerprints
the demo API key by line number: the line moved from 52 to 54 in an earlier
change and broke the working-tree scan. Add an inline `gitleaks:allow`
comment so a future line move can't break it again, and update the
working-tree ignore entry to match; the history entry is untouched.

Adds a sentence to the agents doc's shared-daemon warning noting that
DD_WATCHER_{name}_WATCHEVENTS=false widens the window where a container
created between the controller's cron scans can be claimed by an agent.

(cherry picked from commit 52ebd3e)

* style(test): two spaces before the gitleaks allow comment

(cherry picked from commit 25bd733)

* fix(watchers): refresh remote auth and bound the startup ownership seed

Docker.init() now refreshes a remote watcher's OIDC auth headers before the
DR-106 controller-local seed runs, so the seed's listContainers() call no
longer goes out unauthenticated and gets silently swallowed on an OIDC
watcher. Guarded by wouldRefreshRequireInteractiveOidcDeviceFlow() so a
first-time device-code authorization is deferred to the first scheduled scan
instead of blocking registerWatchers()'s Promise.all() on user interaction.

seedControllerLocalEnumeration() also now races its listContainers() call
against CONTROLLER_LOCAL_SEED_TIMEOUT_MS (10s), so a wedged daemon can no
longer hang Docker.init() and, with it, the whole controller's startup.

Bumps the Docker.ts structure test's line cap to fit both changes.

(cherry picked from commit 020b86f)

* fix(watchers): record only the newest container enumeration for ownership claims

The agent API can call watcher.watch() directly while scheduled and event
scans go through watchFromCronOrchestration(), so two getContainers() calls
can run concurrently. If the older call's listContainers() result settles
after the newer one, it was overwriting the newer call's claim set with
stale data. Add a per-instance generation counter, bumped before the first
await in getContainers(), so only the result from the newest call gets
recorded.

(cherry picked from commit b5772ab)
…s configured (#1025)

fix(registry): prune controller-owned records when no local watcher is registered

pruneOrphanedLocalContainers() early-returned whenever the controller had no
local (non-agent) watcher registered, so with DD_LOCAL_WATCHER=false the record
a container left behind when it moved to a remote agent survived every restart.
The agent's report for that id is then refused by AgentClient's ownership gate
as owned by the controller, and the record is never rewritten to name the agent.

registerWatchers() now reports how many registrations it attempted, and the
prune skips only when registrations were attempted and none of them landed. An
unreachable socket or an unreadable CA file empties the registry for a transient
reason, so that case must not delete every controller-owned record; a controller
configured with no local watcher at all is a real operator decision and every
controller-owned record it holds is orphaned.
#1028)

The pull DR-110 added runs before any destructive step, and pullImage
throws on any registry failure, so performRollback's catch turns a
registry that cannot be reached into "Auto-rollback failed" with the
unhealthy container still running. That is a regression on exactly the
hosts the pull was supposed to help: an airgapped host, or one hitting a
Docker Hub anonymous 429, rolled back fine before, because the backup
image was still on disk. Prune retains it, so the pull is redundant in
precisely the case that now fails.

pullRollbackImage now inspects the rollback reference first and skips the
pull when the image is already on the host, logging that it is. The
registry resolution and getAuthPull move behind the same check, since
getAuthPull is itself a network call on most registries. A failed inspect
answers "not on the host" whatever the failure was, not just a 404: a
briefly unreachable daemon has to fall through to the pull rather than
skip it, because pulling an image that is already there costs a round
trip while skipping one that is absent is the create failure this path
exists to prevent.

An image that is neither on the host nor pullable still refuses, now with
a message that says so, and the compose refresh's requireLocalImage still
catches the genuinely missing case before the stop and remove. The order
in performRollback and the manual API rollback in app/api/backup.ts,
which pulls on its own, are unchanged.

On this line the rollback reference is the hybrid repo:tag@sha256:...
that buildRollbackImageReference builds, and inspecting that exact form
is already load-bearing here rather than something this change
introduces: the compose refresh calls
verifyPulledImageCompatibility(dockerApi, pinnedImage, ...) under
requireLocalImage, which does dockerApi.getImage(pinnedImage).inspect()
on the same hybrid reference, and capturePulledImageIdentity inspects it
too on the way to resolving pinnedImage. isImageOnHost asks the daemon
the same question in the same form, so the reference that skips the pull
is the reference the refresh then verifies.

Refs DR-110

(cherry picked from commit f4cda64)
…esh checks (#1026)

Compose-file-once gates in its batch preflight, so createTriggerContext told
the lifecycle not to defer the pre-update hook and the prune/backup step. They
ran ahead of the refresh, which still has two guards of its own: the container
has vanished since the scan, or its inspect came back without a boolean
State.Running. Either aborts the refresh, and by then the update had already
fired an operator hook, deleted cached images and written a backup row for
something that never happened. Repeating it evicted real rollback points.

That path now defers like the ordinary one. performContainerUpdate forwards the
post-pull hook instead of dropping it, wrapped so skipSecurityGate is always
set, because the preflight already gated the service. The gate suppression is
unchanged, it just moves from "no hook at all" to an explicit flag, and the
deferred steps land inside the refresh after both guards and before anything is
stopped or removed. Compose refreshes on this path are now covered by the
executor's missed-hook guard too.

The mocks that stand in for the refresh had to start calling the hook, which
they now do through a shared helper forwarding the operation id and bound image
identity the real refresh passes. The compose-file-once ordering test expects
hook, prune and backup after the refresh's guards rather than before the
refresh.
… on prune (#1033)

The startup prune of controller-owned container records got two things wrong.

DR-112: it deleted the record with a plain deleteContainer(), so nothing was
stashed, and the stash it would have written could not have been read back
anyway. deleteContainer's replacementExpected stash is keyed on agent plus
watcher plus name, which is exactly what a hand-off to an agent (or a watcher
rename) changes, so the incoming record looked up a key nothing had written. A
snooze, a maturity mode and minimum age, and skipped tags or digests all went
back to defaults, and an absent maturity policy reads as no gate rather than
the default gate. deleteContainer now takes identityChangeExpected, which
stashes the same overrides under the container's Docker id: the id is the one
thing the move leaves alone, because it is the same physical container on both
sides, while the identity is what it changes. insertContainer tries the
identity key first and falls back to the id key, so a recreate (new id, same
identity) still takes the #496 path untouched. A container recreated as part
of the move mints a new id and matches neither key.

DR-113: the prune kept only records whose watcher was registered, and #1025
protected the all-failed case only. With two configured local watchers where
one fails on an unreadable CA file, a missing socket or a rejected value, the
survivor keeps the registered set non-empty, so the failed watcher's records
were pruned as though it had been renamed away. registerWatchers now returns
the configured watcher names rather than a count, and the prune keeps a record
whose watcher is registered or still configured. It also awaits allSettled
rather than all: Promise.all resolves the await on the first rejection while
the other registrations are still in flight, so the prune could read a
registry emptier than the one the run actually produced.
…binaries at the cut (1.8) (#1031)

* build(docker): pin base images to multi-arch index digests

#881 rolled the node:24-alpine and alpine:3.24 pins to digests that name a
single amd64 manifest rather than the image index. buildx resolves a digest
pin the same way for every --platform, so the arm64 stage built on an amd64
rootfs and every arm64 image from v1.7.0-rc.4 onward shipped x86-64 tini,
node and healthcheck binaries under an arm64 label (#1021).

Both pins are back on image index digests that carry linux/amd64 and
linux/arm64. openssl stays at 3.5.8-r0 on arm64, verified in a local
linux/arm64 build, so the six CVEs #881 fixed stay fixed.

(cherry picked from commit 2b50613)

* ci(release): verify base image indexes and platform binaries at the cut

Nothing in the pipeline noticed that rc.4 through rc.10 published x86-64
binaries under an arm64 label (#1021): the digests were real, the manifest
list named both platforms, and the scans read the amd64 image.

Two guards close that. check-dockerfile-base-indexes.sh inspects every
digest-pinned FROM and fails unless it is an image index carrying every
platform we build for, wired into ci-verify's build job (which already has
Docker Hub egress and runs on every PR) and into release-cut before the
staging build. check-image-arch.sh reads the ELF e_machine of /sbin/tini,
/usr/local/bin/node and /bin/healthcheck inside the image, per platform,
and runs after the staging build resolves a digest and before anything is
signed, tagged or promoted, so a mismatched image cannot reach a public
tag. Both are exercised by node:test suites with a fake docker on PATH.

(cherry picked from commit 5a5f85b)

* ci(release): tighten the platform guards from review

- docs(changelog): tighten the arm64-index CHANGELOG bullet's spacing to match the list
- fix(scripts): pull the per-platform image fresh in check-image-arch.sh instead of relying on the daemon's mismatch re-pull
- ci(workflows): retry the base-image-index check in ci-verify.yml so a transient Docker Hub 429 doesn't fail the build job

(cherry picked from commit 4bab5a7)

* ci(release): fold Dockerfile continuations in the base index guard

check-dockerfile-base-indexes.sh matched FROM against physical lines, so a
pin written as `FROM --platform=... \` with the reference on the next line
was never inspected, and another valid pin was enough for the guard to
pass. Fold each logical instruction first: a line ending in the escape
character continues on the next one, comment and blank lines inside a
continuation are dropped (BuildKit warns on an empty continuation line and
keeps folding), CRLF endings are tolerated, and the escape character comes
from a `# escape=` parser directive when one precedes the first
instruction. Tests cover the continued FROM with a single-platform pin on
the continued line, the backtick escape, a late directive that must be
ignored, and a continuation that ends the file.

Refs DR-111, review on #1024

(cherry picked from commit 87c40b7)
* feat(portainer): persist recovery state for stack updates

(cherry picked from commit 8abef2ffcac3999fb244a69303dab4326685fabf)

* fix(portainer): reconcile recovery descriptors correctly on restart

Fixes six defects found in review of the cherry-picked recovery-state
commit before it can be considered mergeable:

1. normalizeImageReference returned undefined for an env-interpolated
   image, so an undefined-vs-undefined comparison matched "original"
   and recovery re-PUT over an operator's manual edit. The compose
   branch now throws when the running service image cannot be resolved
   to a concrete reference, treating it as unverifiable rather than a
   match, and expires instead of mutating the stack. Covered by the
   "unresolvable compose image" test.

2. Env-mode restore wrote TAG="" when the variable was absent at
   capture. originalValue is now string | null, capture records null
   when the variable is missing, and a new removeStackEnv helper
   deletes the variable on restore instead of writing it back empty.
   Covered by the null-originalValue capture and restore tests plus
   the removeStackEnv-with-no-Env-array test.

3. A transient Portainer API failure at boot (stack or stack-file
   fetch) is now caught and rethrown as
   PortainerRecoveryUnavailableError, which recovery.ts already
   classifies as expired rather than failed, so it can't fire a false
   update-failed notification. Covered by both "Portainer API is
   unavailable" tests.

4. The reissue redeploy had no restore-on-failure, and
   markOperationTerminal wiped the descriptor before recovery could
   use it. Reconciliation now keeps the pre-fetched stack file/env
   around, restores the previous definition when the reissue redeploy
   or its wait fails, and no longer calls markOperationTerminal itself
   so the descriptor survives until recovery.ts records the terminal
   state. Covered by the reissue-fails-then-restore-succeeds and
   combined-error (restore-put fails, restore-wait fails) tests.

5. The validator dereferenced field.slice on a missing field. New
   describePortainerRecoveryDescriptorProblems reports every missing
   or malformed field by name instead of crashing, and the caller
   throws a message naming them. Covered by the field-name-reporting
   test and the malformed-field-format variants (env missing prefix,
   env empty suffix, compose missing prefix).

6. Portainer.ts coverage was 95.75/91.48 (restore branch and both
   terminal short-circuits untested). Added ~25 tests bringing
   Portainer.ts, update-operation.ts, container-update-operation.ts,
   and recovery.ts to 100% lines/branches/functions/statements,
   including a new recovery.ts test for an agent-owned Portainer
   operation expiring instead of being reconciled.

Also declares portainerRecovery on UpdateOperationBase (opaque to the
store, never surfaced via toApiUpdateOperation, cleared by
markOperationTerminal), and updates the CHANGELOG Unreleased/Fixed
section to describe the reconcile-or-expire behavior in place of the
prior always-expires behavior.
…enance window (#1035)

fix(agent): gate an agent-owned container's auto-install by its maintenance window (DR-96)

AgentWatcher implemented no isMaintenanceWindowOpen(), so
Trigger.deferAutoUpdateForMaintenanceWindow treated every agent-owned
watcher as unresolvable and failed open: an agent-owned container's
automatic install ran regardless of the watcher's configured
maintenance window, while the container panel (reading the same
watcher's masked maintenancewindowopen field for display only) still
reported the update as deferred until the window opened.

AgentWatcher.isMaintenanceWindowOpen() now answers the same question a
local DockerWatcher does. Under controller Docker transport the
delegate watcher IS a live DockerWatcher, so its own state is
authoritative. Otherwise the real watcher runs on the remote agent
process, so the freshest state available to the controller is the
masked maintenancewindowopen boolean from that agent's most recent
watcher-snapshot report (AgentClient.getWatcherSnapshot), refreshed on
every one of the agent's own scan cycles; before a snapshot has
arrived it falls back to the handshake-time configuration, and with
neither it fails open, matching DockerWatcher's own "unconfigured
window lets updates through" default and keeping an older agent that
omits the field ungated rather than newly and silently blocked.

Once a closed window opens, the agent's own next scheduled report
(not a controller-side poll) re-evaluates the previously deferred
container's eligibility, the same way a local watcher's own next scan
would - the deferral gate already lives in the generic
Trigger.deferAutoUpdateForMaintenanceWindow / getContainerMaintenanceWindowOpen
code, which AgentWatcher now satisfies the same interface for, so
no changes were needed there.

Also updates the four docs pages (getting-started, configuration/watchers,
configuration/actions/update-eligibility, updates) that documented the
old "not deferred for agent watchers yet" behavior as a known
limitation.
…1038 (#1043)

chore(security): allow the shifted webhook doc curl examples before the coalesced edit lands
…expiring once reservations, SPA fallback allowlist (#1038)

* fix(agents): stash update policy on the agent-side prunes too

under its Docker id (deleteContainer's identityChangeExpected option) so
the record that replaces it under a new identity inherits the snooze,
maturity mode/min-age, and skipped tags or digests. Two other prunes that
delete a record ahead of an identity change still called deleteContainer
plainly and lost the policy the same way the startup prune used to:
pruneOrphanedAgentContainers in app/registry/index.ts, which runs when an
agent is removed from config or renamed, and AgentClient.pruneOldContainers
in app/agent/AgentClient.ts, which runs when a container is missing from
an agent's incoming report because it moved to another agent.

Both now pass identityChangeExpected: true, so a container that reappears
under the same Docker id but a different agent, watcher, or name carries
its update policy across the move instead of resetting to defaults.

* chore(ci): drop the no-op pre-commit coverage hook

lefthook.yml ran ./scripts/pre-commit-coverage.sh with no {staged_files}
argument, so the script's `for f in "$@"` loop always saw an empty list
and printed "No app/ or ui/ files staged; skipping tests" on every commit
regardless of what was staged. Commit-time checks are meant to stay cheap,
so rather than wiring {staged_files} through and turning this into a
per-commit test run, the command and the script are gone. Pre-push
coverage already runs the full suite on every push.

Removes the coverage command from lefthook.yml's pre-commit block,
deletes scripts/pre-commit-coverage.sh and its test, updates the two
.github/tests/ci-verify-workflow.test.ts assertions that expected the
hook to exist, and corrects CONTRIBUTING.md's description of what
pre-commit actually runs now.

* fix(notifications): expire once-notification reservations that never settle

reserveOnceNotificationSlot/releaseOnceNotificationSlot guard once=true
sends against an overlapping evaluation of the same result, but the
release only ran when the handler's own promise settled.
runHandlerWithTimeout detaches a handler that misses its 30s deadline
instead of awaiting it forever, so a provider that never resolves or
rejects held its reservation for the process lifetime, and every later
scan for that exact result read it as still in flight and skipped the
send silently.

Give each reservation its own expiry timer, four times the handler
timeout (event.getHandlerTimeoutMs(), a new production-facing getter
alongside the existing test-only one), that deletes the map entry and
logs a warn naming the key if nothing released it first.
releaseOnceNotificationSlot and deregisterComponent now clear that
timer wherever they clear the reservation, so a normal settle or an
explicit teardown never leaves it to fire later against a reused key.

* fix(notifications): route manual scans through the single-flight cron scan

watch() has no re-entrancy guard of its own. POST /api/v1/containers/watch,
POST /api/v1/webhook/watch, and the agent's own watcher API (the endpoint
the controller polls) each called it directly, so a manual "check now"
landing while a cron scan (or another manual scan) was already running
started a second, fully independent handleContainerReports pass. Under
once=true both passes could read "not yet notified" before either had
finished writing its own history, so a trigger sent the same update twice.

All three now route through triggerManualWatch() (app/watchers/manual-watch.ts),
which calls the Docker watcher's existing watchFromCron()/
watchFromCronOrchestration() single-flight orchestration instead of watch()
when a watcher exposes it, falling back to watch() for any watcher type that
does not. ignoreMaintenanceWindow: true preserves manual/API scans' existing
behavior of running regardless of a configured maintenance window. Whether
the call coalesced into an already-running scan is surfaced back: a
`coalesced` field in the JSON body for the containers/webhook endpoints
(added to the WebhookWatchAllResponse OpenAPI schema), and an
X-Drydock-Watch-Coalesced header for the agent endpoint, which keeps its
existing bare ContainerReport[] body so a controller and agent on either
side of a version skew keep working unchanged.

Also, handleMaturityGateClearedEvent's once=true check read notification
history with a bare hasAlreadyNotifiedForResult(), with the matching write
landing an await later once delivery confirms -- the same window
reserveOnceNotificationSlot()/releaseOnceNotificationSlot() exist to close
for the generic update-available path. Two overlapping evaluations of the
same maturity-cleared event could both read "not notified" and both
dispatch. It now takes the same reservation before dispatching, released
from recordEventDeliverySuccess() on success, a new
releaseEventDeliveryReservation() on failure (since a maturity-cleared
dispatch can be optimistic-and-detached or deferred to a later batch
flush, so handleMaturityGateClearedEvent's own call stack returns long
before the send settles), or locally when dispatchContainerForEvent()
returns false without ever queuing or sending anything.

* fix(ui): allowlist SPA routes for the service worker navigation fallback

The workbox navigateFallback denylist only ever excluded /api from the SW's
offline app-shell fallback. Express also owns /auth's OIDC subpaths,
/health, and /metrics: a document navigation to any of them (in particular
the identity provider's /auth/oidc/<name>/cb redirect, #939) was served
index.html instead of reaching the server, so an OIDC login's code
exchange never ran and the SPA silently bounced back to /login.

Replace the denylist with navigateFallbackAllowlist, built directly from
ui/src/router/routes.ts (buildSpaNavigateFallbackPattern, both exported
from vite.config.ts). A route path segment starting with `:` (e.g.
CONTAINER_LOGS's `:id`) becomes a `[^/]+` wildcard; every other segment is
escaped and matched literally, with the trailing slash and query string
both optional to match workbox's NavigationRoute (matched against
pathname + search). A route added to the router without a matching
allowlist entry is now impossible by construction: the allowlist is
Object.values(ROUTES) run through the pattern builder, not a second list
someone has to remember to update in step. Bare /auth is still an SPA
view and still matches; its OIDC subpaths, /health and /metrics do not,
and now correctly fall through to Express, along with any future
server-owned path added without a change here.

ui/tests/config/vite.config.spec.ts pins: every ROUTES path (concrete,
trailing-slash, and query-string variants) matches; known server-owned
paths (the OIDC callback, /health, /metrics, /api) do not; bare /auth
matches but its subpaths do not; and the CONTAINER_LOGS wildcard matches
exactly one path segment.

* fix(notifications): key once-reservation release on the reservation that took it

reserveOnceNotificationSlot's key alone did not prove ownership: once
reservation A expired and its timer deleted the key, a fresh reservation B
could take the same key, and A's own release (settling later, success or
failure) would clear B's entry by key lookup, letting a third evaluation
dispatch while B was still supposed to be held.

Every reservation now carries a unique token, and release (from either the
expiry timer or a settling caller) only clears the entry when its token
matches. Threaded the token through every reserve/release pair: the simple,
batch, digest, retry-buffer and maturity-gate paths, plus the batch
dispatcher's queue/flush split via a side map since BatchDispatcher has no
room to carry it on its own entries.

* fix(ui): keep query delimiters out of route parameter wildcards

buildSpaNavigateFallbackPattern's :param wildcard was [^/]+, which accepts
"?", so /containers/:id/logs matched /containers/abc?x/logs (actual pathname
/containers/abc, the rest is query text). Narrow the wildcard to [^/?]+ so a
param segment can't swallow a literal "?" and the literal path segment that
follows it.
… to 1.8 (#1048)

* docs(agents): serve TLS on the agent in the paired registry example

* test(rollback): pass backupScope to every health monitor case
…1052)

* feat(ui): add the approval-queue service layer

Wraps the /api/v1/approvals REST endpoints (list, summary, get, approve,
reject, defer) behind a typed service module, matching the pattern used
by every other view's service. Nothing consumes it yet.

Part of spec-ca-2-approval-queue.md, slice 5.

* feat(ui): add the /approvals route and queue view

Lists every row the reconciler has sighted with its release notes and
soft-blocker hold reasons inline, and approve/reject/defer actions that
call straight through the approval service added earlier. Each row
expands in place for detail rather than opening a separate panel.

Part of spec-ca-2-approval-queue.md, slice 5.

* feat(ui): add a sidebar approvals badge and its SSE wiring

The sidebar nav gets an Approvals entry with a live pending-count badge,
fed by the three approval SSE events the API already emits rather than
polling; eventStream.ts parses each payload and AppLayout re-dispatches
it as a window CustomEvent for any component to consume.

Part of spec-ca-2-approval-queue.md, slice 5.

* feat(ui): add an approvals stat tile to the dashboard

Wires a pending-count stat card into the existing generic dashboard
widget system (own DASHBOARD_WIDGET_IDS entry, layout constraints, and
default position) rather than a bespoke component, so the widget-order
and hide/show machinery already covers it. Fetches once on mount and
then follows the same three approval SSE events as the sidebar badge.

Part of spec-ca-2-approval-queue.md, slice 5.

* feat(notifications): add the update-pending-approval rule

Registers update-pending-approval in DEFAULT_NOTIFICATION_RULES so it
can be enabled and assigned triggers like any other rule. It ships
enabled: false and bellEnabled: false: no audit action names it yet
and no event dispatches to it in this release, so it is reserved for
an upcoming one rather than wired end to end here.

Part of spec-ca-2-approval-queue.md, slice 6.

* docs(approvals): document the approval queue and its API

New configuration/actions/approvals.mdx explains what puts a candidate
in the queue (soft blockers become hold reasons, hard blockers keep it
out) and what each of the three decisions does; new api/approvals.mdx
covers the list, summary, get-one and decision endpoints. Replaces the
inline "## Approvals" section in api/index.mdx (a leftover from the
read-only API slice) with a proper overview-table row pointing at the
dedicated page, and cross-links update-eligibility.mdx and the trigger
Notification Rules table for update-pending-approval, noted there as
shipped disabled with no dispatch wired yet.

Part of spec-ca-2-approval-queue.md, slices 5 and 6.

* docs(changelog): add the approval queue view to the Unreleased notes

Adds the CA-2 slice 5/6 bullet (route, sidebar badge, dashboard tile,
the reserved-but-undispatched notification rule) and closes out the
prior slice's "the queue view itself lands next" line now that it has.
Flips the README feature-comparison rows and roadmap table entry for
the approval queue from partial to shipped to match.

* refactor(ui): keep the approval service types module-private

knip flags exported types with no importer; the view and composable only use
the service functions.

* chore(web): regenerate the analytics route manifest for the approvals docs pages
Alpine replaced cosign 3.0.6-r1 with r2, so every image build fails at
apk add with 'unable to select packages' until the pin follows.
…icy (#1051)

* fix(rollback): resolve auto-rollback image through the identity binding policy

Auto-rollback used a fixed fallback (pre-update digest, then mutable tag) that
ignored the required/optional/disabled post-pull identity policy DR-43 gave
the manual rollback API. It now resolves through the same
resolveRollbackImageReference helper, so a required policy that cannot
establish a digest refuses the rollback as a rollback-failure audit event
instead of silently deploying the mutable tag.

DR-59

* fix(portainer): preserve an explicit docker.io/ prefix when pinning the stack image

applyPortainerDigestPin rewrote the stack file's image using the identity
binder's normalised repository, so docker.io/library/nginx:1.27 came back as
library/nginx:1.27@sha256:.... Reuse preserveExplicitDockerIoPrefix, exported
from Dockercompose.ts, so the pinned reference keeps the prefix the stack file
wrote whenever it wrote one.

DR-65

* test(api): build the api-key enforcement app once per file, not once per case

Each of the ~30 cases in api-key-enforcement.integration.test.ts rebuilt a
Basic authenticator (a real argon2id hash), a LokiJS-backed session store and
a listening HTTP server from scratch. That's cheap in isolation but pushed a
single case past the 5000ms default under the CPU contention of the parallel
app+ui coverage gate. The app, its Basic authenticator and its server are now
built once in beforeAll; beforeEach only resets the mutable state a case can
leave behind: the api-key collection, the session store's rows, and the
authenticator chain a couple of cases deliberately swap out.

DR-63

* fix(demo): send Cross-Origin-Opener-Policy so the weekly DAST scan passes

ZAP rule 90004 checks both COOP and COEP. #878 added the embedder policy; the
2026-09-02 weekly run still flagged the missing opener policy and fail_action
turned the warning into exit 2.
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
…validation (#1061)

* fix(tag): stop isTagPinned treating every non-rolling tag as pinned

The tail of isTagPinned() fell through to an unconditional return true
after checking for a numeric version shape, so a bare distro or variant
tag with no version number (redis:alpine, node:slim, postgres:bookworm,
fpm-alpine, production) was reported as pinned and hidden by the Hide
Pinned UI filter alongside genuinely pinned releases like 1.4.5 or
16-alpine. It now returns the numeric-shape check's own result. Also
corrects the watchers doc, which described Hide Pinned as reading
tagPrecision when it actually reads the shape-based tagPinned field.

* fix(store): emit containerUpdated on a name or displayName delta

hasContainerChangedWithSecurityHashes compared neither name nor
displayName, so a docker rename persisted to the store without ever
satisfying the check that gates containerUpdated. SSE rows, Prometheus
gauge labels, and MQTT topics kept the old name until an unrelated
field also changed. Both fields are now part of the comparison.

* fix(agent): reject a non-array handshake containers body before deregistering triggers

AgentClient._doHandshake validated nothing about the /api/containers
response body, so an object, string, or number body threw inside
processAuthoritativeContainers after deregisterAgentComponents had
already run and before trigger re-registration reached it. handleAckEvent
only logs the failure, and dd:ack fires once per connection, so the
agent's triggers stayed deregistered with no retry. The body is now
checked with Array.isArray immediately after the request and rejected
before any registry mutation, with a warning naming the agent and the
body's type.
…t framework (#1059)

* feat(store): add a swappable node:sqlite driver for the v1.8 store

Groundwork for 7-STORE. Nothing imports it yet.

The driver owns transaction(), pragma(), error normalisation and backup()
rather than exposing the engine's own API, because those are exactly the
places node:sqlite and better-sqlite3 disagree. Keeping them here is what
makes the driver choice a one-file swap later instead of a rewrite spread
across every store module.

Opening applies the store's pragmas in a fixed order and probes for WAL,
falling back to TRUNCATE with a warning when the filesystem refuses it,
since /store is routinely an NFS or CIFS mount with no shared memory. A
memory database skips the probe so agent mode never warns.

* feat(store): add the v1.8 SQLite schema and its migration runner

The full DDL for every collection in the store, plus schema_migrations and
a store_metadata table for bookkeeping that is not schema versioning. Every
table is STRICT, so a type mistake is a write-time error rather than a
silently coerced value, which is the class of bug the JSON store could not
catch at all.

A field is a column when the code queries, filters, sorts or individually
patches it; everything else is JSON in a TEXT column, and the five payloads
the store is documented never to inspect are never indexed.

The runner records applied versions and applies only what is missing, so
running it against an already migrated database does nothing. Each run is
one transaction, so a migration that throws leaves the schema and the
bookkeeping consistent with each other.

* feat(store): add the first-start import framework for the v1.8 store

Reads a serialised LokiJS database, serves both the flat and the {data}
enveloped document shapes, and runs a registry of per-collection importers
into SQLite. secrets is implemented as the worked example; later slices add
one entry to the registry each.

The whole import runs into <database>.importing inside one transaction and
is renamed into place only once it has succeeded, so a partial import is
never visible under the real filename and a crash anywhere before the
rename leaves dd.json untouched for the next start to retry. A leftover
.importing file is discarded rather than resumed: resuming correctly would
need per-collection progress tracking, which a one-shot operation does not
justify.

A marker row in store_metadata makes a second import into the same database
a no-op even when it is called directly. dd.json becomes dd.json.pre-1.8.bak
once the rename lands, and an existing backup is never overwritten, since
that file is the whole rollback story for a downgrade to v1.7.

Sessions are read from the dedicated sessions file when DR-121 has already
split them out, and from the Sessions collection in dd.json otherwise.

* docs(changelog): note the v1.8 SQLite store groundwork

* chore(store): hold the new store db exports to what something reaches

knip runs over the app workspace and reports an export nothing imports.
Three of these were real: the import status union is only ever read through
FirstStartImportResult, so it stops being exported, and the reader's two
public constants were duplicated as string literals in their own test
instead of being used.

The rest are the driver's type surface, which is the point of the module
and stays exported. A test now pins it by annotating each of Statement,
Row, SqlValue, SqlBinding, StatementRunResult and TransactionMode at a real
call site, which also covers the exclusive transaction mode nothing else
exercised.

* fix(store): fsync the store directory after each import rename

The directory fsync ran before the temp-database rename and the legacy
backup rename, so neither directory entry change was durably persisted
on crash. Move the sync after each rename instead.

* fix(store): normalize errors thrown while iterating a statement

node:sqlite evaluates iterate() lazily: the call that produces the
iterator never touches the database, only each next() does. Wrapping
just that call left every later step unguarded, so an error thrown
mid-scan surfaced as the raw ERR_SQLITE_ERROR instead of a StoreError.
Wrap next() and return() through the same guard.

* test(store): cover the no-return-method branch of the iterator wrapper

wrapIterator only sets return() when the underlying iterator has one;
every real and previously-mocked iterator in the suite had one, so that
branch never ran and the 100% coverage gate caught it.
…1055)

* fix(store): stop the session store and main store clobbering dd.json

DR-121: express-session's connect-loki store opened its own LokiJS
instance directly on ${configuration.path}/${configuration.file}, the
exact file the main store already used. LokiJS's saveDatabase() always
serializes the whole in-memory database, so whichever instance saved
last erased the other's writes: a session autosave (every 5 seconds,
armed by any authenticated request touching its session via
express-session's touch) reverted every container and setting written
since boot, while the main store's own autosave deleted the Sessions
collection out from under active logins.

store.getSessionStorePath() now derives a sibling file from the main
store's own configured path and file name (dd.json -> dd-sessions.json
by default), so a custom DD_STORE_FILE still lands on a distinct
sibling instead of colliding with anything else on the volume.
auth.ts's LokiStore now points at that path instead of building the
main store's own file path by hand. The main store also drops and logs
a stale Sessions collection left inside dd.json by an older build
instead of continuing to re-save it forever.

Replaces the shared-file-clobber reproduction test with
session-store-file.test.ts, asserting the two stores use different
files and that saving one never erases what the other wrote, including
the touch-driven case, a restart reload, and the legacy-collection
cleanup.

* docs(store): note the session store's own file and its DR-121 fix

The storage page said mounting /store persists "the state" without
naming what state, and now that sessions live in dd-sessions.json
rather than inside dd.json itself, that sentence needs to say so
explicitly so an operator mounting /store knows both files ride along.

CHANGELOG bullet describes the user-visible effect of DR-121: containers
or settings written after startup could vanish from dd.json after a
crash or hard stop, and every restart logged users out, because the
session store rewrote the same file. Notes that backups stay scoped to
the main store file and sessions are not included.

* fix(store): persist the legacy Sessions cleanup during init

dropLegacySessionsCollection() only removed the collection from the
in-memory Loki database. loadDb() resolved without a save(), and
autosave only fires on a later dirty write, so a store that never
writes again kept the stale Sessions collection in dd.json across a
restart. createCollections() now reports whether it dropped the
collection, and loadDb() flushes once when it did.
… lookup-label helper (#1067)

* fix(agent): keep dd.watch.digest for controller-Docker-transport containers across reports

preserveControllerDockerEnrichment restores a fixed list of controller-owned
top-level fields onto an incoming agent report, but image isn't among them.
Portwing hardcodes image.digest.watch to false on every "live runtime state"
report since digest watching is the controller's own bridged Docker watcher's
job, not Portwing's. Without restoring the field, that report wholesale-
replaces the stored image and silently turns digest watching back off for any
container the dd.watch.digest label had enabled.

* refactor(agent): share resolveLookupImageFromContainerLabels with the agent path

AgentClient's applyRegistryLookupLabels duplicated container-init.ts's
unexported resolveLookupImageFromContainerLabels. Export the helper and call
it from both sites instead; each site's own precedence check (agent-reported
lookupUrl/lookupImage beats the label, checked before the helper runs) is
unchanged.

* docs(changelog): note the DR-38 digest-watch fix

* test(agent): cover the report-without-image arm of the digest-watch restore

preserveControllerDockerEnrichment's digest.watch restore (DR-38) guards on
merged.image existing before spreading it, since EdgeAgentAdapter casts an
incoming dd:container_sync frame straight to Container[] with no schema
validation, so image can be missing at runtime despite the type. Add the
missing case: a stored container with image.digest.watch: true and an
incoming report with no image at all, asserting the restore does not throw
and does not fabricate an image.
#1065)

* fix(store): keep synchronous=FULL when the WAL fallback lands on a rollback journal

applyOpenPragmas always set synchronous=NORMAL, which is only safe under WAL:
under a rollback journal (applyJournalMode's fallback when WAL is refused,
e.g. on NFS/CIFS) a crash between deleting and recreating the journal can
corrupt the database under NORMAL. It now takes the journal mode
applyJournalMode actually granted and uses FULL for a writable file-backed
database that isn't in WAL mode, keeping NORMAL under WAL and for read-only
or memory connections that never probed a journal mode at all.

* feat(store): open dd.sqlite alongside dd.json and add DD_STORE_DB_FILE

Slice 2 of the SQLite store plan (roadmap 7-STORE). init() now opens the
slice 1 SQLite driver at DD_STORE_DB_FILE (default dd.sqlite, next to
DD_STORE_FILE in the same store directory; :memory: in agent mode) and runs
the slice 1 migrations, so the schema exists from a fresh install onward.
save() checkpoints the SQLite WAL (PRAGMA wal_checkpoint(TRUNCATE))
everywhere it already flushes dd.json, getDebugSnapshot() reports the SQLite
path, and isMemoryStore() covers both engines. The same 0600/0700 permission
contract dd.json gets now also applies to dd.sqlite and its -wal/-shm
sidecars. Nothing reads from SQLite yet.

GET /api/v1/store gains configuration.dbFile next to the unchanged path and
file, documented in the OpenAPI schema and asserted in the store e2e feature.
DD_STORE_DB_FILE is parsed as its own flat field in getStoreConfiguration()
rather than through the generic dotted-path walk, which would otherwise nest
it under configuration.db.file.
scttbnsn and others added 8 commits October 19, 2026 23:47
* ci: publish a job and step timing summary

* ci: keep the timing job from failing the run

* ci: escape backslashes in timing table cells
* fix(auth): make a recovery-code login review existing API keys (#1403)

* fix(registry): match GitLab registries on exact host (#1407)

* fix(agent): run batch triggers on the agent's own container records (#1413)

* feat(audit): record ignored image hook labels (#1416)

* feat(agent): warn when an agent still trusts image hook labels (#1415)

* fix(auth): only accept OIDC bearer tokens issued for this client (#1421)
* fix(agent): detect half-open agent event streams (#1405)

* fix(compose): fill the operation record like the Docker action (#1406)

* fix(watcher): don't hold update results for release notes (#1409)

* perf(store): use the stats projection for counts (#1411)

* fix(store): keep a retained policy with the replacement on local renames (#1412)

* fix(api): build the dependency graph from the listed containers (#1419)

* fix(agent): tell Portwing timeouts apart from unreachable agents (#1422)

* feat(api): include versions in update-operation-changed events (#1427)

* feat(notifications): show why a notification was skipped (#1429)

* docs(changelog): sort the batch B entries into their sections
* refactor(app): share error, sleep and status helpers (#1423)

* refactor(component): share one secret-mask helper (#1414)

* feat(api): name the caller behind removed-API hits (#1402)

* refactor(ui): share the search-from-query logic (#1418)

* refactor(ui): seed the operations store from the container list (#1428)

* build(docker): use Alpine's patched zlib and drop the custom build (#1417)

* chore(license): relicense from AGPL-3.0 to MIT (#1410)

* docs(changelog): one heading per section in Unreleased
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* chore(deps): update npm minor/patch (e2e)

* test(deps): track the csv-parse 7.0.3 override

* test(deps): track the yaml 2.9.1 e2e override

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
drydock-website Ready Ready Preview Oct 20, 2026 5:38pm UTC
drydockdemo-website Ready Ready Preview Oct 20, 2026 5:38pm UTC

@biggest-littlest biggest-littlest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tree matches dev/v1.8 at ddc8eee; local gate green.

ALARGECOMPANY
ALARGECOMPANY previously approved these changes Oct 20, 2026

@ALARGECOMPANY ALARGECOMPANY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tree matches dev/v1.8 at ddc8eee; local gate green.

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 429 files, which is 329 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

Usage-priced reviews support at most 300 files.

⚙️ Run configuration
  • Configuration used: Repository: CodesWhat/drydock/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: faffcef5-c1ea-4433-93af-9e73798d8b9d
📥 Commits

Reviewing files that changed from the base of the PR and between 208a923 and 6909b9b.

⛔ Files ignored due to path filters: 13
  • .zap/rules-public-site.tsv is excluded by !**/*.tsv
  • CHANGELOG.md is excluded by !CHANGELOG.md
  • app/package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
  • apps/demo/package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
  • e2e/package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
  • package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
  • test/mosquitto/config/minica-key.pem is excluded by !**/*.pem
  • test/mosquitto/config/minica.pem is excluded by !**/*.pem
  • test/mosquitto/config/mosquitto.dev/cert.pem is excluded by !**/*.pem
  • test/mosquitto/config/mosquitto.dev/key.pem is excluded by !**/*.pem
  • test/qa-proxy-certs/cert.pem is excluded by !**/*.pem
  • test/qa-proxy-certs/key.pem is excluded by !**/*.pem
  • ui/package-lock.json is excluded by !**/package-lock.json, !**/package-lock.json
📒 Files selected for processing: 429
  • .github/tests/ci-verify-workflow.test.ts
  • .github/tests/readme-translations.test.ts
  • .github/tests/zap-rules-files.test.ts
  • .github/workflows/ci-verify.yml
  • .gitignore
  • .gitleaksignore
  • .grype.yaml
  • AGENTS.md
  • CONTRIBUTING.md
  • DEPRECATIONS.md
  • Dockerfile
  • GOVERNANCE.md
  • LICENSE
  • README.de.md
  • README.es.md
  • README.fr.md
  • README.md
  • README.pl.md
  • README.pt-BR.md
  • README.zh-CN.md
  • SECURITY.md
  • app/agent/AgentClient.test.ts
  • app/agent/AgentClient.ts
  • app/agent/PortwingDockerBridge.test.ts
  • app/agent/PortwingDockerBridge.ts
  • app/agent/agent-warnings.test.ts
  • app/agent/agent-warnings.ts
  • app/agent/api/event.test.ts
  • app/agent/api/event.ts
  • app/agent/api/trigger.test.ts
  • app/agent/api/trigger.ts
  • app/agent/components/AgentTrigger.test.ts
  • app/api/agent.test.ts
  • app/api/agent.ts
  • app/api/api-key-review.test.ts
  • app/api/api-key-review.ts
  • app/api/api-keys.test.ts
  • app/api/auth-types.d.ts
  • app/api/auth.test.ts
  • app/api/auth.ts
  • app/api/container-actions.ts
  • app/api/container-dependencies.test.ts
  • app/api/container-dependencies.ts
  • app/api/container-update-dispatch.ts
  • app/api/container.test.ts
  • app/api/container/crud.test.ts
  • app/api/container/crud.ts
  • app/api/container/shared.ts
  • app/api/container/triggers.test.ts
  • app/api/container/triggers.ts
  • app/api/dependency-groups.ts
  • app/api/helpers.ts
  • app/api/icons.ts
  • app/api/images.test.ts
  • app/api/images.ts
  • app/api/index.test.ts
  • app/api/index.ts
  • app/api/legacy-alias-diagnostics.test.ts
  • app/api/legacy-alias-diagnostics.ts
  • app/api/openapi-contract.test.ts
  • app/api/openapi-contract.ts
  • app/api/openapi-scopes.test.ts
  • app/api/openapi/paths/auth.ts
  • app/api/openapi/paths/containers.ts
  • app/api/openapi/schemas.ts
  • app/api/preview-errors.ts
  • app/api/stats.test.ts
  • app/api/stats.ts
  • app/api/totp-session.test.ts
  • app/api/totp-session.ts
  • app/api/trigger.test.ts
  • app/api/trigger.ts
  • app/authentications/providers/oidc/Oidc.test.ts
  • app/authentications/providers/oidc/Oidc.ts
  • app/authentications/providers/oidc/bearer-binding.test.ts
  • app/authentications/providers/oidc/bearer-binding.ts
  • app/debug/dump.test.ts
  • app/debug/dump.ts
  • app/event/index.ts
  • app/model/audit.d.ts
  • app/notifications/outbox-worker.ts
  • app/package.json
  • app/prometheus/compatibility.test.ts
  • app/prometheus/compatibility.ts
  • app/registries/BaseRegistry.test.ts
  • app/registries/BaseRegistry.ts
  • app/registries/BaseRegistry.typecheck.ts
  • app/registries/http-retry.ts
  • app/registries/providers/acr/Acr.ts
  • app/registries/providers/alicr/Alicr.ts
  • app/registries/providers/custom/Custom.test.ts
  • app/registries/providers/custom/Custom.ts
  • app/registries/providers/dhi/Dhi.ts
  • app/registries/providers/docr/Docr.ts
  • app/registries/providers/ecr/Ecr.test.ts
  • app/registries/providers/ecr/Ecr.ts
  • app/registries/providers/gar/Gar.ts
  • app/registries/providers/gcr/Gcr.ts
  • app/registries/providers/ghcr/Ghcr.ts
  • app/registries/providers/gitlab/Gitlab.test.ts
  • app/registries/providers/gitlab/Gitlab.ts
  • app/registries/providers/hub/Hub.ts
  • app/registries/providers/ibmcr/Ibmcr.ts
  • app/registries/providers/mau/Mau.ts
  • app/registries/providers/ocir/Ocir.ts
  • app/registries/providers/quay/Quay.ts
  • app/registries/providers/shared/SelfHostedBasic.ts
  • app/registries/token-bucket.ts
  • app/registry/Component.test.ts
  • app/registry/Component.ts
  • app/registry/component-resolution.ts
  • app/registry/index.test.ts
  • app/release-notes/index.test.ts
  • app/release-notes/index.ts
  • app/release-notes/providers/GithubProvider.ts
  • app/security/assets.test.ts
  • app/security/assets.ts
  • app/security/scan.ts
  • app/stats/aggregator.ts
  • app/store/container.test.ts
  • app/store/container.ts
  • app/store/db/import.ts
  • app/store/update-operation.lifecycle-events.test.ts
  • app/store/update-operation.ts
  • app/triggers/notification-skips.test.ts
  • app/triggers/notification-skips.ts
  • app/triggers/providers/Trigger.skip-reasons.test.ts
  • app/triggers/providers/Trigger.ts
  • app/triggers/providers/docker/ContainerUpdateExecutor.ts
  • app/triggers/providers/docker/Docker.test.ts
  • app/triggers/providers/docker/HookExecutor.test.ts
  • app/triggers/providers/docker/HookExecutor.ts
  • app/triggers/providers/docker/compose-file-sync.ts
  • app/triggers/providers/docker/self-update-controller.ts
  • app/triggers/providers/dockercompose/Dockercompose.compose-exec-and-hooks.test.ts
  • app/triggers/providers/dockercompose/Dockercompose.restore-operations.test.ts
  • app/triggers/providers/dockercompose/Dockercompose.ts
  • app/triggers/providers/mqtt/Hass.ts
  • app/triggers/providers/portainer/Portainer.ts
  • app/updates/dependency-restart.ts
  • app/updates/post-start-liveness.ts
  • app/util/error.test.ts
  • app/util/error.ts
  • app/util/sleep.test.ts
  • app/util/sleep.ts
  • app/watchers/controller-local-container-ids.ts
  • app/watchers/providers/docker/container-processing-release-notes-budget.test.ts
  • app/watchers/providers/docker/container-processing.ts
  • app/watchers/providers/docker/docker-event-orchestration.ts
  • app/watchers/providers/docker/docker-events.ts
  • app/watchers/providers/docker/docker-image-details-orchestration.test.ts
  • app/watchers/providers/docker/docker-image-details-orchestration.ts
  • apps/demo/package.json
  • apps/demo/src/mocks/data/agents.ts
  • apps/demo/src/mocks/data/audit.ts
  • apps/demo/src/mocks/data/containers.ts
  • apps/demo/src/mocks/data/server.ts
  • apps/demo/src/mocks/handlers.contract.test.ts
  • apps/demo/src/mocks/handlers/app.ts
  • apps/demo/src/mocks/handlers/fonts.ts
  • apps/web/scripts/content-security-policy.test.mjs
  • apps/web/scripts/marketing-copy.test.mjs
  • apps/web/src/app/data/faq.ts
  • apps/web/src/app/page.tsx
  • apps/web/src/components/footer.tsx
  • apps/web/src/components/github-badges.tsx
  • apps/web/src/components/hero.tsx
  • apps/web/src/lib/comparison-route-data/arcane.tsx
  • apps/web/src/lib/comparison-route-data/diun.tsx
  • apps/web/src/lib/comparison-route-data/dockge.tsx
  • apps/web/src/lib/comparison-route-data/dockhand.tsx
  • apps/web/src/lib/comparison-route-data/dozzle.tsx
  • apps/web/src/lib/comparison-route-data/komodo.tsx
  • apps/web/src/lib/comparison-route-data/ouroboros.tsx
  • apps/web/src/lib/comparison-route-data/portainer.tsx
  • apps/web/src/lib/comparison-route-data/watchtower.tsx
  • apps/web/src/lib/comparison-route-data/wud.tsx
  • apps/web/src/lib/comparison-route.tsx
  • apps/web/src/lib/site-config.ts
  • apps/web/src/lib/site-content.ts
  • apps/web/src/proxy.ts
  • biome.json
  • content/docs/README.md
  • content/docs/current/api/agent.mdx
  • content/docs/current/api/app.mdx
  • content/docs/current/api/container.mdx
  • content/docs/current/api/index.mdx
  • content/docs/current/api/portwing.mdx
  • content/docs/current/api/store.mdx
  • content/docs/current/configuration/actions/index.mdx
  • content/docs/current/configuration/agents/index.mdx
  • content/docs/current/configuration/authentications/basic/index.mdx
  • content/docs/current/configuration/authentications/index.mdx
  • content/docs/current/configuration/authentications/oidc/index.mdx
  • content/docs/current/configuration/authentications/two-factor/index.mdx
  • content/docs/current/configuration/config-file/index.mdx
  • content/docs/current/configuration/dns/index.mdx
  • content/docs/current/configuration/groups/index.mdx
  • content/docs/current/configuration/hooks/index.mdx
  • content/docs/current/configuration/index.mdx
  • content/docs/current/configuration/logs/index.mdx
  • content/docs/current/configuration/registries/acr/index.mdx
  • content/docs/current/configuration/registries/alicr/index.mdx
  • content/docs/current/configuration/registries/artifactory/index.mdx
  • content/docs/current/configuration/registries/codeberg/index.mdx
  • content/docs/current/configuration/registries/custom/index.mdx
  • content/docs/current/configuration/registries/dhi/index.mdx
  • content/docs/current/configuration/registries/docr/index.mdx
  • content/docs/current/configuration/registries/ecr/index.mdx
  • content/docs/current/configuration/registries/forgejo/index.mdx
  • content/docs/current/configuration/registries/gar/index.mdx
  • content/docs/current/configuration/registries/gcr/index.mdx
  • content/docs/current/configuration/registries/ghcr/index.mdx
  • content/docs/current/configuration/registries/gitea/index.mdx
  • content/docs/current/configuration/registries/gitlab/index.mdx
  • content/docs/current/configuration/registries/harbor/index.mdx
  • content/docs/current/configuration/registries/hub/index.mdx
  • content/docs/current/configuration/registries/ibmcr/index.mdx
  • content/docs/current/configuration/registries/lscr/index.mdx
  • content/docs/current/configuration/registries/mau/index.mdx
  • content/docs/current/configuration/registries/nexus/index.mdx
  • content/docs/current/configuration/registries/ocir/index.mdx
  • content/docs/current/configuration/registries/quay/index.mdx
  • content/docs/current/configuration/registries/trueforge/index.mdx
  • content/docs/current/configuration/rollback/index.mdx
  • content/docs/current/configuration/security/index.mdx
  • content/docs/current/configuration/self-update/index.mdx
  • content/docs/current/configuration/server/index.mdx
  • content/docs/current/configuration/storage/index.mdx
  • content/docs/current/configuration/timezone/index.mdx
  • content/docs/current/configuration/triggers/apprise/index.mdx
  • content/docs/current/configuration/triggers/command/index.mdx
  • content/docs/current/configuration/triggers/discord/index.mdx
  • content/docs/current/configuration/triggers/docker-compose/index.mdx
  • content/docs/current/configuration/triggers/docker/index.mdx
  • content/docs/current/configuration/triggers/google-chat/index.mdx
  • content/docs/current/configuration/triggers/gotify/index.mdx
  • content/docs/current/configuration/triggers/http/index.mdx
  • content/docs/current/configuration/triggers/ifttt/index.mdx
  • content/docs/current/configuration/triggers/index.mdx
  • content/docs/current/configuration/triggers/kafka/index.mdx
  • content/docs/current/configuration/triggers/matrix/index.mdx
  • content/docs/current/configuration/triggers/mattermost/index.mdx
  • content/docs/current/configuration/triggers/mqtt/index.mdx
  • content/docs/current/configuration/triggers/ntfy/index.mdx
  • content/docs/current/configuration/triggers/portainer/index.mdx
  • content/docs/current/configuration/triggers/pushover/index.mdx
  • content/docs/current/configuration/triggers/rocketchat/index.mdx
  • content/docs/current/configuration/triggers/slack/index.mdx
  • content/docs/current/configuration/triggers/smtp/index.mdx
  • content/docs/current/configuration/triggers/teams/index.mdx
  • content/docs/current/configuration/triggers/telegram/index.mdx
  • content/docs/current/configuration/watchers/index.mdx
  • content/docs/current/configuration/webhooks/index.mdx
  • content/docs/current/deprecations/index.mdx
  • content/docs/current/faq/index.mdx
  • content/docs/current/getting-started/index.mdx
  • content/docs/current/guides/security/index.mdx
  • content/docs/current/index.mdx
  • content/docs/current/monitoring/index.mdx
  • content/docs/current/quickstart/index.mdx
  • content/docs/current/updates/index.mdx
  • docker/zlib/APKBUILD
  • docker/zlib/CVE-2026-85091.patch
  • docker/zlib/README.md
  • docker/zlib/fixed.vex.json
  • docker/zlib/write-error-state.c
  • docs/README.md
  • e2e/package.json
  • e2e/tests/security/yaml-lockfile.test.js
  • package.json
  • scripts/changelog-links.test.mjs
  • scripts/check-image-arch.sh
  • scripts/check-image-arch.test.mjs
  • scripts/ci-timing-summary.mjs
  • scripts/ci-timing-summary.test.mjs
  • scripts/community-docs.test.mjs
  • scripts/fixtures/ci-timing-jobs.json
  • scripts/fixtures/ci-timing-runs.json
  • scripts/patch-artillery-csv.mjs
  • scripts/release-docs-archive.test.mjs
  • scripts/release-docs-identity.test.mjs
  • scripts/release-identity.test.mjs
  • scripts/release-precut-check.test.mjs
  • scripts/security-dependency-versions.test.mjs
  • test/generate-tls-fixtures.sh
  • test/generate-tls-fixtures.test.sh
  • test/migration-test-compose.yml
  • test/migration/tls.crt
  • test/migration/tls.key
  • test/mosquitto/config/mosquitto.conf
  • test/qa-proxy-compose.yml
  • test/qa-traefik-compose.yml
  • test/server.crt
  • test/server.key
  • ui/package.json
  • ui/src/components/config/ConfigApiKeysTab.vue
  • ui/src/components/containers/ContainerFullPageTabContent.vue
  • ui/src/components/containers/ContainerSideTabContent.vue
  • ui/src/composables/useApiKeyReview.ts
  • ui/src/composables/useOperationDisplayHold.ts
  • ui/src/composables/useSearchFromQuery.ts
  • ui/src/layouts/AppLayout.vue
  • ui/src/locales/ar/agentsView.json
  • ui/src/locales/ar/appShell.json
  • ui/src/locales/ar/configView.json
  • ui/src/locales/ar/containerComponents.json
  • ui/src/locales/ar/listViews.json
  • ui/src/locales/de/agentsView.json
  • ui/src/locales/de/appShell.json
  • ui/src/locales/de/configView.json
  • ui/src/locales/de/containerComponents.json
  • ui/src/locales/de/listViews.json
  • ui/src/locales/en/agentsView.json
  • ui/src/locales/en/appShell.json
  • ui/src/locales/en/configView.json
  • ui/src/locales/en/containerComponents.json
  • ui/src/locales/en/listViews.json
  • ui/src/locales/es/agentsView.json
  • ui/src/locales/es/appShell.json
  • ui/src/locales/es/configView.json
  • ui/src/locales/es/containerComponents.json
  • ui/src/locales/es/listViews.json
  • ui/src/locales/fr/agentsView.json
  • ui/src/locales/fr/appShell.json
  • ui/src/locales/fr/configView.json
  • ui/src/locales/fr/containerComponents.json
  • ui/src/locales/fr/listViews.json
  • ui/src/locales/it/agentsView.json
  • ui/src/locales/it/appShell.json
  • ui/src/locales/it/configView.json
  • ui/src/locales/it/containerComponents.json
  • ui/src/locales/it/listViews.json
  • ui/src/locales/ja/agentsView.json
  • ui/src/locales/ja/appShell.json
  • ui/src/locales/ja/configView.json
  • ui/src/locales/ja/containerComponents.json
  • ui/src/locales/ja/listViews.json
  • ui/src/locales/ko/agentsView.json
  • ui/src/locales/ko/appShell.json
  • ui/src/locales/ko/configView.json
  • ui/src/locales/ko/containerComponents.json
  • ui/src/locales/ko/listViews.json
  • ui/src/locales/nl/agentsView.json
  • ui/src/locales/nl/appShell.json
  • ui/src/locales/nl/configView.json
  • ui/src/locales/nl/containerComponents.json
  • ui/src/locales/nl/listViews.json
  • ui/src/locales/pl/agentsView.json
  • ui/src/locales/pl/appShell.json
  • ui/src/locales/pl/configView.json
  • ui/src/locales/pl/containerComponents.json
  • ui/src/locales/pl/listViews.json
  • ui/src/locales/pt-BR/agentsView.json
  • ui/src/locales/pt-BR/appShell.json
  • ui/src/locales/pt-BR/configView.json
  • ui/src/locales/pt-BR/containerComponents.json
  • ui/src/locales/pt-BR/listViews.json
  • ui/src/locales/ru/agentsView.json
  • ui/src/locales/ru/appShell.json
  • ui/src/locales/ru/configView.json
  • ui/src/locales/ru/containerComponents.json
  • ui/src/locales/ru/listViews.json
  • ui/src/locales/tr/agentsView.json
  • ui/src/locales/tr/appShell.json
  • ui/src/locales/tr/configView.json
  • ui/src/locales/tr/containerComponents.json
  • ui/src/locales/tr/listViews.json
  • ui/src/locales/uk/agentsView.json
  • ui/src/locales/uk/appShell.json
  • ui/src/locales/uk/configView.json
  • ui/src/locales/uk/containerComponents.json
  • ui/src/locales/uk/listViews.json
  • ui/src/locales/vi/agentsView.json
  • ui/src/locales/vi/appShell.json
  • ui/src/locales/vi/configView.json
  • ui/src/locales/vi/containerComponents.json
  • ui/src/locales/vi/listViews.json
  • ui/src/locales/zh-CN/agentsView.json
  • ui/src/locales/zh-CN/appShell.json
  • ui/src/locales/zh-CN/configView.json
  • ui/src/locales/zh-CN/containerComponents.json
  • ui/src/locales/zh-CN/listViews.json
  • ui/src/locales/zh-TW/agentsView.json
  • ui/src/locales/zh-TW/appShell.json
  • ui/src/locales/zh-TW/configView.json
  • ui/src/locales/zh-TW/containerComponents.json
  • ui/src/locales/zh-TW/listViews.json
  • ui/src/router/index.ts
  • ui/src/services/auth.ts
  • ui/src/services/container.ts
  • ui/src/stores/eventStream.ts
  • ui/src/stores/operations.ts
  • ui/src/stores/operations.typecheck.ts
  • ui/src/types/api.d.ts
  • ui/src/views/AgentsView.vue
  • ui/src/views/AuditView.vue
  • ui/src/views/AuthView.vue
  • ui/src/views/ContainersView.vue
  • ui/src/views/NotificationsView.vue
  • ui/src/views/RegistriesView.vue
  • ui/src/views/TriggersView.vue
  • ui/src/views/WatchersView.vue
  • ui/src/views/containers/useContainerActions.ts
  • ui/src/views/containers/useContainerTriggers.ts
  • ui/src/views/dashboard/useDashboardData.ts
  • ui/tests/components/ConfigApiKeysTab.spec.ts
  • ui/tests/components/ContainerSideTabContent.spec.ts
  • ui/tests/components/containers/ContainerFullPageTabContent.spec.ts
  • ui/tests/composables/useApiKeyReview.spec.ts
  • ui/tests/composables/useSearchFromQuery.spec.ts
  • ui/tests/coverage-contract.spec.ts
  • ui/tests/helpers/audit-actions.ts
  • ui/tests/helpers/operations.ts
  • ui/tests/i18n/audit-action-catalog.spec.ts
  • ui/tests/layouts/AppLayout.spec.ts
  • ui/tests/router/index.spec.ts
  • ui/tests/services/auth.spec.ts
  • ui/tests/services/container.spec.ts
  • ui/tests/stores/operationStore.seed.spec.ts
  • ui/tests/utils/audit-helpers.spec.ts
  • ui/tests/views/AgentsView.spec.ts
  • ui/tests/views/ContainersView.applyContainerPatch.spec.ts
  • ui/tests/views/ContainersView.fullPageRemoval.spec.ts
  • ui/tests/views/ContainersView.spec.ts
  • ui/tests/views/containers/useContainerActions.spec.ts
  • ui/tests/views/dashboard/useDashboardData.spec.ts
  • ui/tests/vitest.config.spec.ts
  • ui/vitest.config.ts

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread app/api/legacy-alias-diagnostics.ts Fixed
@codecov

codecov Bot commented Oct 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@scttbnsn
scttbnsn force-pushed the release/v1.8-rc3-main branch from d5d7b75 to 6909b9b Compare October 20, 2026 19:51
@vercel

vercel Bot commented Oct 20, 2026

Copy link
Copy Markdown

Deployment failed for project drydockdemo-website with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit

@vercel

vercel Bot commented Oct 20, 2026

Copy link
Copy Markdown

Deployment failed for project drydock-website with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit

@biggest-littlest biggest-littlest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tree matches dev/v1.8 at 87d20e9; local gate green.

@ALARGECOMPANY ALARGECOMPANY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tree matches dev/v1.8 at 87d20e9; local gate green.

@scttbnsn
scttbnsn merged commit 9fed1bd into main Oct 20, 2026
33 of 36 checks passed
@scttbnsn
scttbnsn deleted the release/v1.8-rc3-main branch October 20, 2026 20:36
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.

4 participants