Compare commits

..
Author SHA1 Message Date
jschoubben 52d22f07d7 mesh-build-go: fail a change that adds an executable or a file over 5 MB in the lint step unless .binaries-allowed lists it; ignore every module's in-place build binary, and remove the two that were committed (issue 1921, try speed S13) 2026-10-25 02:06:05 +02:00
jschoubben 8f6a2b1f82 mesh-build-go: tests that a workspace is left with only its tree leave out the ask's own cache layer and slots, every such test at once (issue 1921, try speed S2, S10) 2026-10-25 01:46:01 +02:00
jschoubben a0fbd6bcb5 mesh-build-go: the real-go integration tests expect a lint finding as the lint step's, beside the tests (ADR 0542 §2; issue 1921, try speed S4) 2026-10-25 01:40:02 +02:00
jschoubben 5ce46cd197 mesh-build-go: report a lint finding as the lint step's, not the unit level's (ADR 0542 §2), and pin mesh-sdk's try speed-up head with main in it in both Go and Node build modules (issue 1921, try speed S4, S11) 2026-10-25 01:31:10 +02:00
jschoubben 8943a290f5 Merge remote-tracking branch 'origin/main' into feat/try-speed 2026-10-25 01:29:43 +02:00
jschoubben 537e2ef31f mesh-build-go: check with CGO_ENABLED=0 as production builds do, and let the lint's go vet, a compile alone, warm the shared cache for later checks (issue 1921, try speed S11) 2026-10-25 01:29:21 +02:00
jschoubben 545a172bbf mesh-build-go: bound the processes of an ask's levels together, the processors divided by the build capacity, through lock files in its workspace, now that the levels run side by side (issue 1921, try speed S10) 2026-10-25 01:26:50 +02:00
jschoubben 391095ccb9 mesh-build-go: run in the integration level only the tests its tags add, read from the source, so the unit tests are not run twice (issue 1921, try speed S7) 2026-10-25 01:13:21 +02:00
jschoubben 8536021f82 mesh-build-go: bring the mirror to the trunk commit so the merge preview deepens from it, and place the siblings side by side (issue 1921, try speed S6) 2026-10-25 01:10:19 +02:00
jschoubben 23307935b9 mesh-build-go: start the integration level's store and bus beside its compile, and compile the go.mod directories side by side without running a test binary (issue 1921, try speed S5) 2026-10-25 01:06:53 +02:00
jschoubben d5d6fd7412 mesh-build-go: run a unit level's lint beside its tests under one bound, so its wall time is the longer of the two; a lint failure still fails the level (issue 1921, try speed S4) 2026-10-25 01:04:35 +02:00
jschoubben 2e69099354 mesh-build-go: lint only the packages a unit level runs, in one vet pass with every tag unless a tag leaves a file out, so a small change's lint costs a few packages (issue 1921, try speed S3) 2026-10-25 01:02:08 +02:00
jschoubben 5c65b1f3dc mesh-build-go: give each ask a cache layer in its workspace, so what its vet compiles its unit tests and integration level find compiled (issue 1921, try speed S2) 2026-10-25 01:00:32 +02:00
mesh-tools 2167b95a36 Merge pull request 'R6: the gate keeps only the merge's states; walk, plan and step code and their verbs go (issue 1946)' (#475) from feat/r6 into main 2026-10-24 23:00:25 +00:00
jschoubben 4f074e87c7 mesh-build-go: say how long each phase of a fetch ran, so a check's "clone" time splits into mirror, fetch, preview, repack, siblings and modules (issue 1921, try speed S1) 2026-10-25 00:54:55 +02:00
jschoubben d09cbf1523 Ignore the binaries a local go build leaves in three module roots (issue 1943)
mesh/delivery-group group feat/r6 merged: every member is merged
mesh/check pass d09cbf152317 on 6fb0ceeef817 | unit 4378 run, 4719 kept, 15 skipped | integration 5060 run, 4917 kept, 23 skipped | over bound: unit, …
mesh/delivery delivering: 0 machine step(s) passed
2026-10-25 00:02:45 +02:00
jschoubben ac6712cbb5 Pin mesh-sdk to feat/r6 748c564, which carries main's composer (issue 1943) 2026-10-25 00:02:45 +02:00
jschoubben 1968aba6e0 mesh-build-go, mesh-build-node: run every package of a check's level after a failure (issue 1921)
Pins mesh-sdk fix/1921-every-failure, whose check ask no longer stops a
level at its first failing test step, so one check runs every package
and names every failure. A captured fixture with a failing test in each
of two packages proves the build module's test step names both.
2026-10-24 23:47:27 +02:00
jschoubben 5b32342ac2 Merge main into feat/r6: keep R6's gate, which already carries #474's catch-up at start, and drop the seat and walk adoption R6 removed
The reads-outside markers, mesh-build-go's compose job and gitea's forge-account fix come in from main.
2026-10-24 23:03:42 +02:00
jschoubben 6fb0ceeef8 Merge pull request 'R4g finish: mesh-build-go's compose job, the reads-outside marker in the digest, merge-check.sh gone (issues 1295, 1621-1623, 1935)' (#473) from feat/r4g-finish into main
Reviewed-on: #473
2026-10-24 20:56:49 +00:00
mesh-tools c651161787 Merge pull request 'gitea's forge-account reads the forge's own host key file before scanning its sshd, so a forge restart no longer refuses every forge account (issue 1939)' (#472) from fix/1939-forge-host-key into main 2026-10-24 20:05:39 +00:00
jschoubben ee216e4e63 Merge pull request 'The gate keeps a merge it hears before it holds its seat and catches source.main up when it does, so a missed merge no longer leaves the checker on an old trunk (issue 1726)' (#474) from fix/1726-trunk-catch-up into main
Reviewed-on: #474
2026-10-24 19:47:47 +00:00
jschoubben b67b5af5e6 Pin the gate to mesh-sdk feat/r6 at d7a9b968 (issue 1946) 2026-10-24 21:18:26 +02:00
jschoubben d700ceca20 Pin the gate to mesh-sdk feat/r6 at 7f9cc0e5 (issue 1946) 2026-10-24 20:43:41 +02:00
jschoubben 3a1017f559 Pin the gate to mesh-sdk feat/r6 at e45bdc08 (issue 1946) 2026-10-24 20:22:07 +02:00
jschoubben a17d0c8e93 The messenger holds and tells what the delivery view says is in motion, not the controller's plans (issue 1946) 2026-10-24 18:25:38 +02:00
jschoubben 9fa6710296 Pin the gate to mesh-sdk feat/r6 at a523a8cb (issue 1946) 2026-10-24 18:18:06 +02:00
jschoubben 05f849979c Match a fix to a delivered revision by what the gate says of its merge; keep #474's source catch-up at start (issues 1946, 1726) 2026-10-24 18:13:40 +02:00
jschoubben 4dee77df23 Pin the gate to the head of mesh-sdk feat/r6 (issue 1946) 2026-10-24 17:54:30 +02:00
jschoubben 8a79877a15 A newer delivered revision covers the older merges of its repository, as the forge says ancestry (issue 1946) 2026-10-24 17:03:20 +02:00
jschoubben 2139e83a8a Remove a binary built by mistake 2026-10-24 16:58:33 +02:00
jschoubben e7ca8f7af1 The backup module says on the bus when a backup asked for now has ended, so no one polls backed-up (issue 1944, ADR 0539 §16) 2026-10-24 16:58:19 +02:00
jschoubben eb79996336 The tracker derives a fix's delivered from the delivery view's event, not the gate's gone states (issue 1946) 2026-10-24 16:50:50 +02:00
jschoubben 1a271a1fb1 The group's integration test hears the forge's merges once both members are handed (issue 1946, R6-C3) 2026-10-24 16:43:48 +02:00
jschoubben 30d04f6bef Pin the gate to mesh-sdk's release 6 verb tables and prove it holds the seat as they have it (issue 1946, R6-C3)
The integration tests hear the forge's own word that a pull request merged, as the real flow does, and fail on any call of the controller.
2026-10-24 16:35:15 +02:00
jschoubben 5ff337e0dc Gate keeps the merge's six states and asks the controller nothing (issue 1946, R6-C1, C2, C4)
Release 6 deletes the plans: the walk half of the holder, the controller port, the carry-over of the former module and the verbs times, what-if, release, stop and retire-history go; the table holds proposed, ready, rejected, merged, held and closed; the delivery's status and view name the source revision the merge moved.
2026-10-24 16:26:36 +02:00
jschoubben 2fabbe556e Pin to the group's new mesh-sdk head bfc3883a, whose composer reads a fallback to a predefined key as the indexer does (ADR 0348 §7)
mesh/check checking 2fabbe556e73 on 6fb0ceeef817 | novox/mesh-catalog#473 in group feat/r4g-finish
mesh/delivery delivered
mesh/delivery-group group feat/r4g-finish merged: every member is merged
2026-10-24 14:57:35 +02:00
jschoubben 89b9212c4d Pin to the group's new mesh-sdk head 19c1d359, whose composer names the reader's pinned SDK instead of a bare (devel) (ADR 0482 clause 5)
mesh/check failed: shanks: novox/mesh-catalog#473: modules/mesh-build-go/go.mod names mesh-sdk 19c1d35939bc, which is neither on mesh-sdk's trunk nor …
mesh/delivery superseded: a newer head of the same pull request
2026-10-24 14:12:09 +02:00
jschoubben 40da368ee3 Date an adopted merge once, by the walk it was adopted from, so a later walk it follows never makes it look newer than a forge merge heard after it (issue 1726, review)
mesh/check pass 40da368ee34e on ee216e4e63f1 | unit 246 run, 4189 kept | integration 228 run, 4249 kept | over bound: unit, integration (slowest unit …
mesh/delivery delivered
2026-10-24 13:55:10 +02:00
jschoubben 865f3b6455 Say what pacman-tools reads outside: the root predefined.json (issue 1935)
mesh/delivery-group group feat/r4g-finish rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: shanks: novox/mesh-catalog#473: modules/mesh-build-go/go.mod names mesh-sdk bc0c25e51052, which is neither on mesh-sdk's trunk nor …
2026-10-24 13:48:18 +02:00
jschoubben 03524ecd13 Mark pacman-tools as reading outside its directory: its upgrade-order test reads the root predefined.json, which its digest does not cover (issue 1935) 2026-10-24 13:48:09 +02:00
jschoubben 15101c9ce6 Move source.main for a merge the gate caught up after a gap, so the checker re-checks open heads on the trunk that is (issue 1726)
The checker takes a repository's trunk head only from the gate's source.main. On 2026-10-24 gitea's
restarted poll announced mesh-catalog #471's merge while the gate was starting and could not yet tell
whether it held the seat; the gate acknowledged the event unread. It then adopted the controller's walk
of the merge commit, which carries no merge time, so every older merge of the repository looked newer
and source.main was never moved: #472 was checked on the trunk before #471 and its merge refused.

- An event heard before the gate can tell whether it holds the seat is offered again, not taken.
- An adopted merge is dated by its walk's creation, so it settles into source.main as a forge merge does.
- Each time the gate comes to hold the seat it weighs the newest merge of each repository against
  source.main once more, so a merge marked there and never written (as #471) is written; never backwards.
2026-10-24 13:41:56 +02:00
jschoubben f910b0ceb8 Pin mesh-build-go to the group's mesh-sdk head after main's merge, so it builds on the SDK the group delivers 2026-10-24 13:28:16 +02:00
jschoubben 91c3e954ae Merge main into feat/r4g-finish (#471) 2026-10-24 13:26:24 +02:00
jschoubben 545c72bb59 Merge pull request 'gitea's watcher answers its handshake without waiting for the forge, so a slow forge at a tool-runner restart no longer kills its tools and events (issue 1726)' (#471) from fix/1726-gitea-handshake-not-on-forge into main
Reviewed-on: #471
2026-10-24 11:10:39 +00:00
jschoubben 7afcd0282d Set up the forge's webhook without holding the bundle's import, so a slow forge never costs gitea its handshake and its tools (issue 1726)
At 12:37 on 2026-10-24 node-tools on novox was replaced; gitea's watcher bundle awaited the webhook's set-up on the forge's admin API at the top of its import, outlasted the 10 s handshake and was killed, never to start again. gitea_commit_status, gitea_merge_pull_request and gitea_events were then served nowhere: the forge bridge's calls found no responders and no new pull request was announced.
2026-10-24 13:00:57 +02:00
jschoubben 75156fce00 gitea: forge-account reads the forge's own host key file before scanning its sshd (issue 1939)
mesh/check pass 75156fce007e on c65116178720 | unit 246 run, 4192 kept | integration 228 run, 4252 kept | over bound: unit, integration (slowest unit …
mesh/delivery delivered
2026-10-24 12:50:20 +02:00
jschoubben 16d7b1898d Mark the packages whose tests read files outside their directory, found by the review (issue 1935) 2026-10-24 04:38:49 +02:00
jschoubben f04465537b Say in realtree_test that a missing trunk fails, not skips (issue 1295) 2026-10-24 04:37:52 +02:00
jschoubben e870a7f65f Pin to the finish group's mesh-sdk head dd0d156b, so every member names one SDK (issue 1622) 2026-10-24 04:28:14 +02:00
jschoubben e8a59171c7 Merge remote-tracking branch 'origin/feat/r4g-finish-composer' into HEAD 2026-10-24 04:26:03 +02:00
jschoubben ebb25757a2 Merge remote-tracking branch 'origin/feat/r4g-finish-1935' into HEAD 2026-10-24 04:26:02 +02:00
jschoubben a715c8821f mesh-build-go: a catalogue module's non-Go files enter its packages' digests; markers where tests read other modules (issue 1935) 2026-10-24 03:59:08 +02:00
jschoubben b6ed8599c8 mesh-build-go: a package marked reads-outside is never kept, so tests that read files outside their directory run on every check (issue 1935) 2026-10-24 03:46:45 +02:00
jschoubben ae6f7d5b54 Make a missing trunk copy a failure again, now the checker places it (issue 1295) 2026-10-24 03:43:49 +02:00
190 changed files with 3724 additions and 10874 deletions
+308
View File
@@ -29,3 +29,311 @@ modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
modules/mesh-config/mesh-config
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
*.test
# binaries a local go build leaves in a module root
/checks/paths/paths
/modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate
# A binary built in place, named like its module directory or a cmd directory, in the module root or in that cmd directory
# (issue 1921, try speed S13), for every module; the lint step of every check refuses one a change adds, a module added
# later included.
/modules/adwaita/adwaita
/modules/adwaita/adwaita-tools
/modules/adwaita/cmd/adwaita-tools/adwaita-tools
/modules/artifact-store-tools/artifact-store-tools
/modules/artifact-store-tools/cmd/artifact-store-tools/artifact-store-tools
/modules/asus-zephyrus-g14/asus-zephyrus-g14
/modules/asus-zephyrus-g14/zephyrus
/modules/asus-zephyrus-g14/cmd/zephyrus/zephyrus
/modules/audit-logger/audit-logger
/modules/avahi/avahi
/modules/avahi/avahi-tools
/modules/avahi/cmd/avahi-tools/avahi-tools
/modules/baserow/baserow
/modules/blueman/blueman
/modules/blueman/blueman-tools
/modules/blueman/cmd/blueman-tools/blueman-tools
/modules/bluetooth/bluetooth
/modules/bluetooth/bluetooth-tools
/modules/bluetooth/cmd/bluetooth-tools/bluetooth-tools
/modules/brother/brother
/modules/brother/cmd/brother/brother
/modules/buildah/buildah
/modules/ca-trust/ca-trust
/modules/claude-code/claude-code
/modules/claude-code/cmd/claude-code/claude-code
/modules/claude-licence-manager/claude-licence-manager
/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager
/modules/clipmenu/clipmenu
/modules/clipmenu/clipmenu-tools
/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools
/modules/confluence/confluence
/modules/cups/cups
/modules/cups/cups-tools
/modules/cups/cmd/cups-tools/cups-tools
/modules/dbus/dbus
/modules/dbus/dbus-tools
/modules/dbus/cmd/dbus-tools/dbus-tools
/modules/desk-channel/desk-channel
/modules/desk-channel/cmd/desk-channel/desk-channel
/modules/dev-tools/dev-tools
/modules/disk-load/disk-load
/modules/disk-load/cmd/disk-load/disk-load
/modules/distribution/distribution
/modules/dmenu/dmenu
/modules/dmenu/dmenu-tools
/modules/dmenu/cmd/dmenu-tools/dmenu-tools
/modules/dnsmasq/dnsmasq
/modules/dnsmasq/dnsmasq-tools
/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools
/modules/docker/docker
/modules/docker/docker-tools
/modules/docker/cmd/docker-tools/docker-tools
/modules/docker-compose/docker-compose
/modules/docker-compose/docker-compose-tools
/modules/docker-compose/cmd/docker-compose-tools/docker-compose-tools
/modules/dunst/dunst
/modules/dunst/dunst-tools
/modules/dunst/cmd/dunst-tools/dunst-tools
/modules/fail2ban/fail2ban
/modules/fail2ban/fail2ban-tools
/modules/fail2ban/cmd/fail2ban-tools/fail2ban-tools
/modules/feh/feh
/modules/feh/feh-tools
/modules/feh/cmd/feh-tools/feh-tools
/modules/flatpak/flatpak
/modules/flatpak/flatpak-tools
/modules/flatpak/cmd/flatpak-tools/flatpak-tools
/modules/fonts/fonts
/modules/fonts/fonts-tools
/modules/fonts/cmd/fonts-tools/fonts-tools
/modules/forticlient/forticlient
/modules/forticlient/forticlient-tools
/modules/forticlient/cmd/forticlient-tools/forticlient-tools
/modules/gitea/gitea
/modules/gitea/agent-teams
/modules/gitea/cmd/agent-teams/agent-teams
/modules/gitea/agent-token
/modules/gitea/cmd/agent-token/agent-token
/modules/gitea/forge
/modules/gitea/cmd/forge/forge
/modules/gitea/forge-account-provider
/modules/gitea/cmd/forge-account-provider/forge-account-provider
/modules/gitea/npm-registry
/modules/gitea/cmd/npm-registry/npm-registry
/modules/gitea/repositories
/modules/gitea/cmd/repositories/repositories
/modules/gitlab/gitlab
/modules/gnome-keyring/gnome-keyring
/modules/gnome-keyring/gnome-keyring-tools
/modules/gnome-keyring/cmd/gnome-keyring-tools/gnome-keyring-tools
/modules/go/go
/modules/grafana/grafana
/modules/hello-web/hello-web
/modules/home-assistant/home-assistant
/modules/hostname/hostname
/modules/hostname/hostname-tools
/modules/hostname/cmd/hostname-tools/hostname-tools
/modules/i3/i3
/modules/i3/i3-tools
/modules/i3/cmd/i3-tools/i3-tools
/modules/i3status-rust/i3status-rust
/modules/i3status-rust/i3status-rust-tools
/modules/i3status-rust/cmd/i3status-rust-tools/i3status-rust-tools
/modules/icecast/icecast
/modules/influxdb/influxdb
/modules/invoicing/invoicing
/modules/jira/jira
/modules/kernel/kernel
/modules/kernel/kernel-tools
/modules/kernel/cmd/kernel-tools/kernel-tools
/modules/keycloak/keycloak
/modules/keycloak/keycloak-provider
/modules/keycloak/cmd/keycloak-provider/keycloak-provider
/modules/lab/lab
/modules/lemurs/lemurs
/modules/lemurs/lemurs-tools
/modules/lemurs/cmd/lemurs-tools/lemurs-tools
/modules/letta/letta
/modules/localization/localization
/modules/localization/localization-tools
/modules/localization/cmd/localization-tools/localization-tools
/modules/local-model-consumer/local-model-consumer
/modules/logrotate/logrotate
/modules/logrotate/logrotate-tools
/modules/logrotate/cmd/logrotate-tools/logrotate-tools
/modules/mailu/mailu
/modules/mailu/automx
/modules/mailu/cmd/automx/automx
/modules/marrytts/marrytts
/modules/matrix/matrix
/modules/memory-pressure/memory-pressure
/modules/memory-pressure/cmd/memory-pressure/memory-pressure
/modules/mesh-build-go/mesh-build-go
/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
/modules/mesh-build-image/mesh-build-image
/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/modules/mesh-build-node/mesh-build-node
/modules/mesh-build-node/cmd/mesh-build-node/mesh-build-node
/modules/mesh-catalog/mesh-catalog
/modules/mesh-cli/mesh-cli
/modules/mesh-cli/cmd/mesh-cli/mesh-cli
/modules/mesh-config/mesh-config
/modules/mesh-config/cmd/mesh-config/mesh-config
/modules/mesh-delivery-gate/mesh-delivery-gate
/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/mesh-delivery-gate
/modules/mesh-files/mesh-files
/modules/mesh-files/cmd/mesh-files/mesh-files
/modules/mesh-issues/mesh-issues
/modules/mesh-issues/cmd/mesh-issues/mesh-issues
/modules/mesh-knowledge/mesh-knowledge
/modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
/modules/mesh-mail/mesh-mail
/modules/mesh-mail/cmd/mesh-mail/mesh-mail
/modules/mesh-mail-notifier/mesh-mail-notifier
/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mesh-mail-notifier
/modules/mesh-mail-reader/mesh-mail-reader
/modules/mesh-mail-reader/cmd/mesh-mail-reader/mesh-mail-reader
/modules/mesh-mail-web/mesh-mail-web
/modules/mesh-mail-web/cmd/mesh-mail-web/mesh-mail-web
/modules/mesh-provisioner/mesh-provisioner
/modules/mesh-provisioner/cmd/mesh-provisioner/mesh-provisioner
/modules/mesh-provisioner-client/mesh-provisioner-client
/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client
/modules/mesh-reads/mesh-reads
/modules/mesh-reads/cmd/mesh-reads/mesh-reads
/modules/mesh-vault/mesh-vault
/modules/mesh-vault/cmd/mesh-vault/mesh-vault
/modules/mesh-watcher/mesh-watcher
/modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher
/modules/messenger/messenger
/modules/messenger/cmd/messenger/messenger
/modules/minio/minio
/modules/minio/object-store
/modules/minio/cmd/object-store/object-store
/modules/model-usage/model-usage
/modules/mongodb/mongodb
/modules/mosquitto/mosquitto
/modules/mounts/mounts
/modules/mounts/cmd/mounts/mounts
/modules/mssql/mssql
/modules/n8n/n8n
/modules/nats/nats
/modules/nats/mesh-nats-snapshot
/modules/nats/cmd/mesh-nats-snapshot/mesh-nats-snapshot
/modules/nats/nats-tools
/modules/nats/cmd/nats-tools/nats-tools
/modules/netcheck/netcheck
/modules/netcheck/cmd/netcheck/netcheck
/modules/networkmanager/networkmanager
/modules/networkmanager/uplink-tools
/modules/networkmanager/cmd/uplink-tools/uplink-tools
/modules/nextcloud/nextcloud
/modules/nextcloud-client/nextcloud-client
/modules/nextcloud-client/nextcloud-client-tools
/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud-client-tools
/modules/nfs-server/nfs-server
/modules/nfs-server/cmd/nfs-server/nfs-server
/modules/nftables/nftables
/modules/nm-applet/nm-applet
/modules/nm-applet/nm-applet-tools
/modules/nm-applet/cmd/nm-applet-tools/nm-applet-tools
/modules/node-env/node-env
/modules/nodejs/nodejs
/modules/nodered/nodered
/modules/ollama/ollama
/modules/only-office/only-office
/modules/only-office/cmd/only-office/only-office
/modules/openai-consumer/openai-consumer
/modules/openrazer/openrazer
/modules/openrazer/openrazer-tools
/modules/openrazer/cmd/openrazer-tools/openrazer-tools
/modules/pacman/pacman
/modules/pacman/pacman-tools
/modules/pacman/cmd/pacman-tools/pacman-tools
/modules/photos-eef/photos-eef
/modules/photos-filip/photos-filip
/modules/picom/picom
/modules/picom/picom-tools
/modules/picom/cmd/picom-tools/picom-tools
/modules/polychromatic/polychromatic
/modules/polychromatic/polychromatic-tools
/modules/polychromatic/cmd/polychromatic-tools/polychromatic-tools
/modules/postgres/postgres
/modules/postgres/postgres-provider
/modules/postgres/cmd/postgres-provider/postgres-provider
/modules/power/power
/modules/power/cmd/power/power
/modules/powerlevel10k/powerlevel10k
/modules/records/records
/modules/records/cmd/records/records
/modules/redis/redis
/modules/restic/restic
/modules/restic/restic-backups
/modules/restic/cmd/restic-backups/restic-backups
/modules/rofi/rofi
/modules/rofi/rofi-tools
/modules/rofi/cmd/rofi-tools/rofi-tools
/modules/route-adapter/route-adapter
/modules/route-adapter/route-provider
/modules/route-adapter/cmd/route-provider/route-provider
/modules/screen-lock/screen-lock
/modules/screen-lock/screen-lock-tools
/modules/screen-lock/cmd/screen-lock-tools/screen-lock-tools
/modules/searxng/searxng
/modules/sensors/sensors
/modules/sensors/cmd/sensors/sensors
/modules/showcase/showcase
/modules/slack/slack
/modules/slack/slack-tools
/modules/slack/cmd/slack-tools/slack-tools
/modules/snapd/snapd
/modules/snapd/snapd-tools
/modules/snapd/cmd/snapd-tools/snapd-tools
/modules/ssh-client/ssh-client
/modules/ssh-client/ssh-client-tools
/modules/ssh-client/cmd/ssh-client-tools/ssh-client-tools
/modules/sshd/sshd
/modules/step-ca/step-ca
/modules/sudo/sudo
/modules/sudo/sudo-tools
/modules/sudo/cmd/sudo-tools/sudo-tools
/modules/supabase/supabase
/modules/systemd/systemd
/modules/systemd/systemd-tools
/modules/systemd/cmd/systemd-tools/systemd-tools
/modules/systemd-networkd/systemd-networkd
/modules/systemd-networkd/uplink-tools
/modules/systemd-networkd/cmd/uplink-tools/uplink-tools
/modules/systemd-resolved/systemd-resolved
/modules/systemd-resolved/resolver-tools
/modules/systemd-resolved/cmd/resolver-tools/resolver-tools
/modules/telegram/telegram
/modules/telegram/cmd/telegram/telegram
/modules/time-sync/time-sync
/modules/time-sync/time-sync-tools
/modules/time-sync/cmd/time-sync-tools/time-sync-tools
/modules/triggerhappy/triggerhappy
/modules/triggerhappy/triggerhappy-tools
/modules/triggerhappy/cmd/triggerhappy-tools/triggerhappy-tools
/modules/umami/umami
/modules/unifi/unifi
/modules/website/website
/modules/xclip/xclip
/modules/xclip/xclip-tools
/modules/xclip/cmd/xclip-tools/xclip-tools
/modules/xdg/xdg
/modules/xdg/xdg-tools
/modules/xdg/cmd/xdg-tools/xdg-tools
/modules/xorg/xorg
/modules/xorg/cmd/xorg/xorg
/modules/xterm/xterm
/modules/xterm/xterm-tools
/modules/xterm/cmd/xterm-tools/xterm-tools
/modules/zfs/zfs
/modules/zfs/zfs-tools
/modules/zfs/cmd/zfs-tools/zfs-tools
/modules/zsh/zsh
/modules/zsh-autosuggestions/zsh-autosuggestions
/modules/zsh-syntax-highlighting/zsh-syntax-highlighting
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+2 -1
View File
@@ -251,7 +251,8 @@ func main() {
}
var grown []string
if baseDir == "" {
fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged")
fmt.Fprintln(os.Stderr, "NOT COMPARED: no base branch's lists were given (-base <dir>), so additions to them were not judged; that is a failure, not a pass (issue 1295)")
os.Exit(2)
} else {
baseShared, _, err1 := list(filepath.Join(baseDir, "shared"))
baseKnown, _, err2 := list(filepath.Join(baseDir, "known"))
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+2 -2
View File
@@ -32,8 +32,8 @@ func TestNoModuleNamesAnothersState(t *testing.T) {
}
}
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Skipped,
// and said, where no trunk is beside.
// And neither list gains a line the trunk's copies at the merge base do not hold, unless the line says why after `# allow:`. Fails, saying
// NOT COMPARED, where no trunk is beside (issue 1295).
func TestTheListsOfPathsHoldNoLineTheTrunksDoNot(t *testing.T) {
sharedPath, okShared := trunk.Copy(t, "checks/paths/shared")
knownPath, okKnown := trunk.Copy(t, "checks/paths/known")
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+14 -18
View File
@@ -3,14 +3,14 @@
//
// The checker fetches the change's own repository at its base commit beside the change, as it fetches a sibling, and says where
// in MESH_CHECK_BESIDE; the copy is under the repository's name (the same convention the other repositories' tests use). With
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the comparison is skipped and the test says NOT COMPARED
// WITH THE TRUNK, naming why: the checker that runs before R4g's delivery places siblings but not the change's own repository,
// so R4g's own check cannot hold the copy (issue 1295 notes that a missing copy becomes a failure once that checker is gone). A trunk that holds no such file had
// MESH_CHECK_BESIDE unset, or set without the catalogue's copy in it, the test fails saying NOT COMPARED WITH THE TRUNK and
// why: the checker places the change's own repository since R4g (issue 1295), so a missing copy is a fault, never a pass. A trunk that holds no such file had
// none: the list's first copy, which nothing was added to.
package trunk
import (
"errors"
"fmt"
"os"
"path/filepath"
"testing"
@@ -23,13 +23,11 @@ const Repository = "mesh-catalog"
const BesideEnv = "MESH_CHECK_BESIDE"
// Copy is the trunk's copy of the file at rel (a path from the catalogue's root), written to a file of the test's own: its path,
// and whether the trunk holds one (false: its first copy). It skips the test, saying so, when no copy of the catalogue is beside.
// and whether the trunk holds one (false: its first copy). It fails the test when no copy of the catalogue is beside.
func Copy(t testing.TB, rel string) (string, bool) {
t.Helper()
raw, found, skip, err := Read(rel)
raw, found, err := Read(rel)
switch {
case skip != "":
t.Skip(skip)
case err != nil:
t.Fatal(err)
case !found:
@@ -43,27 +41,25 @@ func Copy(t testing.TB, rel string) (string, bool) {
return path, true
}
// Read is the trunk's copy of rel and whether the trunk holds one. skip is the words to skip the comparison with, when no trunk is
// named; err is a trunk named and not there, or unreadable.
func Read(rel string) (content []byte, found bool, skip string, err error) {
// Read is the trunk's copy of rel and whether the trunk holds one. err is a trunk not beside the check (MESH_CHECK_BESIDE unset,
// or without the catalogue's directory), or a copy unreadable: a comparison that cannot be made is never a pass.
func Read(rel string) (content []byte, found bool, err error) {
beside := os.Getenv(BesideEnv)
if beside == "" {
return nil, false, "NOT COMPARED WITH THE TRUNK: " + BesideEnv + " is not set, so the trunk's copy of " + rel +
" is not beside this run; the checker places it", nil
return nil, false, fmt.Errorf("NOT COMPARED WITH THE TRUNK: %s is not set, so the trunk's copy of %s is not beside this run; the checker places the change's own repository there (issue 1295)", BesideEnv, rel)
}
dir := filepath.Join(beside, Repository)
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
return nil, false, "NOT COMPARED WITH THE TRUNK: " + Repository + " is not beside this check in " + BesideEnv + "=" + beside +
", so " + rel + " is not judged against the trunk's copy (the checker before R4g places no copy of the change's own repository)", nil
return nil, false, fmt.Errorf("NOT COMPARED WITH THE TRUNK: %s is not beside this check in %s=%s, so %s is not judged against the trunk's copy; the checker places the change's own repository there at its trunk (issue 1295)", Repository, BesideEnv, beside, rel)
} else if err != nil {
return nil, false, "", err
return nil, false, err
}
raw, err := os.ReadFile(filepath.Join(dir, rel))
if errors.Is(err, os.ErrNotExist) {
return nil, false, "", nil
return nil, false, nil
}
if err != nil {
return nil, false, "", err
return nil, false, err
}
return raw, true, "", nil
return raw, true, nil
}
+14 -14
View File
@@ -17,27 +17,27 @@ func TestTheCloneBesideTheCheckIsTheTrunk(t *testing.T) {
t.Fatal(err)
}
t.Setenv(BesideEnv, beside)
raw, found, skip, err := Read("checks/x/known")
if err != nil || skip != "" || !found || string(raw) != "a\nb\n" {
t.Fatalf("read %q found %v skip %q err %v", raw, found, skip, err)
raw, found, err := Read("checks/x/known")
if err != nil || !found || string(raw) != "a\nb\n" {
t.Fatalf("read %q found %v err %v", raw, found, err)
}
if _, found, skip, err := Read("checks/x/absent"); err != nil || skip != "" || found {
t.Fatalf("a file the trunk does not hold: found %v skip %q err %v; wanted its first copy", found, skip, err)
if _, found, err := Read("checks/x/absent"); err != nil || found {
t.Fatalf("a file the trunk does not hold: found %v err %v; wanted its first copy", found, err)
}
}
func TestWithNothingBesideTheComparisonIsSkippedAndSaidNotMade(t *testing.T) {
func TestWithNothingBesideTheComparisonFailsAndSaysNotMade(t *testing.T) {
t.Setenv(BesideEnv, "")
_, found, skip, err := Read("checks/x/known")
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" {
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED", found, skip, err)
_, found, err := Read("checks/x/known")
if found || err == nil || !strings.Contains(err.Error(), "NOT COMPARED") {
t.Fatalf("found %v, err %v; wanted a failure saying NOT COMPARED", found, err)
}
}
func TestACloneBesideWithoutTheCatalogueIsSkippedAndSaidNotMade(t *testing.T) {
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not (the checker before R4g)
_, found, skip, err := Read("checks/x/known")
if found || err != nil || len(skip) < 12 || skip[:12] != "NOT COMPARED" || !strings.Contains(skip, "not beside") {
t.Fatalf("found %v, skip %q, err %v; wanted NOT COMPARED naming the missing copy", found, skip, err)
func TestACloneBesideWithoutTheCatalogueFailsAndSaysNotMade(t *testing.T) {
t.Setenv(BesideEnv, t.TempDir()) // the siblings are there, the catalogue's own base is not
_, found, err := Read("checks/x/known")
if found || err == nil || !strings.Contains(err.Error(), "NOT COMPARED") || !strings.Contains(err.Error(), "not beside") {
t.Fatalf("found %v, err %v; wanted a failure naming the missing copy", found, err)
}
}
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -65,6 +65,7 @@ func configFromEnv(getenv func(string) string) (Config, error) {
HostFile: getenv("MESH_FORGE_HOST_FILE"),
OriginsFile: getenv("MESH_GITEA_ORIGINS_FILE"),
Port: port,
HostKeyFile: getenv("MESH_GITEA_HOST_KEY_FILE"), // optional
Forge: forgehttp.Client{URL: strings.TrimRight(getenv("MESH_GITEA_URL"), "/"), User: getenv("MESH_GITEA_ADMIN_USER"),
PasswordFile: getenv("MESH_GITEA_ADMIN_PASSWORD_FILE"), Timeout: 20 * time.Second},
Runner: execRunner{},
@@ -44,10 +44,13 @@ type Config struct {
// origins, one per line.
HostFile, OriginsFile string
// Port is where the forge's sshd is published on the mesh.
Port int
Forge forgehttp.Client
Runner Runner
Log func(format string, a ...any)
Port int
// HostKeyFile is the forge's own ed25519 host key (the .pub file in its data directory). It is read first; when it
// is unset or unreadable the forge's sshd is asked with ssh-keyscan instead (issue 1939).
HostKeyFile string
Forge forgehttp.Client
Runner Runner
Log func(format string, a ...any)
}
// Service answers the consumers of the provision `forge-account` (novox/hq ADR 0399, ADR 0400; issue 955): for each
@@ -448,14 +451,30 @@ func (s *Service) httpsBase() (string, error) {
return "", fmt.Errorf("%s names no https origin", s.cfg.OriginsFile)
}
// scan reads the forge's ed25519 host key from its own sshd and writes it as the machines' known_hosts line.
// scan gives the forge's ed25519 host key as the machines' known_hosts line. The key the forge itself holds is read
// from its file first; only when that cannot be read is the forge's sshd asked over the network, and when that fails
// too (the forge's container is restarting) the key read before is kept, as a host key does not change (issue 1939).
func (s *Service) scan(ctx context.Context) (string, error) {
host, err := s.hostName()
if err != nil {
return "", err
}
if s.cfg.HostKeyFile != "" {
raw, ferr := os.ReadFile(s.cfg.HostKeyFile)
if ferr == nil {
if line, perr := knownHostLine("127.0.0.1 "+strings.TrimSpace(string(raw)), host, s.cfg.Port); perr == nil {
return line, nil
}
}
}
out, err := s.cfg.Runner.Run(ctx, "ssh-keyscan", "-t", "ed25519", "-p", fmt.Sprint(s.cfg.Port), "127.0.0.1")
if err != nil {
s.mu.Lock()
kept := s.known
s.mu.Unlock()
if kept != "" {
return kept, nil
}
return "", fmt.Errorf("the forge's ssh host key cannot be read: %w", err)
}
return knownHostLine(string(out), host, s.cfg.Port)
@@ -696,3 +696,42 @@ func TestNothingIsSaidForAPerNodeUserWithoutMeshKeys(t *testing.T) {
}
}
}
// The forge's own host key file is read first: no scan over the network is needed (issue 1939).
func TestTheHostKeyIsReadFromTheForgesOwnFile(t *testing.T) {
r := newRig(t)
f := filepath.Join(t.TempDir(), "ssh_host_ed25519_key.pub")
if err := os.WriteFile(f, []byte("ssh-ed25519 AAAAC3FILEKEY forge@host\n"), 0o644); err != nil {
t.Fatal(err)
}
r.svc.cfg.HostKeyFile = f
r.runner.err = errors.New("sshd down")
got, err := r.svc.scan(context.Background())
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3FILEKEY" {
t.Fatalf("scan = %q, %v", got, err)
}
if r.runner.args != nil {
t.Errorf("the forge's sshd was asked: %v", r.runner.args)
}
}
// A file that cannot be read falls back to the forge's sshd.
func TestAnUnreadableHostKeyFileFallsBackToTheScan(t *testing.T) {
r := newRig(t)
r.svc.cfg.HostKeyFile = filepath.Join(t.TempDir(), "missing.pub")
got, err := r.svc.scan(context.Background())
if err != nil || got != "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY" {
t.Fatalf("scan = %q, %v", got, err)
}
}
// While the forge restarts, a key read before is kept: the sshd is not up, the key has not changed.
func TestARestartingForgeKeepsTheKeyReadBefore(t *testing.T) {
r := newRig(t)
r.svc.known = "[novox.internal]:222 ssh-ed25519 AAAAC3HOSTKEY"
r.runner.err = errors.New("connection refused")
got, err := r.svc.scan(context.Background())
if err != nil || got != r.svc.known {
t.Fatalf("scan = %q, %v", got, err)
}
}
+5 -1
View File
@@ -589,7 +589,11 @@ if (gitea) {
};
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
await listenForForge(client);
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
void listenForForge(client);
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
+1
View File
@@ -537,6 +537,7 @@
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_ORIGINS_FILE": "${dir:mesh-state}/origins",
"MESH_GITEA_SSH_PORT": "222",
"MESH_GITEA_HOST_KEY_FILE": "${dir:data}/ssh/ssh_host_ed25519_key.pub",
"MESH_FORGE_HOST_FILE": "/etc/gitea/forge-host"
}
}
+50
View File
@@ -109,3 +109,53 @@ test("a bundle whose read of the checker's owed is granted late writes the lates
assert.match(got.statuses[0], /"state":"success".*pass at 4/);
assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said");
});
// **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up
// asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while
// every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never
// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge
// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every
// request and never answers: the import resolves at once all the same, with the bundle's tools registered.
test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => {
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
const script = `
const http = await import("node:http");
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const forge = http.createServer(() => {});
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gitea-1726-"));
fs.writeFileSync(path.join(dir, "admin.password"), "pw");
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
process.env.MESH_GITEA_STATE_DIR = dir;
process.env.MESH_GITEA_ADMIN_PASSWORD_FILE = path.join(dir, "admin.password");
process.env.MESH_GITEA_HOOK_PORT = "0";
process.env.MESH_GITEA_HOOK_HOST = "127.0.0.1";
const { useBroker } = await import("@novox/mesh-sdk/messaging");
useBroker(() => ({
subscribe: async () => () => {}, publish: async () => {}, request: async () => { throw new Error("no responders"); },
handle: async () => () => {}, close: async () => {},
}));
await import(${JSON.stringify(index)});
const { collectTools } = await import("@novox/mesh-sdk/tools");
console.log(JSON.stringify({ imported: true, tools: collectTools().flatMap((r) => r.tools.map((t) => t.name)) }));
process.exit(0);`;
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_ADMIN_USER: "admin", MESH_GITEA_HOOK_URL: "http://127.0.0.1:1" };
for (const k of ["MESH_SERVED_MODULE", "MESH_GITEA_ADMIN_PASSWORD", "MESH_GITEA_CONFIG_FILE"]) delete env[k];
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const ended = new Promise<void>((r) => child.on("exit", () => r()));
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
await ended;
clearTimeout(timer);
const line = out.split("\n").find((l) => l.startsWith("{"));
assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
const got = JSON.parse(line!) as { tools: string[] };
for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) {
assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`);
}
});
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+5 -1
View File
@@ -102,7 +102,11 @@ integration level, the postgres major and the nats-server version; and, when the
workspace (`beside`), each one's repository and commit, which the answer echoes as `beside` (the checker keeps a verdict only
for the siblings it was judged with). The text's form is `DigestVersion` 3. The Go version is not in it: the toolchain enters a
fingerprint once, as the version the build nodes agree on (ADR 0477 §3), and the standard library's packages are that
version's. A package with no tests at the level has no digest and is named in `without_tests`. A directory that vendors
version's. A package with no tests at the level has no digest and is named in `without_tests`. A catalogue module's packages also hash the module's non-Go files (the nearest ancestor directory holding `module.json`: its manifest, README, `files/` and the rest, so `../../module.json` is covered; digest form 5). A package whose directory holds a file named `reads-outside` (any content; say what the tests read) is never kept (issue 1935): its tests read
files outside it, which a digest from `go list` cannot name, so its digest is a fresh random one on every call, the ask finds no kept
verdict for it and runs it, and the answer names it in `reads_outside` and the module's log says why. The checker still writes a
verdict under that digest, which nothing ever reads again. The marker is the plain way to say it until each verdict keeps the files
its tests opened (Go's test log). A directory that vendors
is listed and tested from its vendor directory (`-mod=vendor`).
## A change to this module is judged by its candidate
@@ -116,7 +116,7 @@ func TestTheIntegrationLevelDigestsUnderBothTags(t *testing.T) {
t.Errorf("the integration digest listed %q", s.command)
}
}
if DigestVersion != "4" {
if DigestVersion != "5" {
t.Errorf("the digest's form changed in kind and is still version %s", DigestVersion)
}
}
@@ -0,0 +1,90 @@
package main
// Stray build output (issue 1921, try speed S13): the lint step fails a change that adds an executable (ELF or Mach-O) or
// a file over MaxAddedFile, naming each, unless the repository's .binaries-allowed lists its path. Build binaries were
// committed three times in one night; the forge keeps them for ever.
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// MaxAddedFile is the largest file a change may add without .binaries-allowed naming it.
const MaxAddedFile = 5 << 20
// BinariesAllowed is the file in a repository's root listing, one path per line ("#" starts a comment), the executables
// and large files it keeps on purpose.
const BinariesAllowed = ".binaries-allowed"
// executableMagic are the first bytes of an ELF and of each Mach-O form (32 and 64 bits, both byte orders, universal).
var executableMagic = [][]byte{[]byte("\x7fELF"), {0xfe, 0xed, 0xfa, 0xce}, {0xfe, 0xed, 0xfa, 0xcf}, {0xce, 0xfa, 0xed, 0xfe},
{0xcf, 0xfa, 0xed, 0xfe}, {0xca, 0xfe, 0xba, 0xbe}}
// addedBinaries is the lint finding of the files the merge adds to the trunk that are executables or over MaxAddedFile,
// nil when there is none. The paths come from git (the merge commit against its first parent, the trunk); a tree with no
// merge (no first parent) adds nothing to judge.
func (m *Module) addedBinaries(ctx context.Context, run forgekey.Runner, tree string) []Failure {
r := run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", tree, "diff", "--name-only", "--no-renames", "--diff-filter=A", "HEAD^1", "HEAD")
if r.Status != 0 || r.Err != "" {
return nil
}
allowed := map[string]bool{}
if raw, err := os.ReadFile(filepath.Join(tree, BinariesAllowed)); err == nil {
scan := bufio.NewScanner(bytes.NewReader(raw))
for scan.Scan() {
if l := strings.TrimSpace(scan.Text()); l != "" && !strings.HasPrefix(l, "#") {
allowed[filepath.ToSlash(l)] = true
}
}
}
var found []string
for _, p := range strings.Split(r.Stdout, "\n") {
if p = strings.TrimSpace(p); p == "" || allowed[p] {
continue
}
path := filepath.Join(tree, filepath.FromSlash(p))
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
continue // narrowed away, or not a file
}
if info.Size() > MaxAddedFile {
found = append(found, fmt.Sprintf("%s (%d bytes, over %d)", p, info.Size(), MaxAddedFile))
continue
}
if isExecutable(path) {
found = append(found, p+" (an executable)")
}
}
if len(found) == 0 {
return nil
}
sort.Strings(found)
return []Failure{{Package: ".", Test: "binaries", Step: StepLint,
Output: "the change adds build output or a large file (remove it, or list its path in " + BinariesAllowed + "): " + strings.Join(found, ", ")}}
}
func isExecutable(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
head := make([]byte, 4)
if n, _ := f.Read(head); n < 4 {
return false
}
for _, magic := range executableMagic {
if bytes.Equal(head, magic) {
return true
}
}
return false
}
@@ -0,0 +1,67 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 1921, try speed S13: the lint step fails a change that adds an executable (ELF or Mach-O) or a file over 5 MB,
// naming it, unless .binaries-allowed in the repository's root lists its path; a file the change does not add is not
// judged, and neither is an allowed one.
func TestTheLintStepFailsAChangeThatAddsABinaryOrALargeFile(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "slow")
tree := filepath.Join(m.Work, ws, "tree")
write := func(name string, body []byte) {
os.MkdirAll(filepath.Dir(filepath.Join(tree, name)), 0o755)
if err := os.WriteFile(filepath.Join(tree, name), body, 0o644); err != nil {
t.Fatal(err)
}
}
write("go.mod", []byte("module fixture/slow\n"))
elf := append([]byte("\x7fELF"), make([]byte, 64)...)
macho := append([]byte{0xcf, 0xfa, 0xed, 0xfe}, make([]byte, 64)...)
write("modules/x/cmd/x/x", elf)
write("modules/y/y", macho)
write("big.tar", make([]byte, 5<<20+1))
write("tools/allowed-tool", elf)
write("old/binary", elf) // there before the change
write("modules/x/doc.md", []byte("text\n"))
write(".binaries-allowed", []byte("# kept on purpose\ntools/allowed-tool\n"))
inner := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if name == "git" && strings.Contains(strings.Join(args, " "), "--diff-filter=A") {
return forgekey.Ran{Stdout: "modules/x/cmd/x/x\nmodules/y/y\nbig.tar\ntools/allowed-tool\nmodules/x/doc.md\n.binaries-allowed\n"}
}
return inner(ctx, env, name, args...)
}
res, st := runUnit(t, m, states, ws)
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
var found []Failure
for _, f := range res.Failed {
if f.Test == "binaries" {
found = append(found, f)
}
}
if res.Passed || len(found) != 1 || found[0].Step != StepLint {
t.Fatalf("failed = %+v, want one lint finding of the binaries", res.Failed)
}
out := found[0].Output
for _, want := range []string{"modules/x/cmd/x/x", "modules/y/y", "big.tar"} {
if !strings.Contains(out, want) {
t.Errorf("the finding does not name %s: %s", want, out)
}
}
for _, not := range []string{"allowed-tool", "old/binary", "doc.md"} {
if strings.Contains(out, not) {
t.Errorf("the finding names %s: %s", not, out)
}
}
}
@@ -0,0 +1,40 @@
package main
import (
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1921/S11 (the lead's decision): a check compiles and runs with CGO_ENABLED=0, as a production build does, so it tests
// what ships and the warm cache matches; the lint's go vet, a compile alone, writes the shared cache, while a go test that
// runs a test binary reads it beneath the ask's own layer.
func TestACheckRunsWithoutCgoAndOnlyItsCompileWritesTheSharedCache(t *testing.T) {
m, states, ws, calls := workspaceFor(t, "failing")
var seen []shutOff
m.Run = throughBubblewrap(t, m.Run, &seen)
if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
for _, c := range *calls {
if strings.HasPrefix(c.line, "go ") && !slices.Contains(c.env, "CGO_ENABLED=0") {
t.Errorf("%s ran without CGO_ENABLED=0: %v", c.line, c.env)
}
}
vets, tests := 0, 0
for _, s := range seen {
switch {
case strings.Contains(s.command, " vet "):
vets++
checkShutOff(t, m, s, true) // the shared caches bound read-write
case strings.Contains(s.command, " test "):
tests++
checkShutOff(t, m, s, false)
}
}
if vets == 0 || tests == 0 {
t.Errorf("%d vets and %d tests ran shut off, want both", vets, tests)
}
}
@@ -7,6 +7,7 @@ package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -24,12 +25,20 @@ import (
// 2: no Go version in the text; the toolchain enters a fingerprint as the agreed version (ADR 0477 §3). 3: the siblings'
// commits (the lead's decision L5) and the integration level's tags integration,isolation (the checker's agreement).
// 4: the siblings' commits enter only the digest of a package whose tests reach a file naming MESH_CHECK_BESIDE (ADR 0536 §1).
const DigestVersion = "4"
// 5: a catalogue module's non-Go files enter the digest of its packages (issue 1935).
const DigestVersion = "5"
// besideVariable is the name a file bears when it reads where the siblings lie; a package whose tests reach such a file
// depends on the siblings' commits.
const besideVariable = "MESH_CHECK_BESIDE"
// ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a
// repository's documents, a sibling directory): such a package is never kept (issue 1935). The digest hashes only what
// the Go toolchain lists, so it cannot name what a test opens elsewhere; a marker file, beside the Go files where a
// reader of the package sees it, is the plain way to say so until the checker keeps the files a test opened (the Go
// test log) with each verdict.
const ReadsOutsideMarker = "reads-outside"
// integrationTags are the build tags of the integration level, its run and its digest alike: integration, and isolation
// for the bubblewrap proofs (mesh-sdk's and the build modules'), which a build node can run.
var integrationTags = []string{"integration", "isolation"}
@@ -89,6 +98,9 @@ type DigestResult struct {
// only when they are the siblings it gave the fetch.
Beside []string `json:"beside,omitempty"`
WithoutTests []string `json:"without_tests,omitempty"`
// ReadsOutside names the packages that carry the marker: each has a digest that no kept verdict can have (a fresh
// random one), so the check runs it every time.
ReadsOutside []string `json:"reads_outside,omitempty"`
// JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
@@ -199,6 +211,12 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
}
}
sort.Strings(res.WithoutTests)
sort.Strings(res.ReadsOutside)
for _, p := range res.ReadsOutside {
if m.Say != nil {
m.Say("the %s level of %s always runs: its directory carries %s, so its tests read files outside it that no digest names", level, p, ReadsOutsideMarker)
}
}
return res, nil
}
@@ -256,6 +274,17 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
if testMain == nil {
return fmt.Errorf("go list gave no test binary for %s", path)
}
if _, err := os.Stat(filepath.Join(root.Dir, ReadsOutsideMarker)); err == nil {
// The digest of a package that reads outside itself is one nothing can have kept: the key is a fresh random
// hex, so the ask finds no kept verdict and runs it, and what the checker writes under it is never read again.
var nonce [32]byte
if _, err := rand.Read(nonce[:]); err != nil {
return err
}
res.Packages[path] = hex.EncodeToString(nonce[:])
res.ReadsOutside = append(res.ReadsOutside, path)
continue
}
d, err := packageDigest(root, testMain, pkgs, tree, header, beside)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
@@ -303,6 +332,15 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
}
}
}
// A catalogue module's tests read its manifest, its README and its files from the package's directory (../../module.json):
// every non-Go file of the module's directory is an input (issue 1935). The module is the nearest ancestor with a module.json.
if modDir := enclosingModule(tree, root.Dir); modDir != "" {
more, err := moduleFileLines(tree, root, modDir)
if err != nil {
return "", err
}
lines = append(lines, more...)
}
data := filepath.Join(root.Dir, "testdata")
if _, err := os.Stat(data); err == nil {
err := filepath.WalkDir(data, func(path string, e fs.DirEntry, err error) error {
@@ -328,6 +366,41 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
return hex.EncodeToString(h.Sum(nil)), nil
}
// enclosingModule is the nearest ancestor of dir, inside tree, that holds a module.json, or "" when there is none.
func enclosingModule(tree, dir string) string {
tree = filepath.Clean(tree)
for d := filepath.Clean(dir); d != tree && strings.HasPrefix(d, tree+string(filepath.Separator)); d = filepath.Dir(d) {
if st, err := os.Stat(filepath.Join(d, "module.json")); err == nil && !st.IsDir() {
return d
}
}
return ""
}
// moduleFileLines are the content lines of every file of a module's directory that is not Go source (Go source is the
// packages' own, listed by go list), skipping .git and node_modules.
func moduleFileLines(tree string, p *goPackage, modDir string) ([]string, error) {
var lines []string
err := filepath.WalkDir(modDir, func(path string, e fs.DirEntry, err error) error {
if err != nil {
return err
}
if e.IsDir() {
if n := e.Name(); n == ".git" || n == "node_modules" {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".go") {
return nil
}
l, err := contentLine(tree, p, path)
lines = append(lines, l)
return err
})
return lines, err
}
func fileLine(tree string, p *goPackage, name string) (string, error) {
return contentLine(tree, p, filepath.Join(p.Dir, name))
}
@@ -189,3 +189,71 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
t.Errorf("a new version left the digest as it was")
}
}
// Issue 1935: a package whose directory carries the reads-outside marker is never kept: its digest is one no kept verdict
// can have (it differs on every call, though nothing changed), and the answer names it. A package without the marker
// keeps its digest.
func TestAPackageWithTheReadsOutsideMarkerIsNeverKept(t *testing.T) {
t.Parallel()
m, tree := digestFixture(t)
plain := digests(t, m, "unit")
if len(plain.ReadsOutside) != 0 {
t.Fatalf("a package carries the marker in the fixture: %v", plain.ReadsOutside)
}
edit(t, tree, "c/"+ReadsOutsideMarker, "the tests read ../docs\n")
first, second := digests(t, m, "unit"), digests(t, m, "unit")
c := "fixture/digest/c"
if len(first.Packages[c]) != 64 || first.Packages[c] == second.Packages[c] || first.Packages[c] == plain.Packages[c] {
t.Errorf("c carries the marker and its digest is stable: %s, %s, before %s", first.Packages[c], second.Packages[c], plain.Packages[c])
}
if strings.Join(first.ReadsOutside, ",") != c {
t.Errorf("the answer does not name the package that reads outside: %v", first.ReadsOutside)
}
a := "fixture/digest/a"
if first.Packages[a] != plain.Packages[a] || first.Packages[a] != second.Packages[a] {
t.Errorf("a has no marker and its digest moved: %s, %s, before %s", first.Packages[a], second.Packages[a], plain.Packages[a])
}
}
// Issue 1935: a catalogue module's packages read ../../module.json and the module's files: a change to them changes the
// digest of the module's package, and a change to another module's files does not.
func TestACatalogueModulesFilesChangeTheDigestOfItsPackagesOnly(t *testing.T) {
t.Parallel()
m, tree := digestFixture(t)
for _, mod := range []string{"mod1", "mod2"} {
dir := filepath.Join(tree, mod, "cmd", "p")
if err := os.MkdirAll(filepath.Join(tree, mod, "files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
edit(t, tree, mod+"/module.json", `{"module":"`+mod+`"}`)
edit(t, tree, mod+"/README.md", "# "+mod+"\n")
edit(t, tree, mod+"/files/f.env", "A=1\n")
edit(t, tree, mod+"/cmd/p/p.go", "package p\n")
edit(t, tree, mod+"/cmd/p/p_test.go", "package p\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestReads(t *testing.T) {\n\tif _, err := os.ReadFile(\"../../module.json\"); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n")
}
p1, p2 := "fixture/digest/mod1/cmd/p", "fixture/digest/mod2/cmd/p"
before := digests(t, m, "unit")
if len(before.Packages[p1]) != 64 || len(before.Packages[p2]) != 64 {
t.Fatalf("no digest for the module packages: %+v", before.Packages)
}
if before.Packages[p1] == before.Packages[p2] {
t.Errorf("two modules, one digest")
}
for _, file := range []string{"mod1/module.json", "mod1/README.md", "mod1/files/f.env"} {
edit(t, tree, file, "changed "+file+"\n")
after := digests(t, m, "unit")
if after.Packages[p1] == before.Packages[p1] {
t.Errorf("a change to %s did not change the module's digest", file)
}
if after.Packages[p2] != before.Packages[p2] {
t.Errorf("a change to %s changed the other module's digest", file)
}
before = after
}
if got := digests(t, m, "unit"); got.Packages["fixture/digest/c"] == "" {
t.Errorf("a package outside any module lost its digest")
}
}
@@ -49,6 +49,51 @@ type FetchResult struct {
// Beside are the sibling repositories placed as plain directories under the workspace's beside directory, which
// build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4).
Beside []Sibling `json:"beside,omitempty"`
// Took is how long each phase of the fetch ran, in seconds (issue 1921, the try speed-up's first measurement): what
// the checker reports as one "clone" time, split.
Took *FetchTook `json:"took,omitempty"`
}
// FetchTook is the time of each phase of a fetch, in seconds; its phases add up to Total. Mirror is bringing the
// repository's mirror to the commit, Fetch the fetch and checkout into the tree, Merge the merge preview, Dissociate
// the repack that makes the tree independent of the mirror, Beside the placing of the siblings (their own mirror,
// fetch and repack), Other the narrowing and the listing of the go.mod directories, and Modules the download and
// verify of the Go modules.
type FetchTook struct {
Mirror float64 `json:"mirror"`
Fetch float64 `json:"fetch"`
Merge float64 `json:"preview"` // named for the merge preview, apart from the result's merge commit
Dissociate float64 `json:"dissociate"`
Beside float64 `json:"siblings"`
Other float64 `json:"other"`
Modules float64 `json:"modules"`
Total float64 `json:"total"`
}
// phaseClock stamps the phases of a fetch: each lap is the time since the one before, so the laps add up to the total.
type phaseClock struct {
now func() time.Time
start, since time.Time
}
func newPhaseClock(now func() time.Time) *phaseClock {
t := now()
return &phaseClock{now: now, start: t, since: t}
}
// lap adds the time since the last lap to *into.
func (c *phaseClock) lap(into *float64) {
t := c.now()
*into += t.Sub(c.since).Seconds()
c.since = t
}
func (c *phaseClock) total() float64 { return c.since.Sub(c.start).Seconds() }
// String is the one log line a fetch says of its phases.
func (t FetchTook) String() string {
return fmt.Sprintf("mirror %.1fs, fetch %.1fs, merge %.1fs, dissociate %.1fs, beside %.1fs, other %.1fs, modules %.1fs, total %.1fs",
t.Mirror, t.Fetch, t.Merge, t.Dissociate, t.Beside, t.Other, t.Modules, t.Total)
}
// fetchTool is the fetch step tool.
@@ -109,43 +154,77 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (res any, err e
}
func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id string, siblings []siblingArg) (any, error) {
res, err := m.fetchPhases(ctx, req, id, siblings)
if err != nil {
return nil, err
}
if m.Say != nil && res.Took != nil {
m.Say("the fetch of %s at %s took %s", req.Repository, short(req.Commit), res.Took)
}
return res, nil
}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
func (m *Module) fetchPhases(ctx context.Context, req buildstep.FetchRequest, id string, siblings []siblingArg) (FetchResult, error) {
repository, commit, ref, base := req.Repository, req.Commit, req.Ref, req.Base
var err error
took := &FetchTook{}
clock := newPhaseClock(m.now)
defer func() { took.Total = clock.total() }()
root := filepath.Join(m.Work, id)
tree := filepath.Join(root, "tree")
if err := os.MkdirAll(tree, 0o700); err != nil {
return nil, err
return FetchResult{}, err
}
remote := m.remote(repository)
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.fetchOptions(ctx, repository, commit, ref)...); err != nil {
return nil, err
}
res := FetchResult{Workspace: id, Commit: commit}
opts := m.fetchOptions(ctx, repository, commit, ref)
mergeOpts := m.FetchOptions
if base != "" {
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
return nil, err
// The mirror is brought to the trunk commit too, so the merge preview deepens the tree from it and asks the forge
// for no history the mirror holds (issue 1921, try speed S6).
mergeOpts = m.fetchOptions(ctx, repository, base, "")
}
clock.lap(&took.Mirror)
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, opts...); err != nil {
return FetchResult{}, err
}
clock.lap(&took.Fetch)
res := FetchResult{Workspace: id, Commit: commit, Took: took}
if base != "" {
if err := m.merge(ctx, &res, tree, remote, base, commit, mergeOpts); err != nil {
return FetchResult{}, err
}
}
clock.lap(&took.Merge)
if err := m.dissociate(ctx, tree); err != nil {
return nil, err
return FetchResult{}, err
}
clock.lap(&took.Dissociate)
if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil {
return nil, err
return FetchResult{}, err
}
clock.lap(&took.Beside)
if (base == "" || res.Merge != "") && len(req.Paths) > 0 {
if err := buildstep.Narrow(tree, req); err != nil {
return nil, err
return FetchResult{}, err
}
// Said beside the tree, so the steps that list the tree's go.mod files leave out those narrowed away.
if err := os.WriteFile(filepath.Join(root, narrowedMark), nil, 0o600); err != nil {
return nil, err
return FetchResult{}, err
}
}
dirs, err := m.goModDirs(ctx, tree)
if err != nil {
return nil, err
return FetchResult{}, err
}
res.Modules = dirs
clock.lap(&took.Other)
if base != "" && res.Merge == "" {
return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested
}
@@ -168,9 +247,10 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, id s
}()
}
wg.Wait()
clock.lap(&took.Modules)
for _, err := range errs {
if err != nil {
return nil, err
return FetchResult{}, err
}
}
return res, nil
@@ -293,6 +373,7 @@ func (m *Module) goEnv() []string {
modcache = m.GoModCache
}
return []string{
"CGO_ENABLED=0", // as a production build compiles (build.go), so a check tests what ships (issue 1921, S11)
"GOTOOLCHAIN=local",
"GOFLAGS=-mod=mod",
"GOMODCACHE=" + modcache,
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@@ -302,3 +303,61 @@ func TestWorkAndCacheAreTheDirectoriesTheManifestPlacesOutsideTheHome(t *testing
}
}
}
// 1921/S1: the fetch says how long each phase ran (mirror, fetch, merge, dissociate, beside, other, modules), and the
// phases add up to its total, so a try's "clone" time is split into its parts.
func TestTheFetchSaysTheTimeOfEachPhaseAndTheyAddUpToTheTotal(t *testing.T) {
var mu sync.Mutex
now := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
advance := func(s int) {
mu.Lock()
now = now.Add(time.Duration(s) * time.Second)
mu.Unlock()
}
f := &fakeStep{gomods: "go.mod\n", files: map[string]string{"go.mod": "module x\n", "go.sum": ""}, rule: func(line string) (forgekey.Ran, bool) {
switch {
case strings.Contains(line, "/mirror/") && strings.Contains(line, " fetch "):
advance(1)
case strings.Contains(line, " fetch --no-tags --depth 1 "):
advance(2)
case strings.Contains(line, " merge --no-ff "):
advance(3)
case strings.Contains(line, " repack "):
advance(4)
case strings.HasSuffix(line, " mod download"):
advance(5)
}
return forgekey.Ran{}, false
}}
m, states := fetchModule(t, f)
said := []string{}
m.Say = func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
m.Now = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": strings.Repeat("b", 40)}
if _, err := fetchTool(t, m)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
if res.Took == nil {
t.Fatalf("the result says no phase times: %s", st.Result)
}
got := *res.Took
// the mirror is brought to the head and to the base (1 s each), and the preview deepens from it (1 s) before merging
want := FetchTook{Mirror: 2, Fetch: 2, Merge: 4, Dissociate: 4, Modules: 5, Total: 17}
if got != want {
t.Errorf("phases = %+v, want %+v", got, want)
}
if sum := got.Mirror + got.Fetch + got.Merge + got.Dissociate + got.Beside + got.Other + got.Modules; sum != got.Total {
t.Errorf("the phases add up to %v, not the total %v", sum, got.Total)
}
if !strings.Contains(strings.Join(said, "\n"), "dissociate 4.0s") {
t.Errorf("no log line names the phases: %q", said)
}
}
@@ -10,8 +10,10 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -62,34 +64,99 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa
if err != nil {
return nil, err
}
postgres, stopStore, err := m.startStore(ctx, run)
if err != nil {
return nil, fmt.Errorf("the integration level's store: %w", err)
}
defer stopStore()
nats, stopBus, err := m.startBus(ctx, filepath.Join(run, "nats"))
if err != nil {
return nil, fmt.Errorf("the integration level's bus: %w", err)
}
defer stopBus()
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
byDir, err := packagesByDir(tree, dirs, packages)
if err != nil {
return nil, err
}
// The tests the tags add, not the unit tests again (issue 1921, try speed S7): only the packages holding one compile
// and run, with a -run pattern naming them. A try that names its own pattern runs it as asked.
var runByDir map[string]string
if pattern == "" {
tagged, err := taggedTests(tree, dirs, packages, integrationTags)
if err != nil {
return nil, err
}
if len(tagged) == 0 {
res := &TestResult{Level: LevelIntegration, Packages: []PackageResult{}}
res.judge(m.integrationBounds())
return res, nil // no test the tags add: nothing to start or run
}
byDir, runByDir, packages = map[string][]string{}, map[string]string{}, nil
for d, tr := range tagged {
byDir[d], runByDir[d] = tr.packages, tr.pattern
packages = append(packages, tr.packages...)
}
sort.Strings(packages)
}
// The store, the bus and the compile start together (issue 1921, try speed S5); the setup is the longest of them.
// The compile runs no test binary (-exec true), so it needs neither server: whether the tests compile is for the run
// to say, and its failure is not read here; the run below finds them built.
var (
wg sync.WaitGroup
postgres, nats string
stopStore, stopBus func()
storeErr, busErr error
crash any
crashMu sync.Mutex
)
guard := func() {
if p := recover(); p != nil {
crashMu.Lock()
crash = p
crashMu.Unlock()
}
}
wg.Add(2)
go func() { defer wg.Done(); defer guard(); postgres, stopStore, storeErr = m.startStore(ctx, run) }()
go func() {
defer wg.Done()
defer guard()
nats, stopBus, busErr = m.startBus(ctx, filepath.Join(run, "nats"))
}()
slots := m.slots(workspace) // shared with the ask's unit level (issue 1921, try speed S10)
var slotErr error
for _, d := range dirs {
if len(byDir[d]) == 0 {
continue
}
m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-run", "^$", "-count=1"}, byDir[d]...)...)
release, err := slots.acquire(ctx)
if err != nil {
slotErr = err
break
}
wg.Add(1)
go func() {
defer wg.Done()
defer release()
defer guard()
m.Run(ctx, m.goEnvFor(filepath.Join(tree, d)), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-exec", "true", "-count=1"}, byDir[d]...)...)
}()
}
wg.Wait()
if stopStore != nil {
defer stopStore()
}
if stopBus != nil {
defer stopBus()
}
if crash != nil {
panic(crash)
}
if slotErr != nil {
return nil, slotErr
}
if storeErr != nil {
return nil, fmt.Errorf("the integration level's store: %w", storeErr)
}
if busErr != nil {
return nil, fmt.Errorf("the integration level's bus: %w", busErr)
}
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
setup := m.clock().Sub(began)
return m.runLevel(ctx, tree, levelOpts{
workspace: workspace, run: pattern,
workspace: workspace, run: pattern, runByDir: runByDir,
level: LevelIntegration, tags: integrationTags, packages: packages, bounds: m.integrationBounds(),
env: env,
setup: setup, wall: true,
@@ -6,6 +6,7 @@ import (
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
@@ -35,6 +36,8 @@ type integFake struct {
// command runs, with its line.
gomods string
onTest func(line string)
// onCompile is called, unlocked, as each compile of the setup runs (issue 1921, try speed S5).
onCompile func(line string)
}
func newIntegFake(t *testing.T, captured string) *integFake {
@@ -71,7 +74,10 @@ func (f *integFake) run(ctx context.Context, env []string, name string, args ...
return forgekey.Ran{Stdout: f.gomods}
}
return forgekey.Ran{Stdout: "go.mod\n"}
case name == "go" && strings.Contains(line, "-run ^$"):
case name == "go" && strings.Contains(line, "-exec true"):
if f.onCompile != nil {
f.onCompile(line)
}
f.tick("compile")
case name == "go":
if f.onTest != nil {
@@ -123,9 +129,32 @@ func integModule(t *testing.T, f *integFake) (*Module, *keptStates) {
if err := os.MkdirAll(filepath.Join(m.Work, "ws1", "tree"), 0o755); err != nil {
t.Fatal(err)
}
// A test the integration tag adds, so the level has a package to run (issue 1921, try speed S7).
writeTaggedTest(t, filepath.Join(m.Work, "ws1", "tree"), "fixture/slow")
return m, states
}
// taggedTestFile is the file writeTaggedTest writes: one test the integration tag adds.
const taggedTestFile = "level_integration_test.go"
// writeTaggedTest gives the go.mod directory dir the module path mp and one test the integration tag adds, in its root
// package and in its package x.
func writeTaggedTest(t *testing.T, dir, mp string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+mp+"\n\ngo 1.25.0\n"), 0o644); err != nil {
t.Fatal(err)
}
body := "//go:build integration\n\npackage x\n\nimport \"testing\"\n\nfunc TestTakesAWhile(t *testing.T) {}\n"
for _, d := range []string{dir, filepath.Join(dir, "x")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, taggedTestFile), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}
func runLevelTool(t *testing.T, m *Module, states *keptStates, level string) (TestResult, delivery.Step) {
t.Helper()
for _, tool := range m.Tools(m.StepTools()) {
@@ -314,11 +343,11 @@ func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) {
if !strings.Contains(f.lines(), " stop") {
t.Errorf("the half-started store was not stopped:\n%s", f.lines())
}
if f.busDir != "" {
t.Errorf("the bus was started after the store failed")
if f.busDir != "" && !f.busDown {
t.Errorf("the bus, started beside the store, was not stopped after the store failed")
}
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1")); len(entries) != 1 || entries[0].Name() != "tree" {
t.Errorf("the workspace holds %v, want only its tree", entries)
if left := workspaceLeft(filepath.Join(m.Work, "ws1")); len(left) != 1 || left[0] != "tree" {
t.Errorf("the workspace holds %v, want only its tree", left)
}
}
@@ -377,3 +406,138 @@ func TestTheSocketPathStaysUnderTheLimitForALongRunDirectory(t *testing.T) {
t.Errorf("the short socket directory %s outlived the store (%v)", sock, err)
}
}
// 1921/S5: the setup compiles the go.mod directories side by side, up to the bound, beside the store's and the bus's
// start, and compiles without running a test binary, so it needs neither; the setup stays one number.
func TestTheSetupCompilesTheDirectoriesAtOnceBesideTheStoreAndTheBus(t *testing.T) {
f := newIntegFake(t, "slow")
f.gomods = "a/go.mod\nb/go.mod\nc/go.mod\nd/go.mod\ne/go.mod\n"
var mu sync.Mutex
running, most := 0, 0
storeStarting, storeUp, overlap := false, false, false
f.onCompile = func(line string) {
mu.Lock()
running++
most = max(most, running)
mu.Unlock()
time.Sleep(40 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
}
m, states := integModule(t, f)
m.Parallel = 3
tree := filepath.Join(m.Work, "ws1", "tree")
for _, d := range []string{"a", "b", "c", "d", "e"} {
os.MkdirAll(filepath.Join(tree, d), 0o755)
writeTaggedTest(t, filepath.Join(tree, d), "x/"+d)
}
inner := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := strings.Join(args, " ")
if name == "pg_ctl" && strings.HasSuffix(line, " start") {
mu.Lock()
storeStarting = true
mu.Unlock()
time.Sleep(100 * time.Millisecond) // the store takes a while to start
defer func() { mu.Lock(); storeUp = true; mu.Unlock() }()
}
if name == "go" && strings.Contains(line, "-exec true") {
time.Sleep(10 * time.Millisecond)
mu.Lock()
overlap = overlap || (storeStarting && !storeUp)
mu.Unlock()
for _, e := range env {
if strings.HasPrefix(e, "MESH_TEST_") {
t.Errorf("the compile was given %s: it runs no test binary", e)
}
}
}
return inner(ctx, env, name, args...)
}
res, st := runLevelTool(t, m, states, "integration")
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if most != 3 {
t.Errorf("%d compiles ran at once, want the bound of 3", most)
}
mu.Lock()
defer mu.Unlock()
if !overlap {
t.Errorf("no compile ran while the store was starting:\n%s", f.lines())
}
if res.Setup < 0 {
t.Errorf("setup = %v", res.Setup)
}
}
// 1921/S7: the integration level runs the tests its tags add, not the unit tests again: only the packages holding a
// tagged test compile and run, with a -run pattern naming those tests; a tree with no tagged test starts no store.
func TestTheIntegrationLevelRunsOnlyTheTestsItsTagsAdd(t *testing.T) {
f := newIntegFake(t, "slow")
m, states := integModule(t, f)
tree := filepath.Join(m.Work, "ws1", "tree")
os.RemoveAll(filepath.Join(tree, "x"))
os.Remove(filepath.Join(tree, taggedTestFile))
for name, body := range map[string]string{
"go.mod": "module fixture/slow\n",
"unit/u_test.go": "package unit\n\nimport \"testing\"\n\nfunc TestUnit(t *testing.T) {}\n",
"store/s_test.go": "package store\n\nimport \"testing\"\n\nfunc TestPlain(t *testing.T) {}\n",
"store/s_integration_test.go": "//go:build integration\n\npackage store\n\nimport \"testing\"\n\nfunc TestMain(m *testing.M) {}\nfunc TestReal(t *testing.T) {}\nfunc Testable() {}\nfunc helper() {}\n",
"store/fake_test.go": "//go:build !integration\n\npackage store\n\nimport \"testing\"\n\nfunc TestFake(t *testing.T) {}\n",
"iso/iso_test.go": "//go:build integration && isolation && linux\n\npackage iso\n\nimport \"testing\"\n\nfunc TestShutOff(t *testing.T) {}\n",
"plat/p_linux_test.go": "//go:build linux\n\npackage plat\n\nimport \"testing\"\n\nfunc TestLinux(t *testing.T) {}\n",
} {
os.MkdirAll(filepath.Dir(filepath.Join(tree, name)), 0o755)
if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
var compiled, ran []string
for _, c := range f.calls {
switch {
case strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, "-exec true"):
compiled = append(compiled, c.line)
case strings.HasPrefix(c.line, "go ") && strings.Contains(c.line, " -json"):
ran = append(ran, c.line)
}
}
if len(ran) != 1 || !strings.Contains(ran[0], "-run ^(TestReal|TestShutOff)$") ||
!strings.HasSuffix(ran[0], " fixture/slow/iso fixture/slow/store") {
t.Errorf("the run = %q, want the tagged tests of iso and store alone", ran)
}
if len(compiled) != 1 || !strings.HasSuffix(compiled[0], " fixture/slow/iso fixture/slow/store") {
t.Errorf("the compile = %q, want iso and store alone", compiled)
}
g := newIntegFake(t, "slow")
m, states = integModule(t, g)
tree = filepath.Join(m.Work, "ws1", "tree")
os.RemoveAll(filepath.Join(tree, "x"))
os.Remove(filepath.Join(tree, taggedTestFile))
os.WriteFile(filepath.Join(tree, "u_test.go"), []byte("package x\n\nimport \"testing\"\n\nfunc TestUnit(t *testing.T) {}\n"), 0o644)
res, st := runLevelTool(t, m, states, "integration")
if st.State != delivery.StepPassed || !res.Passed || !res.NoTests {
t.Fatalf("a tree with no tagged test: %s %+v", st.State, res)
}
if strings.Contains(g.lines(), "pg_ctl") || strings.Contains(g.lines(), "go -C") {
t.Errorf("a tree with no tagged test started the store or ran go:\n%s", g.lines())
}
}
// workspaceLeft is what a workspace holds besides the directories its ask keeps there (askDirs: its cache layer and its
// bound on processes, gone with the workspace; issue 1921, S2 and S10).
func workspaceLeft(dir string) []string {
var left []string
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if !slices.Contains(askDirs, e.Name()) {
left = append(left, e.Name())
}
}
return left
}
@@ -29,21 +29,30 @@ func (m *Module) caches() []string {
}
// isolation is how a run step of the workspace is shut off: the workspace (its tree, its siblings, its run directories,
// its packed artifacts) read-write, the caches beneath a throwaway layer unless write, and an environment of a PATH and
// its packed artifacts) read-write, the caches beneath the ask's own layer unless write, and an environment of a PATH and
// a locale alone (the step's HOME is its own, inside its /tmp).
func (m *Module) isolation(workspace string, write bool) buildstep.Isolation {
path := os.Getenv("PATH")
if path == "" {
path = defaultPath
}
return buildstep.Isolation{
iso := buildstep.Isolation{
Workspace: filepath.Join(m.Work, workspace),
Caches: m.caches(),
WriteCaches: write,
Env: []string{"PATH=" + path, "LC_ALL=C"},
}
if !write {
// What one command of the ask compiles is kept for its later commands (the vet, the unit tests, the integration
// level's compile), in the workspace and gone with it (issue 1921, try speed S2).
iso.CacheLayer = filepath.Join(iso.Workspace, cacheLayerDir)
}
return iso
}
// cacheLayerDir is the directory in a workspace's root where an ask keeps what its commands wrote into the caches.
const cacheLayerDir = "cachelayer"
// stepRunner is the runner of a run step of the workspace: buildstep.Isolated over m.Run, so every command it runs is
// shut off; write is true for a trunk build alone (the shared caches are written by trunk builds only). Inside is the
// integration level's own process, already running shut off (level.go): it runs its commands as they are.
@@ -20,6 +20,7 @@ import (
type shutOff struct {
flags []string
binds, roBinds, overlays []string
uppers map[string]string // the writable layer each --overlay gives a cache: cache -> upper
env []string
command string
chdir string
@@ -27,7 +28,7 @@ type shutOff struct {
// bwrapArity is how many words each bubblewrap option the SDK writes takes.
var bwrapArity = map[string]int{"--unshare-all": 0, "--die-with-parent": 0, "--new-session": 0, "--clearenv": 0,
"--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1,
"--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1, "--overlay": 3,
"--ro-bind": 2, "--ro-bind-try": 2, "--bind": 2, "--symlink": 2, "--setenv": 2}
// throughBubblewrap is the fake runner of a step's tests: a bubblewrap command is read back (recorded in seen) and its
@@ -56,6 +57,11 @@ func throughBubblewrap(t *testing.T, run forgekey.Runner, seen *[]shutOff) forge
s.roBinds = append(s.roBinds, args[i+1])
case "--overlay-src":
s.overlays = append(s.overlays, args[i+1])
case "--overlay":
if s.uppers == nil {
s.uppers = map[string]string{}
}
s.uppers[args[i+3]] = args[i+1]
case "--setenv":
s.env = append(s.env, args[i+1]+"="+args[i+2])
case "--chdir":
@@ -105,6 +111,9 @@ func checkShutOff(t *testing.T, m *Module, s shutOff, write bool) {
t.Errorf("a trunk build's %s does not write the shared cache %s (bound %v, layered %v)", s.command, c, bound, layered)
case !write && (bound || !layered):
t.Errorf("%s may write the shared cache %s (bound %v, layered %v): only a trunk build does", s.command, c, bound, layered)
case !write && !strings.HasPrefix(s.uppers[c], filepath.Join(m.Work, "ws1", "cachelayer")+"/"):
// 1921/S2: what one command of the ask compiles is kept for its later commands, in the workspace
t.Errorf("%s writes the cache %s into %q, not the ask's cache layer in its workspace", s.command, c, s.uppers[c])
}
}
if want := filepath.Join(m.Work, "ws1"); !slices.Contains(s.binds, want) {
@@ -131,7 +140,8 @@ func TestTheUnitLevelRunsEveryGoTestShutOff(t *testing.T) {
t.Fatalf("%d go commands ran, %d of them shut off: %v", ran, len(seen), *calls)
}
for _, s := range seen {
checkShutOff(t, m, s, false)
// the lint's go vet, a compile alone, writes the shared cache (issue 1921, S11): cgo_test.go
checkShutOff(t, m, s, strings.Contains(s.command, " vet "))
}
}
@@ -41,6 +41,8 @@ type levelSpec struct {
Run string `json:"run,omitempty"`
Setup time.Duration `json:"setup"`
Bound time.Duration `json:"bound"`
// Parallel is the ask's bound on processes, which the process shares with the ask's other level (issue 1921, S10).
Parallel int `json:"parallel,omitempty"`
}
// levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run.
@@ -83,7 +85,7 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
// workspace read-write, the program alone read-only, the shared caches beneath a throwaway layer, the Node build
// module's toolchains read-only for the integration level.
func (m *Module) shutOff(ctx context.Context, exe string, spec levelSpec) (levelAnswer, error) {
spec.Work, spec.Cache = m.Work, m.Cache
spec.Work, spec.Cache, spec.Parallel = m.Work, m.Cache, m.parallel()
raw, err := json.Marshal(spec)
if err != nil {
return levelAnswer{}, err
@@ -165,7 +167,7 @@ func runLevelProcess(args []string) int {
return say(levelAnswer{Error: fmt.Sprintf("%q is not a workspace id", spec.Workspace)})
}
m := &Module{Inside: true, Recipe: Current(), Work: spec.Work, Cache: spec.Cache, Home: os.Getenv("HOME"), UnitBound: spec.Bound,
IntegrationBounds: Bounds{Setup: spec.Setup, Run: spec.Bound}, Say: func(string, ...any) {}}
IntegrationBounds: Bounds{Setup: spec.Setup, Run: spec.Bound}, Parallel: spec.Parallel, Say: func(string, ...any) {}}
m.Run = StepRunner(m.Home)
return say(m.answer(context.Background(), spec))
}
@@ -57,8 +57,14 @@ type Failure struct {
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
Cause string `json:"cause,omitempty"`
// Step is StepLint for a finding of the lint step (gofmt, go vet), which runs beside the unit level and is not one of
// the levels (ADR 0542 §2): the checker reports it as the lint step's failure. Empty for a test's failure.
Step string `json:"step,omitempty"`
}
// StepLint is the Step of a lint finding.
const StepLint = "lint"
// TestResult is what a test step passes with.
type TestResult struct {
Level string `json:"level"`
@@ -73,7 +79,7 @@ type TestResult struct {
Elapsed float64 `json:"elapsed"`
// Setup is the level's setup time in seconds, when it has a setup.
Setup float64 `json:"setup,omitempty"`
// Lint is the unit level's gofmt and go vet time in seconds, before its tests (ADR 0529 §1).
// Lint is the unit level's gofmt and go vet time in seconds, run beside its tests (ADR 0529 §1; issue 1921).
Lint float64 `json:"lint,omitempty"`
// Bound and SetupBound are the bounds the level was judged by, in seconds.
Bound float64 `json:"bound"`
+104 -30
View File
@@ -60,17 +60,23 @@ func lintable(dir string, each func(path string) error) error {
})
}
// unformatted are the Go files of a directory gofmt would change, relative to the tree, and the build tags they name.
func unformatted(tree, d string) (files, tags []string, err error) {
// unformatted are the Go files of a directory gofmt would change, relative to the tree, the build tags the directory's
// files name, and whether a file is left out by one of them (a negated tag, //go:build !integration). Only files whose
// directory (relative to the go.mod directory, "." for its own) is in only are checked for gofmt; nil checks every file.
func unformatted(tree, d string, only map[string]bool) (files, tags []string, negated bool, err error) {
seen := map[string]bool{}
err = lintable(filepath.Join(tree, d), func(path string) error {
base := filepath.Join(tree, d)
err = lintable(base, func(path string) error {
src, err := os.ReadFile(path)
if err != nil {
return err
}
if out, ferr := format.Source(src); ferr != nil || !bytes.Equal(out, src) {
rel, _ := filepath.Rel(tree, path)
files = append(files, filepath.ToSlash(rel))
pkgDir, _ := filepath.Rel(base, filepath.Dir(path))
if only == nil || only[filepath.ToSlash(pkgDir)] {
if out, ferr := format.Source(src); ferr != nil || !bytes.Equal(out, src) {
rel, _ := filepath.Rel(tree, path)
files = append(files, filepath.ToSlash(rel))
}
}
scan := bufio.NewScanner(bytes.NewReader(src))
for scan.Scan() {
@@ -79,8 +85,15 @@ func unformatted(tree, d string) (files, tags []string, err error) {
break
}
if expr, ok := strings.CutPrefix(line, "//go:build "); ok {
for _, tag := range buildTag.FindAllString(expr, -1) {
if !notATag[tag] && !strings.HasPrefix(tag, "go1") && !seen[tag] {
for _, at := range buildTag.FindAllStringIndex(expr, -1) {
tag := expr[at[0]:at[1]]
if notATag[tag] || strings.HasPrefix(tag, "go1") {
continue
}
if before := strings.TrimRight(expr[:at[0]], " ("); strings.HasSuffix(before, "!") {
negated = true
}
if !seen[tag] {
seen[tag] = true
tags = append(tags, tag)
}
@@ -91,62 +104,123 @@ func unformatted(tree, d string) (files, tags []string, err error) {
})
sort.Strings(files)
sort.Strings(tags)
return files, tags, err
return files, tags, negated, err
}
// lint is gofmt and go vet of one go.mod directory d of the tree, run with run in the directory's go environment; a
// failure of either is a Failure of the level naming what failed.
func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string, env []string) ([]Failure, error) {
files, tags, err := unformatted(tree, d)
// boundOf is a bound of n processes of this process alone.
func boundOf(n int) func(context.Context) (func(), error) {
slots := make(chan struct{}, n)
return func(context.Context) (func(), error) {
slots <- struct{}{}
return func() { <-slots }, nil
}
}
// lintScope is what of a go.mod directory d the lint covers, from the patterns the level runs there: every package
// (./..., and the directories nil) when a pattern is relative or none is given, else the import paths given and the
// directories of their files, relative to d.
func lintScope(tree, d string, patterns []string) (targets []string, dirs map[string]bool) {
if len(patterns) == 0 {
return []string{"./..."}, nil
}
raw, _ := os.ReadFile(filepath.Join(tree, d, "go.mod"))
mp := modulePath(string(raw))
dirs = map[string]bool{}
for _, p := range patterns {
if strings.HasPrefix(p, "./") || p == "." {
return []string{"./..."}, nil
}
rel, ok := strings.CutPrefix(p, mp)
if mp == "" || !ok || (rel != "" && !strings.HasPrefix(rel, "/")) {
return []string{"./..."}, nil // a package of no module here: the whole directory, as before
}
if rel = strings.TrimPrefix(rel, "/"); rel == "" {
rel = "."
}
dirs[rel] = true
targets = append(targets, p)
}
return targets, dirs
}
// lint is gofmt and go vet of one go.mod directory d of the tree, run with run in the directory's go environment, over
// the packages the level runs there (patterns, as packagesByDir gives them; issue 1921, try speed S3); a failure of either
// is a Failure of the level naming what failed. go vet runs once with every tag the directory names, which vets the
// untagged files too; only a directory where a tag leaves a file out vets also without tags, both passes at once.
func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string, env, patterns []string) ([]Failure, error) {
targets, only := lintScope(tree, d, patterns)
files, tags, negated, err := unformatted(tree, d, only)
if err != nil {
return nil, fmt.Errorf("reading the Go files of %s: %w", d, err)
}
var failed []Failure
if len(files) > 0 {
failed = append(failed, Failure{Package: d, Test: "gofmt",
failed = append(failed, Failure{Package: d, Test: "gofmt", Step: StepLint,
Output: "not as gofmt writes them (run gofmt -w): " + strings.Join(files, ", ")})
}
sets := [][]string{nil}
if len(tags) > 0 {
sets = append(sets, tags)
sets := [][]string{tags}
if len(tags) > 0 && negated {
sets = [][]string{nil, tags}
}
for _, set := range sets {
type vetRun struct {
name string
r forgekey.Ran
}
runs := make([]vetRun, len(sets))
var wg sync.WaitGroup
for i, set := range sets {
args := []string{"-C", filepath.Join(tree, d), "vet"}
name := "go vet"
if len(set) > 0 {
args = append(args, "-tags", strings.Join(set, ","))
name += " -tags " + strings.Join(set, ",")
}
args = append(args, "./...")
r := run(ctx, env, "go", args...)
if r.Err != "" {
return nil, fmt.Errorf("%s in %s could not be run: %s", name, d, firstLine(r.Err+"\n"+r.Stderr))
args = append(args, targets...)
runs[i].name = name
wg.Add(1)
go func() {
defer wg.Done()
runs[i].r = run(ctx, env, "go", args...)
}()
}
wg.Wait()
for _, v := range runs {
if v.r.Err != "" {
return nil, fmt.Errorf("%s in %s could not be run: %s", v.name, d, firstLine(v.r.Err+"\n"+v.r.Stderr))
}
if r.Status != 0 {
failed = append(failed, Failure{Package: d, Test: name, Output: strings.TrimSpace(r.Stderr + "\n" + r.Stdout)})
if v.r.Status != 0 {
failed = append(failed, Failure{Package: d, Test: v.name, Step: StepLint, Output: strings.TrimSpace(v.r.Stderr + "\n" + v.r.Stdout)})
}
}
return failed, nil
}
// lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930).
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) {
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930). byDir names
// the packages the level runs in each directory (packagesByDir); nil lints each directory whole. acquire takes a slot of
// the bound the lint shares with the ask's tests; nil gives it one of its own.
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string, byDir map[string][]string, acquire func(context.Context) (func(), error)) ([]Failure, error) {
type answer struct {
failed []Failure
err error
}
answers := make([]answer, len(dirs))
slots := make(chan struct{}, m.parallel())
if acquire == nil {
acquire = boundOf(m.parallel())
}
var wg sync.WaitGroup
var crash any // a panic of a lint goes on in the caller, as a panic of a test run does
var crashMu sync.Mutex
for i, d := range dirs {
release, err := acquire(ctx)
if err != nil {
answers[i].err = err
break
}
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
defer release()
defer func() {
if p := recover(); p != nil {
crashMu.Lock()
@@ -154,7 +228,7 @@ func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string,
crashMu.Unlock()
}
}()
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d))
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d), byDir[d])
}()
}
wg.Wait()
@@ -4,11 +4,13 @@ import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -28,8 +30,9 @@ func writeTree(t *testing.T, files map[string]string) string {
}
// 1887/D7-go, ADR 0529 §1: an unformatted file fails the unit level naming the file (testdata and vendor are left
// alone), and go vet runs with no tag and with every tag the files name together, integration and isolation included, so
// a vet error behind isolation fails the level naming the run.
// alone), and go vet runs with every tag the files name together, integration and isolation included, so a vet error
// behind isolation fails the level naming the run. One pass with every tag vets the untagged files too (issue 1921, try
// speed S3), as no file here is left out by a tag.
func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) {
tree := writeTree(t, map[string]string{
"go.mod": "module x\n\ngo 1.26\n",
@@ -51,11 +54,11 @@ func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) {
}
return forgekey.Ran{}
}
failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil)
failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil, nil)
if err != nil {
t.Fatal(err)
}
if strings.Join(vetted, " | ") != "vet ./... | vet -tags integration,isolation ./..." {
if strings.Join(vetted, " | ") != "vet -tags integration,isolation ./..." {
t.Errorf("vet ran %v", vetted)
}
if len(failed) != 2 || failed[0].Test != "gofmt" || !strings.Contains(failed[0].Output, "b.go") ||
@@ -67,14 +70,14 @@ func TestTheUnitLevelRunsGofmtAndVetWithEveryTag(t *testing.T) {
}
// A formatted directory that vets passes.
clean := writeTree(t, map[string]string{"go.mod": "module y\n", "a.go": "package y\n"})
if failed, err := (&Module{}).lint(context.Background(), func(context.Context, []string, string, ...string) forgekey.Ran { return forgekey.Ran{} }, clean, ".", nil); err != nil || len(failed) != 0 {
if failed, err := (&Module{}).lint(context.Background(), func(context.Context, []string, string, ...string) forgekey.Ran { return forgekey.Ran{} }, clean, ".", nil, nil); err != nil || len(failed) != 0 {
t.Errorf("a clean directory: %+v, %v", failed, err)
}
}
// 1887/D7-go: through the step, the unit level of a tree holding an unformatted file fails, naming the file.
func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) {
m, states, ws, calls := workspaceFor(t, "empty")
m, states, ws, _ := workspaceFor(t, "empty")
tree := filepath.Join(m.Work, ws, "tree")
for name, body := range map[string]string{"go.mod": "module x\n", "x.go": "package x\nfunc X() {}\n"} {
if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil {
@@ -85,50 +88,117 @@ func TestAnUnformattedFileFailsTheUnitLevel(t *testing.T) {
if res.Passed || len(res.Failed) != 1 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "x.go") {
t.Fatalf("result = %+v, want the level failed naming x.go", res)
}
// Review round 2 item 5: before the level's tests, which do not run once gofmt failed.
for _, c := range *calls {
if strings.Contains(c.line, " test -json") {
t.Errorf("the tests ran after gofmt failed: %s", c.line)
}
// 1921/S4: the lint runs beside the unit level's tests, not before them: both have started before either ends, a lint
// failure still fails the level, and the result reports the lint's time and the tests' failures both.
func TestTheLintRunsBesideTheUnitTestsAndItsFailureStillFailsTheLevel(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "failing")
tree := filepath.Join(m.Work, ws, "tree")
for name, body := range map[string]string{"go.mod": "module fixture/failing\n", "x.go": "package x\nfunc X() {}\n"} {
if err := os.WriteFile(filepath.Join(tree, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
m.Parallel = 4
inner := m.Run
var vetStarted, testStarted sync.Once
vet, test := make(chan struct{}), make(chan struct{})
timeout := time.After(2 * time.Second)
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := strings.Join(args, " ")
switch {
case strings.Contains(line, " vet "):
vetStarted.Do(func() { close(vet) })
select { // the vet ends only once a test has started
case <-test:
case <-timeout:
t.Error("no test started while the vet ran")
}
case strings.Contains(line, " test -json"):
testStarted.Do(func() { close(test) })
select {
case <-vet:
case <-timeout:
t.Error("no vet started while the tests ran")
}
}
return inner(ctx, env, name, args...)
}
res, st := runUnit(t, m, states, ws)
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if res.Passed || len(res.Failed) < 2 || res.Failed[0].Test != "gofmt" || res.Failed[1].Test != "TestThreeIsFour" {
t.Fatalf("failed = %+v, want gofmt's failure, then the test's", res.Failed)
}
// ADR 0542 §2: the lint's failure is the lint step's, not the unit level's.
if res.Failed[0].Step != StepLint || res.Failed[1].Step != "" {
t.Errorf("steps = %q, %q; want the gofmt failure the lint step's and the test's the level's", res.Failed[0].Step, res.Failed[1].Step)
}
}
// Issue 1930: the lint of many directories runs side by side up to the level's bound, not one after another, and the
// failures still join in directory order.
func TestTheLintOfManyDirectoriesRunsAtOnceUpToTheBound(t *testing.T) {
files := map[string]string{}
var dirs []string
for _, d := range []string{"a", "b", "c", "d", "e", "f"} {
files[d+"/go.mod"] = "module x/" + d + "\n"
files[d+"/x.go"] = "package x\n"
dirs = append(dirs, d)
}
tree := writeTree(t, files)
var mu sync.Mutex
running, most := 0, 0
// 1921/S3: the lint of a level that runs some packages vets those packages alone, in one pass with every tag their
// directory names, and reports a gofmt fault only in their files: a fault in a package the level keeps was reported
// when that package changed.
func TestTheLintOfSomePackagesVetsThemInOnePassAndFormatsOnlyTheirFiles(t *testing.T) {
tree := writeTree(t, map[string]string{
"go.mod": "module x\n\ngo 1.26\n",
"a/a.go": "package a\n\nfunc A() {}\n",
"a/a_integration_test.go": "//go:build integration\n\npackage a\n",
"b/b.go": "package b\nfunc B() {}\n",
"a/deeper/d.go": "package deeper\nfunc D() {}\n",
})
var vetted []string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
mu.Lock()
running++
most = max(most, running)
mu.Unlock()
time.Sleep(30 * time.Millisecond)
mu.Lock()
running--
mu.Unlock()
if strings.HasSuffix(args[1], "b") || strings.HasSuffix(args[1], "e") {
return forgekey.Ran{Status: 1, Stderr: "bad " + filepath.Base(args[1])}
}
vetted = append(vetted, strings.Join(args[2:], " "))
return forgekey.Ran{}
}
m := &Module{Parallel: 3}
failed, err := m.lintAll(context.Background(), run, tree, dirs, func(string) []string { return nil })
failed, err := (&Module{}).lint(context.Background(), run, tree, ".", nil, []string{"x/a"})
if err != nil {
t.Fatal(err)
}
if most < 2 || most > 3 {
t.Errorf("%d lint commands ran at once, want 2 or 3 (the bound)", most)
if strings.Join(vetted, " | ") != "vet -tags integration x/a" {
t.Errorf("vet ran %v, want one pass over x/a with its tag", vetted)
}
if len(failed) != 2 || failed[0].Package != "b" || failed[1].Package != "e" {
t.Errorf("failed = %+v, want b's then e's", failed)
if len(failed) != 0 {
t.Errorf("the faults of packages the level keeps were reported: %+v", failed)
}
vetted = nil
failed, err = (&Module{}).lint(context.Background(), run, tree, ".", nil, []string{"x/b"})
if err != nil {
t.Fatal(err)
}
if len(failed) != 1 || failed[0].Test != "gofmt" || !strings.Contains(failed[0].Output, "b/b.go") || strings.Contains(failed[0].Output, "d.go") {
t.Errorf("failed = %+v, want gofmt naming b/b.go alone", failed)
}
if strings.Join(vetted, " | ") != "vet -tags integration x/b" {
t.Errorf("vet ran %v", vetted)
}
}
// 1921/S3: a file a tag leaves out (//go:build !integration) is vetted by no pass with the tag, so a directory holding
// one keeps the pass without tags beside the pass with them.
func TestADirectoryWithAFileATagLeavesOutKeepsThePassWithoutTags(t *testing.T) {
tree := writeTree(t, map[string]string{
"go.mod": "module x\n",
"a.go": "package x\n",
"fake.go": "//go:build !integration\n\npackage x\n",
"real_test.go": "//go:build integration\n\npackage x\n",
})
var mu sync.Mutex
var vetted []string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
mu.Lock()
vetted = append(vetted, strings.Join(args[2:], " "))
mu.Unlock()
return forgekey.Ran{}
}
if _, err := (&Module{}).lint(context.Background(), run, tree, ".", nil, nil); err != nil {
t.Fatal(err)
}
slices.Sort(vetted)
if strings.Join(vetted, " | ") != "vet -tags integration ./... | vet ./..." {
t.Errorf("vet ran %v, want the pass without tags and the pass with them", vetted)
}
}
@@ -12,6 +12,7 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func fetchOnce(t *testing.T, m *Module, states *keptStates, args map[string]any) FetchResult {
@@ -188,3 +189,61 @@ func TestTheWorkspaceIsSelfContainedAfterAFetchThroughTheMirror(t *testing.T) {
t.Errorf("the workspace is not whole: %v %s", err, out)
}
}
// 1921/S6: with a base, the mirror is brought to the trunk commit too, and the merge preview deepens the tree from the
// mirror: it asks the forge for no history the mirror holds.
func TestTheMirrorIsBroughtToTheBaseAndThePreviewFetchesFromIt(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(false)}
m, states := fetchModule(t, f)
mirror := filepath.Join(m.Cache, "mirror", "novox", "mesh-sdk.git")
f.files = map[string]string{"modules/m/module.json": "{}\n", "modules/m/go.sum": "sum\n", "go.mod": "module x\n", "go.sum": ""}
fetchOnce(t, m, states, map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA})
lines := strings.Join(f.lines(), "\n")
if !strings.Contains(lines, "-C "+mirror+" fetch --no-tags origin "+baseSHA) {
t.Errorf("the mirror was not brought to the base:\n%s", lines)
}
if !strings.Contains(lines, "fetch --no-tags --unshallow "+mirror+" "+baseSHA) || strings.Contains(lines, "--unshallow origin") {
t.Errorf("the preview fetched the trunk's history from the forge:\n%s", lines)
}
}
// 1921/S6: the siblings are placed side by side: each sibling's fetch has started before any of them ends.
func TestTheSiblingsArePlacedAtOnce(t *testing.T) {
var mu sync.Mutex
started := map[string]bool{}
both := make(chan struct{})
f := &fakeStep{gomods: "go.mod\n"}
f.rule = func(line string) (forgekey.Ran, bool) {
if !strings.Contains(line, "/beside/") || !strings.Contains(line, " fetch --no-tags --depth 1 ") {
return forgekey.Ran{}, false
}
mu.Lock()
for _, name := range []string{"mesh-catalog", "mesh-host"} {
if strings.Contains(line, "/beside/"+name) {
started[name] = true
}
}
if len(started) == 2 {
select {
case <-both:
default:
close(both)
}
}
mu.Unlock()
select {
case <-both:
case <-time.After(2 * time.Second):
return forgekey.Ran{Status: 1, Stderr: "the other sibling's fetch never started"}, true
}
return forgekey.Ran{}, false
}
m, states := fetchModule(t, f)
res := fetchOnce(t, m, states, map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{
map[string]any{"repository": "novox/mesh-host", "commit": commitSHA},
map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA},
}})
if len(res.Beside) != 2 || res.Beside[0].Dir != "mesh-catalog" || res.Beside[1].Dir != "mesh-host" {
t.Errorf("beside = %+v, want both, in name order", res.Beside)
}
}
@@ -13,6 +13,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
@@ -34,8 +35,9 @@ func (m *Module) remote(repository string) string {
}
// merge makes the merge preview of the head onto base and writes the answer into res.
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
// opts are the fetch's options, with the mirror when it holds base.
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string, opts []buildstep.Option) error {
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, opts...)
if err != nil {
return err
}
@@ -89,23 +91,22 @@ func (m *Module) besideDir(workspace string) string {
// placeSiblings fetches each sibling into beside/<name> of the workspace root, as a plain directory. A symlink anywhere
// in a placed tree fails the step naming it: the mesh creates none, and a test must not be led out of its sibling.
func (m *Module) placeSiblings(ctx context.Context, root string, siblings []siblingArg) ([]Sibling, error) {
var placed []Sibling
for _, s := range siblings {
name := s.repository[strings.Index(s.repository, "/")+1:]
dir := filepath.Join(root, "beside", name)
if err := os.MkdirAll(dir, 0o700); err != nil {
// The siblings are placed side by side (issue 1921, try speed S6); the first failure in the order given is the step's.
placed := make([]Sibling, len(siblings))
errs := make([]error, len(siblings))
var wg sync.WaitGroup
for i, s := range siblings {
wg.Add(1)
go func() {
defer wg.Done()
placed[i], errs[i] = m.placeSibling(ctx, root, s)
}()
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, s.ref, m.fetchOptions(ctx, s.repository, s.commit, s.ref)...); err != nil {
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
}
if err := m.dissociate(ctx, dir); err != nil {
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
}
if link := firstSymlink(dir); link != "" {
return nil, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
}
placed = append(placed, Sibling{Repository: s.repository, Commit: s.commit, Dir: name})
}
sort.Slice(placed, func(i, j int) bool { return placed[i].Dir < placed[j].Dir })
if len(placed) > 0 {
@@ -121,6 +122,25 @@ func (m *Module) placeSiblings(ctx context.Context, root string, siblings []sibl
return placed, nil
}
// placeSibling fetches one sibling into beside/<name> of the workspace root.
func (m *Module) placeSibling(ctx context.Context, root string, s siblingArg) (Sibling, error) {
name := s.repository[strings.Index(s.repository, "/")+1:]
dir := filepath.Join(root, "beside", name)
if err := os.MkdirAll(dir, 0o700); err != nil {
return Sibling{}, err
}
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, s.ref, m.fetchOptions(ctx, s.repository, s.commit, s.ref)...); err != nil {
return Sibling{}, fmt.Errorf("beside %s: %w", s.repository, err)
}
if err := m.dissociate(ctx, dir); err != nil {
return Sibling{}, fmt.Errorf("beside %s: %w", s.repository, err)
}
if link := firstSymlink(dir); link != "" {
return Sibling{}, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
}
return Sibling{Repository: s.repository, Commit: s.commit, Dir: name}, nil
}
// besideRecord is the file in a workspace's root naming the siblings the fetch placed beside it.
const besideRecord = "beside.json"
@@ -85,7 +85,8 @@ func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
t.Errorf("trees = %+v", res.Trees)
}
lines := strings.Join(f.lines(), "\n")
for _, want := range []string{"fetch --no-tags --unshallow origin " + baseSHA, "checkout -q --detach " + baseSHA,
mirror := filepath.Join(m.Cache, "mirror", "novox", "mesh-sdk.git")
for _, want := range []string{"fetch --no-tags --unshallow " + mirror + " " + baseSHA, "checkout -q --detach " + baseSHA,
"core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
if !strings.Contains(lines, want) {
t.Errorf("git was not asked %q:\n%s", want, lines)
@@ -290,8 +291,8 @@ func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(
}
tested := 0
for _, c := range f.calls {
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") {
continue
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") || strings.Contains(c.line, "-exec true") {
continue // the setup's compile runs no test binary, so it is given nothing a test reads (issue 1921, S5)
}
tested++
got := ""
@@ -0,0 +1 @@
These tests read other catalogue modules' files, outside this module (issue 1935): never kept.
@@ -24,9 +24,7 @@ func threeModules(t *testing.T, f *integFake) (*Module, *keptStates) {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module fixture/"+d+"\n\ngo 1.25.0\n"), 0o644); err != nil {
t.Fatal(err)
}
writeTaggedTest(t, dir, "fixture/"+d)
}
return m, states
}
@@ -72,7 +70,7 @@ func TestOnlyTheSelectedDirectoryCompilesAndRuns(t *testing.T) {
if !strings.HasPrefix(l, "go -C "+filepath.Join(tree, "b")+" test ") {
t.Errorf("go test ran outside the selected directory: %s", l)
}
if strings.Contains(l, "-run ^$") {
if strings.Contains(l, "-exec true") {
compiled++
} else {
ran++
@@ -91,7 +89,7 @@ func TestWithoutASelectionEveryDirectoryCompilesAndRuns(t *testing.T) {
compiled, ran := 0, 0
for _, l := range strings.Split(f.lines(), "\n") {
if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") {
if strings.Contains(l, "-run ^$") {
if strings.Contains(l, "-exec true") {
compiled++
} else {
ran++
@@ -0,0 +1,81 @@
package main
// The bound on the processes one ask starts (issue 1921, try speed S10; ADR 0541): the levels of an ask run side by side,
// the unit level in this process and the integration level in a shut-off process of its own, and they share one bound,
// the processors divided by the node's build capacity. The bound lives in the ask's workspace as lock files, one per
// slot, so both processes count against it.
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"sync/atomic"
"syscall"
)
// slotsDir is the directory in a workspace's root holding its slots.
const slotsDir = "slots"
// askDirs are the directories an ask keeps in its workspace's root beside its tree, gone with the workspace: its cache
// layer and its bound on processes.
var askDirs = []string{cacheLayerDir, slotsDir}
// numCPU is the node's processor count.
func numCPU() int { return runtime.NumCPU() }
// slotPool is the bound of one workspace: n slots, each a lock file under dir.
type slotPool struct {
dir string
n int
next *atomic.Uint32
}
// slotTurns spreads the waits of the commands that find every slot taken over the slots.
var slotTurns atomic.Uint32
// slots is the bound of the workspace's processes, m.parallel() slots.
func (m *Module) slots(workspace string) slotPool {
return slotPool{dir: filepath.Join(m.Work, workspace, slotsDir), n: m.parallel(), next: &slotTurns}
}
// acquire takes a slot of the pool, waiting for one when every slot is taken, and answers what frees it. A free slot is
// taken at once; when none is, the wait is on one slot, taken in turn, until its holder frees it.
func (p slotPool) acquire(ctx context.Context) (func(), error) {
if err := os.MkdirAll(p.dir, 0o700); err != nil {
return nil, fmt.Errorf("the workspace's bound on processes: %w", err)
}
take := func(i int, how int) (func(), bool, error) {
f, err := os.OpenFile(filepath.Join(p.dir, "slot-"+strconv.Itoa(i)), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return nil, false, err
}
if err := syscall.Flock(int(f.Fd()), how); err != nil {
f.Close()
if err == syscall.EWOULDBLOCK {
return nil, false, nil
}
return nil, false, err
}
return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, true, nil
}
for i := 0; i < p.n; i++ {
release, ok, err := take(i, syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
return nil, fmt.Errorf("the workspace's bound on processes: %w", err)
}
if ok {
return release, nil
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
release, _, err := take(int(p.next.Add(1))%p.n, syscall.LOCK_EX)
if err != nil {
return nil, fmt.Errorf("the workspace's bound on processes: %w", err)
}
return release, nil
}
@@ -0,0 +1,61 @@
package main
import (
"context"
"strings"
"sync/atomic"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1921/S10 (ADR 0541): the levels of one ask, running side by side, share one bound on the processes they start: the
// bound is the workspace's, so a slot another level of the ask holds is one this level waits for.
func TestTheLevelsOfAnAskShareOneBoundOnProcesses(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "failing")
m.Parallel = 1
held, err := m.slots(ws).acquire(context.Background()) // the other level holds the one slot
if err != nil {
t.Fatal(err)
}
var ran atomic.Int32
inner := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if strings.Contains(strings.Join(args, " "), " go ") || name == "go" {
ran.Add(1)
}
return inner(ctx, env, name, args...)
}
done := make(chan delivery.Step, 1)
go func() { _, st := runUnit(t, m, states, ws); done <- st }()
time.Sleep(150 * time.Millisecond)
if n := ran.Load(); n != 0 {
t.Errorf("%d go commands ran while the ask's one slot was held by its other level", n)
}
held()
select {
case st := <-done:
if st.State != delivery.StepPassed {
t.Errorf("the step ended %s: %s", st.State, st.Reason)
}
case <-time.After(3 * time.Second):
t.Fatal("the level never ran after the slot was freed")
}
if ran.Load() == 0 {
t.Error("no go command ran")
}
}
// 1921/S10 (ADR 0541): the bound is the processors divided by the node's build capacity, not each level half of them.
func TestTheBoundIsTheProcessorsDividedByTheBuildCapacity(t *testing.T) {
m := &Module{Capacity: func() int { return 3 }}
if got, want := m.parallel(), max(1, numCPU()/3); got != want {
t.Errorf("parallel = %d, want %d", got, want)
}
m = &Module{}
if got, want := m.parallel(), max(1, numCPU()/2); got != want {
t.Errorf("with no capacity known parallel = %d, want %d", got, want)
}
}
@@ -0,0 +1,161 @@
package main
// The integration level runs the tests its tags add, not the unit tests again (issue 1921, try speed S7): `go test
// -tags integration` without -run would run every untagged test too, and the unit level ran those already. Which tests a
// tag adds is read from the source: a test function in a _test.go file whose //go:build line holds with the level's
// tags and does not hold without them.
import (
"go/ast"
"go/build/constraint"
"go/parser"
"go/token"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
// taggedRun is what the integration level runs in one go.mod directory: the packages holding a test the tags add, by
// import path, and the -run pattern naming those tests.
type taggedRun struct {
packages []string
pattern string
}
// taggedTests reads, for each go.mod directory, the tests that tags add. selected are the packages the level was asked
// to run (import paths, as build_go_digest keys them); none, or a relative pattern, is every package. A directory with no
// test the tags add is left out of the answer.
func taggedTests(tree string, dirs, selected, tags []string) (map[string]taggedRun, error) {
want := map[string]bool{}
for _, p := range selected {
if strings.HasPrefix(p, "./") || p == "." {
want = nil
break
}
want[p] = true
}
if len(selected) == 0 {
want = nil
}
with := map[string]bool{}
for _, t := range tags {
with[t] = true
}
out := map[string]taggedRun{}
for _, d := range dirs {
raw, _ := os.ReadFile(filepath.Join(tree, d, "go.mod"))
mp := modulePath(string(raw))
base := filepath.Join(tree, d)
pkgs := map[string]bool{}
names := map[string]bool{}
err := lintable(base, func(path string) error {
if !strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
expr := buildLine(src)
if expr == nil || !expr.Eval(func(tag string) bool { return with[tag] || platformTag(tag) }) || expr.Eval(platformTag) {
return nil // no constraint, or one the tags do not change: the unit level's
}
rel, _ := filepath.Rel(base, filepath.Dir(path))
pkg := mp
if rel != "." {
pkg = mp + "/" + filepath.ToSlash(rel)
}
if want != nil && !want[pkg] {
return nil
}
found := testNames(path, src)
for _, n := range found {
names[n] = true
}
if len(found) > 0 {
pkgs[pkg] = true
}
return nil
})
if err != nil {
return nil, err
}
if len(pkgs) == 0 {
continue
}
run := taggedRun{packages: sortedKeys(pkgs)}
run.pattern = "^(" + strings.Join(sortedKeys(names), "|") + ")$"
out[d] = run
}
return out, nil
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// buildLine is a file's //go:build constraint, nil when it has none.
func buildLine(src []byte) constraint.Expr {
for _, line := range strings.Split(string(src), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "package ") {
break
}
if constraint.IsGoBuild(line) {
if e, err := constraint.Parse(line); err == nil {
return e
}
}
}
return nil
}
// platformTag is how a tag other than the level's evaluates, the same with the level's tags and without them: the
// machine's platform, unix, cgo and every Go version hold; any other tag does not.
func platformTag(tag string) bool {
switch tag {
case runtime.GOOS, runtime.GOARCH, "unix", "cgo", "gc":
return true
}
return strings.HasPrefix(tag, "go1.")
}
// testNames are the top-level test functions of a test file: func TestXxx, as go test runs them, TestMain left out.
func testNames(path string, src []byte) []string {
f, err := parser.ParseFile(token.NewFileSet(), path, src, parser.SkipObjectResolution)
if err != nil {
return nil // a file that does not parse fails the compile, which the run reports
}
var out []string
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || fn.Name.Name == "TestMain" || !isTestName(fn.Name.Name) {
continue
}
out = append(out, fn.Name.Name)
}
return out
}
// isTestName says whether go test runs a function of this name as a test: Test, then nothing or a rune that is not
// lower case.
func isTestName(name string) bool {
rest, ok := strings.CutPrefix(name, "Test")
if !ok {
return false
}
if rest == "" {
return true
}
r, _ := utf8.DecodeRuneInString(rest)
return !unicode.IsLower(r)
}
+68 -25
View File
@@ -10,7 +10,6 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
@@ -171,6 +170,9 @@ type levelOpts struct {
// parallelism, where the unit level's is the slowest package's.
setup time.Duration
wall bool
// runByDir is the -run pattern of each go.mod directory, over run: the tests the integration level's tags add there
// (issue 1921, try speed S7).
runByDir map[string]string
// compose is a state of the level to put in the result (the store and the bus it ran against).
store, bus string
}
@@ -205,35 +207,56 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
todo = append(todo, d)
}
}
// The level's commands share one bound on processes with each other and with the ask's other level: the lint's and
// the tests' (issue 1921, try speed S4, S10).
slots := m.slots(o.workspace)
var lintFailed []Failure
var lintErr error
var lintDone chan struct{}
var crash any // a panic of a run or of the lint goes on in the caller, which cleans up after it
var crashMu sync.Mutex
if o.level == LevelUnit {
// gofmt and go vet of each directory the level runs come before its tests (ADR 0529 §1; issue 1887 D7-go): a
// directory that fails them fails the level at once, and their time is the result's Lint.
linted := m.clock()
failed, err := m.lintAll(ctx, run, tree, todo, func(d string) []string {
return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
})
if err != nil {
return nil, err
}
res.Lint = m.clock().Sub(linted).Seconds()
if len(failed) > 0 {
res.Failed = failed
res.judge(o.bounds)
return res, nil
}
began = m.clock() // the level's running time is its tests'
// gofmt and go vet of each directory the level runs, beside its tests (ADR 0529 §1; issue 1887 D7-go; issue 1921,
// try speed S4): a directory that fails them fails the level, and their time is the result's Lint.
lintDone = make(chan struct{})
go func() {
defer close(lintDone)
defer func() {
if p := recover(); p != nil {
crashMu.Lock()
crash = p
crashMu.Unlock()
}
}()
linted := m.clock()
// go vet compiles and runs nothing of the change, so it warms the shared cache for every later check, a pull
// request's head included (issue 1921, try speed S11; the operator's decision); the tests run beneath the
// ask's own layer.
compile, err := m.stepRunner(o.workspace, true)
if err != nil {
lintErr = err
return
}
lintFailed, lintErr = m.lintAll(ctx, compile, tree, todo, func(d string) []string {
return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
}, byDir, slots.acquire)
lintFailed = append(m.addedBinaries(ctx, m.Run, tree), lintFailed...) // git alone, no code of the change
res.Lint = m.clock().Sub(linted).Seconds()
}()
}
runs := make([]dirRun, len(todo))
slots := make(chan struct{}, m.parallel())
var wg sync.WaitGroup
var crash any // a panic of a run goes on in the caller, which cleans up after it
var crashMu sync.Mutex
var slotErr error
for i, d := range todo {
release, err := slots.acquire(ctx)
if err != nil {
slotErr = err
break
}
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
defer release()
defer func() {
if p := recover(); p != nil {
crashMu.Lock()
@@ -245,7 +268,9 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
if len(o.tags) > 0 {
args = append(args, "-tags", strings.Join(o.tags, ","))
}
if o.run != "" {
if r := o.runByDir[d]; r != "" {
args = append(args, "-run", r)
} else if o.run != "" {
args = append(args, "-run", o.run)
}
args = append(args, byDir[d]...)
@@ -258,9 +283,19 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
}()
}
wg.Wait()
if lintDone != nil {
<-lintDone
}
if crash != nil {
panic(crash)
}
if lintErr != nil {
return nil, lintErr
}
if slotErr != nil {
return nil, slotErr
}
res.Failed = append(res.Failed, lintFailed...) // the lint's failures first, then the tests'
for i, d := range todo {
dr := runs[i]
if (dr.r.Status != 0 || dr.r.Err != "") && len(dr.rd.pkgs) == 0 {
@@ -286,12 +321,20 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
// runAnswer is what a step runner answers.
type runAnswer = forgekey.Ran
// parallel is how many directories a level runs at once.
// parallel is how many processes the levels of one ask start at once, together (ADR 0541; issue 1921, try speed S10):
// the processors divided by the node's build capacity, half of them when the capacity is not known.
func (m *Module) parallel() int {
if m.Parallel > 0 {
return m.Parallel
}
return max(1, runtime.NumCPU()/2)
capacity := 0
if m.Capacity != nil {
capacity = m.Capacity()
}
if capacity <= 0 {
capacity = 2
}
return max(1, numCPU()/capacity)
}
// packagesByDir says which patterns run in which go.mod directory. A relative pattern (./...) runs in every directory, as
@@ -66,7 +66,9 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) {
"tagged": {passed: true, noTests: true},
"empty": {passed: true, noTests: true},
"slow": {passed: true},
"broken": {failed: "."}, // go vet, before the tests since R4g (ADR 0529 §1), names the directory and the compiler's line
// go vet, the lint step beside the tests (ADR 0542 §2), names the directory and the compiler's line; the test run
// of the package that does not compile fails beside it
"broken": {failed: "."},
}
for name, w := range want {
home := t.TempDir()
@@ -84,14 +86,16 @@ func TestUnitLevelRunsTheRealGoOnTheFixtureRepositories(t *testing.T) {
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if res.Passed != w.passed || res.NoTests != w.noTests || (w.failed != "" && (len(res.Failed) != 1 || res.Failed[0].Package != w.failed)) {
lintFirst := w.failed != "." || (len(res.Failed) >= 1 && res.Failed[0].Step == StepLint)
if res.Passed != w.passed || res.NoTests != w.noTests || !lintFirst ||
(w.failed != "" && (len(res.Failed) == 0 || res.Failed[0].Package != w.failed)) {
t.Errorf("%s: result = %+v", name, res)
}
}
}
// 1887/D7-go against the real go: a tree holding an unformatted file fails the unit level naming the file, before any
// test runs, and the level's result carries the lint's time.
// 1887/D7-go against the real go: a tree holding an unformatted file fails the check naming the file, as the lint step's
// failure (ADR 0542 §2), and the level's result carries the lint's time.
func TestAnUnformattedFileFailsTheRealUnitLevel(t *testing.T) {
needTools(t, "the unit level's gofmt and vet", "go", "git", "bwrap")
home := t.TempDir()
@@ -111,8 +115,9 @@ func TestAnUnformattedFileFailsTheRealUnitLevel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if res.Passed || len(res.Failed) == 0 || res.Failed[0].Test != "gofmt" || !strings.Contains(res.Failed[0].Output, "ugly.go") || res.Tests != 0 {
t.Fatalf("result = %+v, want the level failed by gofmt naming ugly.go and no test run", res)
if res.Passed || len(res.Failed) == 0 || res.Failed[0].Test != "gofmt" || res.Failed[0].Step != StepLint ||
!strings.Contains(res.Failed[0].Output, "ugly.go") || res.Lint <= 0 {
t.Fatalf("result = %+v, want the lint step failed by gofmt naming ugly.go, with its time", res)
}
}
@@ -156,9 +161,8 @@ func TestIntegrationLevelRunsAgainstARealStoreAndBusAndLeavesNothing(t *testing.
if !res.Passed || res.Tests != 1 || res.Setup <= 0 || res.Setup > IntegrationSetup.Seconds() {
t.Errorf("result = %+v", res)
}
entries, _ := os.ReadDir(filepath.Join(work, "ws1"))
if len(entries) != 1 || entries[0].Name() != "tree" {
t.Errorf("the workspace holds %v, want only its tree: the run directory is gone", entries)
if left := workspaceLeft(filepath.Join(work, "ws1")); len(left) != 1 || left[0] != "tree" {
t.Errorf("the workspace holds %v besides the ask's own directories, want only its tree: the run directory is gone", left)
}
for deadline := time.Now().Add(5 * time.Second); len(processesNamed(work)) > 0 && time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
}
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -25,8 +26,11 @@ func capturedRunner(t *testing.T, captured string, calls *[]cmdRecord) forgekey.
if strings.Contains(string(raw), `"Action":"fail"`) {
status = 1 // go test exits 1 when a test or a build failed
}
var mu sync.Mutex // the lint and the tests of a level run at once (issue 1921, try speed S4)
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
mu.Lock()
*calls = append(*calls, cmdRecord{env: env, line: name + " " + strings.Join(args, " ")})
mu.Unlock()
switch {
case name == "git" && args[2] == "ls-files":
return forgekey.Ran{Stdout: "go.mod\n"}
@@ -103,6 +107,33 @@ func TestUnitLevelNamesTheFailingTestAndFailsTheLevel(t *testing.T) {
}
}
// Issue 1921: a fixture with a failing test in each of two packages: one level's run names both, each with its own
// cause, and counts every test, the passing one beside a failure too. go test runs every package to its end; nothing
// stops at the first failure.
func TestUnitLevelNamesEveryFailingTestOfEveryPackage(t *testing.T) {
m, states, ws, calls := workspaceFor(t, "twofailing")
res, st := runUnit(t, m, states, ws)
if st.State != delivery.StepPassed || res.Passed {
t.Fatalf("step %s (%s), result %+v: want the step done and the level failed", st.State, st.Reason, res)
}
var got []string
for _, f := range res.Failed {
got = append(got, f.Package+"."+f.Test+": "+f.Cause)
}
want := []string{"fixture/twofailing/one.TestOneIsTwo: One() = 1, want 2", "fixture/twofailing/two.TestTwoIsThree: Two() = 2, want 3"}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("failed\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
if res.Tests != 3 {
t.Errorf("%d tests counted, want 3", res.Tests)
}
for _, c := range *calls {
if strings.Contains(c.line, " test ") && strings.Contains(c.line, "-failfast") {
t.Errorf("go test ran with -failfast: %s", c.line)
}
}
}
// 1290/AC2: a fixture whose tests take more than the bound (lowered by the module's option, not by waiting five
// seconds): the level fails naming the bound and the slowest tests; under the real bound the same run passes.
func TestUnitLevelOverItsBoundFailsNamingTheBoundAndTheSlowestTests(t *testing.T) {
@@ -1,6 +1,7 @@
Each file is the standard output of `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go test -count=1 -json ./...` (go1.27.1)
run in the fixture module of the same name under ../repos, captured on 2026-10-20 for issue 1290. The unit
tests feed them to the parser through a fake runner, so no test here runs `go test`.
tests feed them to the parser through a fake runner, so no test here runs `go test`. twofailing.json was captured the
same way on 2026-10-24 for issue 1921: two packages with a failing test each, and a passing test beside one of them.
golist-external.json is `GOTOOLCHAIN=go1.27.1 GOFLAGS=-mod=mod go list -json=ImportPath,Standard,DepOnly,Module
git.novox.be/novox/mesh-sdk/go/buildstep` run in this module (go1.27.1, 2026-10-20 for issue 1291): a package of a module
@@ -0,0 +1,23 @@
{"Time":"2026-10-24T23:45:37.721718705+02:00","Action":"start","Package":"fixture/twofailing/one"}
{"Time":"2026-10-24T23:45:37.723685152+02:00","Action":"run","Package":"fixture/twofailing/one","Test":"TestOneIsTwo"}
{"Time":"2026-10-24T23:45:37.723726472+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":"=== RUN TestOneIsTwo\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.723743682+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":" one_test.go:6: checking One\n"}
{"Time":"2026-10-24T23:45:37.723750622+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":" one_test.go:8: One() = 1, want 2\n","OutputType":"error"}
{"Time":"2026-10-24T23:45:37.723759302+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Output":"--- FAIL: TestOneIsTwo (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.723889303+02:00","Action":"start","Package":"fixture/twofailing/two"}
{"Time":"2026-10-24T23:45:37.723762972+02:00","Action":"fail","Package":"fixture/twofailing/one","Test":"TestOneIsTwo","Elapsed":0}
{"Time":"2026-10-24T23:45:37.723952274+02:00","Action":"run","Package":"fixture/twofailing/one","Test":"TestOneIsOne"}
{"Time":"2026-10-24T23:45:37.723955484+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Output":"=== RUN TestOneIsOne\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.723959304+02:00","Action":"output","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Output":"--- PASS: TestOneIsOne (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.723964914+02:00","Action":"pass","Package":"fixture/twofailing/one","Test":"TestOneIsOne","Elapsed":0}
{"Time":"2026-10-24T23:45:37.723967704+02:00","Action":"output","Package":"fixture/twofailing/one","Output":"FAIL\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.724225126+02:00","Action":"output","Package":"fixture/twofailing/one","Output":"FAIL\tfixture/twofailing/one\t0.002s\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.724231936+02:00","Action":"fail","Package":"fixture/twofailing/one","Elapsed":0.003}
{"Time":"2026-10-24T23:45:37.725752949+02:00","Action":"run","Package":"fixture/twofailing/two","Test":"TestTwoIsThree"}
{"Time":"2026-10-24T23:45:37.725796309+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":"=== RUN TestTwoIsThree\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.725808239+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":" two_test.go:7: Two() = 2, want 3\n","OutputType":"error"}
{"Time":"2026-10-24T23:45:37.72581671+02:00","Action":"output","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Output":"--- FAIL: TestTwoIsThree (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.72582026+02:00","Action":"fail","Package":"fixture/twofailing/two","Test":"TestTwoIsThree","Elapsed":0}
{"Time":"2026-10-24T23:45:37.72582549+02:00","Action":"output","Package":"fixture/twofailing/two","Output":"FAIL\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.726319344+02:00","Action":"output","Package":"fixture/twofailing/two","Output":"FAIL\tfixture/twofailing/two\t0.002s\n","OutputType":"frame"}
{"Time":"2026-10-24T23:45:37.726362544+02:00","Action":"fail","Package":"fixture/twofailing/two","Elapsed":0.002}
@@ -0,0 +1,3 @@
module fixture/twofailing
go 1.25.0
@@ -0,0 +1,3 @@
package one
func One() int { return 1 }
@@ -0,0 +1,16 @@
package one
import "testing"
func TestOneIsTwo(t *testing.T) {
t.Log("checking One")
if One() != 2 {
t.Fatalf("One() = %d, want 2", One())
}
}
func TestOneIsOne(t *testing.T) {
if One() != 1 {
t.Fatal("one")
}
}
@@ -0,0 +1,3 @@
package two
func Two() int { return 2 }
@@ -0,0 +1,9 @@
package two
import "testing"
func TestTwoIsThree(t *testing.T) {
if Two() != 3 {
t.Errorf("Two() = %d, want 3", Two())
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+10 -4
View File
@@ -1,7 +1,13 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721 h1:N//dDGmry1F3HKeBTWd+z/SfK+wb8bUUMJF0OlgR0/g=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024021031-66bf26c0d721/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024225846-cb548a6a588c h1:GWHP9ozfkFXHVEL2gE4Xa5kc5e8uPO+UL9+mau6iaIs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024225846-cb548a6a588c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024230839-4a12ccb78d19 h1:NqU4hkk/jHdTEcGFnnXvdOJwD8dH5u+qWaCdQQ+JYMc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024230839-4a12ccb78d19/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232250-9b408db24a58 h1:4Damtdqlf6u6nJhvMA99peDy1jmjixtcwFXJ4bWEkLk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232250-9b408db24a58/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95 h1:I8zbgGzRjmgYydPTWo8hmaZW/P/g+qGDUfu3E32/pkU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
@@ -0,0 +1 @@
These tests read other catalogue modules' files, outside this module (issue 1935): never kept.
@@ -0,0 +1 @@
These tests read other catalogue modules' files, outside this module (issue 1935): never kept.
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0
)
+4 -2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95 h1:I8zbgGzRjmgYydPTWo8hmaZW/P/g+qGDUfu3E32/pkU=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024232922-99407c37df95/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+12 -27
View File
@@ -10,15 +10,15 @@ Each directory or archive this module declares says how it treats a file it did
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
## A backup that failed before the send
## What it keeps (ADR 0539 §17)
The controller backs up the data of the modules a delivery moves before it asks or sends anything (ADR 0404). When its
walk's record, in `plan-moved`, names a backup that failed or stayed silent and nothing of the plan was sent, the
delivery goes from delivering to held (the observed row `backup failed`); the held reason names the machine, the
module and the backup's words, and the pull request's comment shows it. The hold does not lift by itself. `release`
of such a delivery asks the controller (`delivery-backups-again`) to take the backups again and the delivery goes on
delivering; a record from before the controller cleared the failure does not hold it again. A held delivery already
ends as covered or delivered when a later merge covers it.
The gate keeps the merge's states: proposed, ready, rejected, merged, held (a merge that reached the trunk without a passing
check) and closed. It asks the operator's Approve and merges once, moving `source.main`. It asks the controller nothing, follows
no plan event and derives nothing after the merge: whether a merged change reached the machines is the view's, from
`source.main`, the wants and the haves. The seat `mesh-delivery` keeps the verbs `deliveries`, `show`, `groups`, `checks`,
`table`, `stalled` (for a merge or a check past its bound), `recheck` and `close`; `times`, `what-if`, `release`, `stop` and
`retire-history` went with the plans, so the gate stays a bundle serving the seat. A delivery an earlier owner kept in a state that
is gone is read as the state it came to (merged, closed or proposed) when the gate starts.
## When a refused or rejected merge is said (ADR 0505 §4, §5)
@@ -33,23 +33,8 @@ names every refusal and rejection at once, and the pull request is told at once;
`needs: new-head` and names its author: the branch, and the session a `Pushed-by: <who@node>` line in the pull
request's description names.
## Taking the seat over from today's module
## The seat's hand-over
The gate claims the seat `mesh-delivery` and took it over from the module `mesh-delivery`, which the catalogue no longer holds.
The controller refuses a merge that removes a module that is assigned, so today's module stayed in the catalogue until it was
unassigned, and went by a handover of verbs after the merge, never by hand on a machine (novox/hq ADR 0449 §4, ADR 0450 §6, ADR 0454):
1. assign `mesh-delivery-gate` to the machine that holds the seat (`mesh-controller.assign`);
2. check that `mesh-controller.seats` still names `mesh-delivery`, held by the running module;
3. unassign `mesh-delivery` from that machine (`mesh-controller.unassign`) and push it (`mesh-controller.push`);
4. the seat passes: the gate reads the controller's assignments and the node-engine's have keys, finds the module on no machine,
carries its deliveries, groups and pull keys over once (the key `carried` in its `deliveries` says it did), reads the controller's
walks and the forge's open pull requests, and acts.
Until then the gate is silent: it takes no event, writes no key, asks nothing and answers no verb but `delivery_status`, which
says why (`holds-the-seat`, `seat-waits`).
The removal of `modules/mesh-delivery` followed the handover as a pull request of its own, merged by the gate (ADR 0454); the
catalogue has one claimant of the seat, and no manifest but the gate's and the issue tracker's names the old module but the names
the handover needed: the three buckets the gate carries over, and the event the issue tracker and the tool runner hear
(`catalogue_test.go`).
The gate took the seat `mesh-delivery` over from the module `mesh-delivery` (ADR 0449 §4, ADR 0450 §6, ADR 0454) and carried its
deliveries, groups and pull keys once; the carry, and the reading of the controller's assignments and the node-engine's have keys
that waited for it, went with release 6. `catalogue_test.go` keeps the catalogue to one claimant of the seat.
@@ -1,50 +1,32 @@
package main
import (
"strings"
"testing"
"time"
)
// One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a
// walk's record says per module whether its build reused a check's build or built, and why.
const (
cat = "novox/mesh-catalog"
headCC = "cc00000000000001"
headDu = "dd00000000000002"
)
// A pull request closed unmerged is stopped. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
// of what its checks built (§5's forget-checks goes with the merge gate's checks); a controller that is down changes
// nothing.
func TestAPullRequestClosedUnmergedIsStoppedAndAsksTheControllerNothing(t *testing.T) {
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
var atCC = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
// A pull request closed unmerged is closed. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
// of what its checks built, and since ADR 0539 §17 the gate asks it nothing at all.
func TestAPullRequestClosedUnmergedIsClosedAndAsksNoOne(t *testing.T) {
w := newWorldOver(t, newMemStore(), newFakeForge(), atCC)
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
w.h.PullUpdated(cc)
w.h.PullClosed(cc)
if len(w.ctl.forgot) != 0 {
t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot)
}
if s := w.state(IDOf(cat, headCC)); s != Stopped {
if s := w.state(IDOf(cat, headCC)); s != Closed {
t.Fatalf("closed unmerged it is %s", s)
}
w.ctl.down = true
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
w.h.PullUpdated(du)
w.h.PullClosed(du)
if s := w.state(IDOf(cat, headDu)); s != Stopped {
t.Fatalf("with the controller down, a closed delivery is %s", s)
}
}
// The walk's record says per module what its build did, and the delivery keeps it beside its walk (§4).
func TestAWalksRecordSaysWhetherEachModuleWasReusedOrBuilt(t *testing.T) {
walk := catWalk("plan-1", mergeCC, atCC.Add(time.Minute), "claude-code", "dunst")
walk.Modules["claude-code"].Made = "reused from the check of novox/mesh-catalog#101 at head cccccccccccc " +
"(merged into trunk 553b71910000): tools sha256:aa"
walk.Modules["dunst"].Made = "built: no check of novox/mesh-catalog#101 built it"
seen := walk.Seen()
if !strings.HasPrefix(seen.Built["claude-code"], "reused from the check of novox/mesh-catalog#101") ||
seen.Built["dunst"] != "built: no check of novox/mesh-catalog#101 built it" {
t.Fatalf("the delivery keeps %v", seen.Built)
}
if catWalk("plan-2", mergeCC, atCC, "dunst").Seen().Built != nil {
t.Fatal("a walk from a controller that says nothing of its builds reads as if it said something")
if s := w.state(IDOf(cat, headDu)); s != Closed {
t.Fatalf("a closed delivery is %s", s)
}
}
@@ -1,14 +1,11 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The tests of novox/hq ADR 0505 "How it is checked" for the gate: a refusal the gate is already answering is no
@@ -76,7 +73,7 @@ func refusedForAMovedTrunk(t *testing.T) (*world, string) {
t.Helper()
w := announced(t)
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errAskedAgain
approve(w)
if len(w.forge.merges) != 1 || w.delivery(id).Ready.RefusedSince == nil {
@@ -125,7 +122,7 @@ func TestATrunkMovedRefusalIsStalledAtTwiceTheLastCheckTimeAtLeastTheFloor(t *te
func TestAnyOtherRefusalIsStalledAtOnce(t *testing.T) {
w := announced(t)
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = refusedWords("novox/app#7")
approve(w)
if got := linesFor(w, id); len(got) != 1 {
@@ -146,7 +143,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
w.h.PullUpdated(p)
w.settleAll()
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
// Judged again (the table's recheck, which the checker's verdict on a new trunk makes): proposed once more.
w.h.mu.Lock()
d := w.h.deliveries[id]
@@ -156,7 +153,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
}
d.Check = nil
w.h.mu.Unlock()
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
approve(w)
w.h.React()
if w.state(id) != Rejected {
@@ -220,48 +217,3 @@ func TestANewHeadWithinTwoHoursKeepsTheRejectedHeadFromTheOperator(t *testing.T)
t.Fatalf("the superseded head reached the operator: %+v", got)
}
}
// ADR 0505 §2: a walk's end said again by the controller with its declared machines (`declared_read`, a touch marked
// declared) acts once: no transition, comment or ask of the controller the first saying did not make.
func TestAWalkSaidAgainWithItsDeclaredMachinesActsOnceInTheGate(t *testing.T) {
w, id, plan := aDeliveringWalk(t)
done := w.ctl.walk(plan)
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
done.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent, SentAt: &sent, Build: "build-1",
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent, Passes: 1, Verdict: "passed", JudgedAt: &sent}}
done.State = walkDone
done = w.ctl.put(done)
body := func(again bool) []byte {
raw, err := json.Marshal(done)
if err != nil {
t.Fatal(err)
}
var said map[string]any
_ = json.Unmarshal(raw, &said)
said["walk"], said["phase"], said["outcome"] = plan, "ended", "delivered"
said["touches"] = []map[string]any{{"machine": "anchor", "modules": []string{"app"}}}
if again {
said["touches"] = append(said["touches"].([]map[string]any),
map[string]any{"machine": "shanks", "modules": []string{}, "declared": true})
said["declared_read"] = true
}
out, _ := json.Marshal(said)
return out
}
if err := take(w.h, stdio.Envelope{Key: ControllerSeat + ".plan-moved", Body: body(false)}); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(id); s != Delivered {
t.Fatalf("the walk's end left it %s", s)
}
transitions, comments, delivers := len(w.delivery(id).Transitions), len(w.forge.comments), len(w.ctl.delivers)
if err := take(w.h, stdio.Envelope{Key: ControllerSeat + ".plan-moved", Body: body(true)}); err != nil {
t.Fatal(err)
}
w.settleAll()
if len(w.delivery(id).Transitions) != transitions || len(w.forge.comments) != comments || len(w.ctl.delivers) != delivers {
t.Fatalf("said again it acted again: transitions %d→%d, comments %d→%d, asks of the controller %d→%d",
transitions, len(w.delivery(id).Transitions), comments, len(w.forge.comments), delivers, len(w.ctl.delivers))
}
}
@@ -1,672 +0,0 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
)
// Which merges a walk answers (novox/hq issue 362, ADR 0276).
//
// On 2026-10-10 two merges of the catalogue 18 seconds apart were walked by one walk at the later merge's
// commit, which superseded the first. This owner found the walk a delivery follows by the walk's id or by the
// commit it was merged as, final or not: the surviving walk matched the dunst delivery, which the supersession
// had already ended, so nothing gave it its word, and the claude-code delivery, merged as another commit,
// matched no walk and was held for a person after ten minutes, for a reason that named neither.
//
// A walk answers every merge it carries: the merges it names (`delivery.merges`, said by a controller that
// batches merges), the merge of its own commit, a merge whose walk it took over, and a merge of its repository
// and branch the forge says its commit contains. A delivery follows the newest walk that answers it, and only a
// delivery not yet ended is ever tied to a walk by what it answers: an ended delivery is moved only by the
// record of its own walk, which no longer moves it.
// seenWalk is a walk's last record and when it was heard.
type seenWalk struct {
walk Walk
at time.Time
}
// keptWalkFor is how long a walk no longer open is kept for a merge heard after it ended; a batch is kept as
// long, past its window's latest close.
const keptWalkFor = 10 * time.Minute
// answer is a delivery a walk answers and the commit of its repository the walk carries for it.
type answer struct {
d *Delivery
carried string
}
// walkAnswers is whether a walk answers a delivery's merge, and the commit it carries for it.
func walkAnswers(w Walk, d *Delivery) (string, bool) {
if w.Release != nil || w.IsBatch() || d.MergedAs == "" {
return "", false
}
if c, ok := w.carriedFor(d.Repository, d.MergedAs); ok {
return c, true
}
if d.Walk != nil && d.Walk.TakenOverBy == w.ID && namesNoMerges(w) && strings.EqualFold(w.Repository, d.Repository) &&
sameBranch(w.Branch, d.Base) {
// The walk that took its walk over, from a controller that names no merges: it answers every merge the one
// it took over answered, by its commit of the same repository and branch. A walk that names its merges
// answers only those (carriedFor, above).
return w.Commit, true
}
return "", false
}
// containedAnswer is, for a controller that does not name the merges a walk answers, whether an open walk
// answers a ready merged delivery by containment: of its repository and branch, made for a merge after it, the
// forge says its commit contains this merge, and it moves every module this merge's plan moves. A later walk that
// moves only what its own merge moved does not deliver this one, however much of the trunk it contains.
func (h *Holder) containedAnswer(w Walk, d *Delivery) (string, bool) {
if !h.mayContain(w, d) || !d.ContainedIn[w.Commit] {
return "", false
}
return w.Commit, true
}
// mayContain is whether an open walk is one to ask the forge about for a ready merged delivery (containedAnswer).
func (h *Holder) mayContain(w Walk, d *Delivery) bool {
if w.Release != nil || w.IsBatch() || !w.Open() || !namesNoMerges(w) || d.State != Ready || d.MergedAs == "" || d.Walk != nil ||
d.Plan == nil || len(d.Plan.Moved) == 0 || !strings.EqualFold(w.Repository, d.Repository) ||
!sameBranch(w.Branch, d.Base) || sameCommit(w.Commit, d.MergedAs) {
return false
}
for _, m := range d.Plan.Moved {
if _, moves := w.Modules[m]; !moves {
return false
}
}
// Made for a merge after this one: the walk's own merge, where this owner holds it, or else the walk itself.
after := w.Created
for _, o := range h.deliveries {
if strings.EqualFold(o.Repository, w.Repository) && sameCommit(o.MergedAs, w.Commit) && !o.MergedAt.IsZero() {
after = o.MergedAt
}
}
return after.After(d.MergedAt)
}
// namesNoMerges is whether a walk is from a controller that does not name the merges it answers.
func namesNoMerges(w Walk) bool { return w.Delivery == nil || len(w.Delivery.Merges) == 0 }
func sameBranch(a, b string) bool { return a == "" || b == "" || strings.EqualFold(a, b) }
// follows is whether a delivery takes a walk that answers it as the one it follows: it has none, it is that walk,
// that walk took its own over, or that walk is newer than the one it follows. A walk record kept before its
// creation was read is followed past only once it is superseded or failed.
func follows(d *Delivery, w Walk) bool {
switch {
case d.Walk == nil || d.Walk.ID == w.ID || d.Walk.TakenOverBy == w.ID:
return true
case d.Walk.Created.IsZero():
return d.Walk.State == walkSuperseded || d.Walk.State == walkFailed
}
return w.Created.After(d.Walk.Created)
}
// deliveriesWalking are the deliveries a walk record moves: every delivery that already follows it — ended or
// not, its own record — and every delivery not ended that it answers and that follows it now. In id order.
func (h *Holder) deliveriesWalking(w Walk) []answer {
var out []answer
for _, d := range h.deliveries {
if d.Walk != nil && d.Walk.ID == w.ID {
carried := d.AnsweredBy
if c, ok := walkAnswers(w, d); ok {
carried = c
}
out = append(out, answer{d: d, carried: carried})
continue
}
if d.State.Final() || !follows(d, w) {
continue
}
if c, ok := walkAnswers(w, d); ok {
out = append(out, answer{d: d, carried: c})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].d.ID < out[j].d.ID })
return out
}
// accounted is whether any delivery, ended or not, follows the walk or is answered by it: a walk known for
// what it answers is never made a delivery of its own.
func (h *Holder) accounted(w Walk) bool {
for _, d := range h.deliveries {
if d.Walk != nil && d.Walk.ID == w.ID {
return true
}
if _, ok := walkAnswers(w, d); ok {
return true
}
}
return false
}
// held is whether a live delivery follows the walk, or a live delivery it answers follows a newer one. A walk whose
// word only stops that lapsed give (lapsedOn, novox/hq issue 753) is not held: it stays under the orphans' timer, so
// past the grace without its word it is listed by `stalled`, saying why it was not given.
func (h *Holder) heldWalk(w Walk) bool {
for _, d := range h.deliveries {
if d.State.Final() {
continue
}
if d.Walk != nil && d.Walk.ID == w.ID {
return true
}
if _, ok := walkAnswers(w, d); ok {
return true
}
}
return false
}
// pickWalk is the newest walk heard that answers a merged delivery with no walk yet; nil for none.
func (h *Holder) pickWalk(d *Delivery) (Walk, string, bool) {
var best Walk
var carried string
found := false
for _, s := range h.walks {
if !follows(d, s.walk) {
continue
}
c, ok := walkAnswers(s.walk, d)
if !ok {
c, ok = h.containedAnswer(s.walk, d)
}
if !ok {
continue
}
if !found || s.walk.Created.After(best.Created) || s.walk.Created.Equal(best.Created) && s.walk.ID > best.ID {
best, carried, found = s.walk, c, true
}
}
return best, carried, found
}
// linkAnswering ties a merged delivery with no walk to the newest walk heard that answers it; under the lock.
func (h *Holder) linkAnswering(d *Delivery) bool {
if d.State.Final() || d.MergedAs == "" || d.Walk != nil {
return false
}
w, carried, ok := h.pickWalk(d)
if !ok {
return false
}
h.linkWalk(d, w, carried)
delete(h.unmatched, w.ID)
return true
}
// containsAsks are the forge's asks whether an open walk of a merged delivery's repository and branch, at
// another commit, contains its merge: for a controller that does not name the merges a walk answers (issue 362),
// asked once per pair, the answer kept on the delivery.
func (h *Holder) containsAsks(d *Delivery, now time.Time) []func() {
var asks []func()
for _, s := range h.walks {
w := s.walk
if !h.mayContain(w, d) {
continue
}
if _, known := d.ContainedIn[w.Commit]; known {
continue
}
if a := h.askContains(d, w.Commit, now); a != nil {
asks = append(asks, a)
}
}
return asks
}
// batchOf is the batch a merged delivery's merge is in, when the controller said one; false for none.
func (h *Holder) batchOf(d *Delivery) (Walk, bool) {
if d.MergedAs == "" {
return Walk{}, false
}
for _, s := range h.walks {
if !s.walk.IsBatch() || s.walk.Delivery == nil {
continue
}
for _, m := range s.walk.Delivery.Merges {
if strings.EqualFold(m.Repository, d.Repository) && sameCommit(m.Commit, d.MergedAs) {
return s.walk, true
}
}
}
return Walk{}, false
}
// batchSaid is what a batch holding a delivery's merge waits for, in words.
func batchSaid(b Walk, now time.Time) string {
said := "its batch " + b.ID + ": " + b.State
bt := b.Delivery.Batch
switch {
case b.State == walkQueued && bt != nil && bt.Behind != "":
said += " behind " + bt.Behind
case b.State == walkAssembling && bt != nil && bt.ClosesAt != nil:
left := bt.ClosesAt.Sub(now).Round(time.Second)
if left < 0 {
left = 0
}
said += fmt.Sprintf(", %s left", left)
if bt.AtMost != nil {
said += " (at the latest " + bt.AtMost.Local().Format("15:04:05") + ")"
}
}
return said + "; plan not yet calculated"
}
// unwalkedWhy is why a merged delivery no walk answers is held: the open walks of its repository and that none
// answers its merge, or that the controller holds none — what happened, not what might have.
func (h *Holder) unwalkedWhy(d *Delivery) string {
var open []string
for _, s := range h.walks {
w := s.walk
if w.Release == nil && !w.IsBatch() && w.Open() && strings.EqualFold(w.Repository, d.Repository) {
open = append(open, fmt.Sprintf("%s at %s", w.ID, shortOf(w.Commit, 8)))
}
}
sort.Strings(open)
head := fmt.Sprintf("merged as %s, and no walk answers its merge within %s: ", shortOf(d.MergedAs, 8), unwalked)
if len(open) == 0 {
return head + "the controller holds no open walk of " + d.Repository + " — its merge was not heard, or " +
"nothing the mesh holds follows " + orNone(d.Base) + ": a person decides"
}
return head + "the open walk(s) of " + d.Repository + " — " + strings.Join(open, ", ") + " — name no merge of " +
"it and the forge did not say they contain it: a person decides"
}
func orNone(s string) string {
if s == "" {
return "its branch"
}
return s
}
// orphanLines are the waiting walks no live delivery holds, past the grace a merge is given to be heard, as
// stalled lines in the state Unheld: nothing would ever give such a walk its word (issue 362). Under the lock.
func (h *Holder) orphanLines(now time.Time) []StalledLine {
var out []StalledLine
for id, first := range h.orphans {
s, known := h.walks[id]
if !known || now.Sub(first) < matchGrace {
continue
}
w := s.walk
if len(h.lapsedOn(w)) > 0 {
// Owed its word by stops that lapsed, and not given it past the grace (review of issue 753): said, why.
says := fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, which the stopped deliveries whose "+
"merge it answers owe it (novox/hq issue 753), and it has not started", w.ID, w.Repository, shortOf(w.Commit, 8))
if why := h.lapsedSaid(w); why != "" {
says += ": " + why
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: w.ID,
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
H2: "none: its word is given at its turn — the operator's past the bound", Says: says})
continue
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: w.ID,
For: now.Sub(first).Round(time.Second).String(), Bound: matchGrace.String(),
H2: "none: no delivery holds the walk, so nothing gives it its word — the operator's",
Says: fmt.Sprintf("the walk %s of %s at %s waits for this owner's word, and no delivery that has not ended "+
"follows it or is answered by it: `mesh-delivery.show` for the deliveries of %s, and `plans go %s --why …` "+
"starts it by hand", w.ID, w.Repository, shortOf(w.Commit, 8), w.Repository, w.ID)})
out[len(out)-1].Says += h.endedSaid(w)
if hold := h.coverHeld(id); hold != "" {
out[len(out)-1].Says += "; it is held back meanwhile: " + hold
}
}
return out
}
// Unheld is a waiting walk no live delivery holds: not a state of the table — only what a stalled line says.
const Unheld State = "unheld"
// seeOrphans keeps when each waiting walk no live delivery holds was first seen so, and forgets the rest; a
// walk still unmatched waits for its merge or its adoption instead. Under the lock.
func (h *Holder) seeOrphans(now time.Time) {
for id, s := range h.walks {
w := s.walk
_, unmatched := h.unmatched[id]
if unmatched || w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits || h.heldWalk(w) {
delete(h.orphans, id)
continue
}
if _, was := h.orphans[id]; !was {
h.orphans[id] = now
h.Logf("[mesh-delivery] the walk %s waits for this owner's word and no live delivery holds it: said once "+
"it is %s so", id, matchGrace)
}
}
for id := range h.orphans {
if _, known := h.walks[id]; !known {
delete(h.orphans, id)
}
}
}
// forgetWalks drops a walk record no longer open after keptWalkFor, and a batch heard of that long past its
// window's latest close. Under the lock.
func (h *Holder) forgetWalks(now time.Time) {
for id, s := range h.walks {
w := s.walk
switch {
case w.IsBatch():
end := s.at
if w.Delivery != nil && w.Delivery.Batch != nil && w.Delivery.Batch.AtMost != nil && w.Delivery.Batch.AtMost.After(end) {
end = *w.Delivery.Batch.AtMost
}
if now.Sub(end) > 3*time.Hour {
delete(h.walks, id)
}
case !w.Open() && now.Sub(s.at) > keptWalkFor:
delete(h.walks, id)
delete(h.deliverErr, id)
}
}
}
// --- the word, once per walk ------------------------------------------------------------------------------
// turnWant is a walk whose turn came for some of the deliveries it carries, and why.
type turnWant struct {
ids []string
whys []string
}
// endedSaid names the ended deliveries a waiting walk answers or that follow it, with their states, so a walk no live
// delivery holds says what it carries instead of only that nothing holds it (novox/hq issue 753); empty for none.
func (h *Holder) endedSaid(w Walk) string {
var ended []string
for _, d := range h.deliveries {
if !d.State.Final() {
continue
}
_, answers := walkAnswers(w, d)
if answers || d.Walk != nil && d.Walk.ID == w.ID {
ended = append(ended, fmt.Sprintf("%s (%s)", d.ID, d.State))
}
}
if len(ended) == 0 {
return ""
}
sort.Strings(ended)
return "; every delivery it carries ended: " + strings.Join(ended, ", ")
}
// lapsedOn are the deliveries, by id, whose word an open walk waiting for this owner's word takes from a stop that
// lapsed (novox/hq issue 753): each was stopped by its group for a member delivered since, so its own change waits for
// a later walk of its repository that contains its merge (issue 747), and this walk answers that merge — it names it,
// or is made at it — and has not left it out. Such a walk is the one that delivery waits for: it gives it its word, so
// a walk whose every other delivery ended is never left without one. A walk at the merge of a delivery stopped for
// itself, or by a person, gets nothing from it. Under the lock.
//
// No member is ordered before another (novox/hq ADR 0418 §2): each such delivery gives its word at once.
func (h *Holder) lapsedOn(w Walk) []string {
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits {
return nil
}
var out []string
for _, d := range h.deliveries {
if h.lapsedAnswered(w, d) {
out = append(out, d.ID)
}
}
sort.Strings(out)
return out
}
// lapsedAnswered is whether a delivery's stop lapsed and the walk answers its merge, not left out.
func (h *Holder) lapsedAnswered(w Walk, d *Delivery) bool {
if d.MergedAs == "" || !h.stopLapsed(d) {
return false
}
if _, ok := walkAnswers(w, d); !ok {
return false
}
_, left := w.leftOut(d.Repository, d.MergedAs)
return !left
}
// lapsedSaid is why a walk whose word stops that lapsed give still waits: the members of their groups not yet
// delivered before them, what the last look held it for, or the controller's refusal of the word. Under the lock.
func (h *Holder) lapsedSaid(w Walk) string {
var parts []string
if why := h.waitsWhy[w.ID]; why != "" {
parts = append(parts, "not let go yet: "+why)
}
if err := h.deliverErr[w.ID]; err != "" {
parts = append(parts, "the controller refused its word: "+err)
}
for _, id := range h.lapsedOn(w) {
if d := h.deliveries[id]; d != nil && strings.Contains(d.LetGoWhy, lapsedWord(w.ID)) {
parts = append(parts, fmt.Sprintf("its word was given for %s at %s, and the controller has not started it",
id, d.LetGo.Local().Format("15:04:05")))
}
}
return strings.Join(parts, "; ")
}
// lapsedOwed is whether a walk's word is owed by stops that lapsed.
func (h *Holder) lapsedOwed(w Walk) bool {
return len(h.lapsedOn(w)) > 0
}
// lapsedTurns registers, for every open walk waiting for this owner's word, the turn of each delivery whose stop
// lapsed and whose merge it answers (lapsedOn, novox/hq issue 753). The word is then given as for any walk: once,
// when every live delivery it carries is at its turn too and it carries no stopped change still held back (words).
// Given once per walk: the walk's id is in the delivery's LetGoWhy once its word was given. Under the lock.
func (h *Holder) lapsedTurns() {
for id, s := range h.walks {
for _, did := range h.lapsedOn(s.walk) {
d := h.deliveries[did]
if strings.Contains(d.LetGoWhy, lapsedWord(id)) {
continue
}
if h.turning == nil {
h.turning = map[string]*turnWant{}
}
want := h.turning[id]
if want == nil {
want = &turnWant{}
h.turning[id] = want
}
if !containsString(want.ids, d.ID) {
want.ids = append(want.ids, d.ID)
want.whys = append(want.whys, fmt.Sprintf("%s: %s its merge %s, stopped by its group for %s, whose change "+
"was delivered since; it waits for a later walk of %s that contains its merge, and this is that "+
"walk (novox/hq issue 753)", d.ID, lapsedWord(id), shortOf(d.MergedAs, 8), d.stoppedFor(), d.Repository))
}
}
}
}
// lapsedWord is how the word a lapsed stop gives a walk names that walk.
func lapsedWord(walk string) string { return "the walk " + walk + " answers" }
// words are the asks of the word of every walk whose turn came for every live delivery it carries: one ask per
// walk, however many deliveries it carries, and none while one of them is not at its turn (ADR 0276 decision 5).
// Under the lock.
func (h *Holder) words() []func() {
walks := make([]string, 0, len(h.turning))
for walk := range h.turning {
walks = append(walks, walk)
}
sort.Strings(walks)
var asks []func()
h.waitsWhy = map[string]string{}
for _, walk := range walks {
want := h.turning[walk]
var waiting []string
for _, d := range h.deliveries {
if d.State.Final() || d.Walk == nil || d.Walk.ID != walk || containsString(want.ids, d.ID) {
continue
}
waiting = append(waiting, fmt.Sprintf("%s (%s)", d.ID, d.State))
}
if len(waiting) > 0 {
sort.Strings(waiting)
h.waitsWhy[walk] = strings.Join(waiting, ", ") + " carried by it not at its turn"
h.Logf("[mesh-delivery] the walk %s is not let go yet: %s", walk, h.waitsWhy[walk])
continue
}
ids, why := append([]string(nil), want.ids...), strings.Join(want.whys, "; ")
// **Never the word for a walk that carries a stopped change** (ADR 0299, review of #221): it is asked to
// leave the merge out first (leaveStopped).
if stopped, _ := h.carriesStopped(walk); len(stopped) > 0 {
h.waitsWhy[walk] = "it carries " + strings.Join(stopped, ", ") + ", whose delivery was stopped: it is " +
"asked to leave that merge out first"
if hold := h.coverHeld(walk); hold != "" {
h.waitsWhy[walk] = hold
}
h.Logf("[mesh-delivery] the walk %s is not let go: it carries %s, whose delivery was stopped", walk,
strings.Join(stopped, ", "))
continue
}
if h.asking[walk] {
continue // its word is being asked
}
h.asking[walk] = true
walk := walk
asks = append(asks, func() {
err := h.Controller.Deliver(walk, why)
h.mu.Lock()
defer h.mu.Unlock()
delete(h.asking, walk)
if err != nil {
if h.deliverErr == nil {
h.deliverErr = map[string]string{}
}
h.deliverErr[walk] = err.Error() // said by `stalled` past the grace (review of novox/hq issue 753)
h.Logf("[mesh-delivery] the walk %s could not be let go yet: %v", walk, err)
h.again("a walk's word the controller refused or did not answer")
return
}
delete(h.deliverErr, walk)
for _, id := range ids {
d := h.deliveries[id]
if d == nil {
continue
}
d.LetGo, d.LetGoWhy = h.Now(), why
if d.Walk != nil && d.Walk.ID == walk {
d.Walk.Waits, d.Walk.GoBy = false, byOwner
}
h.settleAndKeep(d)
}
})
}
h.turning = nil
return asks
}
// endedMovingNothing is whether a delivery ended delivered with nothing for a walk to move: merged, its plan
// moving no module the mesh holds, and no walk followed.
func endedMovingNothing(d *Delivery) bool {
return d.State == Delivered && d.MergedAs != "" && d.Walk == nil && d.MovesNothing()
}
// endedAsNothingToMove are the deliveries a walk answers that ended delivered as nothing for a walk to move (novox/hq
// issues 362, 529: the nzbgeek case); empty when it answers any other. Such a walk is held by nothing: said, never
// let go by this owner. Under the lock.
func (h *Holder) endedAsNothingToMove(w Walk) []string {
var out []string
for _, d := range h.deliveries {
_, answers := walkAnswers(w, d)
if !answers && (d.Walk == nil || d.Walk.ID != w.ID) {
continue
}
if !endedMovingNothing(d) {
return nil
}
out = append(out, d.ID)
}
sort.Strings(out)
return out
}
// WalksWaiting is `stalled` asked with `walks` (novox/hq issue 529): for each walk waiting for this owner's word,
// what holds it, in the shape of a stalled line naming the walk — each held delivery it carries (State held, its
// reason and the verb that releases it), else why its word was not given at the last look (State published), else
// that no live delivery holds it (State unheld). A walk whose word is being given is not listed. The controller's
// `plans` says these instead of pointing at a hand start; the self-check never raises a condition for them.
func (h *Holder) WalksWaiting() []StalledLine {
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
var out []StalledLine
for id, s := range h.walks {
w := s.walk
if w.Release != nil || w.IsBatch() || !w.Open() || !w.Seen().Waits || h.asking[id] {
continue
}
waited := now.Sub(w.Created).Round(time.Second).String()
var held []StalledLine
live := false
for _, d := range h.deliveries {
if d.State.Final() || d.Walk == nil || d.Walk.ID != id {
continue
}
live = true
if d.State == Held {
held = append(held, StalledLine{ID: d.ID, Number: d.Number, State: Held, Walk: id, For: waited,
Bound: "0s", H2: "none: held for a person — the operator's", Says: HeldLine(d)})
}
}
switch {
case len(held) > 0:
out = append(out, held...)
case h.coverHeld(id) != "":
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
Bound: coverAskBound.String(), H2: "none: it is asked to leave the merge out past the bound",
Says: "held back: " + h.coverHeld(id)})
case h.waitsWhy[id] != "":
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
Bound: "0s", H2: "none: it goes at its turn", Says: "not let go yet: " + h.waitsWhy[id]})
case !live && h.lapsedOwed(w):
// Its word comes from the stopped deliveries whose merge it answers (novox/hq issue 753): said, never
// unheld, while the controller has not taken it, with what holds it.
lapsed := h.lapsedOn(w)
says := "its word is owed by the stopped deliveries whose merge it answers, each stopped for a member " +
"delivered since (novox/hq issue 753)"
if len(lapsed) > 0 {
says = "its word is given for " + strings.Join(lapsed, ", ") + ", stopped for a member delivered since, " +
"whose merge it answers (novox/hq issue 753)"
}
if why := h.lapsedSaid(w); why != "" {
says += "; " + why
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Published, Walk: id, For: waited,
Bound: "0s", H2: "none: it goes at its turn", Says: says})
case !live:
says := "no delivery that has not ended follows it or is answered by it, so nothing gives it its word: " +
"`mesh-delivery.show` for the deliveries of " + w.Repository
if ended := h.endedAsNothingToMove(w); len(ended) > 0 {
// The nzbgeek case (novox/hq issues 362, 529): its merges' deliveries ended as nothing for a walk to
// move, yet the walk moves modules — nothing judged those, so nothing here lets it go.
says = "held by nothing: its merges' deliveries ended as nothing-to-move (" + strings.Join(ended, ", ") +
"), yet the walk moves " + strings.Join(walkModules(w), ", ") + " — nothing gives it its word"
}
out = append(out, StalledLine{ID: IDOf(w.Repository, w.Commit), State: Unheld, Walk: id, For: waited,
Bound: "0s", H2: "none: no delivery holds the walk — the operator's", Says: says + h.endedSaid(w)})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Walk != out[j].Walk {
return out[i].Walk < out[j].Walk
}
return out[i].ID < out[j].ID
})
return out
}
// walkModules are the modules a walk's record names, sorted.
func walkModules(w Walk) []string {
out := make([]string, 0, len(w.Modules))
for m := range w.Modules {
out = append(out, m)
}
sort.Strings(out)
return out
}
@@ -140,6 +140,14 @@ func repoName(repository string) string {
func pullSaid(d *Delivery) string { return fmt.Sprintf("%s#%d", repoName(d.Repository), d.Number) }
// touchesSaid is the modules the delivery's check says its change touches, for the ask; the gate plans no delivery.
func touchesSaid(d *Delivery) string {
if d.Check == nil || len(d.Check.Modules) == 0 {
return "no module the check named"
}
return strings.Join(d.Check.Modules, ", ")
}
// composeMergeAsk is the ask for a delivery, or for a group's members together.
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, now time.Time) asks.Ask {
var a asks.Ask
@@ -155,13 +163,10 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
unreviewed = pullSaid(m) + " is " + words
}
reviewers = append(reviewers, fmt.Sprintf("%s: %s", pullSaid(m), words))
plan := "no delivery plan yet"
if m.Plan != nil && m.Plan.Summary != "" {
plan = m.Plan.Summary
}
reaches = append(reaches, plan)
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; review: %s; reaches: %s",
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, plan))
touches := touchesSaid(m)
reaches = append(reaches, touches)
details = append(details, fmt.Sprintf("%s %q at %s: issues: %s; review: %s; touches: %s",
pullSaid(m), m.Title, shortOf(m.Commit, 12), issuesSaid([]*Delivery{m}), words, touches))
}
if group == "" {
a.Headline = phone("Merge into main: "+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
@@ -174,7 +179,7 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
phone(summary, asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Review: "+reviewers[0], asks.PointLength),
phone("Reaches: "+reaches[0], asks.PointLength),
phone("Touches: "+reaches[0], asks.PointLength),
}, "\n")
} else {
var names []string
@@ -191,7 +196,7 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
phone(summary, asks.SummaryLength),
phone("Issues: "+issuesSaid(members), asks.PointLength),
phone("Review: "+strings.Join(reviewers, "; "), asks.PointLength),
phone("Reaches: "+strings.Join(reaches, "; "), asks.PointLength),
phone("Touches: "+strings.Join(reaches, "; "), asks.PointLength),
}, "\n")
}
a.Details = phone(strings.Join(details, "\n"), 1500)
@@ -43,7 +43,7 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
len(a.Options) != 2 || a.Options[0].ID != OptionApprove || a.Options[1].ID != OptionDecline {
t.Fatalf("the ask is not the merge ask: %+v", a)
}
for _, want := range []string{"issue 948", "issue 12", "reviewed by reviewer on shanks", "builds app"} {
for _, want := range []string{"issue 948", "issue 12", "reviewed by reviewer on shanks", "Touches: "} {
if !strings.Contains(a.Explanation, want) {
t.Fatalf("the ask does not say %q: %s", want, a.Explanation)
}
@@ -72,7 +72,6 @@ func TestAMarkedGreenDeliveryIntoMainIsAskedAndNotMergedUntilApproved(t *testing
t.Fatalf("the warrant is not kept: %+v", d.Ready)
}
w.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
w.h.WalkMoved(w.ctl.put(aWalk("plan-7", "novox/app", "m7m7m7m7m7m7", true)))
w.settleAll()
var last Transition
for _, tr := range w.delivery(id).Transitions {
@@ -373,7 +372,7 @@ func TestIssue1025AnOwedMergedIsSaidAfterARestartAndOnce(t *testing.T) {
t.Fatal(err)
}
w.h.mu.Unlock()
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Minute))
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Minute))
again.settleAll()
again.later(time.Hour)
again.settleAll()
@@ -1,154 +0,0 @@
package main
import (
"strings"
"testing"
)
// A failed backup before the send holds the delivery, and a release asks the backups again (novox/hq ADR 0404
// clause 4, issue 1068). The walk's backups field is the controller's contract.
func failedBackup() *WalkBackups {
return &WalkBackups{State: "failed", Asked: []WalkBackupAsked{
{Machine: "ace", Module: "mail", State: "done"},
{Machine: "ace", Module: "pg", State: "failed", Words: "no space left on device"}}}
}
// aDeliveringWalk is a delivery whose walk was let go and runs, nothing sent yet.
func aDeliveringWalk(t *testing.T) (*world, string, string) {
t.Helper()
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
w.settleAll()
if s := w.state(id); s != Delivering {
t.Fatalf("setup: %s, want delivering", s)
}
return w, id, "plan-1"
}
func TestABackupFailedTakesADeliveringDeliveryToHeldOnlyWhileNothingWasSent(t *testing.T) {
w, id, plan := aDeliveringWalk(t)
sent := w.ctl.walk(plan)
sent.Backups = failedBackup()
sent.Modules["app"].SentAt = &w.now
w.h.WalkMoved(w.ctl.put(sent))
w.settleAll()
if s := w.state(id); s == Held {
t.Fatal("held for a failed backup after a module of the plan was sent")
}
w, id, plan = aDeliveringWalk(t)
held := w.ctl.walk(plan)
held.Backups = failedBackup()
w.h.WalkMoved(w.ctl.put(held))
w.settleAll()
d := w.delivery(id)
if d.State != Held {
t.Fatalf("with a failed backup and nothing sent it is %s", d.State)
}
for _, want := range []string{"ace", "pg", "no space left on device"} {
if !strings.Contains(d.HeldWhy, want) {
t.Errorf("the held reason %q does not name %q", d.HeldWhy, want)
}
}
// It does not lift by itself: the same facts read again leave it held.
w.h.WalkMoved(w.ctl.put(w.ctl.walk(plan)))
w.settleAll()
if s := w.state(id); s != Held {
t.Fatalf("read again it is %s, want it still held", s)
}
}
func TestAReleaseOfADeliveryHeldForAFailedBackupAsksTheControllerAgainAndGoesToDelivering(t *testing.T) {
w, id, plan := aDeliveringWalk(t)
held := w.ctl.walk(plan)
held.Backups = failedBackup()
w.h.WalkMoved(w.ctl.put(held))
w.settleAll()
if s := w.state(id); s != Held {
t.Fatalf("setup: %s, want held", s)
}
lettingGo := len(w.ctl.delivers)
if _, err := w.h.Release(id, "freed some space", "jochen"); err != nil {
t.Fatal(err)
}
if got := w.ctl.backupsAgain; len(got) != 1 || got[0] != plan {
t.Fatalf("the controller was asked to take the backups again %v, want once for %s", got, plan)
}
if len(w.ctl.delivers) != lettingGo {
t.Errorf("a started walk was let go again: %v", w.ctl.delivers)
}
if s := w.state(id); s != Delivering {
t.Fatalf("released, it is %s", s)
}
// The record the controller cleared is read: it stays delivering.
w.h.WalkMoved(w.ctl.walk(plan))
w.settleAll()
if s := w.state(id); s != Delivering {
t.Fatalf("with the backups cleared it is %s", s)
}
// Another failure, in a later record, holds it again.
again := w.ctl.walk(plan)
again.Backups = failedBackup()
w.h.WalkMoved(w.ctl.put(again))
w.settleAll()
if s := w.state(id); s != Held {
t.Fatalf("a failed backup in a later record left it %s", s)
}
}
func TestARecordThatStillHoldsTheFailureAfterAReleaseDoesNotHoldItAgain(t *testing.T) {
w, id, plan := aDeliveringWalk(t)
held := w.ctl.walk(plan)
held.Backups = failedBackup()
stale := w.ctl.put(held)
w.h.WalkMoved(stale)
w.settleAll()
if _, err := w.h.Release(id, "freed some space", "jochen"); err != nil {
t.Fatal(err)
}
w.h.WalkMoved(stale) // the record as it was before the controller cleared it
w.settleAll()
if s := w.state(id); s != Delivering {
t.Fatalf("a record from before the release held it again: %s", s)
}
}
func TestADeliveryHeldForAFailedBackupIsNotLetGoByItsWalkAlone(t *testing.T) {
// The controller's own path starts a walk with nobody's word: the held-to-delivering row for that must not
// undo the backup hold.
d := &Delivery{ID: "novox/app@c0", State: Held, MergedAs: "aaaa", Walk: &WalkSeen{ID: "plan-1", State: walkBuilding, Revision: 3,
BackupsFailed: []WalkBackupFailure{{Machine: "ace", Module: "pg", Words: "no space left on device"}}}}
if _, err := Apply(d, EvGo, false, Facts{}); err == nil {
t.Fatal("a walk started on the controller's own path lifted the backup hold")
}
}
func TestAReleaseWhoseBackupsTheControllerRefusedToAskAgainLeavesTheDeliveryHeld(t *testing.T) {
w, id, plan := aDeliveringWalk(t)
held := w.ctl.walk(plan)
held.Backups = failedBackup()
w.h.WalkMoved(w.ctl.put(held))
w.settleAll()
w.ctl.refuseBackupsAgain = true
_, err := w.h.Release(id, "freed some space", "jochen")
if err == nil || !strings.Contains(err.Error(), "held again") {
t.Fatalf("the release of a refused word answered %v, want an error saying it is held again", err)
}
w.settleAll()
d := w.delivery(id)
if d.State != Held || d.BackupsAgainAfter != 0 || !strings.Contains(d.HeldWhy, "no space left on device") {
t.Fatalf("after the refusal it is %s (again-after %d, why %q), want held for the backup", d.State, d.BackupsAgainAfter, d.HeldWhy)
}
// The controller takes the word the next time: the delivery goes on.
w.ctl.refuseBackupsAgain = false
if _, err := w.h.Release(id, "freed some space", "jochen"); err != nil {
t.Fatal(err)
}
if s := w.state(id); s != Delivering {
t.Fatalf("released again, it is %s", s)
}
}
@@ -11,8 +11,6 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
@@ -85,8 +83,8 @@ func newTestBus(t *testing.T) *testBus {
}
b.kvs[name] = kv
}
// The buckets of other modules the manifest reads (`<module>.<name>` is the bucket `<module>_<name>`): the gate reads
// who has the seat there, and today's state to carry (seat.go).
// The buckets of other modules the manifest reads (`<module>.<name>` is the bucket `<module>_<name>`): the teller's answers
// and the checker's verdicts.
for _, name := range m.Reads {
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: strings.Replace(name, ".", "_", 1), History: 10})
if err != nil {
@@ -94,13 +92,9 @@ func newTestBus(t *testing.T) *testBus {
}
b.kvs[name] = kv
}
// The node-engine has said today's module removed on the one machine: the gate may hold the seat (ADR 0450 §6, issue 1369).
if _, err := b.kvs["node-engine.have"].Put(context.Background(), "test.mesh-delivery", []byte(`{"v":1,"phase":"removed","health":"unknown"}`)); err != nil {
t.Fatal(err)
}
// The streams the events are on (mesh.mod.<module>.event.<name>, mesh.seat.<seat>.event.<name>).
if _, err := js.CreateStream(context.Background(), jetstream.StreamConfig{Name: "EVENTS",
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + ControllerSeat + ".event.>", "mesh.seat." + asks.Seat + ".event.>",
Subjects: []string{"mesh.mod.gitea.event.>", "mesh.seat." + asks.Seat + ".event.>",
"mesh.mod." + sdk.ForgeBridgeModule + ".event.>"}}); err != nil {
t.Fatal(err)
}
@@ -208,15 +202,29 @@ func (r *runtime) read(out io.Reader) {
}
}
// untilHolding waits for the gate to hold the seat: it holds it when it reads the controller's walks once, the first thing it
// does after (readOnHolding), and it takes no event before.
func (r *runtime) untilHolding() {
// untilListening waits for the gate to have read its kept state back and subscribed to every event it follows: it takes no
// event before (loadKept), so a subscription is its word that it is ready.
func (r *runtime) untilListening() {
r.t.Helper()
r.until("the gate to hold the seat", 20*time.Second, func() bool {
return len(r.asksOf("seat:"+ControllerSeat+".delivery-walks")) > 0
r.until("the gate to listen to every event it follows", 20*time.Second, func() bool {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.subs) >= len(followed)
})
}
// noControllerAsk fails the test when the gate asked the controller anything: it asks it nothing (ADR 0539 §17, R6-C3).
func (r *runtime) noControllerAsk() {
r.t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
for _, a := range r.asked {
if strings.HasPrefix(a.Key, "seat:mesh-controller.") {
r.t.Fatalf("the gate asked the controller %s: it asks it nothing", a.Key)
}
}
}
func (r *runtime) ping() {
select {
case r.notify <- struct{}{}:
@@ -334,11 +342,8 @@ func (r *runtime) answer(m rpcMessage) {
pulls := append([]OpenPull{}, r.openPulls...)
r.mu.Unlock()
switch a.Key {
case "seat:" + ControllerSeat + ".delivery-walks":
return toolAnswer(map[string]any{"held": true, "walks": []any{}}), nil
case "seat:" + ControllerSeat + ".delivery-plan":
return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"},
Summary: "builds app, sent to the node"}}), nil
case "":
return nil, errors.New("an ask with no key")
case "gitea.gitea_open_pulls":
return toolAnswer(map[string]any{"pulls": pulls}), nil
case "gitea.gitea_commit_statuses":
@@ -355,6 +360,10 @@ func (r *runtime) answer(m rpcMessage) {
"gitea.gitea_add_comment":
return toolAnswer(map[string]any{}), nil
}
if strings.HasPrefix(a.Key, "seat:mesh-controller.") {
// The fake controller of this test fails on any call: the gate has no business with it.
return nil, fmt.Errorf("the controller is not to be called by the gate: %s", a.Key)
}
return nil, fmt.Errorf("the runtime answers no ask %s in this test", a.Key)
}
kv := r.b.kvs[p.State]
@@ -495,12 +504,7 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
defer cancel()
rt.deliverEvents(evctx)
// The module has started when it hears what it follows.
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
return len(rt.subs) >= len(followed)
})
rt.untilHolding()
rt.untilListening()
for _, k := range followed {
if strings.Contains(k, "labeled") || strings.Contains(k, "commented") {
t.Fatalf("the module follows %s: a label or a comment starts nothing here (ADR 0421 §4)", k)
@@ -580,6 +584,8 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
// The bridge says it merged; the mesh's source then holds the merge, in one write.
b.forgeSays(t, sdk.Forge{Kind: sdk.ForgeMerged, Repository: "novox/app", Number: 7, Head: head, Merge: strings.Repeat("b", 40),
Root: "r", Trees: map[string]string{"app": "t"}})
// The forge's own word that the pull request merged follows the bridge's (its webhook): the delivery ends merged.
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 7, head, strings.Repeat("b", 40)))
rt.until("source.main to hold the merge", eventWithin, func() bool {
e, err := b.kvs["source"].Get(ctx, sdk.SourceMainKey)
if err != nil {
@@ -588,6 +594,13 @@ func TestAPassingVerdictAsksTheApproveAndItsApproveMergesWithinSecondsOverARealB
src, err := sdk.DecodeSource(e.Value())
return err == nil && src.Group == "fix/x" && src.Heads["novox/app"] == strings.Repeat("b", 40)
})
// The delivery ends merged (ADR 0539 §17), and the gate asked the controller nothing (R6-C3).
rt.until("the delivery to be merged", eventWithin, func() bool {
e, err := b.kvs["deliveries"].Get(ctx, kvKey(IDOf("novox/app", head)))
var d Delivery
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
})
rt.noControllerAsk()
// Quiet follows: nothing asks the forge or the controller again with no event to say so.
quiet := len(rt.asked)
time.Sleep(1500 * time.Millisecond)
@@ -690,12 +703,7 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
evctx, cancel := context.WithCancel(ctx)
defer cancel()
rt.deliverEvents(evctx)
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
return len(rt.subs) >= len(followed)
})
rt.untilHolding()
rt.untilListening()
const ctlHead, catHead = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2"
for _, p := range []PullEvent{pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"),
pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json")} {
@@ -706,9 +714,6 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
// Both members pass at the checker, whose one verdict covers both heads: the group is asked once, no composed check is asked
// of the controller (issue 1318).
b.checkPasses(t, rt, "feat/x", "a1a1a1a1a1a1", judgedPull{"novox/mesh-controller", 1, ctlHead}, judgedPull{"novox/mesh-catalog", 2, catHead})
if n := len(rt.asksOf("seat:" + ControllerSeat + ".delivery-order")); n != 0 {
t.Fatalf("the controller's delivery-order was asked %d times: no order is asked for", n)
}
var key string
rt.until("the operator to be asked once for the group", 10*time.Second, func() bool { key = onlyAsk(b); return key != "" })
time.Sleep(500 * time.Millisecond)
@@ -758,163 +763,25 @@ func TestAGroupOfTwoWithGreenChecksGetsOneAskAndOnItsApproveBothMergeOneAfterThe
if keys, _ := b.kvs["asks"].Keys(ctx); len(keys) != 1 {
t.Fatalf("a second ask was made: %v", keys)
}
}
// **The acceptance of a failed backup before the send, on the mesh-delivery side** (novox/hq ADR 0404 clause 4, design 47
// milestone 4, issue 1068): a merged delivery whose walk runs is held when the walk's record, as the controller's
// `plan-moved` carries it, names a backup that failed and nothing was sent; the hold names the machine, the module and the
// backup's words, and nothing asks the controller to let it go. One test function, one bus. The walk's backups field is the controller's
// contract (issue 1065).
func TestAPlanMovedCarryingAFailedBackupHoldsTheDeliveryWithTheBackupsWordsOverARealBus(t *testing.T) {
b := newTestBus(t)
rt := b.start(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rt.deliverEvents(ctx)
rt.until("the module to subscribe to the events it follows", 20*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
return len(rt.subs) >= len(followed)
// The forge's own word that each pull request merged follows the bridge's (its webhook), after the second was handed (the
// checker judges the members still open by the trunk the first merge moved, which this test does not play): both members
// end merged, the group with them, and the controller was never called (R6-C3).
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged(one.Repository, one.Number, one.Head.Head, strings.Repeat("a", 40)))
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged(two.Repository, two.Number, two.Head.Head, strings.Repeat("b", 40)))
for _, id := range []string{IDOf("novox/mesh-controller", ctlHead), IDOf("novox/mesh-catalog", catHead)} {
id := id
rt.until(id+" to be merged", eventWithin, func() bool {
e, err := b.kvs["deliveries"].Get(ctx, kvKey(id))
var d Delivery
return err == nil && json.Unmarshal(e.Value(), &d) == nil && d.State == Merged
})
}
rt.until("the group to read merged", eventWithin, func() bool {
e, err := b.kvs["groups"].Get(ctx, kvKey("feat/x"))
var g Group
return err == nil && json.Unmarshal(e.Value(), &g) == nil && g.Said == "merged"
})
rt.untilHolding()
const head, merge = "3333333333333333333333333333333333333333", "4444444444444444444444444444444444444444"
announced := pr("novox/app", 9, head, "fix/y", "src/a.go")
announced.BaseSHA = "a1a1a1a1a1a1"
deliveryOf := func() Delivery {
e, err := b.kvs["deliveries"].Get(context.Background(), kvKey(IDOf("novox/app", head)))
var d Delivery
if err == nil {
_ = json.Unmarshal(e.Value(), &d)
}
return d
}
// Each event is published after the module has taken the one before it: they are on two subjects, and the runner
// may hand over a later one first, which the table would rightly judge on its own (merged before checked).
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
rt.until("the pull request to be taken", 10*time.Second, func() bool { return deliveryOf().State != "" })
b.checkPasses(t, rt, "fix/y", "a1a1a1a1a1a1", judgedPull{"novox/app", 9, head})
rt.until("the verdict to be taken", 10*time.Second, func() bool { d := deliveryOf(); return d.State != "" && d.State != Proposed })
b.publish(t, "mesh.mod.gitea.event.pull.merged", merged("novox/app", 9, head, merge))
rt.until("the merge to be taken", 10*time.Second, func() bool { return deliveryOf().MergedAs != "" })
// Its walk runs, let go: the record names no failed backup yet.
now := time.Now()
walk := aWalk("plan-1", "novox/app", merge, true)
walk.Created, walk.State, walk.Revision = now, walkRolling, 1
walk.Delivery.Go, walk.Delivery.By = &now, "mesh-delivery"
b.publish(t, "mesh.seat."+ControllerSeat+".event.plan-moved", walk)
rt.until("the delivery to be delivering", 10*time.Second, func() bool { return deliveryOf().State == Delivering })
// A backup failed before the send: the delivery is held with the backup's words.
walk.Revision, walk.Backups = 2, &WalkBackups{State: "failed", Asked: []WalkBackupAsked{{Machine: "ace", Module: "pg", State: "failed", Words: "no space left on device"}}}
b.publish(t, "mesh.seat."+ControllerSeat+".event.plan-moved", walk)
rt.until("the delivery to be held for the failed backup", 10*time.Second, func() bool { return deliveryOf().State == Held })
d := deliveryOf()
for _, want := range []string{"ace", "pg", "no space left on device"} {
if !strings.Contains(d.HeldWhy, want) {
t.Fatalf("the held reason %q does not name %q", d.HeldWhy, want)
}
}
// It stays held, and the controller is asked nothing to let it go.
time.Sleep(500 * time.Millisecond)
if s := deliveryOf().State; s != Held {
t.Fatalf("the hold lifted by itself: %s", s)
}
if n := len(rt.asksOf("seat:"+ControllerSeat+".deliver")) + len(rt.asksOf("seat:"+ControllerSeat+".delivery-backups-again")); n != 0 {
t.Fatalf("the controller was asked %d times to let the held walk go or take its backups again", n)
}
}
// **AC3 of issue 1347 (ADR 0437 §7's check): the gate carries a live mesh's open deliveries and groups, once.** The buckets of
// today's module are filled from testdata/live-capture-2026-10-20.json (its source is said in the file), the gate starts with
// today's module on no machine, and every delivery and group arrives in its own buckets unchanged but for the ask an Approve
// waited on; a second start copies nothing.
func TestTheGateCarriesALiveCapturesDeliveriesAndGroupsOnceOverARealBus(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "live-capture-2026-10-20.json"))
if err != nil {
t.Fatal(err)
}
var capture struct {
Deliveries []json.RawMessage `json:"deliveries"`
Groups []json.RawMessage `json:"groups"`
}
if err := json.Unmarshal(raw, &capture); err != nil {
t.Fatal(err)
}
b := newTestBus(t)
ctx := context.Background()
want := map[string]Delivery{}
for _, one := range capture.Deliveries {
var d Delivery
if err := json.Unmarshal(one, &d); err != nil {
t.Fatal(err)
}
if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey(d.ID), one); err != nil {
t.Fatal(err)
}
if d.Ready != nil { // what the carry clears: the ask nobody answered
d.Ready.AskID, d.Ready.Ask, d.Ready.Heads = "", nil, nil
}
want[kvKey(d.ID)] = d
}
for _, one := range capture.Groups {
var g Group
if err := json.Unmarshal(one, &g); err != nil {
t.Fatal(err)
}
if _, err := b.kvs["mesh-delivery.groups"].Put(ctx, kvKey(g.ID), one); err != nil {
t.Fatal(err)
}
}
rt := b.start(t)
rt.untilHolding()
for key, d := range want {
e, err := b.kvs["deliveries"].Get(ctx, key)
if err != nil {
t.Fatalf("the delivery %s did not arrive: %v", key, err)
}
var got Delivery
if err := json.Unmarshal(e.Value(), &got); err != nil {
t.Fatal(err)
}
// What is compared is what the carry copies and the gate never rewrites: who the delivery is. By the time this reads, the
// gate holds the seat and acts on the delivery (it asks a check, owes a note), which changes its check ask, its owed
// effects and its transitions, so a whole-value comparison depends on timing. The carry's own exactness is shown without a
// bus by TestTheCarryCopiesWhatItDoesNotClearByteForByte and TestTheGateCarriesTodaysStateOnceWhenItFirstHoldsTheSeat.
if got.ID != d.ID || got.Repository != d.Repository || got.Commit != d.Commit || got.Number != d.Number || got.Base != d.Base ||
got.Group != d.Group || !got.Created.Equal(d.Created) {
t.Fatalf("the delivery %s changed in the carry:\n got %s\nwant %s", key, mustJSON(got), mustJSON(d))
}
}
for _, one := range capture.Groups {
var g Group
_ = json.Unmarshal(one, &g)
e, err := b.kvs["groups"].Get(ctx, kvKey(g.ID))
if err != nil {
t.Fatalf("the group %s did not arrive: %v", g.ID, err)
}
// The carry copies the group byte for byte (TestTheCarryCopiesWhatItDoesNotClearByteForByte); by the time this reads, the gate
// holds the seat and acts, and writes a group's said state as it does, so what is compared is what only the carry could change.
got := mustDecode[Group](t, e.Value())
if got.ID != g.ID || !slices.Equal(got.Members, g.Members) || !got.Created.Equal(g.Created) {
t.Fatalf("the group %s changed in the carry: %+v", g.ID, got)
}
}
if _, err := b.kvs["deliveries"].Get(ctx, carriedKey); err != nil {
t.Fatalf("the mark carried was not written: %v", err)
}
// A second start copies nothing: a delivery today's module gains meanwhile stays where it is.
_ = rt.in.Close()
_ = rt.cmd.Wait()
late := mustJSON(&Delivery{ID: "novox/app@999999999999", Repository: "novox/app", Commit: "999999999999", Number: 99, State: Proposed})
if _, err := b.kvs["mesh-delivery.deliveries"].Put(ctx, kvKey("novox/app@999999999999"), late); err != nil {
t.Fatal(err)
}
again := b.start(t)
again.untilHolding()
if _, err := b.kvs["deliveries"].Get(ctx, kvKey("novox/app@999999999999")); !errors.Is(err, jetstream.ErrKeyNotFound) {
t.Fatalf("a second start copied a key (%v)", err)
}
rt.noControllerAsk()
}
func mustDecode[T any](t *testing.T, raw []byte) T {
@@ -12,11 +12,10 @@ import (
// **The gate is the only claimant of the seat `mesh-delivery` in the catalogue** (novox/hq ADR 0437 §1, ADR 0449 §4, ADR 0454,
// issue 1351; how it is checked). Today's module `modules/mesh-delivery` was removed by a pull request of its own after the
// handover, so the claimants are exactly one. And no manifest but the gate's and mesh-issues' names the old module in what it
// reads or hears, but through the seat's name (`mesh-delivery` as a seat or a repository, `seat:mesh-delivery.<verb>`) and the
// names below: the three buckets the gate carries over (ADR 0450 §6). The event `mesh-delivery.transition` is no longer heard by anyone (issue 1406).
var oldModuleNames = map[string][]string{
"mesh-delivery-gate": {"mesh-delivery.deliveries", "mesh-delivery.groups", "mesh-delivery.pulls"},
}
// reads or hears, but through the seat's name (`mesh-delivery` as a seat or a repository, `seat:mesh-delivery.<verb>`). The
// three buckets the gate once carried over (ADR 0450 §6) went with the carry (ADR 0539 §17), and the event
// `mesh-delivery.transition` is heard by no one (issue 1406).
var oldModuleNames = map[string][]string{}
// seatClaimants are the modules that claim the seat: the gate alone.
var seatClaimants = []string{"mesh-delivery-gate"}
@@ -15,6 +15,9 @@ const (
ctxRepo = "mesh/repo-check"
)
// shimCheckedKey is the key a test hears a verdict under: said as the checker's (see Checked).
const shimCheckedKey = "mesh-controller.checked"
// CheckedEvent is a verdict as these tests state it. The gate follows no verdict of the controller any more (issue 1318,
// ADR 0524 §5): the shim below says it the way the mesh says it now, as the head's mesh/check and the checker's verdict at
// the heads, so the table, the Approve and the merges are exercised on the one path they have.
@@ -38,7 +41,6 @@ type CheckedEvent struct {
Verdict string `json:"verdict"`
Summary string `json:"summary"`
}
Plan *DeliveryPlan
Group string
Members []struct {
Owner string `json:"owner"`
@@ -85,9 +87,6 @@ func (h *Holder) Checked(c CheckedEvent) {
if d == nil {
return
}
if c.Plan != nil {
d.Plan = c.Plan
}
words := map[string]string{"success": "pass", "warning": "pass", "failure": "failed: " + c.Summary, "error": "error"}[state]
line := words + " " + shortOf(c.Commit, 12)
if c.Trunk != "" {
@@ -69,9 +69,11 @@ type ChecksOf struct {
Since string `json:"since"`
Waits string `json:"waits,omitempty"`
Group string `json:"group,omitempty"`
// Class, Opened and MergedAt are what the issue tracker measures a fix's time by (novox/hq ADR 0313): the
// class of what it moves, when its pull request was first heard, and when it merged.
Class string `json:"class,omitempty"`
// MergedAs is the commit its merge landed as on the trunk, once merged: what the delivery view's `delivered` event is
// matched to a fix by (ADR 0539 clause 17).
MergedAs string `json:"merged_as,omitempty"`
// Opened and MergedAt are what the issue tracker measures a fix's time by (novox/hq ADR 0313): when its pull request
// was first heard, and when it merged.
Opened string `json:"opened,omitempty"`
MergedAt string `json:"merged_at,omitempty"`
}
@@ -113,7 +115,7 @@ func (h *Holder) Checks(repository string, number int, commit string) (*ChecksAn
}
if d != nil {
a.Delivery = &ChecksOf{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), Waits: d.waits,
Group: d.Group, Class: fixClass(&d.Delivery)}
Group: d.Group, MergedAs: d.MergedAs}
if !d.opened.IsZero() {
a.Delivery.Opened = d.opened.UTC().Format(time.RFC3339)
}
@@ -198,8 +200,8 @@ func (h *Holder) deliveryOf(repository string, number int, commit string) *check
// newerHead is whether d is a pull request's head to read before e: one not superseded first, then the newest.
func newerHead(d, e *Delivery) bool {
if (d.State == Superseded) != (e.State == Superseded) {
return e.State == Superseded
if (d.State == Closed) != (e.State == Closed) {
return e.State == Closed
}
return d.Created.After(e.Created)
}
@@ -1,587 +0,0 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
)
// novox/hq issues 319 and 316: a held delivery whose change already runs, or that builds nothing, is not a
// question for a person. A merge that moves nothing is delivered at once; a merge whose every module a later
// merge on the same trunk delivered is covered — never stopped, never an error on its pull request; and a
// group member held for a composed check that had not answered goes on once it passes.
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
// mergedAt is the forge's `pull.merged` at a moment.
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
p.MergeCommit = merge
p.MergedAt = at.UTC().Format(time.RFC3339)
return p
}
// verdictMoving is a passing verdict whose plan moves these modules — or builds nothing, given none.
func verdictMoving(repo string, number int, sha string, modules ...string) CheckedEvent {
c := verdict(repo, number, sha, "pass")
c.Plan = planMoving(repo, modules...)
return c
}
func planMoving(repo string, modules ...string) *DeliveryPlan {
if len(modules) == 0 {
return &DeliveryPlan{Repository: repo, Base: "main", Summary: "builds nothing: the change touches no module of the mesh's graph"}
}
return &DeliveryPlan{Repository: repo, Base: "main", Moved: modules, Summary: "builds " + strings.Join(modules, ", ") + " → anchor"}
}
// deliverLater is a later merge into a trunk, walked: its walk opened for its merge commit, let go, and left
// in the state given, every module of it sent.
func deliverLater(w *world, repo string, number int, base, headSHA, merge string, at time.Time, state string,
modules ...string) string {
w.t.Helper()
p := pr(repo, number, headSHA, fmt.Sprintf("feat/later-%d", number))
p.Base = base
w.h.PullUpdated(p)
w.h.Checked(verdictMoving(repo, number, headSHA, modules...))
w.h.PullMerged(mergedAt(p, merge, at))
walk := aWalk("plan-"+merge[:6], repo, merge, true)
walk.Branch = base
walk.Modules = map[string]*WalkModule{}
for _, m := range modules {
walk.Modules[m] = &WalkModule{}
}
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
walk = w.ctl.walk(walk.ID)
sent := at.Add(5 * time.Minute)
for _, m := range modules {
walk.Modules[m] = &WalkModule{State: "built", SentAt: &sent}
}
walk.State = state
w.h.WalkMoved(w.ctl.put(walk))
w.settleAll()
return IDOf(repo, headSHA)
}
func statusOn(w *world, repo, commit string) string {
w.forge.mu.Lock()
defer w.forge.mu.Unlock()
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
}
// **A merge that moves nothing is delivered at once** (issue 319): there is nothing for a walk to move and
// nothing for a person to decide — whether its check passed, failed or never answered, and whether its plan
// builds nothing at all or only a new module the controller builds and sends nowhere (issue 300).
func TestAMergeThatMovesNothingIsDeliveredAtOnce(t *testing.T) {
for _, c := range []struct {
name string
verdict string // "" is a merge before any verdict
plan *DeliveryPlan
}{
{"builds nothing, merged before its check answered", "", planMoving("novox/hq")},
{"builds nothing, merged on a failed check", "fail", planMoving("novox/hq")},
{"builds nothing, merged on a passing check", "pass", planMoving("novox/hq")},
{"a new module, sent nowhere", "pass", &DeliveryPlan{Repository: "novox/hq", New: []string{"modules/zfs"},
Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
{"a new module, sent nowhere, merged before its check answered", "", &DeliveryPlan{Repository: "novox/hq",
New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere; no bus step"}},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
w.ctl.planned = c.plan
p := pr("novox/hq", 7, head, "docs/x", "02-DECISIONS/x.md")
w.h.PullUpdated(p)
if c.verdict != "" {
v := verdict("novox/hq", 7, head, c.verdict)
v.Plan = c.plan
w.h.Checked(v)
}
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
w.settleAll()
// Past the bound a merge waits for its walk: nothing holds it then either.
w.later(15 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
d := w.delivery(IDOf("novox/hq", head))
if d.State != Delivered {
t.Fatalf("a merge whose plan is %q is %s (%s), not delivered", c.plan.Summary, d.State, d.HeldWhy)
}
if s := statusOn(w, "novox/hq", head); !strings.HasPrefix(s, "success") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
})
}
}
// **A held delivery whose every module a later merge on its trunk delivered is covered** (issue 319): the
// later merge commit contains it, its walk is done, and it sent each module this one moves. Anything short
// of that — a module not carried, an earlier merge, another trunk, a walk that did not finish — leaves it held
// for a person, as before.
func TestAMergeALaterDeliveredWalkCarriedIsCovered(t *testing.T) {
const ctl = "novox/mesh-controller"
for _, c := range []struct {
name string
// The later merge: its trunk, minutes after t0 it merged (this one merged at 30), its walk's end, and
// what it sent.
base string
at int
state string
modules []string
// Whether this one was held for a person before the later merge was delivered.
heldFirst bool
// What the forge says of the later merge containing this one: yes, no, or away until asked again.
forge string
want State
}{
{"carried every module, delivered after this one was held", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "yes", Covered},
{"carried every module, delivered before this one was due to be held", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller", "route-proxy"}, false, "yes", Covered},
{"carried one of its two modules", "main", 35, walkDone, []string{"mesh-controller"}, true, "yes", Held},
{"merged before this one", "main", 25, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"merged into another branch", "release", 35, walkDone, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"its walk failed", "main", 35, walkFailed, []string{"build-agent", "mesh-controller"}, true, "yes", Held},
{"the forge says the later merge does not contain it", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "no", Held},
{"the forge could not say at first, then said it does", "main", 35, walkDone,
[]string{"build-agent", "mesh-controller"}, true, "away", Covered},
} {
t.Run(c.name, func(t *testing.T) {
w := newWorld(t)
pair := merge + " in nnnnnnnnnnnn8888"
switch c.forge {
case "yes", "away":
w.forge.contains[pair] = true
case "no":
w.forge.contains[pair] = false
}
w.forge.containsDown = c.forge == "away"
p := pr(ctl, 1, head, "feat/one", "cmd/a.go")
w.h.PullUpdated(p)
w.h.Checked(verdictMoving(ctl, 1, head, "mesh-controller", "build-agent"))
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(p, merge, t0.Add(30*time.Minute)))
w.settleAll()
if c.heldFirst {
w.later(15 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(IDOf(ctl, head)); s != Held {
t.Fatalf("a merge no walk opened for is %s before the later one", s)
}
}
later := deliverLater(w, ctl, 2, c.base, "bbbbbbbbbbbb2222", "nnnnnnnnnnnn8888", t0.Add(time.Duration(c.at)*time.Minute),
c.state, c.modules...)
if c.state == walkDone && w.state(later) != Delivered {
t.Fatalf("the later merge is %s", w.state(later))
}
w.later(20 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if c.forge == "away" {
// A forge that cannot say is "not yet covered": held, and asked again a minute on.
if s := w.state(IDOf(ctl, head)); s != Held {
t.Fatalf("with the forge away it is %s", s)
}
w.forge.mu.Lock()
w.forge.containsDown = false
w.forge.mu.Unlock()
w.later(2 * time.Minute)
w.settleAll()
}
d := w.delivery(IDOf(ctl, head))
if d.State != c.want {
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
}
s := statusOn(w, ctl, head)
if c.want == Covered {
if !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") ||
!strings.Contains(s, "nnnnnnnn") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
last := d.Transitions[len(d.Transitions)-1]
if !strings.Contains(last.Why, IDOf(ctl, "bbbbbbbbbbbb2222")) {
t.Fatalf("its transition does not name what covered it: %q", last.Why)
}
}
if strings.HasPrefix(s, "error") {
t.Fatalf("its pull request's mesh/delivery is an error: %q", s)
}
// Asked once for the pair, however many ticks read it — once more only after the forge was away.
asked := 0
for _, a := range w.forge.containsAsks {
if a == pair {
asked++
}
}
candidate := c.base == "main" && c.at > 30 && c.state == walkDone && len(c.modules) >= 2
switch {
case !candidate && asked != 0:
t.Fatalf("the forge was asked whether %s for a merge order and walk that cannot cover it", pair)
case candidate && c.forge != "away" && asked != 1:
t.Fatalf("the forge was asked %d time(s) whether %s, want once", asked, pair)
case candidate && c.forge == "away" && asked < 2:
t.Fatalf("the forge, away, was asked %d time(s) whether %s: never again", asked, pair)
}
})
}
}
// A covered member of a group counts as delivered: the member after it is let go, not stopped.
func TestACoveredGroupMemberLetsTheNextOneGo(t *testing.T) {
w := newWorld(t)
const ctl, lab = "novox/mesh-controller", "novox/mesh-lab"
const ctlHead, labHead = "c1c1c1c1c1c1c1c1", "d2d2d2d2d2d2d2d2"
ctlID, labID := IDOf(ctl, ctlHead), IDOf(lab, labHead)
pc, pl := pr(ctl, 1, ctlHead, "feat/g", "cmd/a.go"), pr(lab, 2, labHead, "feat/g", "replays/a.go")
w.h.PullUpdated(pc)
w.h.PullUpdated(pl)
w.h.Checked(verdictMoving(ctl, 1, ctlHead, "mesh-controller"))
w.h.Checked(verdictMoving(lab, 2, labHead, "mesh-lab"))
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/g", ID: "check-1", Verdict: "pass", Summary: "both compose"})
w.later(31 * time.Minute)
w.h.PullMerged(mergedAt(pc, "m1m1m1m1m1m1", t0.Add(30*time.Minute)))
w.h.PullMerged(mergedAt(pl, "m2m2m2m2m2m2", t0.Add(31*time.Minute)))
w.h.WalkMoved(w.ctl.put(aWalk("plan-lab", lab, "m2m2m2m2m2m2", true)))
w.settleAll()
w.forge.contains["m1m1m1m1m1m1 in m3m3m3m3m3m3"] = true
deliverLater(w, ctl, 3, "main", "e3e3e3e3e3e3e3e3", "m3m3m3m3m3m3", t0.Add(35*time.Minute), walkDone, "mesh-controller")
w.later(20 * time.Minute)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if w.state(ctlID) != Covered {
t.Fatalf("the controller's member is %s", w.state(ctlID))
}
if w.state(labID) != Delivering || !containsString(w.ctl.delivers, "plan-lab") {
t.Fatalf("the member after it is %s, let go %v", w.state(labID), w.ctl.delivers)
}
lw := w.ctl.walk("plan-lab")
lw.State = walkDone
w.h.WalkMoved(w.ctl.put(lw))
w.settleAll()
if state, why := GroupState(w.h.groups["feat/g"], w.h.membersOf(w.h.groups["feat/g"])); state != "delivered" {
t.Fatalf("the group is %s: %s", state, why)
}
}
// **On rollout, the deliveries already held close by themselves** (issue 319's held ones), and a restart
// after that moves nothing again: what was read back is where it stays.
func TestHeldDeliveriesReadBackCloseByThemselvesOnce(t *testing.T) {
const ctl, cat = "novox/mesh-controller", "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
coverer := func(repo, module string) *Delivery {
return &Delivery{ID: IDOf(repo, "c0c0c0c0c0c0c0c0"), Repository: repo, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(repo, module), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: []Step{{Machine: "anchor", Module: module, State: StepPassed}, {Machine: "the rest running it", Module: module, State: StepSent}}}
}
held := func(repo string, plan *DeliveryPlan, why string) *Delivery {
return &Delivery{ID: IDOf(repo, head), Repository: repo, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: plan, Transitions: []Transition{{At: merged1, From: Ready, To: Held, Event: EvHold, Why: why}}}
}
const unwalkedWhy = "merged, and the controller opened no walk for it within 10m0s — nothing it holds follows that branch, " +
"or the merge was not heard: a person decides"
withCoverer := func(d *Delivery, change func(c *Delivery)) []*Delivery {
c := coverer(d.Repository, d.Plan.Moved[0])
change(c)
return []*Delivery{d, c}
}
const uncheckedWhy = "merged without a passing check: only a person decides that it goes on"
for _, c := range []struct {
name string
kept []*Delivery
group *Group
want State
}{
{"merged without a passing check, builds nothing",
[]*Delivery{held("novox/hq", planMoving("novox/hq"), "merged without a passing check: only a person decides that it goes on")},
nil, Delivered},
{"a new module sent nowhere, no walk opened",
[]*Delivery{held(cat, &DeliveryPlan{New: []string{"modules/zfs"}, Summary: "builds new: modules/zfs, sent nowhere"}, unwalkedWhy)},
nil, Delivered},
{"no walk opened, a later delivered walk carried its module",
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "mesh-controller")}, nil, Covered},
{"merged without a passing check, a later delivered walk whose own check passed carried its module",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(*Delivery) {}), nil, Covered},
{"merged without a passing check, the later delivery's own check did not pass",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), uncheckedWhy), func(c *Delivery) {
c.Check = &Verdict{Gate: "fail"}
}), nil, Held},
{"no walk opened, the later delivery's walk was of another commit",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Walk.Commit = "e0e0e0e0e0e0e0e0"
}), nil, Held},
{"no walk opened, the later walk sent its module to its first machine only",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Steps = c.Steps[:1]
}), nil, Held},
{"no walk opened, the later walk's branch is another",
withCoverer(held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), func(c *Delivery) {
c.Walk.Branch = "release"
}), nil, Held},
{"no walk opened, a later delivered walk carried another module",
[]*Delivery{held(ctl, planMoving(ctl, "mesh-controller"), unwalkedWhy), coverer(ctl, "route-proxy")}, nil, Held},
{"held for its group, its own walk superseded, a later delivered walk carried its module",
func() []*Delivery {
d := held(cat, planMoving(cat, "systemd"), "its group feat/j's composed check did not pass for the heads that "+
"merged; a person decides that it goes on")
d.Group = "feat/j"
d.Walk = &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"}
other := &Delivery{ID: IDOf("novox/mesh-host", "abababababababab"), Repository: "novox/mesh-host",
Commit: "abababababababab", Number: 3, Base: "main", Group: "feat/j", State: Delivered,
MergedAs: "acacacacacacacac", MergedAt: merged1}
return []*Delivery{d, other, coverer(cat, "systemd")}
}(),
&Group{ID: "feat/j", Members: []string{IDOf(cat, head), IDOf("novox/mesh-host", "abababababababab")}, Closed: true,
Check: &GroupCheck{Asked: "check-1", Verdict: "pass", Commits: map[string]string{IDOf(cat, head): head,
IDOf("novox/mesh-host", "abababababababab"): "abababababababab"}}},
Covered},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
for _, d := range c.kept {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
if c.group != nil {
if err := store.PutGroup(c.group); err != nil {
t.Fatal(err)
}
}
now := merged2.Add(30 * time.Hour)
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, now)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
id := c.kept[0].ID
d := w.delivery(id)
if d.State != c.want {
t.Fatalf("read back held, it is %s, want %s", d.State, c.want)
}
if s := statusOn(w, d.Repository, d.Commit); c.want != Held && !strings.HasPrefix(s, "success") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
// A restart over what it kept: nothing moves again.
again := newWorldOver(t, store, newFakeController(), forge, now.Add(time.Hour))
if err := again.h.Reconcile(); err != nil {
t.Fatal(err)
}
again.settleAll()
if d2 := again.delivery(id); d2.State != d.State || len(d2.Transitions) != len(d.Transitions) {
t.Fatalf("a restart moved it again: %s with %d transitions, was %s with %d", d2.State, len(d2.Transitions),
d.State, len(d.Transitions))
}
})
}
}
// **A module a release carried to every machine the held delivery sends it to covers it** (novox/hq issue
// 351). A build the controller held back for a person and released on their word reaches each machine by a
// backlog walk, which credits the delivery that published the build one step per machine — never the walk's
// own "every machine running it" or "the rest running it". The held delivery's plan says which machines
// receive the module: a passed step on each of them is the module sent everywhere. A machine still judging,
// one that failed, or a plan that names no machines leaves it held, as before.
func TestAModuleAReleaseCarriedToEveryMachineCovers(t *testing.T) {
const cat = "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
laptop, desktop := "laptop", "desktop"
plan := func(machines ...string) *DeliveryPlan {
p := planMoving(cat, "openrazer")
for _, m := range machines {
p.Machines = append(p.Machines, MachinePlan{Machine: m, Receives: []string{"openrazer"}})
}
return p
}
held := func(p *DeliveryPlan) *Delivery {
why := "its group feat/j's composed check did not pass for the heads that merged; a person decides that it goes on"
return &Delivery{ID: IDOf(cat, head), Repository: cat, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: p, Walk: &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"},
Transitions: []Transition{{At: merged1, From: Published, To: Held, Event: EvHold, Why: why}}}
}
step := func(machine string, state StepState) Step {
return Step{Machine: machine, Module: "openrazer", Build: "build-1", State: state, Why: "carried by release-1"}
}
coverer := func(steps ...Step) *Delivery {
return &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: append([]Step{{Machine: "anchor", Module: "power", State: StepPassed},
{Machine: "the rest running it", Module: "power", State: StepSent}}, steps...)}
}
for _, c := range []struct {
name string
held *Delivery
by *Delivery
want State
}{
{"passed on every machine its plan sends it to", held(plan(laptop, desktop)),
coverer(step(laptop, StepPassed), step(desktop, StepPassed)), Covered},
{"still judging on one of them", held(plan(laptop, desktop)),
coverer(step(laptop, StepPassed), step(desktop, StepJudging)), Held},
{"failed on one of them", held(plan(laptop, desktop)),
coverer(step(laptop, StepPassed), step(desktop, StepFailed)), Held},
{"never sent to one of them", held(plan(laptop, desktop)), coverer(step(laptop, StepPassed)), Held},
{"its plan names no machines", held(plan()), coverer(step(laptop, StepPassed), step(desktop, StepPassed)), Held},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
for _, d := range []*Delivery{c.held, c.by} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, merged2.Add(30*time.Hour))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
d := w.delivery(c.held.ID)
if d.State != c.want {
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
}
if c.want == Covered {
if s := statusOn(w, cat, head); !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
if last := d.Transitions[len(d.Transitions)-1]; !strings.Contains(last.Why, "openrazer") {
t.Fatalf("its transition does not name the module: %q", last.Why)
}
}
})
}
}
// **A finished release moves the steps it carried to passed** (novox/hq issue 351): a backlog walk's gate
// credits a step per machine while it judges, and its last record keeps no gate — only which machines it is
// done on. A step credited to it, on a machine it is done on, is passed; a step it failed on, one on a machine
// it is not done on, and a step another walk carried are left as they are.
func TestAFinishedReleaseMovesTheStepsItCarriedToPassed(t *testing.T) {
const cat = "novox/mesh-catalog"
carried := func(machine string, state StepState, by string) Step {
return Step{Machine: machine, Module: "openrazer", Build: "build-1", State: state, Why: "carried by " + by}
}
for _, c := range []struct {
name string
steps []Step
done []string
want map[string]StepState
}{
{"done on both", []Step{carried("laptop", StepJudging, "release-1"), carried("desktop", StepJudging, "release-1")},
[]string{"laptop", "desktop"}, map[string]StepState{"laptop": StepPassed, "desktop": StepPassed}},
{"done on one, the other still to come", []Step{carried("laptop", StepJudging, "release-1"), carried("desktop", StepJudging, "release-1")},
[]string{"laptop"}, map[string]StepState{"laptop": StepPassed, "desktop": StepJudging}},
{"a step it failed on stays failed", []Step{carried("laptop", StepFailed, "release-1")},
[]string{"laptop"}, map[string]StepState{"laptop": StepFailed}},
{"a step another walk carried is not its", []Step{carried("laptop", StepJudging, "release-2")},
[]string{"laptop"}, map[string]StepState{"laptop": StepJudging}},
} {
t.Run(c.name, func(t *testing.T) {
d := &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: t0.Add(time.Hour), Created: t0, MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: t0,
Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone}, Steps: c.steps}
store := newMemStore()
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
w := newWorldOver(t, store, newFakeController(), newFakeForge(), t0.Add(2*time.Hour))
w.h.WalkMoved(Walk{ID: "release-1", Repository: "the builds waiting for a gate", State: walkDone,
Tiers: [][]string{{"openrazer"}}, Modules: map[string]*WalkModule{},
Release: &WalkRelease{Order: []string{"laptop", "desktop"}, Next: len(c.done), Done: c.done}})
got := map[string]StepState{}
for _, s := range w.delivery(d.ID).Steps {
got[s.Machine] = s.State
}
for machine, want := range c.want {
if got[machine] != want {
t.Fatalf("openrazer on %s is %s, want %s (steps %v)", machine, got[machine], want, got)
}
}
})
}
}
// The day's case whole (novox/hq issue 351): a member held for its group's check, its own walk taken over by
// later merges; the one later walk that carried its module failed its gate; a later merge's walk was delivered
// without the module; the controller then held the module's build back for a person, who released it, and the
// release reached both machines. The held delivery is covered by that later merge once the release is done —
// read by its id, since a walk that ended before the holder's restart may be older than the ended walks the
// controller lists back.
func TestAHeldDeliveryWhoseModuleAReleaseCarriedIsCoveredOnceTheReleaseIsDone(t *testing.T) {
const cat = "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
plan := planMoving(cat, "openrazer")
plan.Machines = []MachinePlan{{Machine: "laptop", Receives: []string{"openrazer"}}, {Machine: "desktop", Receives: []string{"openrazer"}}}
why := "its group feat/j's composed check did not pass for the heads that merged; a person decides that it goes on"
held := &Delivery{ID: IDOf(cat, head), Repository: cat, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: plan, Walk: &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"},
Transitions: []Transition{{At: merged1, From: Published, To: Held, Event: EvHold, Why: why}}}
later := &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: []Step{{Machine: "anchor", Module: "power", State: StepPassed}, {Machine: "the rest running it", Module: "power", State: StepSent}}}
store := newMemStore()
for _, d := range []*Delivery{held, later} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, merged2.Add(3*time.Hour))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(held.ID); s != Held {
t.Fatalf("before any release it is %s", s)
}
// The release judges the build on each machine in turn: its gate names what it carried.
release := Walk{ID: "release-1", Repository: "the builds waiting for a gate", State: walkRolling,
Tiers: [][]string{{"openrazer"}}, Modules: map[string]*WalkModule{},
Release: &WalkRelease{Order: []string{"laptop", "desktop"}, Gate: &WalkGate{Machines: []string{"laptop"},
Carried: []WalkCarry{{Module: "openrazer", Node: "laptop", To: "f0f0f0f0f0f0f0f0", Build: "build-1"},
{Module: "openrazer", Node: "desktop", To: "f0f0f0f0f0f0f0f0", Build: "build-1"}}}}}
w.h.WalkMoved(release)
w.settleAll()
if s := w.state(held.ID); s != Held {
t.Fatalf("while the release judges it is %s", s)
}
// Done on both: its last record keeps no gate. It ended while this holder was down, and is older than the
// ended walks the controller lists back: the holder asks for it by its id, since a step it carried still judges.
release.State, release.Release.Gate, release.Release.Done, release.Release.Next = walkDone, nil, []string{"laptop", "desktop"}, 2
w.ctl.put(release)
w.later(time.Hour) // a walk a step still judges is asked for once an hour; at a start, at once
w.settleAll()
d := w.delivery(held.ID)
if d.State != Covered {
t.Fatalf("once the release is done it is %s (%s), want covered", d.State, d.HeldWhy)
}
if last := d.Transitions[len(d.Transitions)-1]; !strings.Contains(last.Why, IDOf(cat, "c0c0c0c0c0c0c0c0")) ||
!strings.Contains(last.Why, "openrazer") {
t.Fatalf("its transition does not name what covered it: %q", last.Why)
}
}
@@ -31,15 +31,6 @@ func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any
if d.MergedAs != "" {
e["merged_as"] = d.MergedAs
}
if d.Walk != nil {
e["walk"] = d.Walk.ID
}
if d.Plan != nil {
e["plan"] = d.Plan.Summary
}
if c := fixClass(d); c != "" {
e["class"] = c
}
if !opened.IsZero() {
e["opened"] = opened.UTC()
}
@@ -49,24 +40,6 @@ func transitionEvent(d *Delivery, t Transition, opened time.Time) map[string]any
return e
}
// fixClass is the class of what a delivery moves (novox/hq ADR 0313 §2): its plan's, as the planner said it;
// none for a plan that moves no module, from a planner that did not say a class; else its walk's class once its
// delivery time is known; empty when nothing says it.
func fixClass(d *Delivery) string {
switch {
case d.Plan != nil && d.Plan.Class != "":
return d.Plan.Class
case d.Plan != nil && len(d.Plan.Moved) == 0 && len(d.Plan.Dependents) == 0 && len(d.Plan.New) == 0:
return classNone
case d.Time != nil:
return d.Time.Class
}
return ""
}
// classNone is the class of a delivery that moves no module: nothing for a walk to move, and no fix budget.
const classNone = "none"
// openedOf is when a delivery's pull request was first heard: the earliest head of it this owner holds, never
// one made from the forge's word at its merge; zero when none is held or it has no pull request. Under the lock.
func (h *Holder) openedOf(d *Delivery) time.Time {
@@ -96,88 +69,50 @@ func noteLine(d *Delivery, t Transition) string {
}
line := fmt.Sprintf("%s delivery %s: %s -> %s (%s%s): %s", t.At.UTC().Format(time.RFC3339), d.ID, orNothing(t.From),
t.To, t.Event, by, t.Why)
if t.To == Proposed && d.Plan != nil {
line += "; predicted: " + d.Plan.Summary
}
if d.Group != "" && (t.To == Proposed || t.To == Published) {
if d.Group != "" && (t.To == Proposed || t.To == Merged) {
line += "; group " + d.Group
}
return oneLine(line)
}
// executedLine is what a delivery did, written on the commit it landed as when it ends.
func executedLine(d *Delivery) string {
var parts []string
for _, s := range d.Steps {
parts = append(parts, fmt.Sprintf("%s on %s %s", s.Module, s.Machine, s.State))
}
walk := ""
if d.Walk != nil {
walk = " (walk " + d.Walk.ID + ")"
}
return oneLine(fmt.Sprintf("%s delivery %s executed%s: %s", time.Now().UTC().Format(time.RFC3339), d.ID, walk,
strings.Join(parts, "; ")))
}
func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") }
// statusOf is the commit status `mesh/delivery`: where the delivery stands, in the forge's words.
// statusOf is the commit status `mesh/delivery`: where the delivery stands, in the forge's words. It says the merge's
// states only (ADR 0539 §17): whether a merged change reached the machines is the view's.
func statusOf(d *Delivery) (string, string) {
why := ""
if len(d.Transitions) > 0 {
why = d.Transitions[len(d.Transitions)-1].Why
}
switch d.State {
case Proposed, Checked:
case Proposed:
return "pending", "checking"
case Ready:
if d.holdsForReview() {
return "pending", clip("ready, "+d.Ready.Waits, 140) // novox/hq ADR 0496: no Approve is asked before a review
}
return "success", "ready: it delivers once merged"
return "success", "ready: it is merged on the Approve"
case Rejected:
return "failure", clip("rejected: "+why, 140)
case Published:
return "pending", "published: its walk waits for its turn"
case Merged:
return "success", clip("merged: "+d.sourceSaid(), 140)
case Held:
return "pending", clip("held for a person: "+d.HeldWhy, 140)
case Delivering:
passed := 0
for _, s := range d.Steps {
if s.State == StepPassed {
passed++
}
}
return "pending", fmt.Sprintf("delivering: %d machine step(s) passed", passed)
case Delivered:
return "success", "delivered"
case Failed:
if d.coveredStop() {
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 747)
}
return "failure", clip("failed: "+why, 140)
case Superseded:
if d.coveredStop() {
return "success", clip(d.coveredStopSaid(), 140) // its walk's taker delivered it (issue 1786)
}
return "warning", clip("superseded: "+why, 140)
case Stopped:
if d.coveredStop() {
return "success", clip(d.coveredStopSaid(), 140) // its change reached the machines (novox/hq issue 480)
}
return "error", clip("stopped: "+why, 140)
case Retired:
return "success", "history: merged before this owner delivered anything, never walked"
case Covered:
by := d.CoveredBy
if by == "" {
by = d.Carrier
}
return "success", clip("covered: a later merge that contains it was delivered: "+by, 140)
case Closed:
return "warning", clip("closed: "+why, 140)
}
return "pending", string(d.State)
}
// sourceSaid names the source revision a merge moved: the trunk commit of its repository that source.main now holds
// (ADR 0539 §17: the gate's last word on a change; what the machines run is the view's).
func (d *Delivery) sourceSaid() string {
if d.MergedAs == "" {
return "not on the trunk"
}
return "source.main moved to " + d.Repository + "@" + shortOf(d.MergedAs, 12)
}
// viewMarker is how the forge's holder finds the one view comment of a pull request.
const viewMarker = "<!-- mesh-delivery:view -->"
@@ -185,31 +120,12 @@ const viewMarker = "<!-- mesh-delivery:view -->"
func ViewBody(d *Delivery, g *Group) string {
var b strings.Builder
fmt.Fprintf(&b, "%s\n**Delivery** `%s` — **%s** since %s\n\n", viewMarker, d.ID, d.State, d.Since.UTC().Format(time.RFC3339))
if d.coveredStop() {
fmt.Fprintf(&b, "**Covered**: %s\n\n", d.coveredStopSaid())
}
if d.HeldWhy != "" && d.State == Held {
fmt.Fprintf(&b, "Held for a person: %s — `mesh-delivery.release` with why lets it go on.\n\n", d.HeldWhy)
fmt.Fprintf(&b, "Held for a person: %s — the merge reached the trunk without a passing check.\n\n", d.HeldWhy)
}
if d.Plan != nil {
fmt.Fprintf(&b, "**Delivery plan** — %s\n", d.Plan.Summary)
for i, tier := range d.Plan.Tiers {
fmt.Fprintf(&b, "- tier %d: %s\n", i, strings.Join(tier, ", "))
}
for _, m := range d.Plan.Machines {
var parts []string
if len(m.Receives) > 0 {
parts = append(parts, "receives "+strings.Join(m.Receives, ", "))
}
if len(m.Waits) > 0 {
parts = append(parts, "waits for a person: "+strings.Join(m.Waits, ", "))
}
fmt.Fprintf(&b, "- %s: %s\n", m.Machine, strings.Join(parts, "; "))
}
for _, s := range d.Plan.Steps {
fmt.Fprintf(&b, "- %s\n", s)
}
b.WriteString("\n")
if d.MergedAs != "" {
fmt.Fprintf(&b, "**Source** — %s. Whether it reached the machines is `mesh-delivery-view`'s, derived from "+
"`source.main`, the wants and the haves.\n\n", d.sourceSaid())
}
if g != nil {
fmt.Fprintf(&b, "**Group** `%s`: %s\n", g.ID, strings.Join(g.Members, ", "))
@@ -218,17 +134,6 @@ func ViewBody(d *Delivery, g *Group) string {
}
b.WriteString("\n")
}
if len(d.Steps) > 0 {
b.WriteString("**Machines**\n")
for _, s := range d.Steps {
fmt.Fprintf(&b, "- %s on %s: %s", s.Module, s.Machine, s.State)
if s.Why != "" {
fmt.Fprintf(&b, " — %s", s.Why)
}
b.WriteString("\n")
}
b.WriteString("\n")
}
b.WriteString("**Transitions**\n")
from := 0
if len(d.Transitions) > 12 {
@@ -237,7 +142,7 @@ func ViewBody(d *Delivery, g *Group) string {
for _, t := range d.Transitions[from:] {
fmt.Fprintf(&b, "- %s %s → %s (%s): %s\n", t.At.UTC().Format("2006-01-02 15:04"), orNothing(t.From), t.To, t.Event, t.Why)
}
b.WriteString("\nThe commit's note under `refs/notes/mesh-plan` keeps every transition: `git log --notes=mesh-plan`.\n")
b.WriteString("\nThe commit's note under `refs/notes/mesh-plan` keeps every transition of this change up to its merge: `git log --notes=mesh-plan`.\n")
return b.String()
}
@@ -0,0 +1,62 @@
package main
import (
"strings"
"testing"
"time"
)
// R6-C4 (ADR 0539 §17): the delivery's status and view say the merge's six states and nothing after the merge: no delivery plan,
// no machine step, no covered or walk line; a merged delivery's view names the source revision its merge moved.
func TestStatusAndViewSayEachOfTheSixStatesWithNoPlanAndNoMachineStep(t *testing.T) {
at := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
base := Delivery{ID: "novox/app@c0ffee000000", Repository: "novox/app", Commit: "c0ffee000000", Number: 7, Since: at,
Transitions: []Transition{{At: at, From: Proposed, To: Rejected, Event: EvRefused, Why: "the gate said fail"}}}
for _, c := range []struct {
state State
mutate func(d *Delivery)
status string
desc string
inView []string
keepOut []string
}{
{Proposed, nil, "pending", "checking", nil, nil},
{Ready, nil, "success", "ready: it is merged on the Approve", nil, nil},
{Rejected, nil, "failure", "rejected: the gate said fail", nil, nil},
{Merged, func(d *Delivery) { d.MergedAs = "bbbbbbbbbbbb1111" }, "success",
"merged: source.main moved to novox/app@bbbbbbbbbbbb", []string{"**Source**", "source.main moved to novox/app@bbbbbbbbbbbb", "mesh-delivery-view"}, nil},
{Held, func(d *Delivery) { d.MergedAs, d.HeldWhy = "bbbbbbbbbbbb1111", "merged without a passing check" }, "pending",
"held for a person: merged without a passing check", []string{"Held for a person: merged without a passing check"}, nil},
{Closed, nil, "warning", "closed: the gate said fail", nil, nil},
} {
d := base
d.State = c.state
if c.mutate != nil {
c.mutate(&d)
}
state, desc := statusOf(&d)
if state != c.status || desc != c.desc {
t.Errorf("%s: the status is %s %q, wanted %s %q", c.state, state, desc, c.status, c.desc)
}
view := ViewBody(&d, nil)
if !strings.Contains(view, "**"+string(c.state)+"**") {
t.Errorf("%s: the view does not say the state: %s", c.state, view)
}
for _, want := range c.inView {
if !strings.Contains(view, want) {
t.Errorf("%s: the view lacks %q: %s", c.state, want, view)
}
}
for _, never := range []string{"**Delivery plan**", "**Machines**", "tier ", "receives", "walk", "Covered", "plans go", "mesh-delivery.release"} {
if strings.Contains(view, never) {
t.Errorf("%s: the view still says %q: %s", c.state, never, view)
}
}
}
// A merge not on the trunk names no source revision.
d := base
d.State = Merged
if s := d.sourceSaid(); s != "not on the trunk" {
t.Errorf("a merged delivery with no merge commit says %q", s)
}
}
@@ -42,9 +42,7 @@ func (h *Holder) takeForgeWanted() bool {
// AnswersChanged takes a change of the teller's answers: the answers to the asks pending are read at the look it asks for.
func (h *Holder) AnswersChanged() {
if h.Holding() {
h.Poke()
}
h.Poke()
}
// CommitStatus takes a commit status set on the forge: one of the merge checks (mesh/check and, on a
@@ -19,7 +19,7 @@ func hear(t *testing.T, w *world, key string, body any) {
if err != nil {
t.Fatal(err)
}
if key == ControllerSeat+".checked" {
if key == shimCheckedKey {
// The gate follows no verdict of the controller any more (issue 1318): a test says it as the checker's.
var c CheckedEvent
if err := json.Unmarshal(raw, &c); err != nil {
@@ -47,7 +47,7 @@ func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
if w.op.count() != 0 {
t.Fatalf("asked before the checks passed: %d", w.op.count())
}
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if w.op.count() != 1 {
t.Fatalf("a pull request whose checks passed asked the operator %d times, wanted once, with no mark", w.op.count())
}
@@ -63,7 +63,7 @@ func TestAPassingVerdictAsksTheOperatorAtOnceWithNoMark(t *testing.T) {
func TestAFailedVerdictAsksNothing(t *testing.T) {
w := announced(t)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
if w.op.count() != 0 {
t.Fatalf("a pull request whose checks failed asked the operator %d times", w.op.count())
}
@@ -78,7 +78,7 @@ func approve(w *world) {
func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
w := announced(t)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if len(w.op.open()) != 1 || len(w.forge.merges) != 0 {
t.Fatalf("asks %v, merges %v", w.op.open(), w.forge.merges)
}
@@ -95,7 +95,7 @@ func TestAnApproveHeardAsAnEventMergesAtOnceAtTheApprovedHead(t *testing.T) {
func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
w := announced(t)
id := IDOf("novox/app", readyHead)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.forge.refuse = errAskedAgain
approve(w)
if len(w.forge.merges) != 1 {
@@ -104,7 +104,7 @@ func TestAPassingVerdictAsksTheMergeAtOnceEvenJustAfterARefusal(t *testing.T) {
w.forge.refuse = nil
w.h.PullMerged(merged("novox/app", 9, "9999999999999999", "t2t2t2t2t2t2"))
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t2t2t2t2t2t2"))
if len(w.forge.merges) != 2 || w.delivery(id).Ready.Merged == nil {
t.Fatalf("the passing verdict did not ask the merge at once: %v %+v", w.forge.merges, w.delivery(id).Ready)
}
@@ -130,7 +130,7 @@ func TestAHeadNotReviewedAtThisHeadIsNotAsked(t *testing.T) {
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Body = c.body
w.h.PullUpdated(p)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if w.op.count() != 0 {
t.Fatalf("an unreviewed pull request was asked: %d", w.op.count())
}
@@ -144,7 +144,7 @@ func TestTheAskNamesTheReviewerOfTheHead(t *testing.T) {
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Body = "Fixes issue 7.\n\nReviewed-by: lead@shanks at " + readyHead + "\n"
w.h.PullUpdated(p)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
a := w.op.ask(w.op.open()[0])
if strings.Contains(a.Explanation, "not reviewed") || !strings.Contains(a.Explanation, "reviewed by lead") {
t.Fatalf("a reviewed head is not said reviewed:\n%s", a.Explanation)
@@ -167,7 +167,7 @@ func TestTheReviewLineCorrectedInTheDescriptionIsReadWithoutAPush(t *testing.T)
// Issue 1022 (ADR 0421 §6): a push ends the Approve and says so once on the pull request.
func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
w := announced(t)
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
old := w.op.open()[0]
w.h.PullUpdated(pr("novox/app", 7, pushed, "feat/x", "src/a.go"))
w.h.React()
@@ -180,7 +180,7 @@ func TestAPushAfterTheAskEndsItAndSaysSoOnceOnThePullRequest(t *testing.T) {
t.Fatalf("the end of the Approve is not said once on the pull request: %v", w.forge.comments)
}
// The new head is asked on its own when its checks pass.
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1"))
if open := w.op.open(); len(open) != 1 || open[0] == old {
t.Fatalf("the new head was not asked afresh: %v", open)
}
@@ -63,7 +63,7 @@ func (s *memStore) Deliveries() ([]*Delivery, error) {
defer s.mu.Unlock()
var out []*Delivery
for key, raw := range s.deliveries {
if key == carriedKey { // the gate's mark that today's state was carried (seat.go), as the real store skips it
if key == carriedKey { // the mark an earlier owner's carry-over wrote, as the real store skips it
continue
}
var d Delivery
@@ -88,299 +88,6 @@ func (s *memStore) Groups() ([]*Group, error) {
return out, nil
}
type fakeController struct {
mu sync.Mutex
walks map[string]Walk
held bool
checks []string
asked int
delivers []string
// backupsAgain are the walks whose failed backups were asked again.
backupsAgain []string
// refuseBackupsAgain refuses the word to take a walk's backups again.
refuseBackupsAgain bool
stops []string
// withouts are the stops that left merges out (novox/hq ADR 0299): "<walk> without <merge>,…".
withouts []string
// refuseWithout refuses every stop with merges to leave out, as a controller before ADR 0299 does; mergedAt are
// the merges' times, by commit, for what a later merge contains.
refuseWithout bool
mergedAt map[string]time.Time
// refuseStop refuses every whole stop too; moves are the modules each merge moves, by commit, for what a left-out
// merge shares (ADR 0299).
refuseStop bool
moves map[string][]string
plans int
down bool
// emptyCheck makes Check answer with no id and no error, as the port does when the answer holds none.
emptyCheck bool
// walkReads counts the reads of the controller's walks (Walks).
walkReads int
// checkReady is the ready flag of each member of each check asked.
checkReady []bool
// baseSHAs is the base head each member of each check asked was sent with.
baseSHAs []string
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
refuseDeliver bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
onCheck func()
// planned is what Plan answers instead of "builds app", when set.
planned *DeliveryPlan
// forgot are the pull requests whose check builds were let go of (novox/hq ADR 0350 §5), "owner/repo#n".
forgot []string
}
// BackupsAgain records the word, and clears the walk's failed backups as the controller does.
func (c *fakeController) BackupsAgain(walk, why string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
if c.refuseBackupsAgain {
return errors.New("the controller refuses the backups")
}
w, known := c.walks[walk]
if !known || w.Backups == nil || w.Backups.State != "failed" {
return fmt.Errorf("%s holds no failed backup", walk)
}
w.Backups = nil
w.Revision++
c.walks[walk] = w
c.backupsAgain = append(c.backupsAgain, walk)
return nil
}
func (c *fakeController) ForgetChecks(owner, repo string, number int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
c.forgot = append(c.forgot, fmt.Sprintf("%s/%s#%d", owner, repo, number))
return nil
}
func newFakeController() *fakeController {
return &fakeController{walks: map[string]Walk{}, held: true, mergedAt: map[string]time.Time{},
moves: map[string][]string{}}
}
func (c *fakeController) Plan(repository, base, head string, paths, dirs, removed []string) (*DeliveryPlan, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.plans++
if c.down {
return nil, errors.New("down")
}
if c.planned != nil {
p := *c.planned
return &p, nil
}
return &DeliveryPlan{Repository: repository, Base: base, Head: head, Moved: []string{"app"}, Summary: "builds app"}, nil
}
func (c *fakeController) Deliver(walk, why string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
if c.refuseDeliver {
return errors.New("the controller refuses the word")
}
w, known := c.walks[walk]
if !known || w.Delivery == nil || w.Delivery.Go != nil {
return fmt.Errorf("%s waits for nobody", walk)
}
now := time.Now()
w.Delivery.Go, w.Delivery.By, w.Delivery.Why = &now, byOwner, why
w.Revision++
c.walks[walk] = w
c.delivers = append(c.delivers, walk)
return nil
}
func (c *fakeController) Stop(walk, why, by string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return errors.New("down")
}
c.stops = append(c.stops, walk)
if c.refuseStop {
return errors.New("the controller refuses")
}
if w, known := c.walks[walk]; known && !w.Open() {
// As the controller: only a walk that is building or rolling is stopped (`delivery-stop`).
return fmt.Errorf("%s is already %s", walk, w.State)
} else if known {
w.State = walkFailed
if w.Delivery == nil {
w.Delivery = &WalkDelivery{}
}
w.Delivery.Stopped, w.Delivery.StoppedWhy = by, why
w.Revision++
c.walks[walk] = w
}
return nil
}
// StopWithout ends a walk without the merges named, as the controller does (novox/hq ADR 0299): each merge named,
// and each later merge of its repository by merge time (mergedAt), is left out; the others are put in a batch,
// "<walk>-again", assembling until a test cuts it. Given a batch, the merges left out go to a stopped record,
// "<walk>-left", and the batch goes on with the others. refuseWithout refuses every such stop, as an older
// controller does.
func (c *fakeController) StopWithout(walk, why, by string, without []string) (StopAnswer, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.down {
return StopAnswer{}, errors.New("down")
}
c.withouts = append(c.withouts, walk+" without "+strings.Join(without, ","))
if c.refuseWithout {
return StopAnswer{}, errors.New(`delivery-stop takes no argument "without"`)
}
w, known := c.walks[walk]
if !known || !(w.Open() || w.IsBatch()) {
return StopAnswer{}, fmt.Errorf("%s is no open walk or batch", walk)
}
wd := WalkDelivery{}
if w.Delivery != nil {
wd = *w.Delivery
}
named := func(m WalkMerge) bool { return containsString(without, m.Repository+"@"+m.Commit) }
contains := func(m WalkMerge) string {
for _, s := range wd.Merges {
if named(s) && s.Repository == m.Repository && c.mergedAt[m.Commit].After(c.mergedAt[s.Commit]) {
return s.Commit
}
}
return ""
}
shares := func(m WalkMerge) (string, []string) {
for _, s := range wd.Merges {
if named(s) {
var both []string
for _, x := range c.moves[m.Commit] {
if containsString(c.moves[s.Commit], x) {
both = append(both, x)
}
}
if len(both) > 0 {
return s.Commit, both
}
}
}
return "", nil
}
a := StopAnswer{Stopped: walk}
var again, left []WalkMerge
for _, m := range wd.Merges {
s, both := shares(m)
switch {
case named(m):
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit})
left = append(left, m)
case len(both) > 0:
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: s, Shares: both})
left = append(left, m)
case contains(m) != "":
a.LeftOut = append(a.LeftOut, WalkLeftOut{Repository: m.Repository, Commit: m.Commit, Contains: contains(m)})
left = append(left, m)
default:
m.Carried = ""
again = append(again, m)
a.WalkedAgain = append(a.WalkedAgain, m.Repository+"@"+m.Commit)
}
}
if len(a.LeftOut) == 0 {
return StopAnswer{}, fmt.Errorf("%s answers no merge %s", walk, strings.Join(without, ", "))
}
if w.IsBatch() && len(again) == 0 {
// Every merge left out: the batch itself is stopped, as the controller does.
wd.Stopped, wd.StoppedWhy, wd.LeftOut, wd.Batch = by, why, a.LeftOut, nil
w.Delivery, w.State = &wd, walkFailed
w.Revision++
c.walks[walk] = w
return a, nil
}
if w.IsBatch() {
rid := walk + "-left"
c.walks[rid] = Walk{ID: rid, Repository: left[0].Repository, Branch: "main", Commit: left[0].Commit,
Created: w.Created, State: walkFailed, Revision: 1, Modules: map[string]*WalkModule{},
Delivery: &WalkDelivery{Stopped: by, StoppedWhy: why, Merges: left, LeftOut: a.LeftOut, WalkedAgainBy: walk}}
wd.Merges = again
w.Delivery = &wd
w.Repository, w.Commit = again[0].Repository, again[0].Commit
w.Revision++
c.walks[walk] = w
a.Stopped, a.WalkedAgainBy = rid, walk
return a, nil
}
if len(again) > 0 {
id := walk + "-again"
closes := w.Created.Add(time.Minute)
c.walks[id] = Walk{ID: id, Repository: again[0].Repository, Branch: "main", Commit: again[0].Commit,
Created: w.Created.Add(time.Minute), State: walkAssembling, Revision: 1,
Modules: map[string]*WalkModule{}, Delivery: &WalkDelivery{Merges: again, Batch: &WalkBatch{ClosesAt: &closes}}}
wd.WalkedAgainBy, a.WalkedAgainBy = id, id
}
wd.Stopped, wd.StoppedWhy, wd.LeftOut = by, why, a.LeftOut
w.Delivery = &wd
w.State = walkFailed
w.Note = "stopped by " + by + ": " + why
w.Revision++
c.walks[walk] = w
return a, nil
}
// cut makes a batch the walk under the same id, waiting for its word, as the controller cuts it.
func (c *fakeController) cut(id string) Walk {
c.mu.Lock()
w := c.walks[id]
c.mu.Unlock()
wd := *w.Delivery
wd.Batch, wd.Awaits = nil, "mesh-delivery"
w.Delivery = &wd
w.State, w.Tiers, w.Modules = walkBuilding, [][]string{{"app"}}, map[string]*WalkModule{"app": {}}
return c.put(w)
}
func (c *fakeController) Walks(walk string) (bool, []Walk, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.walkReads++
if c.down {
return false, nil, errors.New("down")
}
var out []Walk
for id, w := range c.walks {
if walk == "" || walk == id {
out = append(out, w)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return c.held, out, nil
}
// put is the controller keeping a walk; it answers the walk as `plan-moved` would say it.
func (c *fakeController) put(w Walk) Walk {
c.mu.Lock()
defer c.mu.Unlock()
if old, known := c.walks[w.ID]; known {
w.Revision = old.Revision + 1
} else if w.Revision == 0 {
w.Revision = 1
}
c.walks[w.ID] = w
return w
}
func (c *fakeController) walk(id string) Walk {
c.mu.Lock()
defer c.mu.Unlock()
return c.walks[id]
}
type fakeForge struct {
mu sync.Mutex
notes map[string][]string
@@ -569,7 +276,6 @@ type world struct {
t *testing.T
h *Holder
store *memStore
ctl *fakeController
forge *fakeForge
op *fakeOperator
merges *fakeMerges
@@ -584,12 +290,12 @@ type world struct {
}
func newWorld(t *testing.T) *world {
return newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
return newWorldOver(t, newMemStore(), newFakeForge(), time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC))
}
func newWorldOver(t *testing.T, store *memStore, ctl *fakeController, forge *fakeForge, now time.Time) *world {
w := &world{t: t, store: store, ctl: ctl, forge: forge, now: now}
w.h = &Holder{Store: store, Controller: ctl, Forge: forge,
func newWorldOver(t *testing.T, store *memStore, forge *fakeForge, now time.Time) *world {
w := &world{t: t, store: store, forge: forge, now: now}
w.h = &Holder{Store: store, Forge: forge,
Now: func() time.Time { w.mu.Lock(); defer w.mu.Unlock(); return w.now },
Logf: func(format string, a ...any) { t.Logf(format, a...); fmt.Fprintf(&w.logbuf, format+"\n", a...) },
Emit: func(event string, body any) error {
@@ -667,7 +373,7 @@ func pr(repo string, number int, sha, branch string, paths ...string) PullEvent
func verdict(repo string, number int, sha, gate string) CheckedEvent {
owner, name, _ := strings.Cut(repo, "/")
c := CheckedEvent{Owner: owner, Repo: name, Number: number, Commit: sha, Verdict: gate, Summary: "the gate said " + gate,
ID: "check-of-" + sha, Plan: &DeliveryPlan{Repository: repo, Moved: []string{"app"}, Summary: "builds app → anchor"}}
ID: "check-of-" + sha}
return c
}
@@ -679,16 +385,6 @@ func merged(repo string, number int, head, merge string) PullEvent {
return p
}
// aWalk is the controller's walk of a merge commit, waiting for its delivery's word.
func aWalk(id, repo, commit string, waits bool) Walk {
w := Walk{ID: id, Repository: repo, Branch: "main", Commit: commit, Created: time.Date(2026, 10, 6, 12, 31, 0, 0, time.UTC),
State: walkBuilding, Tiers: [][]string{{"app"}}, Modules: map[string]*WalkModule{"app": {}}}
if waits {
w.Delivery = &WalkDelivery{Awaits: "mesh-delivery"}
}
return w
}
// Comment records what was asked (novox/hq issue 722).
func (f *fakeForge) Comment(owner, repo string, number int, body string) error {
f.mu.Lock()
@@ -107,15 +107,3 @@ func TestARefusalLoopInAClosedGroupIsSaidAtOnce(t *testing.T) {
// check set its gate (issue 1049: the forge's "not a success: mesh/repo-check (pending)").
var ownPending = errors.New("the forge refused gitea_merge_pull_request: novox/mesh-controller#1 is not merged: main requires " +
"mesh/merge-gate, mesh/repo-check; not a success: mesh/repo-check (pending). A pull request merges once its checks succeed")
// ownChecks are the checks asked from index on that were a head's own (no group), not a group's composed check.
func ownChecks(w *world, from int) (own, composed []string) {
for _, c := range w.ctl.checks[from:] {
if strings.HasPrefix(c, ":") {
own = append(own, c)
} else {
composed = append(composed, c)
}
}
return
}
@@ -0,0 +1,23 @@
package main
import "time"
var t0 = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
// mergedAt is the forge's `pull.merged` at a moment.
func mergedAt(p PullEvent, merge string, at time.Time) PullEvent {
p.MergeCommit = merge
p.MergedAt = at.UTC().Format(time.RFC3339)
return p
}
// verdictMoving is a passing verdict. The gate plans no delivery any more (ADR 0539 §17), so it names no module.
func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEvent {
return verdict(repo, number, sha, "pass")
}
func statusOn(w *world, repo, commit string) string {
w.forge.mu.Lock()
defer w.forge.mu.Unlock()
return w.forge.statuses[repo+"@"+commit+" mesh/delivery"]
}
@@ -1,227 +0,0 @@
package main
import (
"errors"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
"strings"
"testing"
"time"
)
// History (novox/hq issue 313): merges long past, heard only from the forge's word when this owner first held
// the seat, reached the machines before it existed. They are retired — never held for a person, released or
// delivered — and what is owed for a pull request the forge no longer holds is given up, not tried for ever.
// aPastMerge is the forge's `pull.merged` replayed from the bus's history: merged days before it is heard.
func aPastMerge(repo string, number int, head, merge string, at time.Time) PullEvent {
p := merged(repo, number, head, merge)
p.MergedAt = at.UTC().Format(time.RFC3339)
return p
}
func TestAMergeLongPastHeardFromTheForgesWordIsRetiredAtAdoption(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullMerged(aPastMerge("novox/app", 274, head, merge, w.now.Add(-4*24*time.Hour)))
w.settleAll()
d := w.delivery(id)
if d.State != Retired || !strings.Contains(d.History, "history") {
t.Fatalf("a merge four days past heard from the forge's word is %s (%q)", d.State, d.History)
}
if got := statesOf([]State{d.Transitions[0].To, d.Transitions[1].To}); strings.Join(got, ",") != "proposed,retired" ||
d.Transitions[1].Event != EvHistory {
t.Fatalf("transitions %+v", d.Transitions)
}
// Nothing is said on its pull request — the forge may hold another under its number — and nothing stalls.
if w.forge.viewCalls != 0 || len(w.forge.statuses) != 0 {
t.Fatalf("history was shown on a pull request: %d view(s), statuses %v", w.forge.viewCalls, w.forge.statuses)
}
if len(d.Owed) != 0 {
t.Fatalf("still owed: %+v", d.Owed)
}
if s := w.h.Stalled(); len(s) != 0 {
t.Fatalf("history stalls: %+v", s)
}
// Its record is still kept, on the commit's note.
if len(w.forge.notesOn(head)) == 0 {
t.Fatal("no note of it on its commit")
}
// A merge heard from the forge's word within moments of happening is a live one: held for a person.
live := IDOf("novox/app", "bbbbbbbbbbbb2222")
w.h.PullMerged(aPastMerge("novox/app", 9, "bbbbbbbbbbbb2222", "cccccccccccc3333", w.now.Add(-time.Minute)))
if s := w.state(live); s != Held {
t.Fatalf("a live merge from the forge's word is %s", s)
}
// A merge long past whose walk the controller still holds open is delivered by it, never history.
walked := IDOf("novox/app", "dddddddddddd4444")
w.h.WalkMoved(aWalk("plan-9", "novox/app", "eeeeeeeeeeee5555", true))
w.h.PullMerged(aPastMerge("novox/app", 10, "dddddddddddd4444", "eeeeeeeeeeee5555", w.now.Add(-48*time.Hour)))
if s := w.state(walked); s == Retired {
t.Fatal("a merge with an open walk was retired as history")
}
// A merge the forge announces under a number an older delivery already merged with is not that delivery.
w.h.PullMerged(aPastMerge("novox/app", 274, "", "ffffffffffff6666", w.now.Add(-time.Minute)))
if d := w.delivery(id); d.MergedAs != merge {
t.Fatalf("a reused number merged the old delivery again as %s", d.MergedAs)
}
}
// oldHistory is a held delivery as the first adoption left it: made from the forge's word, held, owing a view
// on a pull request the forge no longer holds.
func oldHistory(id, repo string, number int, created, mergedAt time.Time) *Delivery {
commit := strings.SplitN(id, "@", 2)[1] + "0000"
return &Delivery{ID: id, Repository: repo, Commit: commit, Number: number, Base: "main", MergedAs: commit,
MergedAt: mergedAt, Created: created, Since: created, State: Held,
HeldWhy: "merged without a passing check: only a person decides that it goes on",
Transitions: []Transition{
{At: created, To: Proposed, Event: EvAnnounced, Why: repo + "#274 merged; its head was not heard by this " +
"owner, so it is made from the forge's word"},
{At: created, From: Proposed, To: Held, Event: EvMergedUnchecked, Why: "merged without a passing check"}},
Owed: []Effect{{Kind: EffectView, Since: created, Tries: 11051,
Last: "Gitea API /repos/" + repo + "/issues/274/comments: 500 issue does not exist"}}}
}
func TestRetireHistoryRetiresTheAdoptedHistoryInBulkAndLeavesEveryOtherHeldOne(t *testing.T) {
store := newMemStore()
start := time.Date(2026, 10, 6, 23, 5, 27, 0, time.UTC)
for _, d := range []*Delivery{
oldHistory("novox/hq@018ee359ae52", "novox/hq", 274, start, start.Add(-4*24*time.Hour)),
oldHistory("novox/lab@a4f6bb8ce054", "novox/lab", 53, start, start.Add(-2*time.Hour)),
// Made from the forge's word, but heard a minute after its merge: a live merge, a person's.
oldHistory("novox/hq@7777777777aa", "novox/hq", 3, start, start.Add(-time.Minute)),
} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
w := newWorldOver(t, store, newFakeController(), newFakeForge(), start.Add(36*time.Hour))
// And one heard live: rejected, then merged — a person's to decide.
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "fail"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
if len(w.h.Stalled()) != 3 { // the three adopted at the switch; the one held just now is within its bound
t.Fatalf("stalled before: %+v", w.h.Stalled())
}
dry, err := w.h.RetireHistory("", "", "history adopted at the switch", aPerson, true)
if err != nil || dry.Retired != 2 || dry.LeftHeld != 2 || len(dry.Left) != 2 {
t.Fatalf("a dry retire: %+v %v", dry, err)
}
if s := w.state("novox/hq@018ee359ae52"); s != Held {
t.Fatalf("a dry retire moved a delivery to %s", s)
}
if _, err := w.h.RetireHistory("", "", "", aPerson, false); err == nil {
t.Fatal("a retire with no why was taken")
}
got, err := w.h.RetireHistory("", "", "history adopted at the switch", aPerson, false)
if err != nil || got.Retired != 2 || got.ByRepository["novox/hq"] != 1 || got.ByRepository["novox/lab"] != 1 {
t.Fatalf("retire: %+v %v", got, err)
}
for _, id := range []string{"novox/hq@018ee359ae52", "novox/lab@a4f6bb8ce054"} {
d := w.delivery(id)
if d.State != Retired || d.Transitions[len(d.Transitions)-1].By != aPerson {
t.Fatalf("%s is %s", id, d.State)
}
for _, e := range d.Owed {
if e.Kind == EffectView || e.Kind == EffectStatus {
t.Fatalf("%s still owes its pull request: %+v", id, e)
}
}
}
for _, id := range []string{"novox/hq@7777777777aa", IDOf("novox/app", head)} {
if s := w.state(id); s != Held {
t.Fatalf("%s, not history, is %s", id, s)
}
}
// Nothing was released, delivered or let go.
if len(w.ctl.delivers) != 0 || len(w.ctl.stops) != 0 {
t.Fatalf("a retire asked the controller: deliver %v stop %v", w.ctl.delivers, w.ctl.stops)
}
if s := w.h.Stalled(); len(s) != 1 || s[0].ID != "novox/hq@7777777777aa" {
t.Fatalf("stalled after: %+v", s)
}
// Retired is final: a second retire takes nothing, and nothing leaves it.
again, _ := w.h.RetireHistory("", "", "again", aPerson, false)
if again.Retired != 0 {
t.Fatalf("retired twice: %+v", again)
}
if _, err := w.h.Release("novox/hq@018ee359ae52", "go on", aPerson); err == nil {
t.Fatal("a retired delivery was released")
}
}
func TestRetireHistoryIsRefusedForARealHeldDelivery(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "fail"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.later(48 * time.Hour)
_, err := w.h.RetireHistory(id, "", "looks old", aPerson, false)
var refused ErrRefused
if !errors.As(err, &refused) || !strings.Contains(err.Error(), "heard its head") {
t.Fatalf("a held delivery whose head was heard was retired: %v", err)
}
if s := w.state(id); s != Held {
t.Fatalf("a refused retire left it %s", s)
}
// A delivery that is not held is refused too, by the table.
other := IDOf("novox/app", "bbbbbbbbbbbb2222")
w.h.PullUpdated(pr("novox/app", 8, "bbbbbbbbbbbb2222", "feat/y", "src/b.go"))
if _, err := w.h.RetireHistory(other, "", "old", aPerson, false); err == nil || w.state(other) != Proposed {
t.Fatalf("a proposed delivery was retired: %v", err)
}
if _, err := w.h.RetireHistory("novox/app@nothing", "", "old", aPerson, false); err == nil {
t.Fatal("retired a delivery that does not exist")
}
}
// What the bus cannot take is owed and tried again later each time, and given up after retryLimit tries, saying so. What the
// forge refuses is the bridge's to say (issue 1349): the gate has no retry for it.
func TestOwedWorkTheBusCannotTakeIsTriedAgainLaterAndGivenUpAfterRetryLimit(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.owed.down = true
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
tries := func() int {
for _, e := range w.delivery(id).Owed {
if e.Kind == EffectView {
return e.Tries
}
}
return -1
}
w.h.Flush()
w.h.Flush()
w.h.Flush()
if tries() != 1 {
t.Fatalf("a view the bus could not take was tried %d time(s) at once", tries())
}
w.later(retryFirst + time.Second)
w.h.Flush()
if tries() != 2 {
t.Fatalf("a failed view was not tried again after its wait: %d", tries())
}
if retryAfter(1) != retryFirst || retryAfter(2) != 2*retryFirst || retryAfter(100) != retryCap {
t.Fatalf("the waits: %s %s %s", retryAfter(1), retryAfter(2), retryAfter(100))
}
for range retryLimit {
w.later(retryCap)
w.h.Flush()
}
if tries() != -1 {
t.Fatalf("a view failing %d times is still owed", tries())
}
if st := w.h.Status("listening"); !strings.Contains(strings.Join(st["refused"].([]string), "\n"), "given up") {
t.Fatalf("its drop was not said: %v", st["refused"])
}
// The bus back: what is owed anew is written.
w.owed.down = false
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.Flush()
if len(w.owed.acts(sdk.OwedView)) == 0 {
t.Fatal("nothing was written once the bus was back")
}
}
File diff suppressed because it is too large Load Diff
@@ -7,17 +7,16 @@ import (
"time"
)
// The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*): a pull request's head proposed,
// checked and ready; merged, published, let go, delivering and delivered; a gate that fails, put back and
// failed; a newer head superseding; a restart resuming; the trunk rule; and every transition kept before
// it is said, noted on the commit and shown on the pull request.
// The holder, end to end over fakes (novox/hq ADR 0239 *How it is checked*, cut by ADR 0539 §17): a pull request's head
// proposed and ready; merged; a newer head closing the older; a restart resuming; the trunk rule; and every transition
// kept before it is said, noted on the commit and shown on the pull request.
const (
head = "aaaaaaaaaaaa1111"
merge = "mmmmmmmmmmmm9999"
)
func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
func TestAPullRequestsHeadIsProposedAndReady(t *testing.T) {
w := newWorld(t)
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
p.Body = "" // an empty description is never read again, so the review set below stays
@@ -30,19 +29,19 @@ func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
}
w.h.Checked(verdict("novox/app", 7, head, "pass"))
d := w.delivery(id)
if d.State != Ready || d.Plan == nil || d.Plan.Summary != "builds app → anchor" {
t.Fatalf("a passing verdict left it %s with plan %+v", d.State, d.Plan)
if d.State != Ready {
t.Fatalf("a passing verdict left it %s", d.State)
}
var path []State
for _, tr := range d.Transitions {
path = append(path, tr.To)
}
if strings.Join(statesOf(path), ",") != "proposed,checked,ready" {
if strings.Join(statesOf(path), ",") != "proposed,ready" {
t.Fatalf("its transitions are %v", path)
}
w.settleAll()
notes := w.forge.notesOn(head)
if len(notes) != 3 || !strings.Contains(notes[2], "checked -> ready") {
if len(notes) != 2 || !strings.Contains(notes[1], "proposed -> ready") {
t.Fatalf("the head's note is %q", notes)
}
if !strings.Contains(w.forge.statuses["novox/app@"+head+" mesh/delivery"], "success ready") {
@@ -51,9 +50,9 @@ func TestAPullRequestsHeadIsProposedCheckedAndReady(t *testing.T) {
if v := w.forge.views["novox/app#7"]; !strings.Contains(v, viewMarker) || !strings.Contains(v, "**ready**") {
t.Fatalf("the pull request's view is %q", v)
}
// A failing verdict for another head rejects it, and a newer head supersedes the older.
// A failing verdict for another head rejects it, and a newer head closes the older.
w.h.PullUpdated(pr("novox/app", 7, "bbbbbbbbbbbb2222", "feat/x"))
if w.state(id) != Superseded {
if w.state(id) != Closed {
t.Fatalf("a newer head left the older %s", w.state(id))
}
w.h.Checked(verdict("novox/app", 7, "bbbbbbbbbbbb2222", "fail"))
@@ -70,58 +69,32 @@ func statesOf(ss []State) []string {
return out
}
// A merge publishes; the walk waits for this owner's word; the word is given; the walk's record moves the
// delivery through its machines to delivered — and the note on the merge commit says what was executed.
func TestAMergeIsPublishedLetGoDeliveringAndDelivered(t *testing.T) {
// A merge puts the delivery on the trunk and ends it there: merged, with the merge commit's note and the pull request's
// status naming the source revision it moved. Nothing after the merge is derived (ADR 0539 §17).
func TestAMergeIsMergedAndTheGateDerivesNothingAfterIt(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
// The walk is heard before the merge: it waits for its delivery.
walk := w.ctl.put(aWalk("plan-1", "novox/app", merge, true))
w.h.WalkMoved(walk)
if w.state(id) != Ready {
t.Fatalf("a walk for a commit no merge was heard of moved the delivery to %s", w.state(id))
}
w.h.PullMerged(merged("novox/app", 7, head, merge))
if w.state(id) != Published {
t.Fatalf("a merge with its walk open left it %s", w.state(id))
}
w.settleAll()
if len(w.ctl.delivers) != 1 || w.ctl.delivers[0] != "plan-1" {
t.Fatalf("its walk was let go %v", w.ctl.delivers)
}
if w.state(id) != Delivering {
t.Fatalf("let go, it is %s", w.state(id))
}
// The first machine sent, judged, passed; the rest sent; the walk done.
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
walk = w.ctl.walk("plan-1")
walk.State = walkRolling
walk.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent, Build: "build-1",
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent, Passes: 1}}
w.h.WalkMoved(w.ctl.put(walk))
if d := w.delivery(id); len(d.Steps) != 1 || d.Steps[0].State != StepJudging {
t.Fatalf("judging on its first machine reads %+v", d.Steps)
}
judged := sent.Add(3 * time.Minute)
walk.Modules["app"].Gate.Verdict, walk.Modules["app"].Gate.JudgedAt = "passed", &judged
walk.Modules["app"].SentAt = &judged
walk.State = walkDone
w.h.WalkMoved(w.ctl.put(walk))
d := w.delivery(id)
if d.State != Delivered {
t.Fatalf("a walk done left it %s", d.State)
}
if len(d.Steps) != 2 || d.Steps[0].State != StepPassed || d.Steps[1].Machine != "the rest running it" {
t.Fatalf("its machine steps are %+v", d.Steps)
if d.State != Merged || d.MergedAs != merge {
t.Fatalf("a merge left it %s as %q", d.State, d.MergedAs)
}
w.settleAll()
onMerge := w.forge.notesOn(merge)
if len(onMerge) == 0 || !strings.Contains(strings.Join(onMerge, "\n"), "executed (walk plan-1): app on anchor passed") {
t.Fatalf("the merge commit's note is %q", onMerge)
w.later(72 * time.Hour)
w.settleAll()
if s := w.state(id); s != Merged {
t.Fatalf("time moved a merged delivery to %s", s)
}
if st := w.forge.statuses["novox/app@"+head+" mesh/delivery"]; !strings.HasPrefix(st, "success merged: source.main moved to novox/app@"+merge[:12]) {
t.Fatalf("the pull request's mesh/delivery status is %q", st)
}
view := w.forge.views["novox/app#7"]
if !strings.Contains(view, "**Source**") || !strings.Contains(view, "source.main moved to novox/app@"+merge[:12]) ||
!strings.Contains(view, "mesh-delivery-view") || strings.Contains(view, "**Delivery plan**") || strings.Contains(view, "**Machines**") {
t.Fatalf("the pull request's view is %q", view)
}
// Said on the bus, kept first: every transition emitted once.
n := 0
for _, s := range w.said {
if s["id"] == id {
@@ -133,110 +106,52 @@ func TestAMergeIsPublishedLetGoDeliveringAndDelivered(t *testing.T) {
}
}
// A first machine's gate fails: what it carried is put back (the controller's), the step is rolled back,
// and the delivery failed — never delivered.
func TestAFailedGateIsRolledBackAndTheDeliveryFails(t *testing.T) {
// The trunk rule: a ready delivery whose commit never reached the trunk is never merged.
func TestACommitOffTheTrunkIsNeverMerged(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
w.settleAll()
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
walk := w.ctl.walk("plan-1")
walk.State = walkRolling
walk.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent,
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent}}
w.h.WalkMoved(w.ctl.put(walk))
at := sent.Add(10 * time.Minute)
walk.Modules["app"].Gate.Verdict, walk.Modules["app"].Gate.Why, walk.Modules["app"].Gate.JudgedAt =
"failed", "its tools were not served", &at
walk.Modules["app"].Gate.Rollback = "rolled-back"
walk.State = walkFailed
walk.Note = "app failed its gate on anchor"
w.h.WalkMoved(w.ctl.put(walk))
d := w.delivery(id)
if d.State != Failed || len(d.Steps) != 1 || d.Steps[0].State != StepRolledBack {
t.Fatalf("a failed gate left it %s, steps %+v", d.State, d.Steps)
}
// An older record arriving late changes nothing; a step the table does not hold is refused.
w.h.WalkMoved(aWalk("plan-1", "novox/app", merge, false))
if w.state(id) != Failed {
t.Fatal("an older record of the walk moved a final delivery")
}
}
// A walk superseded that names no walk taking it over (a record from before ADR 0276): superseded, from
// published or delivering. One that names its successor is followed (TestADeliveryFollowsTheWalkThatTookItsOver).
func TestANewerWalkSupersedesAnOlderDelivery(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
walk := aWalk("plan-1", "novox/app", merge, true)
w.h.WalkMoved(w.ctl.put(walk))
walk.State = walkSuperseded
walk.Note = "superseded"
w.ctl.down = true // its word could not be given meanwhile
w.settleAll()
w.h.WalkMoved(w.ctl.put(walk))
if s := w.state(id); s != Superseded {
t.Fatalf("its walk superseded left it %s", s)
}
}
// The trunk rule: a ready delivery whose commit never reached the trunk is never published, whatever walks
// are heard of; and a walk for a commit no delivery landed as is not taken as its.
func TestACommitOffTheTrunkIsNeverPublished(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.WalkMoved(w.ctl.put(aWalk("plan-9", "novox/app", head, true))) // a walk of the head itself: no merge
w.settleAll()
if s := w.state(id); s != Ready {
t.Fatalf("an off-trunk head is %s", s)
}
if len(w.ctl.delivers) != 0 {
t.Fatalf("a walk was let go for a commit off the trunk: %v", w.ctl.delivers)
}
}
// A restart resumes: a new holder over the same state stands every delivery where it was, reads the walks it
// missed back from the controller, and goes on.
// A restart resumes: a new holder over the same state stands every delivery where it was.
func TestARestartResumesFromTheState(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.ctl.put(aWalk("plan-1", "novox/app", merge, true))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(id); s != Delivering {
if s := w.state(id); s != Merged {
t.Fatalf("before the restart it is %s", s)
}
// While it is down, the walk finishes and nobody says so.
walk := w.ctl.walk("plan-1")
walk.State = walkDone
w.ctl.put(walk)
again := newWorldOver(t, w.store, w.ctl, w.forge, w.now.Add(time.Hour))
if s := again.state(id); s != Delivering {
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Hour))
if s := again.state(id); s != Merged {
t.Fatalf("read back, it is %s", s)
}
if err := again.h.Reconcile(); err != nil {
t.Fatal(err)
}
// A delivery kept in a state an earlier owner had and this one does not is read as the state it came to and kept so.
func TestADeliveryKeptInAGoneStateIsReadAsTheMergeStateItCameTo(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.settleAll()
raw := w.store.deliveries[id]
w.store.deliveries[id] = []byte(strings.Replace(string(raw), `"state":"merged"`, `"state":"delivering"`, 1))
again := newWorldOver(t, w.store, w.forge, w.now.Add(time.Hour))
if s := again.state(id); s != Merged {
t.Fatalf("a delivering delivery was read as %s", s)
}
if s := again.state(id); s != Delivered {
t.Fatalf("the walk it missed was not read back: %s", s)
}
if len(w.ctl.delivers) != 1 {
t.Fatalf("a restarted holder let the walk go again: %v", w.ctl.delivers)
again.settleAll()
if !strings.Contains(string(w.store.deliveries[id]), `"state":"merged"`) {
t.Fatalf("the read state was not kept: %s", w.store.deliveries[id])
}
}
@@ -265,7 +180,7 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
w.later(time.Hour) // each failure put its next try later: past every one of them
w.settleAll()
w.settleAll()
if notes := w.forge.notesOn(head); len(notes) != 3 {
if notes := w.forge.notesOn(head); len(notes) != 2 {
t.Fatalf("after the bus came back the note has %d line(s): %q", len(notes), notes)
}
if d := w.delivery(IDOf("novox/app", head)); len(d.Owed) != 0 {
@@ -273,66 +188,8 @@ func TestATransitionIsKeptBeforeItIsSaid(t *testing.T) {
}
}
// At the switch: a walk already running is adopted as a delivery that is delivering; a waiting walk whose pull
// request this owner never heard of waits for a person, and a person's release lets it go.
func TestTheSwitchAdoptsRunningWalksAndHoldsUnknownWaitingOnes(t *testing.T) {
w := newWorld(t)
running := aWalk("plan-old", "novox/mesh-catalog", "cccccccccccc3333", false)
running.Created = w.now.Add(-time.Hour)
running.State = walkRolling
w.ctl.put(running)
waiting := aWalk("plan-new", "novox/app", "dddddddddddd4444", true)
waiting.Created = w.now.Add(time.Minute)
w.ctl.put(waiting)
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.h.React()
if s := w.state(IDOf("novox/mesh-catalog", "cccccccccccc3333")); s != Delivering {
t.Fatalf("a running walk at the switch was adopted as %s", s)
}
if _, made := w.h.deliveries[IDOf("novox/app", "dddddddddddd4444")]; made {
t.Fatal("a new walk was made a delivery before its merge had time to be heard")
}
w.later(3 * time.Minute)
w.h.React()
held := IDOf("novox/app", "dddddddddddd4444")
if s := w.state(held); s != Held {
t.Fatalf("a waiting walk with no pull request is %s", s)
}
if _, err := w.h.Release(held, "", "jochen"); err == nil {
t.Fatal("released without why")
}
if _, err := w.h.Release(held, "checked by hand", "jochen"); err != nil {
t.Fatal(err)
}
if s := w.state(held); s != Delivering || len(w.ctl.delivers) != 1 {
t.Fatalf("released, it is %s and let go %v", s, w.ctl.delivers)
}
}
// mesh-delivery's controller down: nothing is lost — its word is asked again on the next tick.
func TestAControllerThatDoesNotAnswerIsAskedAgain(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.h.WalkMoved(w.ctl.put(aWalk("plan-1", "novox/app", merge, true)))
w.ctl.down = true
w.settleAll()
if s := w.state(id); s != Published {
t.Fatalf("with the controller down it is %s", s)
}
w.ctl.down = false
w.settleAll()
if s := w.state(id); s != Delivering {
t.Fatalf("with the controller back it is %s", s)
}
}
// A merge before the check passed is held for a person; a merge this owner never heard the head of is made
// from the forge's word and published when the forge says its gate passed.
// from the forge's word and merged when the forge says its gate passed.
func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
@@ -348,50 +205,38 @@ func TestAMergeWithoutAPassingCheckIsHeld(t *testing.T) {
w.h.PullMerged(m)
w.settleAll()
d := w.delivery(IDOf("novox/lab", "eeeeeeeeeeee5555"))
// The fake planner says it moves app; with no walk yet it waits published.
if d.State != Ready && d.State != Published {
if d.State != Merged {
t.Fatalf("a merge whose head passed by the forge's word is %s", d.State)
}
}
// A member that fails stops no other (ADR 0418 §5): the plan that fails fails as every plan does, and what was delivered
// stays. Nothing is stopped member by member.
func TestAFailedMemberStopsNoOtherMember(t *testing.T) {
// A group merged whole ends merged, member by member and as a group, and source.main holds its heads (ADR 0539 §17, R6-C2).
func TestAMergedGroupEndsMergedWithSourceMainMoved(t *testing.T) {
w := newWorld(t)
heads := map[string]string{"novox/a": "a1a1a1a1a1a1a1", "novox/b": "b2b2b2b2b2b2b2", "novox/c": "c3c3c3c3c3c3c3"}
heads := map[string]string{"novox/a": "a1a1a1a1a1a1a1", "novox/b": "b2b2b2b2b2b2b2"}
ids := map[string]string{}
for i, repo := range []string{"novox/a", "novox/b", "novox/c"} {
for i, repo := range []string{"novox/a", "novox/b"} {
ids[repo] = IDOf(repo, heads[repo])
w.h.PullUpdated(pr(repo, i+1, heads[repo], "feat/y", "x"))
w.h.Checked(verdict(repo, i+1, heads[repo], "pass"))
}
w.settleAll()
w.h.Checked(CheckedEvent{Group: "feat/y", ID: "check-1", Verdict: "pass", Summary: "compose"})
for i, repo := range []string{"novox/a", "novox/b", "novox/c"} {
mergeCommit := strings.Repeat(string(rune('p'+i)), 12)
w.h.PullMerged(merged(repo, i+1, heads[repo], mergeCommit))
w.h.WalkMoved(w.ctl.put(aWalk("plan-"+repo[6:], repo, mergeCommit, true)))
for i, repo := range []string{"novox/a", "novox/b"} {
w.h.PullMerged(merged(repo, i+1, heads[repo], strings.Repeat(string(rune('p'+i)), 12)))
}
w.settleAll()
if len(w.ctl.delivers) != 3 {
t.Fatalf("every member merged, yet the walks were let go %v", w.ctl.delivers)
for repo, id := range ids {
if s := w.state(id); s != Merged {
t.Errorf("%s is %s, wanted merged", repo, s)
}
}
a := w.ctl.walk("plan-a")
a.State = walkDone
w.h.WalkMoved(w.ctl.put(a))
b := w.ctl.walk("plan-b")
b.State = walkFailed
b.Note = "b failed its gate on anchor"
w.h.WalkMoved(w.ctl.put(b))
w.settleAll()
if w.state(ids["novox/a"]) != Delivered || w.state(ids["novox/b"]) != Failed || w.state(ids["novox/c"]) == Stopped {
t.Fatalf("a %s, b %s, c %s", w.state(ids["novox/a"]), w.state(ids["novox/b"]), w.state(ids["novox/c"]))
g := w.h.groups["feat/y"]
if state, why := GroupState(g, w.h.membersOf(g)); state != "merged" {
t.Fatalf("the group is %s (%s), wanted merged", state, why)
}
if len(w.ctl.stops) != 0 || len(w.ctl.withouts) != 0 {
t.Fatalf("a walk was stopped because another failed: %v %v", w.ctl.stops, w.ctl.withouts)
}
if state, _ := GroupState(w.h.groups["feat/y"], w.h.membersOf(w.h.groups["feat/y"])); state != "failed" {
t.Fatalf("the group is %s", state)
if w.source.main == nil || w.source.main.Heads["novox/a"] != strings.Repeat("p", 12) || w.source.main.Heads["novox/b"] != strings.Repeat("q", 12) {
t.Fatalf("source.main is %+v, wanted both merges", w.source.main)
}
}
@@ -434,44 +279,38 @@ func TestAnAfterLineIsNotReadAndSaidOnceAndNothingWaitsForAnOrder(t *testing.T)
}
}
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that.
// Stalled: a delivery past its state's bound is listed with what H2 may do; close takes only that. Only a proposed
// delivery has a bound now: its check was asked and not answered.
func TestStalledAndCloseWorkFromTheTable(t *testing.T) {
w := newWorld(t)
id := IDOf("novox/app", head)
w.h.PullUpdated(pr("novox/app", 7, head, "feat/x", "src/a.go"))
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
walk := w.ctl.put(aWalk("plan-1", "novox/app", merge, false))
w.h.WalkMoved(walk)
if w.state(id) != Delivering {
t.Fatalf("it is %s", w.state(id))
}
if _, err := w.h.Close(id, "too early"); err == nil {
t.Fatal("closed within its bound")
}
w.later(3 * time.Hour)
stalled := w.h.Stalled()
if len(stalled) != 1 || stalled[0].ID != id || !strings.Contains(stalled[0].H2, "close") {
if len(stalled) != 1 || stalled[0].ID != id || stalled[0].State != Proposed {
t.Fatalf("stalled %+v", stalled)
}
if _, err := w.h.Close(id, "delivery stalled"); err == nil {
t.Fatal("closed with nothing in the walk's record saying it moved")
// A merge is the gate's last word: it is never stalled, however old.
w.h.Checked(verdict("novox/app", 7, head, "pass"))
w.h.PullMerged(merged("novox/app", 7, head, merge))
w.later(72 * time.Hour)
for _, s := range w.h.Stalled() {
if s.ID == id {
t.Fatalf("a merged delivery is stalled: %+v", s)
}
}
walk.State = walkDone
w.ctl.put(walk) // finished, and the event was missed
said, err := w.h.Close(id, "delivery stalled")
if err != nil || w.state(id) != Delivered {
t.Fatalf("H2's close left it %s: %v", w.state(id), err)
if _, err := w.h.Close(id, "x"); err == nil {
t.Fatal("H2 closed a merged delivery")
}
if !strings.Contains(said, "delivered") {
t.Fatalf("close says %q", said)
}
// Held is the operator's: H2 has no transition there.
// A merge without a passing check is held: a person's, with no transition for H2.
w.h.PullUpdated(pr("novox/app", 8, "ffffffffffff0000", "feat/q"))
w.h.PullMerged(merged("novox/app", 8, "ffffffffffff0000", "eeeeeeeeeeee0000"))
w.later(48 * time.Hour)
if _, err := w.h.Close(IDOf("novox/app", "ffffffffffff0000"), "x"); err == nil || !strings.Contains(err.Error(), "operator's") {
t.Fatalf("H2 closed a held delivery: %v", err)
if _, err := w.h.Close(IDOf("novox/app", "ffffffffffff0000"), "x"); err == nil {
t.Fatal("H2 closed a held delivery")
}
}
@@ -520,14 +359,3 @@ func TestGroupsNamesEachMembersPullRequestHeadBaseAndOpen(t *testing.T) {
}
}
}
// composed are the asks of a group's composed check among the checks asked: a member's own check has no group before its colon.
func composed(checks []string) []string {
var out []string
for _, c := range checks {
if !strings.HasPrefix(c, ":") {
out = append(out, c)
}
}
return out
}

Some files were not shown because too many files have changed in this diff Show More