Skip to content

Unreleased.

Changes on main awaiting a release. Availability may depend on an updated agent and explicit runtime acceptance.

This is not a published release. The installer uses the latest published tag by default; --channel main explicitly selects edge code.

Added

  • Terminal, file browser, inspect and metrics for Swarm replicas on worker nodes. They used to work only for a replica on the panel host, and answered swarm_task_elsewhere for one on a worker. Now a replica on a worker that joined the swarm through NineDeploy is reached through that node's agent, with the same gates as any node terminal.
    • The panel asks the Swarm manager which node runs the replica and which container it is. The worker must confirm that the container belongs to this task and this service before anything opens.
    • A terminal stays pinned to that task. If Swarm moves the replica, the old session ends instead of attaching to the new one.
    • A replica on a Swarm node NineDeploy does not manage, or one that is not running, is still refused, with the reason. One-off commands and jobs still run on a replica on the panel host. A terminal on a worker's replica needs the worker's container tools too (the identity check), so a node owner who turned those off gets that refusal.
    • Metrics count every running replica, wherever it runs, through each worker's agent. If any running replica could not be measured (a worker that did not answer, a replica on a node NineDeploy does not manage), the service has no figure for that moment, never a partial one or zero. A replica Swarm cannot place yet does not count.
    • Behaviour change: a Swarm service with a replica on a node NineDeploy does not manage now shows no metrics, and its metric alerts are not evaluated, where it used to show the panel host's average.
    • Checked on two separate Docker daemons: with both replicas on the worker, a terminal ran in the worker's container, inspect and files reached it, and the metrics were measured; with the worker's agent stopped the metrics were unknown; after a forced reschedule a session made for the old task did not attach.
  • Pre-deploy and post-deploy hooks for docker services on a node. They used to be refused on a node, because hooks ran as a shell on the panel host. On a node they now run in a one-off container from the release image, with the service's environment, network and node databases, much like a release command on Heroku or Render.
    • Pre-deploy runs once the image is on the node and before any new container starts. If it fails, the deploy fails and the running release keeps serving. Post-deploy runs from the new release's image once it is healthy; as on the panel host, its failure is only logged.
    • The command runs as on the panel host: its first word replaces the image's entrypoint, with no shell. The container gets no port, volume or Docker socket, is not privileged, and is removed afterwards, also after a timeout or a cancel. Its output reaches the deploy log with secrets masked.
    • The node's agent must be v0.18.27 or newer. Its owner can turn it off with NINEDEPLOY_AGENT_DEPLOY_HOOKS=off. An older agent keeps refusing every hook, and a pre-stop hook stays refused on a node, because it acts on the running container.
  • The restart-loop limit for docker services on a node. It used to be refused on a node, because the panel only read restart counts on its own host. Now the node's agent reports them.
    • Each replica is resolved by name and proved to belong to this deployment of this service before its count is read. The count is read even while the container is restarting.
    • When the limit trips, the panel requests the same ordered Stop as the Stop button. The node carries it out, and service.stop is audited once it confirms. service.restart_limit notes stopRequested.
    • It never cancels a deploy, including one waiting for CI or an approval, and waits while a Start, Restart, sleep or wake is pending. An unreachable node counts nothing and loses nothing.
    • It works with any agent that has ordered lifecycle (v0.18.3 or newer); turning the limit on asks the agent first. A service that also runs on other nodes (fan-out) is still refused.
    • Checked live on a node: one crash did not trip; three did, and the node stopped the container and kept it stopped.
  • Maintenance mode and custom error pages for services on a node. Until now both were refused for a node service, because a node's proxy cannot reach the panel that serves the pages. Now the node serves them itself.
    • The agent runs a small page server, ninedeploy-pages, from its own image. It runs read-only, as nobody, with no published port.
    • The panel renders each page and sends it to the agent. Only once the page server answers does the node's proxy route to it. If that fails, or the node cannot be reached, the service keeps reaching the app and the save changes nothing (502 node_pages_unapplied).
    • A page is served only on the service's own domains, as on the panel host. Maintenance also wins over a sleeping node service.
    • A service in maintenance, or with custom error pages, can now move to such a node. Fan-out services still cannot use either.
    • The node's agent must be v0.18.27 or newer and run as the ninedeploy-agent container. The node's owner can turn it off with NINEDEPLOY_AGENT_NODE_PAGES=off. An older agent is refused with 422 node_agent_outdated, and nothing is saved.
    • Checked live on a node running the agent the documented way.
  • A compact panel layout, to try before it becomes the default. Turn it on under Settings → Appearance → Layout. Nothing changes until you do. It is kept per browser, like the theme.
    • The service page shows six tabs: Overview, Deploys, Environment, Domains, Logs and Settings. The other nine sit under More, which opens by itself when a link leads to one of them. Every ?tab= link still works.
    • Logs shows the running container's output above the activity log.
    • Settings folds the cards a service rarely needs under More settings: target node, build placement, orchestrator, inline Dockerfile, container options, canary, status badge, registry webhook, commit status, restart limit, and maintenance and error pages. A card whose feature is on stays out of the fold, so maintenance mode or a restart limit is never hidden.
    • The sidebar merges Hub into New and moves Networks inside Topology, behind a link shown in Advanced mode. Queue stays, because it is the only list of deploys waiting for approval across services.
    • The browser journeys now also run in the compact layout: environment and rollback, approval, the compact-only views, and every phone page.
    • A coming release makes compact the default and removes the classic layout.
  • A separate manual macOS workflow records the complete configured server unit suite and its file/config manifest, alongside controlled CLI and workspace MCP checks (round 100).
  • Manual macOS server audit workflow records native filesystem, SQLite and controlled Docker CLI regressions without starting a daemon or desktop GUI (round 89).
  • A read-only import plan for Coolify, Dokploy and CapRover. ninedeploy import plan <coolify|dokploy|caprover> --url … reads the other platform's API from your machine and lists what NineDeploy would create:
    • services, with their repository, image or compose source, build pack, port, limits, domains and first volume;
    • environment variable names;
    • databases, with the matching engine.
    • Every option NineDeploy cannot carry, or that needs a person, is listed per item.
    • It creates nothing and changes nothing on the source. Applying a plan (step 2) and moving data (step 3) come later.
    • The credential comes from NINEDEPLOY_IMPORT_TOKEN / NINEDEPLOY_IMPORT_PASSWORD or a hidden prompt, never from the command line, and stays on your machine.
    • A plan holds variable names only, never a value, password, token or key, and every URL loses its user:password@.
    • The mappings were written against Coolify v4.7.0, Dokploy v0.31.0 and CapRover v1.16.0 and are tested with fixtures derived from their source. CapRover v1.16.0 was also checked against a live instance. That run found three gaps the fixtures missed, now fixed: a never-deployed app's placeholder image, stopped apps, and the source version. Coolify and Dokploy have not been checked live yet. See docs/IMPORTING.md.
  • ninedeploy import apply: create what an import plan describes. import apply plan.json shows what it would create; --yes creates it on the panel the CLI is signed in to. It reads the saved plan only, never the source.
    • It creates each service that has an image or a repository, with its port, replicas, limits and first volume. Databases are created empty, on the source's version when the source ran the same image.
    • It never deploys and never adds a domain. Adding a domain requests a certificate, and with the Cloudflare integration on it also writes DNS, both while DNS still points at the old platform. Domains move at the cutover (step 3).
    • Environment values come only from --dotenv <service>=<file>, through the panel's .env import. The flag is not --env-file, because Node reads that flag itself from anywhere on the command line.
    • A receipt next to the plan records each item's state and id, never a value. Running the command again repeats only what is not done, and a run that stopped halfway picks its items up again without duplicates.
    • Checked live: a plan of a real CapRover v1.16.0, applied to a disposable panel, then a resume after a simulated crash.
  • ninedeploy import data: move database contents from Coolify, Dokploy or CapRover.
    • Without --yes it prints the order that loses no writes: lower the TTL, stop writes, take the final dumps, import, deploy and check health, then switch DNS and add the domains.
    • It also prints the dump command to run on the source host for each database. The command reads credentials from the database container's own environment, so no password appears in the output or the shell history.
    • --dump <database>=<file> --yes imports through the panel's chunked dump import with the file's sha256 declared, which the panel checks before restoring.
    • The receipt keeps the import id, hash and size. A stopped upload resumes the same import, and a database is never imported twice.
    • Checked live: a CapRover v1.16.0 PostgreSQL 16 app with 500 rows, dumped with the printed command, arrived with the same row count and checksum.
    • Plans now record where each database runs on the source (runtime). The live run showed that CapRover 1.16 names an app's Swarm service after the app; only legacy apps keep the srv-captain-- prefix.
  • ninedeploy import apply attaches the new database where a copied connection string still names the old one. A --dotenv file is read on your machine. A value such as DATABASE_URL=postgres://…@srv-captain--pg:5432/… that names a source database's host gets the new database attached to the service under that variable, and the attachment sets it at every deploy.
    • It attaches only where the panel's connection string can stand in exactly: postgres://, mysql:// or mariadb:// without ?options, or redis:// on database 0.
    • Every other match is listed with the reason, for a person to change: another scheme spelling, options, a Redis database index, Mongo, a host-only variable, or the old platform's own host (NineDeploy can still reach it, so the app would quietly keep using the old database).
    • Only variable, database and host names reach the output and the receipt. A rerun attaches nothing twice and counts an attachment made by hand. A variable another database already sets is refused. The panel attaches a database to a service under one variable, so when two variables name the same database (Prisma's DATABASE_URL and DIRECT_URL), the second is listed for a person instead of recorded as attached.
    • Checked live: after apply and a deploy, the container's DATABASE_URL named the new database, and the two listed variables kept their copied values.
  • ninedeploy import plan heroku: a plan from a Heroku app's app.json and Procfile. It needs no credential and reads no Heroku API.
    • Each Procfile process becomes a service built from the repository with Nixpacks, with that process's line as its start command. Checked live on a panel: Nixpacks runs the command through a shell, so $PORT expands, and sets PORT to the service port.
    • Workers get the running health check and no domain. release is not mapped to the panel's pre-deploy hook, which runs in the panel's container: Nixpacks runs the Procfile's release line as a build step of each service, which the plan explains. formation quantities become replicas.
    • The heroku-postgresql and heroku-redis add-ons become databases, attached to every service as DATABASE_URL and REDIS_URL. import apply creates those attachments and sets the worker health check. import data prints the heroku pg:backups download for Postgres.
    • Config var values come from heroku config --shell through --dotenv. scripts.postdeploy, heroku.yml, custom buildpacks, other add-ons and dyno sizes are listed for a person.
    • Plans gain optional settings, attachments and a heroku-app runtime; they stay planVersion 1.
  • Service volume import, and ninedeploy import data --volume. A gzipped tar of another host's volume can now be restored into a service's data volume through the panel (POST /v1/services/:id/volume-imports; SDK volumeImports).
    • The archive is uploaded in chunks with its sha256, which the panel checks before the volume is touched. A file that is not a gzipped tar is refused.
    • The import is refused while the service runs or deploys. A volume that already exists is first snapshotted as a pre-import volume backup.
    • It restores through the same staged swap as a volume backup restore, so owners, modes and symlinks are kept, and a bad archive leaves the volume as it was.
    • It is for operators, and for Docker services on the panel host with a volume mount. Swarm services, compose stacks, node services and services spread over several hosts are refused.
    • import data prints the command to run on the source host. A named volume is mounted read-only by name, which works after the app is scaled to 0. Then --volume <service>=<file> --yes imports it with a resumable receipt and never imports twice.
    • Checked live: a CapRover v1.16.0 app volume arrived in the deployed service with the same files, owners, modes, symlink and checksums.
    • New table volume_imports (migration 0124, additive). Stale uploads expire after 24 hours, and finished rows are pruned after 90 days.
  • ninedeploy import data keeps names that came from the source platform out of the commands it prints unless they are plain Docker names. A name with shell syntax becomes a placeholder to fill in by hand, so pasting a command cannot run anything hidden in an app's name.
  • ninedeploy import cutover: add the imported domains once DNS points at NineDeploy. Without --yes it reports, host by host, what is ready; with --yes it adds the domains that pass two checks.
    • The service is running and its latest deploy succeeded. The command never deploys.
    • Every A and AAAA of the hostname already resolves to this panel: a direct DNS query (--resolver picks the server, the output names it), compared with --expect or Settings → DNS records. A leftover record that still names the old host holds it back and is named. A host that fails waits, so no certificate is requested while DNS still points at the old platform.
    • The source's own generated hosts are never added; wildcard hosts are listed for a person. A domain already on the panel counts as done, and a pending ownership proof is verified once DNS passes (its TXT value is printed, never stored).
    • --skip-dns-check <host> covers proxied records, and is refused while the Cloudflare integration is on or unknown, because that integration adds a second A record next to the old one.
    • Checked live on a disposable panel: refused without an expected address, waited while the service was idle, added a host resolving to the panel (Traefik served it), held back one resolving elsewhere, held back a host whose AAAA was not expected until --expect named it, and added nothing on a rerun.

Fixed

  • The stable channel never got its first tag. The daily Stable channel workflow failed on 10 and 11 November with Resource not accessible by integration (403) when it created the stable tag for v0.18.19. The job token had Contents write and the repository has no rulesets, so the most likely cause is GitHub's rule that the built-in token may not write a ref on a commit whose workflow files differ from main's, which every release commit's do once main moves on. The workflow now writes the tag with a STABLE_TAG_TOKEN repository secret: a fine-grained token for this repository with Contents and Workflows read and write. Without it, the run fails with an error that says so. Until a maintainer adds the secret, panels and installers on the stable channel find no stable tag.
  • Coolify import: a public repository is no longer flagged as needing private access. Checked live on Coolify 4.7.5, an app made from a public repository has Coolify's built-in "Public GitHub" source, so every such app was told to reconnect a GitHub App, token or deploy key it never had. Only another source is flagged now. A read token hides private_key_id, so an app on a deploy key is recognised by its SSH repository URL, and one with an HTTPS username is flagged too. The live run's responses are kept as a test fixture. They also confirmed the rest of the plan field for field, and that a read token is given no secret value. Nothing was deployed in that run, so the dump and volume commands import data prints for Coolify are still unrun: the names they use (the database's uuid, the uuid-prefixed volume name the API returned) come from Coolify's source.
  • Dokploy import: a rate-limited API key no longer looks like a wrong one. Checked live on Dokploy v0.31.0, a default API key is limited to 10 requests a day and was refused after 5 calls, with the same 401 as a wrong key. A plan makes 2 requests plus one per application, compose stack and database. The message now names both causes, and the docs say to create the key with rate limiting off. The same live run matched the plan to a seeded project field for field, kept as a test fixture. It also confirmed the Swarm service and volume names that the printed pg_dump and volume commands use: both ran on the Dokploy host.
  • Deploy logs no longer show secret values. A build tool can echo them. Nixpacks, for one, fills a $VAR of a build command into its RUN step, and BuildKit prints that step, so a Procfile release line referencing $DATABASE_URL put the attached database's password in the deploy log, which every member of the service can read. Each deployment now masks its secret variables, attached connection strings (and their password on its own) and resolved vault references as *** in every log line, on disk and live. Checked live: the same deploy that showed the password now shows echo "***". Values shorter than 6 characters are not masked. The image itself still carries the value in its build history, as Nixpacks writes it; keep secrets out of build commands.
  • Volume backup restores (and volume imports) no longer drop symlinks. The staged swap moved entries only when [ -e ] was true, which follows links. A link whose target was elsewhere, or not yet moved (link.html -> index.html), was silently left behind and deleted with the staging directory. Found by the volume import's live proof, and covered by a real-Docker test that fails without the fix.
  • ninedeploy import data now prints a MySQL/MariaDB dump command without --databases. That flag writes CREATE DATABASE and USE into the dump, and the panel restores the file as written. For an admin the import reported success but put every row in a second database beside the managed app database, which the app's connection string names; anyone else was refused. Checked live with MySQL 8.4: the old form put 500 rows into shopdb and left app empty, while the new form put them into app with the same checksum. Dumps taken with the earlier command should be taken again.
  • Swarm deployment regression tests now await database readiness and close their native database clients after every case (F1133).
  • CloudWatch log drains retain captured batches when HTTP 200 acknowledges rejected log events, preserving accepted-event and rejection-boundary handling (F1126).
  • Elasticsearch log drains send bulk NDJSON to the index bulk operation while preserving configured index, query parameters and authentication (F1124).
  • Elasticsearch drains retain queued logs when a bulk acknowledgement is unreadable or malformed instead of recording delivery success (F1122).
  • Elasticsearch drains recognize filtered per-item bulk errors even when the top-level error flag is omitted (F1123).
  • API-only servers now keep query credentials out of missing-route logs when the dashboard has not been built (F1125).
  • Failed OIDC discovery requests now release unused response bodies before returning the existing provider error (F1120).
  • GitHub OAuth token, profile and email refusals now release unused HTTP bodies while preserving diagnostics and unverified-email fallback behavior (F1121).
  • Teams and Resend success responses and Lark HTTP refusal responses now release unused bodies while retaining provider diagnostics and Lark application error handling (F1119).
  • Basic webhook, chat and push notification deliveries now release unused HTTP response bodies on success and refusal, preserving Telegram diagnostic text and existing channel payloads (F1118).
  • PagerDuty and Opsgenie deliveries now release unused success-response bodies before returning, while preserving provider refusal details and notification payloads (F1117).
  • GitLab, Gitea and Bitbucket commit-status reports now cancel unused response bodies before returning, releasing HTTP resources on success and failure without affecting deployments (F1116).
  • Git LFS builds now hydrate verified empty objects locally, avoiding a failed download or misleading credential error when no remote empty object is stored (F1115).
  • Git LFS builds now reject conflicting sizes for the same object before downloading or changing checkout files, instead of silently accepting the last pointer declaration (F1114).
  • Git LFS recognition now validates the complete basic-v1 pointer, preserving ordinary text with pointer-looking prefixes (F1112).
  • Git LFS hydration now accepts native-compatible CRLF, omitted final newlines and outer Unicode whitespace for basic-v1 pointers (F1113).
  • Workspace MCP readiness checks now remove token environment names case-insensitively before child startup, including Windows lowercase and mixed-case aliases (F1111).
  • MCP workspace installs now link a tracked launcher before build output exists, and macOS setup verifies the command after build (F1110).
  • Git LFS fetches now remove temporary objects on batch, stream, verification or checkout-copy failure before returning (F1109).
  • Autoscaler regression fixtures now await SQLite readiness and release owned native clients after each test, with permanent ownership guards (F1108).
  • Backup scheduler now refuses new dump admission after close, including callbacks resuming from pending reads and daily loops between databases (F1107).
  • Backup policy cron publication now ignores superseded reads from saves, periodic refreshes and startup while preserving independent database changes (F1106).
  • Backup drill history rows now include the database identity required by the SDK and already present on single-entry responses (F1105).
  • Structural backup checkers now retain a UUID helper identity and attempt cleanup when Docker commits a create but its CLI reply is lost (F1103).
  • Structural backup checker cleanup now also requests removal of helper-owned anonymous volumes (F1104).
  • Manifest probes now verify SHA-256/SHA-512 against bounded response bytes before scheduling updates and skip unsupported hash algorithms (F1102).
  • Manifest probes now reject malformed OCI content-digest metadata before it can seed an auto-update baseline or queue a deployment, preserving valid registered and opaque digest syntax (F1101).
  • Watch parsing now rejects malformed registry host text before it can alter manifest probe URI path, query, or fragment semantics (F1099).
  • Watch parsing now enforces the native 255-character repository-path limit with official Hub namespace normalization, while preserving registry and tag compatibility (F1100).
  • Watch parsing now rejects malformed image repository components before dispatching registry manifest probes, including empty and dot path segments (F1097).
  • Watch parsing now enforces Docker tag start syntax and the 128-character limit before dispatching a manifest probe (F1098).
  • Image registry classification now recognizes uppercase single-label hosts accepted by Docker CLI, preserving their bound credentials and watch parsing instead of treating them as Hub namespaces (F1096).
  • Explicit docker.io image credentials now use the default Hub login/logout operand so successful logout removes the canonical auth record instead of leaving it behind (F1095).
  • Registry sessions now serialize per host credential store across different registries, preventing Docker CLI snapshot saves from dropping another live login (F1094).
  • Cross-process lock identities now publish only after complete private preparation, so rejected initialization cannot leave a fresh public lock or delete a newer holder (F1093).
  • Cross-process operation locks now complete short native identity writes before granting a holder, preserving recognition and cleanup by its release operation (F1092).
  • Database-import chunks now complete short native writes before advancing upload metadata and fail without acknowledging a zero-progress write (F1091).
  • Concurrent database-import chunk retries now preserve acknowledged bytes by serializing native writes and rechecking upload metadata before writing (F1090).
  • A stale database-import refusal now returns a conflict without deleting staging owned by an admitted worker or emitting a false failure audit (F1089).
  • CLI database import retries reconcile known server jobs, resume interrupted uploads and avoid restarting completed restores after lost responses; recorded job IDs cannot be rebound to a different dump (F1088).
  • Bare :memory: SQLite transactions no longer borrow a nonexistent replacement connection; commit, rollback and foreign-key enforcement match file::memory: (F1087).
  • Failed reset-link replacement rolls back deletion and preserves the previously delivered token on precommit failure (F1086).
  • Reset-link issuance replaces pending tokens atomically through the managed transaction queue, so overlapping completed requests leave one pending token (F1085).
  • Restricted-port sidecar inspection now treats only exact target-specific missing-container diagnostics as absence, preserving config and avoiding replacement on unknown Docker errors (F1084).
  • Failed restricted-port sidecar removal now preserves its live config, propagates the original Docker error and avoids a false successful-removal log (F1083).
  • Restricted-port sidecar sync and removal now run in order per service slug, so an older prepared sync cannot recreate the sidecar after a later removal or overwrite newer routes (F1082).
  • Traffic collection now expires request buckets on advancing writes, bounding per-service history to the latest recorded 24-hour window even when no rate reader runs (F1081).
  • Latest request-time calculations no longer overflow the call stack on large valid bucket histories, preserving scale-to-zero idle decisions during long-running traffic collection (F1080).
  • Automatic image-update digest writes preserve completed opt-outs and replacement baselines across initial observation, refused update and already-admitted enqueue paths (F1079).
  • Automatic image updates recheck opt-in, running state, image/digest and in-flight work atomically when enqueuing, preserving operator changes and avoiding competing duplicate deployments (F1078).
  • Automatic image-update scheduler sweeps no longer overlap and enqueue the same image change twice while an earlier interval is still awaiting I/O (F1077).
  • Restart-loop samples cannot stop replacement runtimes or interrupt deployments admitted while sampling; rejected stale trips no longer contaminate later runtime baselines (F1076).
  • Restart-loop enforcement preserves newer operator policy changes during runtime sampling and does not recreate disabled limits or clear replacement history after an admitted stop (F1075).
  • Shared-certificate renewal checks no longer overlap while reading settings, and pending checks cannot start a proxy render after server close (F1074).
  • Pending runner jobs recheck queue expiry after asynchronous preparation and status/config reads, so an expired snapshot entry cannot consume a runner slot ahead of fresh work (F1073).
  • A PostgreSQL backup taken just after the database started no longer fails. The official image initializes with a temporary server and then restarts, while the container already shows as running. A Backup now click, or a scheduled backup, in those first seconds failed with pg_dump exit 1. A failed dump now waits up to 20 seconds for the server to accept connections over TCP, and retries once. A backup that succeeds the first time sends the same commands as before. The panel's backup toast now shows the reason the server gave, not just "Backup failed".
  • Runner job rechecks keep failed or unrecognized GitHub status reads pending without taking a slot; only confirmed queued jobs start and confirmed non-queued work is dropped (F1072).
  • Self-hosted GitHub runner upkeep no longer applies a GitHub-hosted six-hour age cutoff to busy or unverifiable runners; confirmed-idle/exited cleanup and explicit operator stop remain available (F1071).
  • Runner sweeps retain missing, malformed or unrecognized node/container states instead of treating them as confirmed non-running runners and automatically stopping them (F1070).
  • Queued GitHub runner jobs revalidate current repository, public-repository and label policy before taking a slot, so previously accepted work cannot bypass later runner restrictions (F1069).
  • GitHub runner batches stop admitting additional jobs when their configuration changes during preparation, retaining pending work for a fresh host/capacity pass (F1068).
  • GitHub runner drains recheck pending-job identity after asynchronous preparation of earlier jobs or online status checks, so later workflow events cannot leave stale jobs eligible to start (F1067).
  • Failed CI log file creation or append no longer consumes the chunk sequence; partial writes restore the prior file length and reservation so retries preserve accepted diagnostics (F1066).
  • CI log writes serialize admission and file append per attempt within the panel process, preserving chunk order and acknowledging overlapping retries from current persisted sequence state (F1065).
  • CI results that lose the job transition to cancellation return a conflict instead of success; cancellation closes the current attempt even when a competing result just finished its attempt write (F1064).

Source CHANGELOG.md at 6046cfe8