Compare commits

..
Author SHA1 Message Date
jschoubben 604ccd10d4 Merge remote-tracking branch 'origin/main' into feat/try-speed 2026-10-25 03:17:47 +01:00
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
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 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
294 changed files with 2654 additions and 17360 deletions
+302 -6
View File
@@ -14,7 +14,6 @@ modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
@@ -35,9 +34,306 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/checks/paths/paths
/modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config
checks/placement/placement
# Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools
# 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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and the files it names.
modules
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).
-128
View File
@@ -1,128 +0,0 @@
// Package failurekit refuses a build module whose tests do not run mesh-sdk's conformance kit (issue 1901, its thorough
// fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so
// its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
// exempt from that, and said so. Every build module, test step or not, must also hold a test that calls
// conformance.LeftRunning with its own start sweep, so a step a previous process left running is ended at its start
// (issue 1890).
package failurekit
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// Judged is what the check says of one build module: Refused says why it fails the check, "" when it passes; Exempt
// says why the kit does not apply.
type Judged struct {
Module, Refused, Exempt string
}
type manifest struct {
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
Tools []json.RawMessage `json:"tools"`
}
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
// seat.
func Judge(root string) ([]Judged, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Judged
for _, p := range manifests {
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
seat := ""
for _, c := range m.Claims {
if strings.HasPrefix(c.Name, "node-build-") {
seat = c.Name
}
}
if seat == "" {
continue
}
dir := filepath.Dir(p)
j := Judged{Module: filepath.Base(dir)}
var refused []string
test := testTool(m.Tools)
switch {
case test == "":
j.Exempt = fmt.Sprintf("it claims %s and serves no test step, so the failure contract's cases do not apply", seat)
default:
runs, err := runsTheKit(dir, "conformance.Run(")
if err != nil {
return nil, err
}
if !runs {
refused = append(refused, fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
"(mesh-sdk go/buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test))
}
}
// Every build module keeps steps, so every one runs the left-running case, test step or not (issue 1890).
sweeps, err := runsTheKit(dir, "conformance.LeftRunning(")
if err != nil {
return nil, err
}
if !sweeps {
refused = append(refused, fmt.Sprintf("it claims %s, and no test of it calls conformance.LeftRunning "+
"(mesh-sdk go/buildstep/conformance): run the kit's left-running case with the module's own start sweep (issue 1890)", seat))
}
j.Refused = strings.Join(refused, "; ")
out = append(out, j)
}
return out, nil
}
// testTool is the name of the module's test step tool (<prefix>_test), or "".
func testTool(tools []json.RawMessage) string {
for _, raw := range tools {
var name string
if json.Unmarshal(raw, &name) != nil {
var t struct {
Name string `json:"name"`
}
if json.Unmarshal(raw, &t) != nil {
continue
}
name = t.Name
}
if strings.HasSuffix(name, "_test") {
return name
}
}
return ""
}
// runsTheKit says a _test.go file under dir calls the kit's function call (such as "conformance.Run(").
func runsTheKit(dir, call string) (bool, error) {
found := false
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || found || d.IsDir() || !strings.HasSuffix(p, "_test.go") {
return err
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
found = strings.Contains(string(raw), call) &&
strings.Contains(string(raw), `"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"`)
return nil
})
return found, err
}
-77
View File
@@ -1,77 +0,0 @@
package failurekit
import (
"os"
"path/filepath"
"testing"
)
// 1901/AC7 and 1890/AC8: a build module with a test step and no kit is refused, one that runs the kit passes, one with no
// test step is exempt from the failure cases but not from the left-running case, a module that runs only the failure
// cases is refused, and a module that claims no node-build-* seat is not judged.
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
root := t.TempDir()
const imp = "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/buildstep/conformance\"\n\n"
const sweep = "func TestSweep(t *testing.T) { conformance.LeftRunning(t, nil) }\n"
kit := imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n" + sweep
for path, body := range map[string]string{
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
"modules/a/cmd/a/check_test.go": "package main\n",
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
"modules/b/cmd/b/kit_test.go": kit,
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
"modules/c/cmd/c/kit_test.go": imp + sweep,
"modules/f/module.json": `{"claims":[{"name":"node-build-f"}],"tools":["build_f_build"]}`,
"modules/g/module.json": `{"claims":[{"name":"node-build-g"}],"tools":["build_g_test"]}`,
"modules/g/cmd/g/kit_test.go": imp + "func TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n",
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
"modules/e/cmd/e/kit.go": kit, // not a test file
} {
p := filepath.Join(root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := Judge(root)
if err != nil {
t.Fatal(err)
}
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused", "f": "refused", "g": "refused"}
if len(got) != len(want) {
t.Fatalf("judged %+v, want a, b, c, e, f and g", got)
}
for _, j := range got {
is := "passes"
if j.Refused != "" {
is = "refused"
} else if j.Exempt != "" {
is = "exempt"
}
if is != want[j.Module] {
t.Errorf("%s %s (%+v), want it %s", j.Module, is, j, want[j.Module])
}
}
}
// Every build module of this catalogue with a test step runs the kit.
func TestTheCataloguesBuildModulesRunTheKit(t *testing.T) {
got, err := Judge(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("no module claims a node-build-* seat: the check reads the wrong tree")
}
for _, j := range got {
switch {
case j.Refused != "":
t.Errorf("%s: %s", j.Module, j.Refused)
case j.Exempt != "":
t.Logf("%s is exempt: %s", j.Module, j.Exempt)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/failurekit
go 1.22
-7
View File
@@ -1,7 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Judge("../.."): every manifest, and the directory of each module claiming a node-build-* seat, walked for a test that
# calls the kit. The build modules are the mesh-build-* directories; a build module named otherwise needs its line here.
modules/*/module.json
modules/mesh-build-*
+1 -10
View File
@@ -1,10 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every manifest through mesh-sdk's ReadTreeManifest: the root's predefined keys and each templated module's
# templates (files/ by default, or the directory its manifest names); and the root's kept count of resources that do not say how they are ready.
modules/*/module.json
predefined.json
modules/*/files
modules/*/templates
health-undeclared
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest, and whether the path each image artifact builds from is there.
modules
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 -7
View File
@@ -1,7 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest, a TypeScript module's package.json and whether its lock is there.
modules/*/module.json
modules/*/package.json
modules/*/package-lock.json
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
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).
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/nobinaries
go 1.22
-78
View File
@@ -1,78 +0,0 @@
// Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
package nobinaries
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Kind says what executable format the first bytes of a file are, or "" when they are none.
func Kind(head []byte) string {
switch {
case bytes.HasPrefix(head, []byte("\x7fELF")):
return "ELF"
case bytes.HasPrefix(head, []byte("MZ")):
return "PE"
case bytes.HasPrefix(head, []byte{0xcf, 0xfa, 0xed, 0xfe}), bytes.HasPrefix(head, []byte{0xce, 0xfa, 0xed, 0xfe}),
bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xce}),
bytes.HasPrefix(head, []byte{0xca, 0xfe, 0xba, 0xbe}) && len(head) >= 8 && head[7] < 0x20 && head[6] == 0 && head[5] == 0 && head[4] == 0:
return "Mach-O"
}
return ""
}
// Files are the files of the catalogue's root that the change holds under modules/: the tracked ones when the root is a git
// checkout (an ignored binary a local build left is not the change's), else every file there.
func Files(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
if out, err := cmd.Output(); err == nil && len(out) > 0 {
var files []string
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
if _, err := os.Lstat(filepath.Join(root, f)); err == nil { // a file the change deletes is not there
files = append(files, f)
}
}
return files, nil
}
var files []string
err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !d.Type().IsRegular() {
return err
}
rel, _ := filepath.Rel(root, p)
files = append(files, rel)
return nil
})
return files, err
}
// Find names each file of files (paths from root) that is a compiled program, with its format.
func Find(root string, files []string) ([]string, error) {
var found []string
for _, f := range files {
p := filepath.Join(root, f)
if st, err := os.Lstat(p); err != nil || !st.Mode().IsRegular() {
continue
}
fh, err := os.Open(p)
if err != nil {
return nil, err
}
head := make([]byte, 8)
n, err := io.ReadFull(fh, head)
fh.Close()
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
if k := Kind(head[:n]); k != "" {
found = append(found, fmt.Sprintf("%s (%s)", f, k))
}
}
return found, nil
}
-54
View File
@@ -1,54 +0,0 @@
package nobinaries
import (
"os"
"path/filepath"
"testing"
)
func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
root := t.TempDir()
files := map[string][]byte{
"modules/a/a": []byte("\x7fELF\x02\x01\x01\x00rest"),
"modules/b/cmd/b/b.exe": []byte("MZ\x90\x00"),
"modules/c/c": {0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1},
"modules/d/main.go": []byte("package main\n"),
"modules/e/short": []byte("\x7f"),
"modules/f/empty": {},
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
}
var names []string
for f, b := range files {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
names = append(names, f)
}
got, err := Find(root, names)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
}
}
// The catalogue's own tree holds no compiled program: a compiled program is build output, so remove it and add it to .gitignore.
func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
root := filepath.Join("..", "..")
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
found, err := Find(root, files)
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
}
}
-5
View File
@@ -1,5 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every file under modules/ (git ls-files and a walk).
modules
+1 -7
View File
@@ -1,7 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# that none of the old check scripts stands at the root.
?merge-check.sh
?merge-check-typescript.sh
?check-nested-modules.sh
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest; its lists are this package's own files.
modules/*/module.json
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 -6
View File
@@ -1,6 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# check("../.."): the root's list and every file of the modules it names.
particulars
modules
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and every file of a module.
modules
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
View File
@@ -1,5 +0,0 @@
module checks/placement
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117
-14
View File
@@ -1,14 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
-80
View File
@@ -1,80 +0,0 @@
// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every
// `placement/<node>.json` reads as a placement of the node its name says, and the files together are refused, every
// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not
// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or
// more than one hub (mesh-sdk delivery.CheckPlacements).
//
// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names
// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A
// catalogue with no placement directory has none to check.
func Check(root string) ([]string, error) {
dir := filepath.Join(root, delivery.PlacementDir)
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var found []string
placements := map[string]delivery.Placement{}
for _, e := range entries {
rel := delivery.PlacementDir + "/" + e.Name()
node, ok := delivery.PlacementFileNode(rel)
if e.IsDir() || !ok {
found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only <node>.json, one per node", rel))
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
p, err := delivery.DecodePlacement(raw)
if err != nil {
found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err))
continue
}
if p.Revision != "" {
found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel))
}
placements[node] = p
}
all := func(string) bool { return true }
found = append(found, delivery.CheckPlacements(placements, all, all)...)
sort.Strings(found)
return found, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: placement <catalogue root>")
os.Exit(2)
}
found, err := Check(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every
// rule, and a tree that breaks them is refused with every finding named at once.
func TestPlacementFiles(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s (novox/hq ADR 0549 §1)", f)
}
root := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("a catalogue with no placement directory: %v, %v", found, err)
}
write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`)
write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`)
write("novox.json", `{"v":1,"node":"shanks","modules":[]}`)
write("bad.json", `{"v":1,"node":"bad","modules":{}}`)
write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`)
write("notes.md", "x")
found, err = Check(root)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1",
"ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision",
"notes.md is not a placement file"} {
hit := false
for _, f := range found {
hit = hit || strings.Contains(f, want)
}
if !hit {
t.Errorf("no finding says %q; found %v", want, found)
}
}
}
+1 -6
View File
@@ -1,6 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the root's list of predefined keys and every manifest that names one.
predefined.json
modules/*/module.json
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and the files it names.
modules
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and its templates.
modules
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 -5
View File
@@ -1,5 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
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 -7
View File
@@ -1,7 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# run("../.."): the root's lists of retired words and lines, and every module's texts.
retired-words
retired-lines
modules
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,9 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of this package in every module that carries one.
modules/xorg/internal/desktop/*.go
modules/lemurs/internal/desktop/*.go
modules/i3/internal/desktop/*.go
modules/xterm/internal/desktop/*.go
modules/adwaita/internal/desktop/*.go
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,25 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of desktop.go and its tests in every module that carries one.
modules/blueman/cmd/blueman-tools/desktop.go
modules/blueman/cmd/blueman-tools/desktop_test.go
modules/blueman/cmd/blueman-tools/copies_test.go
modules/forticlient/cmd/forticlient-tools/desktop.go
modules/forticlient/cmd/forticlient-tools/desktop_test.go
modules/forticlient/cmd/forticlient-tools/copies_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
modules/nm-applet/cmd/nm-applet-tools/desktop.go
modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
modules/nm-applet/cmd/nm-applet-tools/copies_test.go
modules/openrazer/cmd/openrazer-tools/desktop.go
modules/openrazer/cmd/openrazer-tools/desktop_test.go
modules/openrazer/cmd/openrazer-tools/copies_test.go
modules/polychromatic/cmd/polychromatic-tools/desktop.go
modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
modules/polychromatic/cmd/polychromatic-tools/copies_test.go
modules/slack/cmd/slack-tools/desktop.go
modules/slack/cmd/slack-tools/desktop_test.go
modules/slack/cmd/slack-tools/copies_test.go
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 -6
View File
@@ -1,6 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# mesh-mail's store.json is the same as brother's; no module scan is left.
modules/mesh-mail/files/store.json
?modules/scan
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).
+9 -107
View File
@@ -15,13 +15,6 @@ package main
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
// - **A store that does not answer does not lose where a message is shown, while the desk runs.** A message
// is shown first and its notification ids kept after; when the state store does not answer then (the bus
// reloading its accounts, 2026-10-26 09:18: the very outage that raised the condition), the work is taken
// all the same and the ids are held in memory, recalled before the store, and kept at the start of the next
// work and at the minute's standing. Otherwise the clearing of that message finds no notification to
// replace and the urgent one stays on screen for good. A desk that restarts while it still holds some loses
// where those are shown; the log says so when they are held.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
@@ -100,9 +93,6 @@ type Desk struct {
pause map[string]pauseSeen
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
lost map[string]time.Time
// unkept is what was shown and not yet kept in the store, because it did not answer: recalled from here
// before the store, and kept at the next piece of work.
unkept map[string]Shown
}
func (d *Desk) init() {
@@ -117,9 +107,6 @@ func (d *Desk) init() {
if d.lost == nil {
d.lost = map[string]time.Time{}
}
if d.unkept == nil {
d.unkept = map[string]Shown{}
}
if d.Now == nil {
d.Now = time.Now
}
@@ -151,7 +138,6 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
}
d.KeepUnkept()
machines := d.Machines()
if m.Secret {
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
@@ -181,16 +167,12 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
switch {
case edit:
var err error
if before, err = d.recall(m.Handle); err != nil {
if before, err = d.Store.Get(m.Handle); err != nil {
return err
}
if before == nil {
d.Logf("[desk-channel] an edit of %s, which nothing here records as shown: shown as a new message; "+
"a notification it was to replace, if any, stays", m.Handle)
}
case !m.Secret:
var err error
if before, err = d.recall(m.Handle); err != nil {
if before, err = d.Store.Get(m.Handle); err != nil {
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
before = nil
}
@@ -236,91 +218,12 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
return nil
}
d.keep(m.Handle, s)
if err := d.Store.Put(m.Handle, s); err != nil {
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
}
return nil
}
// keep keeps what a handle shows in the store. When the store does not answer it is held in memory, where an
// edit, a reminder and a click still find it, and kept later (KeepUnkept). The work is taken either way: offered
// again it would race the message's own later edits, and a redelivery is bounded where an outage is not.
func (d *Desk) keep(handle string, s Shown) {
err := d.Store.Put(handle, s)
d.mu.Lock()
defer d.mu.Unlock()
if err == nil {
delete(d.unkept, handle)
return
}
d.unkept[handle] = s
d.Logf("[desk-channel] shown, and where it is shown could not be kept yet (%v): held in memory, so its edit "+
"and clearing still reach it, and kept when the store answers; %d held, lost if the desk restarts first",
err, len(d.unkept))
}
// KeepUnkept keeps in the store what it did not answer for before: at the start of each piece of work and at
// the minute's standing. It stops at the first write the store does not answer, so one call waits on it once.
func (d *Desk) KeepUnkept() {
d.init()
d.mu.Lock()
held := make(map[string]Shown, len(d.unkept))
for h, s := range d.unkept {
held[h] = s
}
d.mu.Unlock()
for h, s := range held {
if err := d.Store.Put(h, s); err != nil {
return
}
d.mu.Lock()
if cur, ok := d.unkept[h]; ok && sameShown(cur, s) {
delete(d.unkept, h)
}
d.mu.Unlock()
d.Logf("[desk-channel] where %s is shown is kept now", h)
}
}
func (d *Desk) unkeptOf(handle string) (Shown, bool) {
d.mu.Lock()
defer d.mu.Unlock()
s, ok := d.unkept[handle]
return s, ok
}
// recall is what a handle shows: from memory when the store did not keep it yet, else from the store.
func (d *Desk) recall(handle string) (*Shown, error) {
if s, ok := d.unkeptOf(handle); ok {
return &s, nil
}
return d.Store.Get(handle)
}
// handles is every handle shown: the store's and those held in memory.
func (d *Desk) handles() ([]string, error) {
hs, err := d.Store.Handles()
d.mu.Lock()
defer d.mu.Unlock()
seen := map[string]bool{}
for _, h := range hs {
seen[h] = true
}
for h := range d.unkept {
if !seen[h] {
hs = append(hs, h)
}
}
if err != nil && len(d.unkept) == 0 {
return nil, err
}
return hs, nil
}
func sameShown(a, b Shown) bool {
x, err1 := json.Marshal(a)
y, err2 := json.Marshal(b)
return err1 == nil && err2 == nil && string(x) == string(y)
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
urgency := "normal"
@@ -405,12 +308,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
func (d *Desk) find(ticket string) (string, *Shown) {
handles, err := d.handles()
handles, err := d.Store.Handles()
if err != nil {
return "", nil
}
for _, h := range handles {
s, err := d.recall(h)
s, err := d.Store.Get(h)
if err != nil || s == nil {
continue
}
@@ -453,9 +356,9 @@ func (d *Desk) Started(event, machine string) int {
d.lost[machine] = d.Now()
d.mu.Unlock()
n := 0
if handles, err := d.handles(); err == nil {
if handles, err := d.Store.Handles(); err == nil {
for _, h := range handles {
if s, err := d.recall(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if _, here := s.On[machine]; here {
n++
}
@@ -569,7 +472,6 @@ func (d *Desk) Status() map[string]any {
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
"a store that does not answer does not lose where a message is shown while the desk runs: it is held in memory, its edit and clearing still replace it in place, and it is kept when the store answers again; a desk restarted before then loses where those are shown, and its log said how many it held",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -364,64 +363,9 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
}
}
// downStore is a store that does not answer while down: the bus reloading its accounts (2026-10-26 09:18).
type downStore struct {
memShown
down *bool
}
func (s downStore) Get(h string) (*Shown, error) {
if *s.down {
return nil, errors.New("nats: no responders available for request")
}
return s.memShown.Get(h)
}
func (s downStore) Put(h string, v Shown) error {
if *s.down {
return errors.New("nats: no responders available for request")
}
return s.memShown.Put(h, v)
}
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed. And
// (2026-10-26 09:18, an urgent "A new build of mesh-controller cannot be delivered" left on screen for hours
// after its clearing): a message shown while the store does not answer keeps where it is shown in memory, its
// work is taken, its clearing replaces it in place, and it is kept once the store answers.
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
// The raise, then its clearing, taken while the store does not answer; the store then stays down or comes
// back. Each is taken (never offered again, where a redelivered raise would follow its clearing), the
// clearing replaces the urgent notification in place, and the screen and the store end on the clearing.
for _, back := range []bool{false, true} {
t.Run(fmt.Sprintf("the store does not answer, back %v", back), func(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
s, kept := store["m9"]
if back != kept || (kept && s.Message.Title != "Cleared: a new build can be delivered") {
t.Fatalf("store back %v, kept %v: %+v", back, kept, store)
}
if got, _ := d.recall("m9"); got == nil || got.Message.Title != "Cleared: a new build can be delivered" {
t.Fatalf("recalled %+v", got)
}
})
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
@@ -100,7 +100,6 @@ func run(d *Desk) {
}()
go func() {
for {
d.KeepUnkept()
d.SayStanding()
time.Sleep(time.Minute)
}
@@ -1,25 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of desktop.go and its tests in every module that carries one.
modules/blueman/cmd/blueman-tools/desktop.go
modules/blueman/cmd/blueman-tools/desktop_test.go
modules/blueman/cmd/blueman-tools/copies_test.go
modules/forticlient/cmd/forticlient-tools/desktop.go
modules/forticlient/cmd/forticlient-tools/desktop_test.go
modules/forticlient/cmd/forticlient-tools/copies_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
modules/nm-applet/cmd/nm-applet-tools/desktop.go
modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
modules/nm-applet/cmd/nm-applet-tools/copies_test.go
modules/openrazer/cmd/openrazer-tools/desktop.go
modules/openrazer/cmd/openrazer-tools/desktop_test.go
modules/openrazer/cmd/openrazer-tools/copies_test.go
modules/polychromatic/cmd/polychromatic-tools/desktop.go
modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
modules/polychromatic/cmd/polychromatic-tools/copies_test.go
modules/slack/cmd/slack-tools/desktop.go
modules/slack/cmd/slack-tools/desktop_test.go
modules/slack/cmd/slack-tools/copies_test.go
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).
-47
View File
@@ -275,53 +275,6 @@ reported no key gets no entry, and its client says it waits. Keys titled `mesh:`
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
## The mesh's accounts read every repository (the operator, 2026-10-26)
The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox`
through its team; the build modules fetch over ssh as `nox-builder` (ADR 0466 §3), in a team that reads the
organisation's code; the machines' agents use `nox-agent` (ADR 0410 §1). Any other repository each saw only when public,
so a private repository of a user answered every tool 404 and every build's fetch "not authorized to read". `readall.ts`
makes each of these accounts (one list, `meshAccounts` in `tools-account.ts`, whose two Go names
`internal/forgeaccount`'s test holds to its own constants) a collaborator that **reads only** on each repository it
cannot read: one repository at a time, no organisation membership, the least the forge offers. An account the forge
does not hold yet (the forge-account provider makes `nox-builder` and `nox-agent` when their first consumer asks) is
named and asked nothing; the start's pass is run again ten minutes apart until every account was there once.
- **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is
asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and
`forge.fork` at once, and the first event of any repository this process has not seen readable (the forge may not
send a creation for every owner). Nothing polls for it.
- **Not caught:** a repository made private after it was seen readable. The forge sends no event for a change of
visibility, and the start's pass already saw it readable, so its events ask nothing. It is granted at the module's
next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile. A repository whose permission
the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on.
- **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a
repository's administration, which on another user's repository only the site administrator holds. It is used for
five calls alone: list every repository, read one, whether an account exists, read an account's permission on one,
add an account as a read collaborator. The tools' own calls keep their token.
- **Tools:** `gitea_unreadable_repos` (per account, every repository it cannot read, the permission the forge gives
it there and why, a count per account and the accounts the forge does not hold) and `gitea_grant_read` (the same
grant by hand, now, on every repository or on those named, for every account or those named; each grant read back
from the forge, a refusal answered in the forge's own words).
### The merging account writes the repositories the mesh delivers
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
a user's repository could be checked and asked but never merged (issue 1945: car-hunter #2's status refused 403 after
the operator's Approve). `writes.ts` makes it a collaborator that **writes** on each repository that builds a registered
module, and on no other (the operator, 2026-10-26): the build modules' and the agents' accounts keep read.
- **Which:** the indexer's state `mesh-delivery-indexer.modules` (in this module's `reads`), one key per registered
module, each naming its repository. A repository of `novox` needs nothing: the team writes it. Every other repository
of a user stays read-only.
- **When:** the state's watch hands every module at the start and each one registered after; a repository not yet
written is granted then, read back from the forge, and said on the bus as `gitea.repo.writable` {owner, repo,
account}, on which mesh-delivery-gate reads that repository again. Nothing polls.
- **Tools:** `gitea_unwritable_repos` (every delivered repository the account cannot write, its permission and why)
and `gitea_grant_write` (the same grant by hand, on every delivered repository or those named; what it granted, what
was written already, what the forge refused in its own words, and the repositories left read-only and why).
`gitea_commit_statuses` answers `merge.writes`, whether the account may write the repository at all.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
+14 -215
View File
@@ -50,9 +50,6 @@ export interface GiteaRepo {
default_branch?: string;
/** When anything last moved in it — a push, and so a merge. */
updated_at?: string;
/** Whether the account asking may write it (the forge's permissions.push or admin): a commit status and a merge need
* it. Absent when the forge did not say. */
writes?: boolean;
}
/** An issue, with its labels flattened to names. */
@@ -101,10 +98,6 @@ export interface GiteaPull {
html_url: string;
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
body?: string;
/** Whether the forge would merge it into its base now (gitea's `mergeable`): false while it is in conflict, while the
* forge is still working that out after its base moved, or while it is a work in progress; absent when the forge did not
* say. What a group's merge reads of every member before it merges any (novox/hq issue 1275). */
mergeable?: boolean;
}
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
@@ -174,27 +167,8 @@ function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
console.error(`the setting ${key} is ignored: it comes from the vault`);
}
/** How long the forge may take to answer one request before it is cut and said (novox/hq issue 1577: a request for a 5 MB
* file never ended, and every read through the module timed out behind it). */
export const FORGE_REQUEST_TIMEOUT_MS = 20_000;
/** The most a tool's answer carries of one file: the bus's message bound is 1 MiB and a file travels as text (issue 1577). */
export const MAX_FILE_BYTES = 512 * 1024;
/** One request to the forge, cut at the limit and failing loud naming the request. */
async function timedFetch(url: string, init: RequestInit, label: string, timeoutMs: number): Promise<Response> {
try {
return await fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
} catch (err) {
if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Gitea API ${label}: no answer in ${timeoutMs / 1000} s, the request was cut`);
throw err;
}
}
export class GiteaClient {
readonly baseUrl: string;
/** The limit of one request; a test sets it short. */
requestTimeoutMs = FORGE_REQUEST_TIMEOUT_MS;
private readonly tokens: TokenSource;
/** A token given as a string is one somebody configured; a source decides for itself (token.ts). */
@@ -249,14 +223,14 @@ export class GiteaClient {
private send(path: string, options: RequestInit, token: string): Promise<Response> {
checkSentPath(path);
return timedFetch(`${this.baseUrl}/api/v1${path}`, {
return fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
...(options.headers as Record<string, string> | undefined),
},
}, `${options.method ?? "GET"} ${path}`, this.requestTimeoutMs);
});
}
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
@@ -559,7 +533,7 @@ export class GiteaClient {
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. Reads only. */
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, listed?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; listed?: Record<string, Record<string, string>> }> {
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = [];
@@ -586,23 +560,7 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree;
}
// **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name
// to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a
// merge. A directory the commit lacks lists nothing (the catalogue before its first placement file).
let files: Record<string, Record<string, string>> | undefined;
if (listed !== undefined) {
files = {};
for (const dir of listed) {
files[dir] = {};
const prefix = dir === "" ? "" : `${dir}/`;
for (const e of entries) {
if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue;
const name = e.path.slice(prefix.length);
if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha);
}
}
}
if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files };
if (paths === undefined) return { root, trees };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part.
@@ -617,7 +575,7 @@ export class GiteaClient {
named[dir][p] = id;
}
}
return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
return { root, trees, paths: named };
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -806,22 +764,6 @@ export class GiteaClient {
/** One file's contents at a ref (default the repository's default branch), decoded. */
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
// The size first, from the file's directory listing, which carries no content: a file over what an answer can carry is
// refused without its body being fetched (issue 1577). A file the listing does not show is asked for as before.
const clean = path.replace(/^\/+|\/+$/g, "");
pathSegments("file path", clean); // a path that is another path or user is refused before anything reaches the forge
const slash = clean.lastIndexOf("/");
const dir = slash < 0 ? "" : clean.slice(0, slash);
let listing: any[] | null = null;
try {
listing = await this.request<any[]>(`${repoPath(owner, repo)}/contents${dir ? "/" + pathSegments("file path", dir) : ""}${qs}`);
} catch (err) {
if (!(err instanceof Error) || !/: 404 /.test(err.message)) throw err;
}
const entry = Array.isArray(listing) ? listing.find((e) => e?.path === clean) : undefined;
if (entry && Number(entry.size) > MAX_FILE_BYTES) {
throw new Error(`the file is ${Number(entry.size)} bytes, over the ${MAX_FILE_BYTES / 1024} KiB a tool answer can carry; read it from a clone`);
}
const f = await this.request<any>(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`);
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
@@ -847,29 +789,14 @@ export class GiteaClient {
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
/** How long a merge waits before it asks again after the forge's 405 "Please try again later" (novox/hq issue 1275):
* the forge says so while it works out whether the pull request merges, for a few seconds after its base moved. */
static mergeRetryAfter: number[] = [1000, 2000, 4000];
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
headCommit?: string): Promise<void> {
indexOf(index);
for (let attempt = 0; ; attempt++) {
try {
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
...(headCommit ? { head_commit_id: headCommit } : {}) }),
});
return;
} catch (err) {
// Only the forge's transient answer is asked again, briefly; every other refusal, and this one past the last
// wait, is the caller's to say. head_commit_id keeps a head that moved in between from landing.
const wait = GiteaClient.mergeRetryAfter[attempt];
if (wait === undefined || !tryAgainLater(err)) throw err;
await new Promise((r) => setTimeout(r, wait));
}
}
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
...(headCommit ? { head_commit_id: headCommit } : {}) }),
});
}
// ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ----
@@ -885,8 +812,6 @@ export class GiteaClient {
html_url: r.html_url,
default_branch: r.default_branch,
updated_at: r.updated_at ?? undefined,
...(r.permissions && typeof r.permissions === "object"
? { writes: r.permissions.push === true || r.permissions.admin === true } : {}),
};
}
@@ -917,7 +842,6 @@ export class GiteaClient {
updated_at: p.updated_at ?? undefined,
html_url: p.html_url,
body: typeof p.body === "string" ? p.body : undefined,
mergeable: typeof p.mergeable === "boolean" && !p.merged ? p.mergeable : undefined,
};
}
}
@@ -929,25 +853,6 @@ interface AdminResponse {
readonly body: any;
}
/** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */
export interface ForgeRepository {
full_name: string;
owner: string;
name: string;
private: boolean;
archived: boolean;
/** The owner's own visibility: public, limited or private. A public repository of an owner not public is hidden
* from whoever is no member. */
owner_visibility: string;
}
function forgeRepository(r: any): ForgeRepository {
const owner = String(r?.owner?.login ?? r?.owner?.username ?? "");
const name = String(r?.name ?? "");
return { full_name: String(r?.full_name ?? `${owner}/${name}`), owner, name, private: r?.private === true,
archived: r?.archived === true, owner_visibility: String(r?.owner?.visibility ?? "public") };
}
/**
* The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only
* by the provisioner (novox/hq ADR 0048/0076).
@@ -991,14 +896,14 @@ export class GiteaAdmin {
* throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an
* unexpected status becomes an error, and only where the caller says so. */
private async request(path: string, options: RequestInit = {}): Promise<AdminResponse> {
const res = await timedFetch(`${this.baseUrl}/api/v1${path}`, {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authorization,
...(options.headers as Record<string, string> | undefined),
},
}, `${options.method ?? "GET"} ${path}`, FORGE_REQUEST_TIMEOUT_MS);
});
const text = await res.text();
let body: any = null;
if (text) {
@@ -1065,105 +970,6 @@ export class GiteaAdmin {
return repos;
}
// ---- The tools' account reads every repository (readall.ts) ----
/** Every repository on the forge, of every user and organisation: the search as the site administrator, whom the
* forge answers every repository, private ones and those of private owners included. Only a read. */
async everyRepository(): Promise<ForgeRepository[]> {
const all: ForgeRepository[] = [];
for (let page = 1; page <= 200; page++) {
const res = await this.request(`/repos/search?limit=50&page=${page}`);
if (res.status !== 200) GiteaAdmin.fail("/repos/search", res);
const list: any[] = Array.isArray(res.body?.data) ? res.body.data : [];
all.push(...list.map(forgeRepository));
if (list.length < 50) break;
}
return all;
}
/** One repository as the site administrator sees it, or null when the forge holds none by that name. */
async repository(owner: string, repo: string): Promise<ForgeRepository | null> {
const res = await this.request(repoPath(owner, repo));
if (res.status === 404) return null;
if (res.status !== 200) GiteaAdmin.fail(repoPath(owner, repo), res);
return forgeRepository(res.body);
}
/** Whether the forge holds an account of this name. */
async accountExists(user: string): Promise<boolean> {
const path = `/users/${encodeURIComponent(user)}`;
const res = await this.request(path);
if (res.status === 404) return false;
if (res.status !== 200) GiteaAdmin.fail(path, res);
return true;
}
/** What the forge gives an account on a repository, effective: none, read, write, admin or owner. It is the repository as
* that account sees it (the site administrator acts as the account with `sudo`), so access through an organisation's team
* counts. The collaborator endpoint is not used: it names only a collaborator's own permission, and says "read" of an
* account that writes through a team (novox/hq issue 1945). */
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
const path = `${repoPath(owner, repo)}?sudo=${encodeURIComponent(user)}`;
const res = await this.request(path);
if (res.status === 404) return "none";
if (res.status !== 200) GiteaAdmin.fail(path, res);
const p = res.body?.permissions;
if (p?.admin === true) return res.body?.owner?.login === user ? "owner" : "admin";
if (p?.push === true) return "write";
return p?.pull === true ? "read" : "none";
}
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
* it, which on a repository of another user only the site administrator is. Idempotent: the forge answers 204 again. */
async addReadCollaborator(owner: string, repo: string, user: string): Promise<void> {
await this.addCollaborator(owner, repo, user, "read");
}
/** Make an account a collaborator of a repository that writes: pushes a branch, sets a commit status, merges (writes.ts:
* only on a repository that builds a registered module). An existing read collaborator is changed to write. */
async addWriteCollaborator(owner: string, repo: string, user: string): Promise<void> {
await this.addCollaborator(owner, repo, user, "write");
}
/** The rule that applies to main in a repository, as its administrator reads it: a rule named main, else the first glob
* rule covering main; null when none does. With the administrator's login. */
async mainRule(owner: string, repo: string): Promise<BranchProtection | null> {
const rules = await this.branchProtections(owner, repo);
return rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH)
?? rules.find((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)) ?? null;
}
/** Put an account on main's merge whitelist in a repository of a user, and nothing else (the operator, 2026-10-26:
* write, the rule untouched). Only an existing rule whose merge whitelist is on is changed, and only its whitelist: the
* account is added to the logins already there. Push, status checks, approvals and the administrator override stay as
* the operator set them; with no rule, or the whitelist off, nothing is written (whoever writes may merge). Read back:
* the forge drops a whitelist login that cannot write the repository and still answers success, so a whitelist read back
* without the account fails loud. Answers the rule as the forge holds it after. With the administrator's login. */
async ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }> {
const rule = await this.mainRule(owner, repo);
const listed = rule?.merge_whitelist_usernames ?? [];
if (!rule || rule.enable_merge_whitelist !== true || listed.includes(account)) return { changed: false, rule };
const name = String(rule.rule_name ?? rule.branch_name);
const path = `${repoPath(owner, repo)}/branch_protections/${encodeURIComponent(name)}`;
const res = await this.request(path, { method: "PATCH", body: JSON.stringify({ merge_whitelist_usernames: [...listed, account] }) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === name);
if (!after) throw new Error(`main's rule ${name} of ${owner}/${repo} was written, but the forge does not list it`);
const held = after.merge_whitelist_usernames ?? [];
if (after.enable_merge_whitelist === true && !held.includes(account)) {
throw new Error(`${account} was added to the merge whitelist of main's rule ${name} of ${owner}/${repo}, but the forge ` +
`holds [${held.join(", ")}]: it drops a login that cannot write the repository`);
}
return { changed: true, rule: after };
}
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
if (res.status === 204 || res.status === 200) return;
GiteaAdmin.fail(path, res);
}
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
* create won the race) are all success. */
async ensureOrg(name: string): Promise<void> {
@@ -1282,9 +1088,9 @@ export class GiteaAdmin {
* other answer rejects (novox/hq issue 120).
*/
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
const me = await timedFetch(`${this.baseUrl}/api/v1/user`, {
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
}, "GET /user", FORGE_REQUEST_TIMEOUT_MS);
});
if (me.status === 401 || me.status === 403) return false;
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
@@ -1354,10 +1160,3 @@ export function directoriesAbove(paths: string[]): string[] {
function encodePath(path: string): string {
return pathSegments("file path", path);
}
/** Whether an error is the forge's 405 "Please try again later" on a merge: its answer while the pull request's
* mergeability is being worked out (or it is in conflict, which a later ask says the same way). */
export function tryAgainLater(err: unknown): boolean {
const words = err instanceof Error ? err.message : String(err);
return / 405 /.test(words) && /try again later/i.test(words);
}
+3 -50
View File
@@ -58,11 +58,8 @@ import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGa
import { GateWriter } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindInBackground, bindUntilHeld } from "./bind.js";
import { bindInBackground } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
import { ReadAll, readAllTools } from "./readall.js";
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -503,21 +500,8 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// **The mesh's accounts read every repository** (readall.ts; the operator, 2026-10-26): the tools', the build modules'
// and the machines' agents' (meshAccounts, tools-account.ts), granted with the site
// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on
// first use, so a module whose admin login is missing still serves the rest and the tools say why.
let readAllOnce: ReadAll | null = null;
const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)));
// **The merging account writes every repository that builds a registered module** (writes.ts; the operator,
// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll.
let writesOnce: DeliveredWrites | null = null;
const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(),
process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body)));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...(gitea ? [commitStatusTool({
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gates,
}), mergeTool(gitea, { gates })] : [])]);
@@ -559,12 +543,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
refused: (reason, from) => pace.refused(reason, from),
publish: (event) => emit(event.name, event.body),
act: async (event) => {
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
await actOn(client, event);
} });
act: (event) => actOn(client, event) });
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
@@ -615,32 +594,6 @@ if (gitea) {
// 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);
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
// their first consumer asks, and the forge sends no event for a user made) is looked for again ten minutes apart
// until every account was granted once; the line of each pass names it.
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).then((r) => {
if (r.absent.length) setTimeout(readAllAtStart, 600_000);
}, (err) => {
console.error(`[gitea] whether the mesh's accounts read every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`);
setTimeout(readAllAtStart, 60_000);
});
try {
readAll();
readAllAtStart();
} catch (err) {
console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`);
}
// The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a
// repository that builds a registered module is made writable at the start and when its first module is registered
// (writes.ts). A read not granted yet is said and asked again until it holds.
try {
const w = writes();
void bindUntilHeld(INDEXER_MODULES, () => state<unknown>(INDEXER_MODULES).watch((change) => w.take(change as ModulesChange)));
} catch (err) {
console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`);
}
// 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,10 +1,6 @@
package forgeaccount
import (
"os"
"regexp"
"testing"
)
import "testing"
func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
if got := ForgeUser("g14", "machine"); got != "nox-agent" {
@@ -19,18 +15,6 @@ func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
if got := NodeAccount("g14"); got != "agent-g14" {
t.Fatalf("the token tool's per-node account = %q", got)
}
// The accounts that read every repository (readall.ts, meshAccounts in tools-account.ts) name these two users: the
// list is held to the Go constants here, so the two cannot drift apart (the operator, 2026-10-26).
ts, err := os.ReadFile("../../tools-account.ts")
if err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"BUILDER_ACCOUNT": BuilderUser, "AGENT_ACCOUNT": AgentUser} {
m := regexp.MustCompile(`export const ` + name + ` = "([^"]+)";`).FindSubmatch(ts)
if m == nil || string(m[1]) != want {
t.Errorf("tools-account.ts %s = %q, want %q (internal/forgeaccount)", name, m, want)
}
}
}
func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) {
-13
View File
@@ -15,19 +15,6 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean {
} catch { return false; }
}
/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses
* us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false
* is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an
* older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */
export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean {
const file = env.MESH_GITEA_CONFIG_FILE;
if (!file) return true;
try {
const v = (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)["check-gates-merge"];
return !(v === false || v === "false");
} catch { return true; }
}
/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
let said = false;
+2 -8
View File
@@ -19,7 +19,6 @@ import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { checkGatesMerge } from "./mainrules.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
@@ -32,13 +31,11 @@ export interface MergeDeps {
emit?: (type: string, body: unknown) => Promise<void>;
stateDir?: string;
now?: () => Date;
/** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */
checkGates?: () => boolean;
}
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) };
now: given.now ?? (() => new Date()) };
return {
name: "gitea_merge_pull_request",
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
@@ -129,10 +126,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
}
const kept = maintained(required);
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
// **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's
// Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without
// approved_heads the check still judges.
if (byCheck && (deps.checkGates() || !approved)) {
if (byCheck) {
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
const check = checks[CHECK_STATUS];
+2 -11
View File
@@ -40,7 +40,6 @@
],
"emits": [
"repo.created",
"repo.writable",
"issue.opened",
"pull.merged",
"pull.updated",
@@ -182,12 +181,6 @@
"trusted": true,
"default": {}
},
"check-gates-merge": {
"type": "boolean",
"description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.",
"trusted": true,
"default": true
},
"main-mergers": {
"type": "list",
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
@@ -323,8 +316,7 @@
"takes-config": [
"url",
"keep-main-rules",
"main-mergers",
"check-gates-merge"
"main-mergers"
]
},
{
@@ -568,7 +560,6 @@
"mesh-build-go.keys",
"mesh-build-node.keys",
"mesh-build-image.keys",
"mesh-delivery-checker.owed",
"mesh-delivery-indexer.modules"
"mesh-delivery-checker.owed"
]
}
-263
View File
@@ -1,263 +0,0 @@
// The mesh's own forge accounts read every repository on the forge (the operator, 2026-10-26: "the mesh's forge account
// should get read access to all repositories"; and "why wouldn't the forge account get access to all repo's?").
//
// **Every account the mesh's parts use, from one list** (MESH_ACCOUNTS, tools-account.ts): the tools' `mesh-tools`, the
// build modules' `nox-builder` (their fetch over ssh, ADR 0466 §3) and the machines' `nox-agent` (ssh-client's key,
// ADR 0410 §1). The first pass granted the tools' account alone, so the build modules' fetch of jschoubben/car-hunter
// still failed on ace and g14 ("nox-builder ... is not authorized to read"). The two Go names are the ones of
// internal/forgeaccount, whose test holds the list to them. An account the forge does not hold yet (its consumer never
// asked) is said and left out, never a refusal per repository.
//
// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every
// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on
// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership.
//
// - **at the start**, one pass over every repository of every user and organisation;
// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at
// once, and the first event of any repository this process has not yet seen readable, since the forge may not
// send a repository's creation for every owner. Nothing polls for it.
//
// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public
// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at
// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile.
//
// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration,
// which on another user's repository only the site administrator holds; the module holds that login for setting the
// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read
// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools'
// own calls, which keep their token.
//
// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator
// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and
// `gitea_grant_read` grants again now.
import type { ForgeRepository } from "./client.js";
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
export interface ReadAllForge {
everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
/** Whether the forge holds the account at all. */
accountExists(user: string): Promise<boolean>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
}
/** A repository an account cannot read, and why. */
export interface Unreadable {
account: string;
repository: string;
permission: string;
private: boolean;
archived: boolean;
why: string;
}
export interface Granted {
/** Repositories looked at (for each account). */
checked: number;
/** The accounts looked at: those the forge holds. */
accounts: string[];
/** Accounts of the list the forge does not hold yet: nothing is granted to them until it does. */
absent: string[];
/** Made readable now, by a read collaborator: each repository and the account. */
granted: { account: string; repository: string }[];
/** The forge refused the grant: each repository, the account and the forge's own words. */
refused: { account: string; repository: string; why: string }[];
}
const READS = new Set(["read", "write", "admin", "owner"]);
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
/** Why the account cannot read a repository, from what the forge says of it. */
export function whyUnreadable(repo: ForgeRepository, account: string): string {
const parts: string[] = [];
if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`);
else if (repo.owner_visibility !== "public") {
parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`);
} else parts.push(`the forge answers ${account} no read on it`);
if (repo.archived) parts.push("archived");
return parts.join("; ");
}
export class ReadAll {
/** Per account, the repositories this process has seen it read, by full name: an event of a repository every account
* was seen reading asks nothing more. */
readonly readable = new Map<string, Set<string>>();
readonly accounts: readonly string[];
constructor(private readonly forge: ReadAllForge, accounts: readonly string[] | string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {
this.accounts = [...new Set(typeof accounts === "string" ? [accounts] : accounts)];
if (!this.accounts.length) throw new Error("no forge account to give read: the list is empty");
for (const a of this.accounts) this.readable.set(a, new Set());
}
/** The accounts of the list (or of those named) the forge holds, and those it does not hold yet. */
private async present(only?: string[]): Promise<{ accounts: string[]; absent: string[] }> {
const wanted = only && only.length ? only : [...this.accounts];
for (const a of wanted) if (!this.accounts.includes(a)) throw new Error(`"${a}" is not one of the mesh's accounts: ${this.accounts.join(", ")}`);
const accounts: string[] = [];
const absent: string[] = [];
for (const a of wanted) ((await this.forge.accountExists(a)) ? accounts : absent).push(a);
return { accounts, absent };
}
/** Every repository each account cannot read, and why, as the forge says it now. */
async unreadable(only?: string[]): Promise<{ checked: number; accounts: { account: string; unreadable: number }[]; absent: string[]; unreadable: Unreadable[] }> {
const { accounts, absent } = await this.present(only);
const repos = await this.forge.everyRepository();
const out: Unreadable[] = [];
for (const account of accounts) {
for (const repo of repos) {
try {
const u = await this.judge(repo, account);
if (u) out.push(u);
} catch (err) {
// A permission the forge would not answer is said for that repository; the rest are still looked at.
out.push({ account, repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
why: `the forge did not answer ${account}'s permission on it — ${said(err)}` });
}
}
}
return { checked: repos.length, accounts: accounts.map((account) => ({ account, unreadable: out.filter((u) => u.account === account).length })),
absent, unreadable: out };
}
/** Grant read on every repository an account cannot read (or on those named, `owner/name`), for every account of the
* list the forge holds (or for those named). */
async grant(only?: string[], accountsOnly?: string[]): Promise<Granted> {
const { accounts, absent } = await this.present(accountsOnly);
let repos: ForgeRepository[];
if (only && only.length) {
repos = [];
for (const full of only) {
const [owner, name, ...rest] = full.split("/");
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
const repo = await this.forge.repository(owner, name);
if (!repo) throw new Error(`the forge holds no repository ${full}`);
repos.push(repo);
}
} else repos = await this.forge.everyRepository();
const result: Granted = { checked: repos.length, accounts, absent, granted: [], refused: [] };
for (const account of accounts) {
for (const repo of repos) {
const repository = repo.full_name;
let u: Unreadable | null;
try {
u = await this.judge(repo, account);
} catch (err) {
// One repository whose permission cannot be read is a refusal of its own, never the end of the pass.
result.refused.push({ account, repository, why: `its permission could not be read — ${said(err)}` });
continue;
}
if (!u) continue;
try {
await this.forge.addReadCollaborator(repo.owner, repo.name, account);
} catch (err) {
result.refused.push({ account, repository, why: said(err) });
continue;
}
// Read back: the forge's own word that the account reads it now, never the grant's answer alone.
let after: string;
try {
after = await this.forge.repositoryPermission(repo.owner, repo.name, account);
} catch (err) {
result.refused.push({ account, repository, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (READS.has(after)) {
this.readable.get(account)!.add(repository);
result.granted.push({ account, repository });
} else {
result.refused.push({ account, repository, why: `the grant was answered, but the forge still gives ${account} "${after}"` });
}
}
}
return result;
}
/** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */
async atStart(): Promise<Granted> {
const r = await this.grant();
const per = r.accounts.map((a) => {
const g = r.granted.filter((x) => x.account === a).map((x) => x.repository);
return `${a} ${g.length} granted read now${g.length ? ` (${g.join(", ")})` : ""}`;
});
this.log(`the mesh's accounts read every repository: ${r.checked} looked at; ${per.join("; ")}` +
`${r.absent.length ? `; not on the forge yet, so not granted: ${r.absent.join(", ")}` : ""}` +
`${r.refused.length ? `; ${r.refused.length} refused: ` + r.refused.map((x) => `${x.account} on ${x.repository} — ${x.why}`).join("; ") : ""}`);
return r;
}
/** A forge event: a repository made (or forked), or the first event of one some account was not seen reading, is
* granted read now to every account that cannot read it. */
async onEvent(event: { name: string; body: unknown }): Promise<void> {
const b = (event.body ?? {}) as { owner?: string; repo?: string };
if (!b.owner || !b.repo) return;
const full = `${b.owner}/${b.repo}`;
if (event.name === "forge.repository.deleted") {
for (const seen of this.readable.values()) seen.delete(full);
return;
}
const made = event.name === "forge.repository.created" || event.name === "forge.fork";
// Seen readable once by every account, its events ask nothing: so a repository made private after that is not
// caught here (the forge says no change of visibility), only at the next start or by gitea_grant_read.
if (!made && [...this.readable.values()].every((seen) => seen.has(full))) return;
const r = await this.grant([full]);
for (const x of r.granted) this.log(`${x.account} was given read on ${full}, on the forge's ${event.name}`);
for (const x of r.refused) this.log(`${x.account} could not be given read on ${x.repository} — ${x.why}`);
}
private async judge(repo: ForgeRepository, account: string): Promise<Unreadable | null> {
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, account);
const seen = this.readable.get(account)!;
if (READS.has(permission)) {
seen.add(repo.full_name);
return null;
}
seen.delete(repo.full_name);
return { account, repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, account) };
}
}
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : undefined);
/** The two tools: what each account cannot read and why, and the grant now. */
export function readAllTools(readAll: () => ReadAll): Tool[] {
return [
{
name: "gitea_unreadable_repos",
description: "Every repository on the forge one of the mesh's own forge accounts cannot read, and why, per account: " +
"the tools' (mesh-tools), the build modules' fetch over ssh (nox-builder) and the machines' agents' (nox-agent). " +
"For each, the permission the forge gives it there, whether the repository is private or archived, and in words " +
"the reason (a private repository of a user with the account no collaborator, an owner not public); a count per " +
"account, and the accounts the forge does not hold yet. Empty when every account reads every repository. Read " +
"with the site administrator's login, which alone sees every repository.",
input: {
accounts: { type: "array", items: { type: "string" }, description: "the accounts to look at; empty or absent, every one of the mesh's" },
},
run: async (args) => readAll().unreadable(strings(args.accounts)),
},
{
name: "gitea_grant_read",
description: "Give each of the mesh's own forge accounts (mesh-tools, nox-builder, nox-agent) read on every " +
"repository it cannot read now, or on the repositories named, or for the accounts named: each becomes a " +
"collaborator that reads only, the least the forge offers. The module does this itself at its start and on the " +
"forge's event of a repository made; this is the same act by hand, such as when a build's fetch says its account " +
"is not authorized to read a repository. Answers how many were looked at, which were granted to which account " +
"(each read back from the forge), which the forge refused, in its own words, and the accounts it does not hold " +
"yet. Needs the site administrator's login, used for this grant alone.",
input: {
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" },
accounts: { type: "array", items: { type: "string" }, description: "the accounts to grant; empty or absent, every one of the mesh's" },
},
run: async (args) => readAll().grant(strings(args.repositories), strings(args.accounts)),
},
];
}
+1 -15
View File
@@ -183,15 +183,6 @@ test("named paths: each asked path's git object id at the commit, a file's or a
});
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
// Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none.
const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]);
assert.deepEqual(listed.listed, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": {},
"": { "README.md": h("1"), "module.json": h("2") },
placement: {},
});
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed");
} finally {
server.close();
}
@@ -218,12 +209,7 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments");
const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any);
assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } });
for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad));
}
assert.ok("paths" in tool.input, "the tool names the argument");
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length;
-13
View File
@@ -525,18 +525,6 @@ test("gitea_merge_pull_request judges mesh/check on a main requiring no status,
state.statuses = line(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
// check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says;
// without approved_heads it is refused as before.
const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false });
const mergeUngated = (args: Record<string, unknown> = { approved_heads }) =>
ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) {
state.merged = false;
state.statuses = statuses;
await assert.rejects(mergeUngated({}), /Approve/);
assert.equal(((await mergeUngated()) as Record<string, unknown>).merged, true, JSON.stringify(statuses));
}
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
@@ -640,7 +628,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" });
await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined);
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
"GET /repos/novox/mesh-catalog/contents/modules/gitea", // the file's size first (issue 1577)
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
});
-68
View File
@@ -1,68 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
// **The forge's 405 "Please try again later" on a merge is asked again briefly** (novox/hq issue 1275, 2026-10-26 13:40):
// mesh-delivery#73's merge was answered so a few seconds after its base moved, and counted as refused at once. The forge
// says it while it works out whether the pull request merges; the merge is asked again after a short wait, and every other
// refusal is answered at once. A pull request's own mergeable is carried, so a group can be read whole before it merges.
/** A forge whose merge of novox/hq#7 answers each of `answers` in turn (the last for ever), and whose pull request says
* `mergeable`. */
async function serve(answers: { status: number; body: string }[], mergeable?: boolean) {
const { GiteaClient } = await import("../client.ts");
let merges = 0;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "", "http://x");
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7/merge" && req.method === "POST") {
const a = answers[Math.min(merges++, answers.length - 1)];
res.statusCode = a.status;
return res.end(a.body);
}
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7") {
return res.end(JSON.stringify({ number: 7, state: "open", merged: false, base: { ref: "main" },
head: { ref: "b", sha: "a".repeat(40) }, html_url: "h/7", ...(mergeable === undefined ? {} : { mergeable }) }));
}
res.statusCode = 404;
res.end("{}");
});
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
GiteaClient.mergeRetryAfter = [1, 1, 1];
return { client, server, merges: () => merges };
}
const later = { status: 405, body: '{"message":"Please try again later"}' };
const ok = { status: 200, body: "" };
test("a merge answered 'try again later' while the forge works out its mergeability, then merged", async (t) => {
const cases: { name: string; answers: { status: number; body: string }[]; merged: boolean; asked: number }[] = [
{ name: "merged on the third ask", answers: [later, later, ok], merged: true, asked: 3 },
{ name: "still 'later' after the last wait: refused, with the forge's words", answers: [later], merged: false, asked: 4 },
{ name: "a refusal of another kind is answered at once", answers: [{ status: 409, body: '{"message":"head moved"}' }], merged: false, asked: 1 },
];
for (const c of cases) {
await t.test(c.name, async () => {
const { client, server, merges } = await serve(c.answers);
try {
const done = client.mergePullRequest("novox", "hq", 7, "merge", false, "a".repeat(40));
if (c.merged) await done;
else await assert.rejects(done, (err: Error) => /40[59]/.test(err.message));
assert.equal(merges(), c.asked);
} finally {
server.close();
}
});
}
});
test("a pull request carries the forge's mergeable, and none when the forge did not say", async () => {
for (const mergeable of [true, false, undefined]) {
const { client, server } = await serve([ok], mergeable);
try {
assert.equal((await client.getPullRequest("novox", "hq", 7)).mergeable, mergeable);
} finally {
server.close();
}
}
});
-45
View File
@@ -90,48 +90,3 @@ test("a pass pages until it reaches back past its floor, and no further", async
server.close();
}
});
// **One forge request never holds the others, and a file too big for an answer is not downloaded** (novox/hq issue 1577): a
// request for a 4.9 MB file never ended and every read through the module timed out behind it.
test("a request the forge never answers is cut at the limit and says which request", async () => {
const { GiteaClient } = await import("../client.ts");
const server = createServer((req) => { if (!req.url?.includes("fast")) return; });
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
client.requestTimeoutMs = 150;
try {
const started = Date.now();
await assert.rejects(client.api("/repos/novox/hq/pulls"), /Gitea API GET \/repos\/novox\/hq\/pulls: no answer in 0\.15 s/);
assert.ok(Date.now() - started < 3000);
} finally {
server.closeAllConnections();
await new Promise<void>((r) => server.close(() => r()));
}
});
test("a file over 512 KiB is refused from its directory listing, and its body is never asked for", async () => {
const { GiteaClient } = await import("../client.ts");
const asked: string[] = [];
const server = createServer((req, res) => {
asked.push(req.url ?? "");
res.setHeader("Content-Type", "application/json");
if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin?")) {
return res.end(JSON.stringify([{ path: "bin/big", type: "file", size: 5_000_000 }, { path: "bin/small", type: "file", size: 5 }]));
}
if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin/small")) {
return res.end(JSON.stringify({ type: "file", encoding: "base64", sha: "s", size: 5, content: Buffer.from("hello").toString("base64") }));
}
res.statusCode = 404;
res.end("{}");
});
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
try {
await assert.rejects(client.getFile("novox", "hq", "bin/big", "main"),
/the file is 5000000 bytes, over the 512 KiB a tool answer can carry; read it from a clone/);
assert.ok(!asked.some((u) => u.includes("/contents/bin/big")), asked.join("\n"));
assert.equal((await client.getFile("novox", "hq", "bin/small", "main")).content, "hello");
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
+3 -293
View File
@@ -16,7 +16,7 @@ import { join } from "node:path";
import { MintedToken } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin";
@@ -26,19 +26,6 @@ interface Account { admin: boolean; password: string; active: boolean }
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
interface Team { id: number; name: string; units: any; members: Set<string> }
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
const forgeRepos: ForgeRepo[] = [];
const userRules = new Map<string, any>();
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
const [owner, name] = full.split("/");
const r = { owner, name, full, private: isPrivate, ownerVisibility, collaborators: new Map<string, string>() };
forgeRepos.push(r);
return r;
}
const repoJSON = (r: ForgeRepo) => ({ full_name: r.full, name: r.name, owner: { login: r.owner, visibility: r.ownerVisibility },
private: r.private, archived: r.archived === true });
function fakeForge() {
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
const tokens: Token[] = [];
@@ -74,18 +61,11 @@ function fakeForge() {
return null;
};
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
const permissionOf = (u: string | null, r: ForgeRepo): string => {
if (isAdmin(u)) return "admin";
if (u === r.owner) return "owner";
if (r.owner === "novox" && teams.some((t) => t.members.has(u ?? "") && t.units.units_map?.["repo.code"] === "write")) return "write";
if (u && r.collaborators.has(u)) return r.collaborators.get(u)!;
return !r.private && r.ownerVisibility === "public" ? "read" : "none";
};
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const p = url.pathname.replace(/^\/api\/v1/, "");
const me = url.searchParams.has("sudo") && isAdmin(who(req)) ? url.searchParams.get("sudo") : who(req);
const me = who(req);
calls.push(`${req.method} ${p} as ${me}`);
if (me === null) return json(res, 401, { message: "unauthorized" });
@@ -169,23 +149,6 @@ function fakeForge() {
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
return json(res, 404, { message: "branch does not exist" });
}
// A repository of a user: its rules are its administrator's (a site administrator, or a collaborator with admin).
m = p.match(/^\/repos\/(?!novox\/)([^/]+)\/([^/]+)\/branch_protections(?:\/(.*))?$/);
if (m) {
const key = `${m[1]}/${m[2]}`;
const repo = forgeRepos.find((r) => r.full === key);
if (!repo || !["admin", "owner"].includes(permissionOf(me, repo))) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET" && m[3] === undefined) return json(res, 200, userRules.has(key) ? [userRules.get(key)] : []);
// As gitea does: a whitelist login that cannot write the repository is dropped, and the write still answered.
const keep = (b: any) => (Array.isArray(b.merge_whitelist_usernames)
? { ...b, merge_whitelist_usernames: b.merge_whitelist_usernames.filter((u: string) => ["write", "admin", "owner"].includes(permissionOf(u, repo))) } : b);
if (req.method === "PATCH" && m[3] === "main") {
if (!userRules.has(key)) return json(res, 404, { message: "branch protection does not exist" });
Object.assign(userRules.get(key), keep(await read(req)));
return json(res, 200, userRules.get(key));
}
if (req.method === "POST") { const b = keep(await read(req)); userRules.set(key, b); return json(res, 201, b); }
}
// gitea's reqAdmin on the whole group: reading the rules is administration too.
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/);
if (m) {
@@ -213,37 +176,7 @@ function fakeForge() {
if (!writes) return json(res, 403, { message: "write on code required" });
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
}
// Every repository of every owner, and who reads it (readall.ts): the site administrator reads all; the tools' account
// a repository of novox through its team, a public one of a public owner, and one it is a collaborator of.
if (req.method === "GET" && p === "/repos/search") {
const page = Number(url.searchParams.get("page") ?? "1");
const seen = forgeRepos.filter((r) => permissionOf(me, r) !== "none");
return json(res, 200, { data: page === 1 ? seen.map(repoJSON) : [] });
}
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
if (m) {
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
if (repo && req.method === "GET" && !m[3] && url.searchParams.has("sudo") && repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
if (req.method === "GET" && !m[3]) {
const mine = permissionOf(me, repo);
return json(res, 200, { ...repoJSON(repo), permissions: { pull: true, push: ["write", "admin", "owner"].includes(mine), admin: ["admin", "owner"].includes(mine) } });
}
const user = decodeURIComponent(m[3] ?? "");
if (req.method === "GET" && m[4]) {
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
// As gitea answers: the collaborator's own permission, not what a team gives the account.
return json(res, 200, { permission: repo.collaborators.get(user) ?? "read" });
}
if (req.method === "PUT" && !m[4]) {
// gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
if (!isAdmin(me) && me !== repo.owner) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (repoFaults.refuse.has(repo.full)) return json(res, 422, { message: "the forge would not have it" });
repo.collaborators.set(user, (await read(req))?.permission ?? "write");
return json(res, 204, null);
}
}
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; mainRules: typeof mainRules;
@@ -469,226 +402,3 @@ test("a mint with keep-main-rules false (the default) writes no branch protectio
const writes = forge.calls.slice(from).filter((c: string) => /branch_protections/.test(c) && !/^GET /.test(c) && !/^(GET|HEAD) /.test(c));
assert.deepEqual(writes, []);
});
// The mesh's accounts read every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and
// jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen; and
// once the tools' account read them, the build modules' fetch as nox-builder still could not.
test("the start's pass gives each of the mesh's accounts read, as a collaborator that reads only, on each repository it could not read, and on no other; an account the forge does not hold is said, not refused", async () => {
const env = await environment();
await fresh(env).listRepos(); // the account and its team exist, as after any mint
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true }); // made by the forge-account provider
forge.users.delete(AGENT_ACCOUNT); // no machine's key asked for it yet
forgeRepos.splice(0);
addRepo("novox/hq", true);
addRepo("jschoubben/txt-game", false);
addRepo("jschoubben/car-hunter", true);
addRepo("jschoubben/snake", true);
addRepo("shy/open", false, "limited");
addRepo("elsewhere/secret", true);
const { GiteaAdmin } = await import("../dist/client.js");
const { ReadAll, readAllTools } = await import("../dist/readall.js");
const lines: string[] = [];
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), meshAccounts(), (l: string) => lines.push(l));
assert.deepEqual(readAll.accounts, [TOOLS_ACCOUNT, BUILDER_ACCOUNT, AGENT_ACCOUNT]);
const [unreadableTool, grantTool] = readAllTools(() => readAll);
// What the tool says before, per account: the four the tools' account cannot read, each with why; the builders'
// account, in no write team here, also not novox/hq; nox-agent not on the forge.
const before = await unreadableTool.run({}) as any;
assert.equal(before.checked, 6);
const of = (account: string) => before.unreadable.filter((u: any) => u.account === account).map((u: any) => u.repository).sort();
assert.deepEqual(of(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
assert.deepEqual(of(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
assert.deepEqual(before.accounts, [{ account: TOOLS_ACCOUNT, unreadable: 4 }, { account: BUILDER_ACCOUNT, unreadable: 5 }]);
assert.deepEqual(before.absent, [AGENT_ACCOUNT]);
assert.match(before.unreadable.find((u: any) => u.account === BUILDER_ACCOUNT && u.repository === "jschoubben/car-hunter").why,
/a private repository of jschoubben, on which nox-builder is no collaborator/);
const builderOnly = await unreadableTool.run({ accounts: [BUILDER_ACCOUNT] }) as any;
assert.deepEqual(builderOnly.accounts, [{ account: BUILDER_ACCOUNT, unreadable: 5 }]);
await assert.rejects(unreadableTool.run({ accounts: ["someone"] }), /not one of the mesh's accounts/);
assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why,
/a private repository of jschoubben, on which mesh-tools is no collaborator/);
assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/);
assert.deepEqual((await fresh(env).listAllRepos()).map((r) => r.full_name).sort(), ["jschoubben/txt-game", "novox/hq"]);
// The account's own token cannot grant itself: the forge asks the repository's administration (the reason for the admin login).
const own = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
const self = await fetch(`${forge.url}/api/v1/repos/jschoubben/car-hunter/collaborators/${TOOLS_ACCOUNT}`,
{ method: "PUT", headers: { Authorization: `token ${own}`, "Content-Type": "application/json" }, body: JSON.stringify({ permission: "read" }) });
assert.equal(self.status, 404, "a repository the account cannot read is not even there for it");
const from = forge.calls.length;
const r = await readAll.atStart();
const grantedTo = (account: string) => r.granted.filter((x: any) => x.account === account).map((x: any) => x.repository).sort();
assert.deepEqual(grantedTo(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
assert.deepEqual(grantedTo(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
assert.deepEqual(r.refused, []);
assert.deepEqual(r.absent, [AGENT_ACCOUNT]);
const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT "));
assert.equal(puts.length, 9, "the repositories each read already were left alone, and the absent account was asked nothing");
assert.ok(!puts.some((c) => c.includes(AGENT_ACCOUNT)));
assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants");
for (const repo of forgeRepos.filter((x) => x.collaborators.size)) {
for (const [account, permission] of repo.collaborators) assert.equal(permission, "read", `${account} on ${repo.full} reads only`);
}
assert.equal(forgeRepos.find((x) => x.full === "jschoubben/car-hunter")!.collaborators.get(BUILDER_ACCOUNT), "read");
assert.match(lines.join("\n"), /6 looked at; mesh-tools 4 granted read now .*; nox-builder 5 granted read now .*; not on the forge yet, so not granted: nox-agent/);
// The account made later is granted by hand (or at the next pass), its rows like any other.
forge.users.set(AGENT_ACCOUNT, { admin: false, password: "unused", active: true });
const agent = await grantTool.run({ accounts: [AGENT_ACCOUNT], repositories: ["jschoubben/car-hunter"] }) as any;
assert.deepEqual(agent.granted, [{ account: AGENT_ACCOUNT, repository: "jschoubben/car-hunter" }]);
assert.deepEqual(agent.absent, []);
forge.users.delete(AGENT_ACCOUNT);
// After: the account's own token sees every repository, and the tool names none.
assert.equal((await fresh(env).listAllRepos()).length, 6);
assert.deepEqual(((await unreadableTool.run({})) as any).unreadable, []);
// Granting again asks nothing of the forge but reads.
const again = forge.calls.length;
assert.deepEqual(((await grantTool.run({})) as any).granted, []);
assert.ok(forge.calls.slice(again).every((c) => c.startsWith("GET ")));
});
test("on the forge's events: a repository made is granted read at once, a known readable one asks nothing, a refusal is said, not thrown", async () => {
const env = await environment();
await fresh(env).listRepos();
forgeRepos.splice(0);
addRepo("jschoubben/txt-game", false);
const { GiteaAdmin } = await import("../dist/client.js");
const { ReadAll, readAllTools } = await import("../dist/readall.js");
const lines: string[] = [];
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true });
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), [TOOLS_ACCOUNT, BUILDER_ACCOUNT], (l: string) => lines.push(l));
await readAll.atStart();
const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [
{ name: "a repository made", event: "forge.repository.created", repo: "jschoubben/new-private", make: true, asks: true, reads: "read" },
{ name: "a fork made", event: "forge.fork", repo: "someone/fork", make: true, asks: true, reads: "read" },
{ name: "a push to a repository seen readable", event: "forge.push", repo: "jschoubben/txt-game", asks: false, reads: "read" },
{ name: "a push to a private repository never seen", event: "forge.push", repo: "jschoubben/quiet", make: true, asks: true, reads: "read" },
{ name: "a repository the forge refuses the grant on", event: "forge.repository.created", repo: "jschoubben/stuck", make: true, refuse: true, asks: true, reads: "none" },
{ name: "a repository whose permission the forge will not answer", event: "forge.repository.created", repo: "jschoubben/mute", make: true, unanswered: true, asks: true, reads: "none" },
];
for (const c of cases) {
if (c.make) addRepo(c.repo, true);
if (c.refuse) repoFaults.refuse.add(c.repo);
if (c.unanswered) repoFaults.permission.add(c.repo);
const from = forge.calls.length;
const [owner, repo] = c.repo.split("/");
await readAll.onEvent({ name: c.event, body: { owner, repo } });
assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`);
for (const account of [TOOLS_ACCOUNT, BUILDER_ACCOUNT]) {
assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(account) ?? (c.make ? "none" : "read"), c.reads, `${c.name}: ${account}`);
}
}
assert.match(lines.join("\n"), /mesh-tools could not be given read on jschoubben\/stuck — .*422/);
assert.match(lines.join("\n"), /nox-builder was given read on someone\/fork, on the forge's forge.fork/);
const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any;
assert.deepEqual(named.refused.map((x: any) => `${x.account} ${x.repository}`), [`${TOOLS_ACCOUNT} jschoubben/stuck`, `${BUILDER_ACCOUNT} jschoubben/stuck`]);
await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/);
assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/);
// A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it.
addRepo("jschoubben/after-mute", true);
const pass = await readAll.grant();
assert.ok(pass.granted.some((x: any) => x.repository === "jschoubben/after-mute" && x.account === BUILDER_ACCOUNT), "the pass did not stop at the repository it could not judge");
assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why)));
const listed = await readAllTools(() => readAll)[0].run({}) as any;
assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown");
repoFaults.refuse.clear();
repoFaults.permission.clear();
});
// The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator,
// 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there.
test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
const env = await environment();
const client = fresh(env);
await client.listRepos(); // the account and its team exist, as after any mint
forgeRepos.splice(0);
addRepo("novox/hq", true);
for (const full of ["jschoubben/car-hunter", "jschoubben/snake", "jschoubben/dotfiles"]) addRepo(full, true).collaborators.set(TOOLS_ACCOUNT, "read");
const { GiteaAdmin } = await import("../dist/client.js");
const { DeliveredWrites, writeTools, WRITABLE_EVENT } = await import("../dist/writes.js");
const lines: string[] = [];
const said: { type: string; body: any }[] = [];
const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l),
async (type: string, body: unknown) => { said.push({ type, body }); });
const [unwritableTool, grantTool] = writeTools(() => writes);
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write");
// 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once.
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true });
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true });
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true });
const from = forge.calls.length;
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write", "a user's repository is written, not administered");
assert.equal(userRules.has("jschoubben/car-hunter"), false, "a repository with no rule for main is given none");
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/);
assert.match(lines.join("\n"), /broken names no repository/);
// 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's
// repository no module builds from is left read-only, named with why, and a grant naming it asks the forge nothing.
repoFaults.refuse.add("jschoubben/snake");
await writes.take({ key: "snake", op: "put", value: { v: 1, repository: "jschoubben/snake" } });
assert.match(lines.join("\n"), /could not be given write on jschoubben\/snake, which builds snake — .*422/);
const listed = await unwritableTool.run({}) as any;
assert.deepEqual(listed.unwritable.map((u: any) => [u.repository, u.permission]), [["jschoubben/snake", "read"]]);
assert.match(listed.unwritable[0].why, /builds snake.*gitea_grant_write/);
const all = await grantTool.run({}) as any;
assert.deepEqual(all.delivered, ["jschoubben/car-hunter", "jschoubben/snake", "novox/hq"]);
assert.deepEqual(all.already, ["jschoubben/car-hunter", "novox/hq"]);
assert.deepEqual(all.granted, []);
assert.match(all.refused.find((x: any) => x.repository === "jschoubben/snake").why, /422.*would not have it/);
assert.deepEqual(all.left_read_only.map((x: any) => x.repository), ["jschoubben/dotfiles"]);
assert.match(all.left_read_only[0].why, /builds no registered module/);
const puts = forge.calls.length;
const named = await grantTool.run({ repositories: ["jschoubben/dotfiles"] }) as any;
assert.deepEqual([named.granted, named.left_read_only.map((x: any) => x.repository)], [[], ["jschoubben/dotfiles"]]);
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
repoFaults.refuse.clear();
// The operator's rule is left as set (the operator, 2026-10-26: "Write, rule untouched"): only mesh-tools is added to its
// merge whitelist; a status the mesh does not set and required approvals are said as blocking the mesh's merge, unchanged.
const operators = { rule_name: "main", enable_push: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "ci/external"], required_approvals: 2,
block_admin_merge_override: false, require_signed_commits: true, block_on_outdated_branch: true, block_on_rejected_reviews: true };
userRules.set("jschoubben/snake", structuredClone(operators));
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
assert.deepEqual(after.granted, ["jschoubben/snake"]);
assert.equal(repo("jschoubben/snake").collaborators.get(TOOLS_ACCOUNT), "write");
assert.deepEqual(userRules.get("jschoubben/snake"), { ...operators, merge_whitelist_usernames: ["jschoubben", TOOLS_ACCOUNT] }, "only the whitelist changed");
assert.deepEqual(after.merge_whitelist, [{ repository: "jschoubben/snake", changed: true, listed: ["jschoubben", TOOLS_ACCOUNT] }]);
const blockedWhy = "writable, but main's rule blocks the mesh's merge: it requires the status ci/external, which the mesh does not set; " +
"it requires 2 approving reviews on the forge, which the gate does not give; it requires signed commits, which the mesh does not make; " +
"it refuses a branch behind main, which the mesh does not bring up to date; " +
"it refuses a merge after a review asked for changes, which the gate does not clear";
assert.deepEqual(after.merge_blocked, [{ repository: "jschoubben/snake", why: blockedWhy }]);
const again = await unwritableTool.run({}) as any;
assert.deepEqual([again.unwritable, again.merge_blocked], [[], [{ repository: "jschoubben/snake", why: blockedWhy }]]);
const writesBefore = forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length;
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_whitelist[0].changed, false);
assert.equal(forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length, writesBefore, "a listed account is not written again");
// The mesh's own statuses never block: a rule requiring only them, with mesh-tools listed, is not reported.
userRules.set("jschoubben/snake", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT],
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "mesh/repo-check"] });
assert.deepEqual(((await unwritableTool.run({})) as any).merge_blocked, []);
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_blocked, []);
// The forge drops a whitelist login that cannot write and still answers 200: the whitelist is read back and that fails loud.
userRules.set("jschoubben/dotfiles", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"] });
await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT),
/mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/);
userRules.delete("jschoubben/dotfiles");
// A module gone from the indexer leaves its repository out of the delivered list.
await writes.take({ key: "snake", op: "delete" });
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
});
-3
View File
@@ -34,9 +34,6 @@ test("both statuses say the head and the trunk commit the verdict judged, first,
const [old] = statusesFor({ ...c, trunk: undefined, gate: { verdict: "pass", summary: "every machine composes" } });
assert.equal(old.description, "pass: every machine composes");
assert.equal(trunkNamed(old.description), undefined);
// ADR 0548 §8: a carried verdict's line names the head on the new trunk first, so the merge tool merges it on that trunk.
assert.equal(trunkNamed(`pass ${HEAD.slice(0, 12)} on ${MOVED.slice(0, 12)} | carried from ${TRUNK.slice(0, 12)}, mesh-catalog 0123456789ab | unit 0 run, 4 kept`),
MOVED.slice(0, 12));
});
test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => {
-9
View File
@@ -22,15 +22,6 @@
import { keepMainRules } from "./mainrules.js";
export const TOOLS_ACCOUNT = "mesh-tools";
/** The build modules' account, whose keys fetch over ssh (internal/forgeaccount BuilderUser, ADR 0466 §3). */
export const BUILDER_ACCOUNT = "nox-builder";
/** The machines' agents' account, whose keys ssh-client reports (internal/forgeaccount AgentUser, ADR 0410 §1). */
export const AGENT_ACCOUNT = "nox-agent";
/** Every forge account the mesh's own parts use: each reads every repository on the forge (readall.ts; the operator,
* 2026-10-26). internal/forgeaccount's test holds the two Go names to its own constants. */
export function meshAccounts(tools: string = TOOLS_ACCOUNT): string[] {
return [tools, BUILDER_ACCOUNT, AGENT_ACCOUNT];
}
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
export const TOOLS_ORG = "novox";
export const TOOLS_TEAM = "mesh-tools";
+7 -30
View File
@@ -76,17 +76,6 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
return out;
}
/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
* the empty name); anything else is refused before the forge is asked. Absent is absent. */
function listedDirs(raw: unknown): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
for (const d of raw as string[]) {
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
}
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
}
@@ -385,7 +374,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
// ---- Contents and branches ----
{
name: "gitea_get_file",
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch). Refuses a file over 512 KiB, which a tool answer cannot carry: read it from a clone.",
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -503,7 +492,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_statuses",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -519,12 +508,11 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string; mergeable?: boolean } | undefined;
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
if (number > 0) {
const p = await gitea.getPullRequest(owner, repo, number);
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url,
...(p.mergeable === undefined ? {} : { mergeable: p.mergeable }) };
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
if (!ref) ref = p.head_sha ?? "";
if (!base) base = p.base ?? "";
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
@@ -539,18 +527,8 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
} catch (err) {
unreadable = err instanceof Error ? err.message : String(err);
}
// Whether this account may write the repository at all (issue 1945): a commit status and a merge need it, and a
// repository of a user it only reads refuses both 403. Not said when the forge would not answer.
let writes: boolean | undefined;
try {
writes = (await gitea.getRepo(owner, repo)).writes;
} catch {
writes = undefined;
}
const merge = mergeableUnder(base, rule, statuses, unreadable);
return { commit: combined.sha || ref, pull, combined: combined.state, statuses,
merge: writes === undefined ? merge : { ...merge, writes,
...(writes ? {} : { cannot_write: `the forge account cannot write ${owner}/${repo}: gitea_grant_write` }) } };
merge: mergeableUnder(base, rule, statuses, unreadable) };
},
},
{
@@ -561,20 +539,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed));
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
},
},
{
-321
View File
@@ -1,321 +0,0 @@
// The account that sets commit statuses and merges writes every repository that builds a registered module, and no other
// (the operator, 2026-10-26: "yes the mesh should get write access", on the repositories the mesh delivers; novox/hq issue
// 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first
// act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts).
//
// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge
// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines'
// `nox-agent` only fetch, and keep read (readall.ts).
//
// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its
// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads
// that state (its manifest's `reads`) with a watch: the watch hands the latest value of every key when it starts, and then
// every change, so a repository is granted at the module's start and when a module of it is first registered. Nothing
// polls. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
// repository of a user (the operator's dotfiles, say) is left as it is: read only.
//
// **Every grant is read back** from the forge (its permission for the account), and said on the bus as
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
// whose merge the forge would refuse, reads that repository again on it.
//
// **A repository of a user is written, and main's rule is left as the operator set it** (the operator, 2026-10-26, asked
// "Write, rule untouched"): the account is a write collaborator there, and only added to main's merge whitelist when that
// rule keeps one (client.ts ensureMergerListed). What else in main's rule stops the mesh's merge (a status the mesh does not
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
// blocked, with why (merge_blocked).
//
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
import type { ForgeRepository } from "./client.js";
import type { BranchProtection } from "./protection.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */
export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
/** The event said once an account writes a repository it could not write before. */
export const WRITABLE_EVENT = "repo.writable";
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
export interface WriteForge {
everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
mainRule(owner: string, repo: string): Promise<BranchProtection | null>;
ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }>;
}
/** The statuses every pull request the mesh delivers carries (mesh-delivery's checks); a rule requiring another blocks. */
const MESH_CONTEXTS = new Set([CHECK_STATUS, CHECK_CONTEXT, REPO_CHECK_CONTEXT]);
/** What in main's rule stops the mesh's merge, in words; empty when nothing does. Changed by nobody here: the operator's. */
export function mergeBlocks(rule: BranchProtection | null, account: string): string[] {
if (!rule) return [];
const blocks: string[] = [];
if (rule.enable_merge_whitelist === true && !(rule.merge_whitelist_usernames ?? []).includes(account)) {
blocks.push(`its merge whitelist [${(rule.merge_whitelist_usernames ?? []).join(", ")}] leaves out ${account} (gitea_grant_write adds it)`);
}
if (rule.enable_status_check === true) {
for (const c of rule.status_check_contexts ?? []) {
if (!MESH_CONTEXTS.has(c)) blocks.push(`it requires the status ${c}, which the mesh does not set`);
}
}
const approvals = Number(rule.required_approvals ?? 0);
if (approvals > 0) blocks.push(`it requires ${approvals} approving review${approvals === 1 ? "" : "s"} on the forge, which the gate does not give`);
if (rule.require_signed_commits === true) blocks.push("it requires signed commits, which the mesh does not make");
if (rule.block_on_outdated_branch === true) blocks.push("it refuses a branch behind main, which the mesh does not bring up to date");
if (rule.block_on_rejected_reviews === true) blocks.push("it refuses a merge after a review asked for changes, which the gate does not clear");
return blocks;
}
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
export interface ModulesChange {
key: string;
op: "put" | "delete";
value?: unknown;
revision?: number;
current?: boolean;
}
export interface Written {
account: string;
/** The repositories that build a registered module, as the indexer says now. */
delivered: string[];
/** Made writable now, each read back from the forge. */
granted: string[];
/** Written already (a collaborator of a user's repository), or written through the organisation's team. */
already: string[];
/** Each repository of a user: whether the account was added to main's merge whitelist now, and the whitelist read back
* (null: main's rule keeps no whitelist, or there is no rule, and whoever writes may merge). */
merge_whitelist: { repository: string; changed: boolean; listed: string[] | null }[];
/** Writable, but main's rule (the operator's, left as set) stops the mesh's merge, and why. */
merge_blocked: { repository: string; why: string }[];
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
refused: { repository: string; why: string }[];
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
left_read_only: { repository: string; permission: string; why: string }[];
}
const WRITES = new Set(["write", "admin", "owner"]);
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
function split(full: string): [string, string] {
const [owner, name, ...rest] = String(full ?? "").split("/");
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
return [owner, name];
}
export class DeliveredWrites {
/** Module to the repository it builds from, as the indexer's state says. */
readonly modules = new Map<string, string>();
/** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */
heard = false;
/** Repositories seen written by the account in this process: a module registered again asks nothing more. */
readonly writable = new Set<string>();
constructor(private readonly forge: WriteForge, readonly account: string, readonly org: string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`),
private readonly emit: (type: string, body: unknown) => Promise<void> = async () => {}) {}
/** The repositories that build a registered module, sorted. */
delivered(): string[] {
return [...new Set(this.modules.values())].sort();
}
/** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */
async take(change: ModulesChange): Promise<void> {
this.heard = true;
if (change.op === "delete") {
this.modules.delete(change.key);
return;
}
const repository = String((change.value as { repository?: unknown } | undefined)?.repository ?? "").trim();
try {
split(repository);
} catch {
this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
return;
}
this.modules.set(change.key, repository);
if (this.writable.has(repository)) return;
try {
const r = await this.grant([repository]);
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`);
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository}, which builds ${change.key} — ${x.why}`);
} catch (err) {
this.log(`whether ${this.account} writes ${repository} (module ${change.key}) could not be settled — ${said(err)}`);
}
}
/** Grant write on every delivered repository the account cannot write (or on those named, each of which must be
* delivered); with none named, also say which repositories it cannot write are left so, and why. */
async grant(only?: string[]): Promise<Written> {
if (!this.heard) {
throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` +
"(the watch is not granted or not answered); nothing is granted");
}
const delivered = this.delivered();
const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
const targets: string[] = [];
for (const full of named ?? delivered) {
split(full);
if (delivered.includes(full)) targets.push(full);
else result.left_read_only.push({ repository: full, permission: "unchanged",
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
}
for (const full of targets) {
const [owner, name] = split(full);
let permission: string;
try {
if (!(await this.forge.repository(owner, name))) {
result.refused.push({ repository: full, why: "the forge holds no repository by that name" });
continue;
}
permission = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
continue;
}
if (owner === this.org) {
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
"set again at the next mint of the tools' token)" });
continue;
}
// A repository of a user: the account writes it, and is put on main's merge whitelist when the rule keeps one.
let changed = false;
if (!WRITES.has(permission)) {
try {
await this.forge.addWriteCollaborator(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: said(err) });
continue;
}
let after: string;
try {
after = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (!WRITES.has(after)) {
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
continue;
}
changed = true;
}
try {
const { changed: listedNow, rule } = await this.forge.ensureMergerListed(owner, name, this.account);
result.merge_whitelist.push({ repository: full, changed: listedNow,
listed: rule?.enable_merge_whitelist === true ? (rule.merge_whitelist_usernames ?? []) : null });
const blocks = mergeBlocks(rule, this.account);
if (blocks.length > 0) result.merge_blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
result.refused.push({ repository: full, why: `${this.account} writes it, but could not be put on main's merge whitelist — ${said(err)}` });
continue;
}
this.writable.add(full);
if (!changed) {
result.already.push(full);
continue;
}
result.granted.push(full);
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
}
if (!named) {
for (const repo of await this.forge.everyRepository()) {
if (repo.owner === this.org || delivered.includes(repo.full_name)) continue;
let permission: string;
try {
permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
} catch (err) {
permission = `unknown — ${said(err)}`;
}
if (WRITES.has(permission)) continue;
result.left_read_only.push({ repository: repo.full_name, permission,
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
}
}
return result;
}
/** Every delivered repository the account cannot write, as the forge says now, and why. */
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
merge_blocked: { repository: string; why: string }[] }> {
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`);
const delivered = this.delivered();
const out: { repository: string; permission: string; why: string }[] = [];
const blocked: { repository: string; why: string }[] = [];
for (const full of delivered) {
const [owner, name] = split(full);
let permission: string;
try {
permission = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
continue;
}
if (WRITES.has(permission)) {
if (owner === this.org) continue; // the organisation's main rules are the mesh's own (mainrules.ts)
try {
const blocks = mergeBlocks(await this.forge.mainRule(owner, name), this.account);
if (blocks.length > 0) blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
blocked.push({ repository: full, why: `writable, but main's rule could not be read — ${said(err)}` });
}
continue;
}
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort();
out.push({ repository: full, permission, why: `builds ${modules.join(", ")}, and the forge gives ${this.account} ` +
`"${permission}" on it: its commit status and its merge are refused until gitea_grant_write grants it` });
}
return { account: this.account, delivered: delivered.length, unwritable: out, merge_blocked: blocked };
}
}
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
/** The two tools: which delivered repositories the merging account cannot write, and the grant now. */
export function writeTools(writes: () => DeliveredWrites): Tool[] {
return [
{
name: "gitea_unwritable_repos",
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " +
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
"delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
"a user it writes whose main's rule (the operator's, left as set) still stops the mesh's merge: a merge whitelist " +
"without it, a required status the mesh does not set, required approving reviews. Read with the site administrator's login.",
input: {},
run: async () => writes().unwritable(),
},
{
name: "gitea_grant_write",
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository of a " +
"user that builds a registered module, or on the repositories named (each must build one). Main's rule there is left " +
"as the operator set it; only when it keeps a merge whitelist is mesh-tools added to it, read back (merge_whitelist). " +
"What else in that rule stops the mesh's merge (a required status the mesh does not set, required approving reviews) " +
"is changed by nobody and said in merge_blocked. " +
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
"was written already, what the forge refused in its own words, and which other repositories it left read-only " +
"and why (they build no registered module). Needs the site administrator's login, used for this grant alone.",
input: {
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every delivered one" },
},
run: async (args) => writes().grant(Array.isArray(args.repositories) ? args.repositories.map(String) : undefined),
},
];
}
+1 -9
View File
@@ -1,9 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of this package in every module that carries one.
modules/xorg/internal/desktop/*.go
modules/lemurs/internal/desktop/*.go
modules/i3/internal/desktop/*.go
modules/xterm/internal/desktop/*.go
modules/adwaita/internal/desktop/*.go
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,9 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of this package in every module that carries one.
modules/xorg/internal/desktop/*.go
modules/lemurs/internal/desktop/*.go
modules/i3/internal/desktop/*.go
modules/xterm/internal/desktop/*.go
modules/adwaita/internal/desktop/*.go
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,7 +14,6 @@ var Kept = []announce.State{
{Name: "toolchains", Readable: true},
{Name: "keys", Readable: true},
{Name: "steps", Readable: true},
{Name: "pauses", Readable: true},
}
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -44,7 +43,6 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
return delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil
},
Kept: m.KeptLookup,
Has: m.Has,
Release: func(ws string) error { return m.spaces().Release(ws) },
Logf: m.Say,
}
@@ -82,31 +80,19 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
source = buildstep.SeatAsks{Language: delivery.AskGo}
}
capacity := m.Capacity
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
if m.ReadPauses.Get != nil {
if m.ReadSteps.Get != nil {
load = func(key string) ([]byte, error) {
raw, err := m.ReadPauses.Get(key)
raw, err := m.ReadSteps.Get(key)
return raw, err
}
}
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has,
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, Load: load,
Capacity: capacity, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
@@ -125,18 +111,3 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
return buildstep.SeatTools(Seat, "Go", p)
}
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
const StepEndTool = "build_go_step_end"
// endLeftRunning is the module's start sweep (issue 1890): it ends the steps of its node that a previous process left
// running (buildstep.EndStaleAtStart; every step names its owner). main runs it once at the start, and the module's test
// runs it through the conformance kit's left-running case.
func (m *Module) endLeftRunning(ctx context.Context, scan buildstep.Scan, booted time.Time, logf func(string, ...any)) {
buildstep.EndStaleAtStart(ctx, scan, m.Node, booted, logf)
}
// stepScan is the module's steps state as buildstep reads it.
func (m *Module) stepScan() buildstep.Scan {
return buildstep.ScanOf(stdio.State(delivery.StepsState))
}
@@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
@@ -126,28 +125,12 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct {
Config map[string]struct {
Default json.RawMessage `json:"default"`
Default string `json:"default"`
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
// Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default.
if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskGo]) {
t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskGo])
}
if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskGo] {
t.Errorf("the settings template holds cores-per-step %g", got)
}
// And the build hours (the operator, 2026-10-26): a setting of their own, empty by default (any hour), in the template.
if hours := string(doc.Config[buildstep.BuildHoursSetting].Default); hours != `""` {
t.Errorf("the build-hours default = %s, want empty", hours)
}
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
}
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities)
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q, want %q", doc.Config[buildstep.CapacitySetting].Default, buildstep.DefaultCapacities)
}
settings := resource(t, m, "settings")
if settings["path"] != "${dir:state}/settings.json" {
@@ -116,7 +116,7 @@ func TestTheIntegrationLevelDigestsUnderBothTags(t *testing.T) {
t.Errorf("the integration digest listed %q", s.command)
}
}
if DigestVersion != "6" {
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)
}
}
}
@@ -183,12 +183,12 @@ func (m *Module) buildEnv(dir string) []string {
} else if modfile, ok := m.pinModfile(dir); ok {
mod += " -modfile=" + modfile // a pin on a pull request's head the fetch placed beside the workspace (pins.go)
}
return append([]string{
return []string{
"CGO_ENABLED=0",
"GOFLAGS=" + recipeFlags + " " + mod,
"GOTOOLCHAIN=local",
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
"GOPROXY=off",
}, m.threadEnv()...)
}
}
@@ -13,8 +13,6 @@ import (
"path/filepath"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace.
@@ -110,7 +108,7 @@ func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, err
r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD")
commit := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || commit == "" {
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", firstLine(r.Err+"\n"+r.Stderr))
}
return commit, nil
}
@@ -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)
}
}
@@ -1,42 +0,0 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
// no slice to size a step to, and no thread limit is set.
// cpuLimits is what one step is held to now.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment (nothing without a share) every go command of a step is given for its threads: GOMAXPROCS sets its -p, the compiler's
// concurrency, a test binary's processors and -parallel.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
}
+16 -182
View File
@@ -11,18 +11,14 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind.
@@ -30,54 +26,19 @@ import (
// 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).
// 5: a catalogue module's non-Go files enter the digest of its packages (issue 1935).
// 6: a reads-outside marker that lists what its tests read enters the digest with those files' contents (issue 1936).
const DigestVersion = "6"
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), which the Go toolchain does not list (issue 1935). It lists, one per
// line, what those tests read, as paths from the repository's root; a path may be a glob (filepath.Match), and a
// directory it names is read whole (issue 1936). Blank lines and lines starting with # are not read. A line starting
// with ? names a path whose absence is what the tests read (a file that must not be there): it may match nothing.
//
// The digest then hashes, besides what the toolchain lists, every line of the marker, every file it names (under its
// path in the tree, by content) and every file of the package's own directory, Go files included (a file its build tags
// exclude is one go list does not hash). A marker that lists nothing, a line that is not a clean relative path, or a
// line without ? that matches nothing makes the package one that is never kept (its digest a fresh random hex): a list
// nobody can trust keeps nothing. The exact fix, the files a test opened kept
// from the Go test log with each verdict, replaces the lists (issue 1936).
// 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"
// NeedsMarker is the name of a file in a package's directory that names, one per line, what its integration tests need
// of the node they run on (issue 890; mesh-sdk delivery.AskNeeds): `node-build-node` for tests that read the Node build
// module's installed toolchain. The digest reports it (DigestResult.Needs); a check ask leaves such a package untested on
// a node that lacks it, and the checker asks it again of a node that has it. A marker beside the tests, as
// ReadsOutsideMarker is, and not a manifest field: it is a property of the package's tests, read only where they are
// run, and the manifest describes the module on the machines. The unit level is never given anything of the node (ADR
// 0533 §1), so a marker binds the integration level alone. Blank lines and lines starting with # are not read.
const NeedsMarker = "check-needs"
// readNeeds is what the marker in dir names; nil when there is none.
func readNeeds(dir string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(dir, NeedsMarker))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var needs []string
for _, l := range strings.Split(string(raw), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
needs = append(needs, l)
}
}
return needs, nil
}
// 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"}
@@ -137,15 +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 whose marker lists nothing it can trust (ReadsOutsideMarker): each has a digest
// that no kept verdict can have (a fresh random one), so the check runs it every time.
// 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"`
// neverKept is, per package of ReadsOutside, why its marker cannot be trusted; hashedFiles caches the content lines
// of the files markers name, which several packages share (the catalogue's manifests).
neverKept map[string]string
hashedFiles map[string]string
// Needs are the packages whose tests at this level need something of the node, as their NeedsMarker names it.
Needs []delivery.PackageNeeds `json:"needs,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"`
@@ -251,16 +206,15 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
if err != nil {
return DigestResult{}, err
}
if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil {
if err := digestModule(&res, pkgs, tree, header, besideHeader); err != nil {
return DigestResult{}, err
}
}
sort.Strings(res.WithoutTests)
sort.Strings(res.ReadsOutside)
sort.Slice(res.Needs, func(i, j int) bool { return res.Needs[i].Package < res.Needs[j].Package })
for _, p := range res.ReadsOutside {
if m.Say != nil {
m.Say("the %s level of %s always runs: its %s %s, so its tests read files outside it that no digest names", level, p, ReadsOutsideMarker, res.neverKept[p])
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
@@ -302,7 +256,7 @@ func parsePackages(out string) (map[string]*goPackage, error) {
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
// module that is listed, not only a dependency, and not a variant of another.
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string, needs bool) error {
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string) error {
roots := []string{}
for path, p := range pkgs {
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
@@ -320,36 +274,18 @@ 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 needs {
n, err := readNeeds(root.Dir)
if err != nil {
return fmt.Errorf("%s: its %s: %w", path, NeedsMarker, err)
}
if len(n) > 0 {
res.Needs = append(res.Needs, delivery.PackageNeeds{Package: path, Needs: n})
}
}
declared, why, err := declaredReads(tree, root, res.hashed())
if err != nil {
return fmt.Errorf("%s: its %s: %w", path, ReadsOutsideMarker, err)
}
if why != "" {
// The digest of a package whose reads outside itself are not listed 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.
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)
if res.neverKept == nil {
res.neverKept = map[string]string{}
}
res.neverKept[path] = why
continue
}
d, err := packageDigest(root, testMain, pkgs, tree, header, beside, declared)
d, err := packageDigest(root, testMain, pkgs, tree, header, beside)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
@@ -362,15 +298,14 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of
// every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header
// only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's.
// The lines a package's ReadsOutsideMarker declares join them (declaredReads).
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, declared []string) (string, error) {
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside []string) (string, error) {
reached := map[string]*goPackage{root.ImportPath: root}
for _, dep := range testMain.Deps {
if p := pkgs[dep]; p != nil {
reached[dep] = p
}
}
lines := append([]string{}, declared...)
lines := []string{}
readsBeside := false
for _, p := range reached {
if p.Standard || p.Module == nil {
@@ -492,104 +427,3 @@ func contentLine(tree string, p *goPackage, path string) (string, error) {
sum := sha256.Sum256(raw)
return "file " + label + " " + hex.EncodeToString(sum[:]), nil
}
// hashed is the cache of content lines a digest step shares across its packages.
func (r *DigestResult) hashed() map[string]string {
if r.hashedFiles == nil {
r.hashedFiles = map[string]string{}
}
return r.hashedFiles
}
// declaredReads are the digest lines a package's ReadsOutsideMarker adds: each of its lines, the content line of every
// file it names, and those of every file of the package's own directory. Without a marker it answers nothing. why is
// set, and the lines nil, when the marker cannot be trusted: it lists nothing, a line is not a clean relative path, or
// a line without ? matches nothing.
func declaredReads(tree string, root *goPackage, cache map[string]string) (lines []string, why string, err error) {
raw, err := os.ReadFile(filepath.Join(root.Dir, ReadsOutsideMarker))
if errors.Is(err, fs.ErrNotExist) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
var patterns []string
for _, l := range strings.Split(string(raw), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
patterns = append(patterns, l)
}
}
if len(patterns) == 0 {
return nil, "lists nothing", nil
}
seen := map[string]bool{}
add := func(path string) error {
if seen[path] {
return nil
}
seen[path] = true
l, ok := cache[path]
if !ok {
var err error
if l, err = contentLine(tree, root, path); err != nil {
return err
}
cache[path] = l
}
lines = append(lines, "read "+l)
return nil
}
for _, l := range patterns {
pattern, absent := strings.CutPrefix(l, "?")
if !cleanTreePattern(pattern) {
return nil, fmt.Sprintf("names %q, which is not a clean path from the repository's root", l), nil
}
matches, err := filepath.Glob(filepath.Join(tree, filepath.FromSlash(pattern)))
if err != nil {
return nil, fmt.Sprintf("names %q, which is not a glob", l), nil
}
if len(matches) == 0 && !absent {
return nil, fmt.Sprintf("names %q, which matches nothing (a path whose absence is read starts with ?)", l), nil
}
lines = append(lines, "reads "+l)
for _, match := range matches {
if err := walkFiles(match, add); err != nil {
return nil, "", err
}
}
}
// Every file of the package's own directory: its lists, its marker, and its Go files too, since go list leaves a
// file its build tags exclude (an integration test a unit test globs) out of what the digest hashes. A file the
// toolchain lists is hashed twice, which changes nothing.
err = walkFiles(root.Dir, add)
return lines, "", err
}
// cleanTreePattern says a marker's line is a path from the repository's root that stays inside it.
func cleanTreePattern(p string) bool {
if p == "" || strings.ContainsAny(p, " \t\\") || strings.HasPrefix(p, "/") || path.Clean(p) != p {
return false
}
for _, e := range strings.Split(p, "/") {
if e == ".." || e == "." {
return false
}
}
return true
}
// walkFiles calls each with every file at or under path, skipping .git and node_modules, in lexical order.
func walkFiles(path string, each func(string) error) error {
return filepath.WalkDir(path, func(p string, e fs.DirEntry, err error) error {
if err != nil {
return err
}
if e.IsDir() {
if n := e.Name(); p != path && (n == ".git" || n == "node_modules") {
return filepath.SkipDir
}
return nil
}
return each(p)
})
}
@@ -173,7 +173,7 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
pkgs[root.ImportPath] = root
digest := func() string {
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, nil)
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -190,84 +190,28 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
}
}
// Issue 1935, 1936: a package whose reads-outside marker lists nothing it can trust 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 marker that lists
// what the tests read is kept: a change to a file it names changes the package's digest, a change elsewhere does not.
func TestAPackageWithTheReadsOutsideMarkerIsKeptOnlyByWhatItLists(t *testing.T) {
// 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)
}
c, a := "fixture/digest/c", "fixture/digest/a"
if err := os.MkdirAll(filepath.Join(tree, "docs"), 0o755); err != nil {
t.Fatal(err)
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])
}
edit(t, tree, "docs/one.md", "one\n")
edit(t, tree, "other.md", "other\n")
for name, marker := range map[string]string{
"an empty marker": "# nothing listed\n\n",
"a sentence": "the tests read ../docs\n",
"a path climbing out": "../docs\n",
"a path matching nothing": "docs/none.md\n",
"an absolute path": "/etc/passwd\n",
"a path that is not clean": "docs/./one.md\n",
"a good line and a bad line": "docs\nnope/*.md\n",
} {
edit(t, tree, "c/"+ReadsOutsideMarker, marker)
first, second := digests(t, m, "unit"), digests(t, m, "unit")
if len(first.Packages[c]) != 64 || first.Packages[c] == second.Packages[c] || first.Packages[c] == plain.Packages[c] {
t.Errorf("%s: c's digest is stable: %s, %s, before %s", name, first.Packages[c], second.Packages[c], plain.Packages[c])
}
if strings.Join(first.ReadsOutside, ",") != c {
t.Errorf("%s: the answer does not name the package that reads outside: %v", name, first.ReadsOutside)
}
if first.Packages[a] != plain.Packages[a] || first.Packages[a] != second.Packages[a] {
t.Errorf("%s: a has no marker and its digest moved: %s, %s, before %s", name, first.Packages[a], second.Packages[a], plain.Packages[a])
}
if strings.Join(first.ReadsOutside, ",") != c {
t.Errorf("the answer does not name the package that reads outside: %v", first.ReadsOutside)
}
// A list: a glob, a directory read whole, and a path whose absence is read.
edit(t, tree, "c/"+ReadsOutsideMarker, "# what c's tests read\ndocs/*.md\ndocs\n?gone\n")
before := digests(t, m, "unit")
if len(before.ReadsOutside) != 0 || len(before.Packages[c]) != 64 || before.Packages[c] == plain.Packages[c] {
t.Fatalf("a listed marker is not kept: %v, %s (no marker %s)", before.ReadsOutside, before.Packages[c], plain.Packages[c])
}
if again := digests(t, m, "unit"); again.Packages[c] != before.Packages[c] {
t.Errorf("nothing changed and c's digest moved")
}
for _, step := range []struct {
file, text string
moves bool
}{
{"other.md", "changed\n", false}, // outside the list
{"d/d.go", "package d\n\n// D.\n", false}, // a package c's tests do not reach
{"docs/one.md", "changed\n", true}, // a listed file
{"docs/two.md", "new\n", true}, // a new file the glob matches
{"gone", "here\n", true}, // a path whose absence was read
{"c/note.txt", "changed\n", true}, // the package's own file that is not Go
{"c/x_integration_test.go", "//go:build integration\n\npackage c\n", true}, // a Go file its build tags leave out (go list's IgnoredGoFiles)
} {
edit(t, tree, step.file, step.text)
after := digests(t, m, "unit")
if moved := after.Packages[c] != before.Packages[c]; moved != step.moves {
t.Errorf("a change to %s: c's digest moved %v, want %v", step.file, moved, step.moves)
}
if after.Packages[a] != plain.Packages[a] {
t.Errorf("a change to %s moved a's digest", step.file)
}
before = after
}
// Issue 890/AC3: a package whose directory carries the needs marker is named with its needs at the integration level,
// comments and blank lines not read, and at the unit level not at all.
edit(t, tree, "a/"+NeedsMarker, "# its integration tests copy the installed Node toolchain\n\nnode-build-node\n")
if n := digests(t, m, LevelIntegration).Needs; len(n) != 1 || n[0].Package != a || strings.Join(n[0].Needs, ",") != "node-build-node" {
t.Errorf("the integration level's needs = %+v, want %s needing node-build-node", n, a)
}
if n := digests(t, m, LevelUnit).Needs; len(n) != 0 {
t.Errorf("the unit level names needs: %+v", n)
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])
}
}
@@ -0,0 +1,112 @@
package main
import (
"encoding/json"
"os"
"strings"
"testing"
)
// A failed test keeps the lines that say why it failed, not the lines it logged first (issue 1901). The fixture is
// the real `go test -json` of a test that logs a line and then fails with a multi-line t.Fatalf.
func TestAFailedTestKeepsItsFatalMessageAfterItsLogLines(t *testing.T) {
raw, err := os.ReadFile("testdata/captured/go-test-json-log-then-fatal.jsonl")
if err != nil {
t.Fatal(err)
}
res := &TestResult{}
rd := newReader(res, ".")
rd.read(string(raw))
rd.finish()
if len(res.Failed) != 1 {
t.Fatalf("failures = %+v, want one", res.Failed)
}
f := res.Failed[0]
for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} {
if !strings.Contains(f.Output, want) {
t.Errorf("1901/AC1: output lacks %q: %q", want, f.Output)
}
}
if want := "3 subscription(s) derive to a subject no emitter owns:"; f.Cause != want && !strings.HasSuffix(f.Cause, want) {
t.Errorf("1901/AC1: cause = %q, want the t.Fatalf line, not the t.Logf line", f.Cause)
}
}
// A failure longer than the bound keeps its last lines, where the cause is, and says how many earlier lines it dropped.
func TestAFailureLongerThanTheBoundKeepsItsLastLines(t *testing.T) {
var lines []string
for i := 0; i < 400; i++ {
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
}
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
out := clip(lines, "")
if len(out) > outputBytes+100 {
t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), outputBytes)
}
if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") {
t.Errorf("1901/AC1: out = %q...%q", out[:40], out[len(out)-60:])
}
}
// An older checker, which knows no "cause" field, still reads the new build module's answer, and the output it gets
// holds at least what the older build module kept (its first lines, for an output shorter than the bound).
func TestAnOlderCheckerReadsTheNewFailureAndGetsAtLeastWhatItKept(t *testing.T) { // 1901/AC3
raw, err := os.ReadFile("testdata/captured/go-test-json-log-then-fatal.jsonl")
if err != nil {
t.Fatal(err)
}
res := &TestResult{}
rd := newReader(res, ".")
rd.read(string(raw))
rd.finish()
b, err := json.Marshal(res.Failed)
if err != nil {
t.Fatal(err)
}
var old []struct {
Package string `json:"package"`
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
}
if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 {
t.Fatalf("1901/AC3: an older checker refused the answer: %v, %d failures", err, len(old))
}
first := strings.SplitN(old[0].Output, "\n", 2)[0]
if first == "" || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") {
t.Errorf("1901/AC3: older checker's output = %q", old[0].Output)
}
}
// A long failure, whose earlier lines are left out, still starts with a real line for an older checker, which keeps only the
// first non-empty line of the output cut to 120 characters, and not with the marker.
func TestALongFailureStillStartsWithARealLineForAnOlderChecker(t *testing.T) { // 1901/AC3
var lines []string
for i := 0; i < 400; i++ {
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
}
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine)
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
if len(l) > 120 {
l = l[:120]
}
return l
}
}
return ""
}
for _, cause := range []string{"the real cause", ""} {
out := clip(lines, cause)
got := oldFirst(out)
if strings.HasPrefix(got, "... (") || got == "" {
t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got)
}
if cause != "" && got != cause {
t.Errorf("1901/AC3: older checker reads %q, want the cause", got)
}
if !strings.HasSuffix(out, "--- FAIL: TestX (0.00s)") {
t.Errorf("1901/AC3: the last lines are lost: %q", out[len(out)-40:])
}
}
}
+104 -27
View File
@@ -18,7 +18,6 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
@@ -42,16 +41,59 @@ type FetchResult struct {
// head onto it, the paths the merge changes, and the tree of each module directory (a directory holding a
// module.json) in the merge. A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the
// files, and the workspace is left at the trunk commit.
Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"`
// Programs are the changed files that are compiled programs (issue 1577); a merge answers it, a pointer to an empty list when there are none, absent for a fetch that made no merge.
Programs *[]buildstep.Program `json:"programs,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
// 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.
@@ -112,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
}
@@ -171,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
@@ -258,7 +335,7 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error {
func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod")
if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
}
dirs := []string{}
for _, l := range strings.Split(r.Stdout, "\n") {
@@ -295,7 +372,8 @@ func (m *Module) goEnv() []string {
if m.GoModCache != "" {
modcache = m.GoModCache
}
return append([]string{
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,
@@ -303,7 +381,7 @@ func (m *Module) goEnv() []string {
"GOPRIVATE=git.novox.be",
"GIT_TERMINAL_PROMPT=0",
"GIT_SSH_COMMAND=ssh -o BatchMode=yes",
}, m.threadEnv()...)
}
}
// goEnvFor is goEnv for a command run in dir: a directory that vendors (vendor/modules.txt) is read from its vendor
@@ -334,11 +412,10 @@ func (m *Module) clock() time.Time {
func (m *Module) now() time.Time { return m.clock() }
// causeLine is the first line of a go command's output that says something other than Go's own progress: `go:
// downloading ...` comes first when Go fetches a module, and `# <package>` heads a compile error of go build or go vet
// (issue 1703: the reason said only the package), and the cause of a failure on a later line.
// downloading ...` comes first when Go fetches a module, and the cause of a failure on a later line.
func causeLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") && !strings.HasPrefix(l, "# ") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") {
return l
}
}
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
@@ -303,15 +304,60 @@ func TestWorkAndCacheAreTheDirectoriesTheManifestPlacesOutsideTheHome(t *testing
}
}
// issue 1703: a go command's reason is its cause, not Go's progress nor go build's `# <package>` heading.
func TestCauseLineSkipsProgressAndThePackageHeading(t *testing.T) {
for in, want := range map[string]string{
"go: downloading x v1\n# git.example/m/cmd\n./main.go:3:2: undefined: y\n": "./main.go:3:2: undefined: y",
"\ngo: example.com/x@v1: reading: 404\n": "go: example.com/x@v1: reading: 404",
"go: downloading x v1\n": "go: downloading x v1",
} {
if got := causeLine(in); got != want {
t.Errorf("causeLine(%q) = %q, want %q", in, got, want)
// 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)
}
}
@@ -5,13 +5,10 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
@@ -31,7 +28,6 @@ func toolchainValues(env []string) []string {
// 1572/AC1 (ADR 0477 §1): every environment a step gives its go commands says GOTOOLCHAIN=local, once, and names no
// release.
func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
m := &Module{Cache: t.TempDir()}
vend := t.TempDir()
os.MkdirAll(filepath.Join(vend, "vendor"), 0o755)
@@ -45,24 +41,6 @@ func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
if got := toolchainValues(env); len(got) != 1 || got[0] != "GOTOOLCHAIN=local" {
t.Errorf("%s: GOTOOLCHAIN settings %v, want exactly GOTOOLCHAIN=local", name, got)
}
// Issue 1806: and its threads, its part of the node's processor cap (probeEnv runs no build).
if procs := strings.Count(strings.Join(env, "\n"), "GOMAXPROCS="); name != "probeEnv" && procs != 1 {
t.Errorf("%s: GOMAXPROCS said %d times, want once: %v", name, procs, env)
}
}
// Without the composer's share no thread limit; with it, the share over its own three slots (ADR 0553 §4).
t.Setenv("MESH_BUILD_CPU_SHARE", "")
m = &Module{Cache: t.TempDir(), Node: "shanks", Capacity: func() int { return 3 }}
if l := m.cpuLimits(); l.Threads != 0 || strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS=") {
t.Errorf("without the share: %+v, goEnv %v", l, m.goEnv())
}
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
want := buildcpu.Threads(50, runtime.NumCPU(), 3)
if l := m.cpuLimits(); l.Slots != 3 || !strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS="+strconv.Itoa(want)) {
t.Errorf("limits %+v, goEnv %v; want %d threads", l, m.goEnv(), want)
}
if st := m.cpuStatus(); st["limits"].(buildcpu.Limits) != m.cpuLimits() {
t.Errorf("the status says %v", st)
}
}
@@ -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: append([]string{"PATH=" + path, "LC_ALL=C"}, m.threadEnv()...),
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 "))
}
}
@@ -1,33 +0,0 @@
package main
import (
"context"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"
)
// 1901/AC7: the module's own parse path (readGoTest, then the level's judging) keeps the failure contract on every captured
// go test -json case of the SDK's conformance kit, and its result names the module that sent it.
func TestTheTestStepKeepsTheFailureContract(t *testing.T) {
conformance.Run(t, conformance.GoTest, func(o conformance.Output) []buildstep.Failure {
res := &TestResult{Level: LevelUnit, By: buildstep.By("mesh-build-go")}
readGoTest(res, ".", o.Stdout)
res.judge(Bounds{Run: UnitBound})
if res.Passed {
t.Errorf("a failed run judged passed: %+v", res)
}
return res.Failed
})
}
// 1890/AC7: the module's own start sweep (endLeftRunning, as main runs it) keeps the conformance kit's left-running
// case: a step a previous process left running is ended at once and said, whatever its age, and a step of this process
// is kept.
func TestTheStartSweepEndsWhatAPreviousProcessLeft(t *testing.T) {
conformance.LeftRunning(t, func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any)) {
(&Module{Node: node}).endLeftRunning(ctx, scan, before, logf)
})
}
@@ -11,13 +11,11 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// levelArg is the first argument that makes this binary a step's shut-off process: the integration level's, and, when
@@ -43,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.
@@ -85,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
@@ -142,49 +142,6 @@ func (m *Module) nodeToolchains() []string {
return []string{dir}
}
// nodeToolchainMark is the file the Node build module writes in a toolchain directory once it is installed whole
// (mesh-build-node toolchain.go installedMark).
const nodeToolchainMark = ".installed"
// Has says whether this node has a need a check ask or a package's tests name (issue 890, ADR 0552; mesh-sdk
// delivery.AskNeeds), read from the node itself at every ask: node-build-node when the Node build module's toolchains
// directory, given to the integration level as nodeToolchains gives it, holds a toolchain installed whole (its mark and
// its package-lock.json readable by this account), and node, npm and bwrap are installed: what mesh-build-node's
// integration tests (realMachine) look for before they run. Any other need it lacks.
func (m *Module) Has(need string) bool {
if m.HasNeed != nil {
return m.HasNeed(need)
}
switch need {
case delivery.NeedNodeBuildNode:
dirs := m.nodeToolchains()
if len(dirs) == 0 {
return false
}
for _, p := range []string{"node", "npm", "bwrap"} {
if _, err := exec.LookPath(p); err != nil {
return false
}
}
entries, err := os.ReadDir(dirs[0])
if err != nil {
return false
}
for _, e := range entries {
dir := filepath.Join(dirs[0], e.Name())
if _, err := os.Stat(filepath.Join(dir, nodeToolchainMark)); err != nil {
continue
}
if f, err := os.Open(filepath.Join(dir, "package-lock.json")); err == nil {
f.Close()
return true
}
}
return false
}
return false
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
@@ -210,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))
}
+217 -26
View File
@@ -6,12 +6,13 @@ package main
// step that could not run.
import (
"bufio"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The two levels (ADR 0445 §2).
@@ -30,14 +31,39 @@ type Bounds struct {
Setup, Run time.Duration
}
// PackageResult, TestTime and Failure are the SDK's failure contract (buildstep, issue 1901), with the JSON names this
// module always sent: a Failure names its test, its file, its cause (never empty, never a passing line) and its own
// output up to 4096 bytes, cause first.
type (
PackageResult = buildstep.GoPackage
TestTime = buildstep.TestTime
Failure = buildstep.Failure
)
// PackageResult is one package's part of a level.
type PackageResult struct {
Dir string `json:"dir"`
Package string `json:"package"`
Passed bool `json:"passed"`
Elapsed float64 `json:"elapsed"`
Tests int `json:"tests"`
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
NoTests bool `json:"no_tests,omitempty"`
}
// TestTime is one test and the time it took.
type TestTime struct {
Package string `json:"package"`
Test string `json:"test"`
Elapsed float64 `json:"elapsed"`
}
// Failure is a test that failed, or a package that failed with no test to name (a build that failed, a TestMain that
// panicked); Output is the last lines it wrote, up to outputBytes (issue 1901), and Cause the first line of what the
// test reported as its error (t.Error, t.Fatal), without its file and line, when go test said which lines those were.
type Failure struct {
Package string `json:"package"`
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 {
@@ -53,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"`
@@ -71,23 +97,188 @@ type TestResult struct {
// JudgedBy is the program that ran the level (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
// By is the build module and the SDK it was built with (buildstep.By): the checker names it when a failure hides
// its cause.
By string `json:"by,omitempty"`
}
// readGoTest reads one go.mod directory's `go test -json` into res through the SDK's reader (issue 1901: one failure
// contract for every build module, buildstep.Failure), and answers the lines that were no event and how many packages
// it named.
func readGoTest(res *TestResult, dir, out string) (noise []string, packages int) {
read := buildstep.ReadGoTest(out, dir)
res.Tests += read.Tests
res.Skipped += read.Skipped
res.Failed = append(res.Failed, read.Failed...)
res.Packages = append(res.Packages, read.Packages...)
res.Slowest = append(res.Slowest, read.Times...)
res.Elapsed = max(res.Elapsed, read.Elapsed)
return read.Noise, len(read.Packages)
// event is one line of `go test -json` (test2json, and the build events go test adds).
type event struct {
Action string
Package string
Test string
Output string
OutputType string // "error" for a t.Error or t.Fatal message, "error-continue" for its further lines (go test -json)
ImportPath string
FailedBuild string
Elapsed float64
}
// outputBytes is the most a failure keeps of its output, and outputLineBytes the most of one line. **A failure keeps its
// last lines** (issue 1901): a test logs before it fails, so the lines that say why are at the end, and the first
// fifteen lines kept before lost them (the verdict of group r4g kept a t.Logf line, not the t.Fatalf after it). Four
// kilobytes holds a long assertion message and stays far under a bus message's or a verdict's bound.
const (
outputBytes = 4096
outputLineBytes = 1024
)
// reader collects the events of one go.mod directory's `go test -json` into a result.
type reader struct {
res *TestResult
dir string
pkgs map[string]*PackageResult
order []string
builds map[string][]string // build output by import path
pkgOut map[string][]string
testOut map[string][]string // by package + "\x00" + test
causes map[string]string // the first error line of a test, by the same key
times []TestTime
failedIn map[string]bool // packages with a failed test
}
func newReader(res *TestResult, dir string) *reader {
return &reader{res: res, dir: dir, pkgs: map[string]*PackageResult{}, builds: map[string][]string{},
pkgOut: map[string][]string{}, testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{}}
}
// read takes go test's output. Lines that are not events are kept for a failure that has nothing else to say.
func (r *reader) read(out string) (noise []string) {
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 1<<20), 1<<26)
for sc.Scan() {
var ev event
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
if l := strings.TrimSpace(sc.Text()); l != "" {
noise = append(noise, l)
}
continue
}
r.event(ev)
}
return noise
}
func (r *reader) pkg(name string) *PackageResult {
p := r.pkgs[name]
if p == nil {
p = &PackageResult{Dir: r.dir, Package: name}
r.pkgs[name] = p
r.order = append(r.order, name)
}
return p
}
func keep(lines []string, out string) []string {
if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") {
lines = append(lines, t)
}
return lines
}
func (r *reader) event(ev event) {
switch {
case ev.Package == "" && ev.ImportPath != "":
if ev.Action == "build-output" {
r.builds[ev.ImportPath] = keep(r.builds[ev.ImportPath], ev.Output)
}
case ev.Package == "":
case ev.Test == "":
p := r.pkg(ev.Package)
switch ev.Action {
case "output":
r.pkgOut[ev.Package] = keep(r.pkgOut[ev.Package], ev.Output)
case "pass":
p.Passed, p.Elapsed = true, ev.Elapsed
case "fail":
p.Passed, p.Elapsed = false, ev.Elapsed
if ev.FailedBuild != "" {
r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...)
}
case "skip":
p.Passed, p.NoTests = true, true
}
default:
key := ev.Package + "\x00" + ev.Test
switch ev.Action {
case "output":
r.testOut[key] = keep(r.testOut[key], ev.Output)
if ev.OutputType == "error" && r.causes[key] == "" {
r.causes[key] = errorWords(ev.Output)
}
case "pass", "fail", "skip":
p := r.pkg(ev.Package)
p.Tests++
r.res.Tests++
if ev.Action == "skip" {
r.res.Skipped++
}
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
if ev.Action == "fail" {
r.failedIn[ev.Package] = true
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key], r.causes[key]), Cause: r.causes[key]})
}
}
}
}
// clip joins the last lines of a failure's output that fit outputBytes, each cut to outputLineBytes, and says how many
// earlier lines it left out. **When it leaves lines out, its first line is still a real one** (issue 1901/AC3): the failure's
// cause, or failing that the first non-empty line of the output, then the marker. An older checker takes only the first
// non-empty line of the output, and a marker there would be less than the older pair kept.
func clip(lines []string, cause string) string {
size, from := 0, len(lines)
for from > 0 {
n := len(cutLine(lines[from-1])) + 1
if size+n > outputBytes && from < len(lines) {
break
}
size += n
from--
}
kept := make([]string, 0, len(lines)-from+1)
if from > 0 {
head := strings.TrimSpace(cause)
for i := 0; head == "" && i < from; i++ {
head = strings.TrimSpace(lines[i])
}
if head != "" {
kept = append(kept, cutLine(head))
}
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
}
for _, l := range lines[from:] {
kept = append(kept, cutLine(l))
}
return strings.Join(kept, "\n")
}
// cutLine cuts a line to outputLineBytes, on a character boundary.
func cutLine(l string) string {
if len(l) <= outputLineBytes {
return l
}
return strings.ToValidUTF8(l[:outputLineBytes], "") + "..."
}
var fileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
// errorWords is the words of an error line of a test: trimmed, without the file and line go test put before them.
func errorWords(out string) string {
return strings.TrimSpace(fileAndLine.ReplaceAllString(strings.TrimSpace(out), ""))
}
// finish moves what was read into the result: the packages in order, a failure for each failed package that has no
// failed test to name it, and the tests' times.
func (r *reader) finish() {
for _, name := range r.order {
p := r.pkgs[name]
if !p.Passed && !r.failedIn[name] {
r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name], "")})
}
if !p.NoTests && p.Elapsed > r.res.Elapsed {
r.res.Elapsed = p.Elapsed
}
r.res.Packages = append(r.res.Packages, *p)
}
r.res.Slowest = append(r.res.Slowest, r.times...)
}
// judge sets Passed and Why from what was read and the bounds. A level with no failure, over no bound, passes; with no
+105 -34
View File
@@ -14,7 +14,6 @@ import (
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -61,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() {
@@ -80,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)
}
@@ -92,64 +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, buildstep.ConsoleFailure(d, "gofmt", "not as gofmt writes them (run gofmt -w): "+strings.Join(files, ", ")))
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, buildstep.ConsoleFailure(d, name, 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) {
// The ask's phases say it (issue 1890): lint of the directories, then their tests.
buildstep.Phase(ctx, fmt.Sprintf("unit: lint of %d directories", len(dirs)))
defer buildstep.Phase(ctx, fmt.Sprintf("unit: tests of %d directories", len(dirs)))
// 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()
@@ -157,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)
}
}
@@ -11,7 +11,6 @@ import (
"os"
"os/user"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -41,8 +40,6 @@ func main() {
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Pauses: stdio.State(delivery.PausesState).Put,
ReadPauses: readOf(stdio.State(delivery.PausesState)),
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
Ctx: ctx,
Say: logf,
@@ -71,14 +68,7 @@ func main() {
os.Exit(1)
}
steps := m.StepTools()
booted := time.Now()
puller := m.Puller(steps, nil)
m.Held = puller
if m.Node != "" {
// A step a previous process left running is ended (issue 1890): one that names another owner, or none and
// started before booted.
go m.endLeftRunning(ctx, m.stepScan(), booted, logf)
}
if m.Node != "" {
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
go func() { _ = puller.Pull(ctx) }()
@@ -92,13 +92,9 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
if (s.Name == "steps") != (s.TTLSeconds > 0) {
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
}
}
slices.Sort(states)
if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) {
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat {
@@ -9,7 +9,6 @@ import (
"syscall"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process.
@@ -68,7 +67,7 @@ func (m *Module) dissociate(ctx context.Context, dir string) error {
return nil // no mirror was used
}
if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" {
return fmt.Errorf("git repack in %s: %s", dir, forgekey.Said(r.Err+"\n"+r.Stderr))
return fmt.Errorf("git repack in %s: %s", dir, firstLine(r.Err+" "+r.Stderr))
}
return os.Remove(alternates)
}
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"os/exec"
@@ -13,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 {
@@ -122,35 +122,20 @@ func TestTwoUpdatesOfOneMirrorNeverRunAtOnce(t *testing.T) {
}
}
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it, and the line saying so carries
// git's own words. The forge's refusal of a repository its account cannot read came out as "git -C: error:" (ace,
// 2026-10-26: git writes a bare "error:" line before the forge's message).
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it.
func TestAMirrorThatCannotBeUpdatedLetsTheFetchGoOnWithoutIt(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
f.rule = func(line string) (r runAnswer, ok bool) {
if strings.Contains(line, "mirror") && strings.Contains(line, " fetch ") {
return runAnswer{Status: 128, Stderr: "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.\nerror: \nfatal: Could not read from remote repository.\n"}, true
return runAnswer{Status: 1, Stderr: "fatal: no space left\n"}, true
}
return r, false
}
m, states := fetchModule(t, f)
var mu sync.Mutex
var said []string
m.Say = func(format string, a ...any) {
mu.Lock()
defer mu.Unlock()
said = append(said, fmt.Sprintf(format, a...))
}
ws := fetchOnce(t, m, states, fetchArgs).Workspace
if _, err := os.Stat(filepath.Join(m.Work, ws, "tree", ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the workspace borrows from a mirror that is not there")
}
mu.Lock()
defer mu.Unlock()
all := strings.Join(said, "\n")
if !strings.Contains(all, "is not used: git fetch: error: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.; fatal: Could not read from remote repository.") {
t.Errorf("the mirror's line lost git's words:\n%s", all)
}
}
func sleepABit() { time.Sleep(30 * time.Millisecond) }
@@ -204,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)
}
}
@@ -28,12 +28,7 @@ type Module struct {
// Toolchains and Steps are the Put of the states of those names.
Toolchains, Steps buildstep.Put
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
Held *buildstep.Puller
ReadSteps, ReadToolchainsState StateRead
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
Pauses buildstep.Put
ReadPauses StateRead
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
Emit func(eventType string, body any) error
@@ -75,8 +70,6 @@ type Module struct {
// NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the
// integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere.
NodeToolchains string
// HasNeed answers Has in place of the node's own reading; a test's.
HasNeed func(need string) bool
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
// program, StartBus in bus.go, when nil).
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
@@ -125,7 +118,6 @@ func (m *Module) Start(ctx context.Context) {
m.Say("the toolchain: %v", err)
}
m.CleanWorkspaces(time.Now())
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
select {
case <-ctx.Done():
return
@@ -189,14 +181,13 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
}
// Status is what the module reports of itself: the node, the recipe, the toolchain found and why it refuses, if it
// does, and the processors its steps may take (issue 1806).
// does.
func (m *Module) Status(ctx context.Context) map[string]any {
err := m.Check(ctx)
installed, _ := m.Probe(ctx)
out := map[string]any{
"node": m.Node, "recipe": m.Recipe.Version(), "installed": installed,
"postgres-major": m.Recipe.PostgresMajor, "nats-server": m.Recipe.NatsServer, "refusing": err != nil,
"cpu": m.cpuStatus(),
}
if err != nil {
out["why"] = err.Error()
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
{Name: "short_probe", Run: run},
}, m.StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
continue
}
var reason string
@@ -71,47 +71,6 @@ func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T
if slices.Contains(s.roBinds, missing) {
t.Errorf("a missing %s is bound: %v", missing, s.roBinds)
}
// Issue 890/AC4: such a node lacks node-build-node; one with an installed toolchain and node and npm has it; nothing
// else is had.
bin := t.TempDir()
for _, p := range []string{"node", "npm", "bwrap"} {
if err := os.WriteFile(filepath.Join(bin, p), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin)
m := &Module{NodeToolchains: missing}
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a node with no Node toolchain has node-build-node")
}
installed := t.TempDir()
m.NodeToolchains = installed
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a node with an empty toolchains directory has node-build-node")
}
half := filepath.Join(installed, "0.1.0")
if err := os.Mkdir(half, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(half, "package-lock.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a toolchain without its installed mark counts")
}
if err := os.WriteFile(filepath.Join(half, nodeToolchainMark), nil, 0o644); err != nil {
t.Fatal(err)
}
if !m.Has(delivery.NeedNodeBuildNode) || m.Has("gpu") {
t.Errorf("with a toolchain installed: node-build-node %v, gpu %v; want true, false", m.Has(delivery.NeedNodeBuildNode), m.Has("gpu"))
}
if err := os.Remove(filepath.Join(bin, "bwrap")); err != nil {
t.Fatal(err)
}
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a node with no bubblewrap installed has node-build-node")
}
}
// 1891 (ADR 0533 §1): the unit level is not given them.
@@ -15,8 +15,6 @@ import (
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const forgePrefix = "git.novox.be/novox/"
@@ -127,7 +125,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin
repository := "novox/" + p.repo
r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head")
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr))
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, firstLine(r.Err+" "+r.Stderr))
}
commit, ref := "", ""
for _, l := range strings.Split(r.Stdout, "\n") {
@@ -13,6 +13,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
@@ -34,16 +35,14 @@ 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
}
res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if mr.Merge != "" {
res.Programs = &mr.Programs
}
if res.Merge == "" {
return nil
}
@@ -92,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 {
@@ -124,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"
@@ -77,7 +77,7 @@ func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
t.Fatal(err)
}
if res.Base != baseSHA || res.Merge != mergeSHA || res.Commit != commitSHA || len(res.Conflicts) != 0 ||
strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" || res.Programs == nil || len(*res.Programs) != 0 { // 1577: a merge says it found no program
strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" {
t.Errorf("result = %s", st.Result)
}
mt, ok := res.Trees["modules/m"]
@@ -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 := ""
+23 -99
View File
@@ -7,12 +7,9 @@ package main
import (
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -42,53 +39,6 @@ func readOf(s stdio.KeptState) StateRead {
}}
}
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
const (
MaxStateReads = 1000
stateReadParallel = 16
)
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
// stops starting reads after the first error and returns it.
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
out := make([]json.RawMessage, len(keys))
var (
wg sync.WaitGroup
mu sync.Mutex
fail error
next = make(chan int)
)
for w := 0; w < stateReadParallel && w < len(keys); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
raw, err := get(keys[i])
mu.Lock()
if err != nil && fail == nil {
fail = err
}
mu.Unlock()
out[i] = raw
}
}()
}
for i := range keys {
mu.Lock()
stop := fail != nil
mu.Unlock()
if stop {
break
}
next <- i
}
close(next)
wg.Wait()
return out, fail
}
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
func (m *Module) stepKey(id string) (string, error) {
id = strings.TrimSpace(id)
@@ -128,44 +78,29 @@ type StepLine struct {
Started string `json:"started"`
Ended string `json:"ended,omitempty"`
Reason string `json:"reason,omitempty"`
What string `json:"what,omitempty"`
}
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
// read. The values still order the lines, since an ask step's id carries the ask time.
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
if limit <= 0 {
limit = 10
}
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
return nil, false, fmt.Errorf("the steps state cannot be read here")
return nil, fmt.Errorf("the steps state cannot be read here")
}
start := time.Now()
keys, err := m.ReadSteps.Keys()
if err != nil {
return nil, false, err
}
own := keys[:0:0]
for _, k := range keys {
if strings.HasPrefix(k, m.Node+".") {
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
own = append(own, k)
}
}
}
sort.Sort(sort.Reverse(sort.StringSlice(own)))
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
own, cut = own[:n], true
}
raws, err := readValues(own, m.ReadSteps.Get)
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
if err != nil {
return nil, cut, err
return nil, err
}
var steps []delivery.Step
for _, raw := range raws {
for _, k := range keys {
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
continue
}
raw, err := m.ReadSteps.Get(k)
if err != nil {
return nil, err
}
if len(raw) == 0 {
continue
}
@@ -181,13 +116,13 @@ func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error)
}
out := make([]StepLine, 0, len(steps))
for _, s := range steps {
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
if !s.Ended.IsZero() {
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, l)
}
return out, cut, nil
return out, nil
}
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
@@ -195,30 +130,24 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
return nil, fmt.Errorf("the toolchains state cannot be read here")
}
start := time.Now()
keys, err := m.ReadToolchainsState.Keys()
if err != nil {
return nil, err
}
cut := len(keys) > MaxStateReads
if cut {
keys = keys[:MaxStateReads] // a node has a key per language: far fewer in practice
}
raws, err := readValues(keys, m.ReadToolchainsState.Get)
log.Printf("toolchains read: %d keys read in %v (cut %v: keys past %d were not read)", len(keys), time.Since(start).Round(time.Millisecond), cut, MaxStateReads)
if err != nil {
return nil, err
}
out := map[string]delivery.Toolchain{}
for i, raw := range raws {
for _, k := range keys {
raw, err := m.ReadToolchainsState.Get(k)
if err != nil {
return nil, err
}
if len(raw) == 0 {
continue
}
t, err := delivery.DecodeToolchain(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", keys[i], err)
return nil, fmt.Errorf("%s: %w", k, err)
}
out[keys[i]] = t
out[k] = t
}
return out, nil
}
@@ -242,13 +171,8 @@ func (m *Module) ReadTools() []stdio.Tool {
Input: map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
Run: func(args map[string]any) (any, error) {
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
ans := map[string]any{"node": m.Node, "steps": steps}
if cut {
ans["cut"] = true
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
}
return ans, err
steps, err := m.RecentSteps(intArg(args["limit"]))
return map[string]any{"node": m.Node, "steps": steps}, err
}},
{Name: ToolchainsReadTool,
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
@@ -3,10 +3,8 @@ package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
@@ -96,7 +94,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"})
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
m := readModule(steps, fakeState{})
out, err := tool(t, m, StepsReadTool)(map[string]any{})
@@ -111,7 +109,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
if !slices.Equal(ids, []string{"c", "b", "a"}) {
t.Errorf("ids = %v, want c b a", ids)
}
if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed {
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
t.Errorf("first = %+v", lines[0])
}
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
@@ -147,62 +145,3 @@ func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
t.Errorf("the read refused: %v", err)
}
}
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
for _, row := range []struct {
perNode, limit int
reads int64
steps int
cut bool
}{
{20, 0, 20, 10, false},
{600, 0, 100, 10, true},
{1500, 300, MaxStateReads, 300, true},
} {
var reads, inFlight, maxFlight atomic.Int64
stored := map[string][]byte{}
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
for i := 0; i < row.perNode; i++ {
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
}
}
m := &Module{Node: "shanks"}
m.ReadSteps = StateRead{
Keys: func() ([]string, error) {
var k []string
for key := range stored {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
reads.Add(1)
now := inFlight.Add(1)
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
}
time.Sleep(time.Millisecond)
inFlight.Add(-1)
if !strings.HasPrefix(key, "shanks.") {
t.Errorf("read %s, a key of another node", key)
}
return stored[key], nil
},
}
start := time.Now()
lines, cut, err := m.RecentSteps(row.limit)
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
}
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
}
if maxFlight.Load() < 2 {
t.Errorf("%d a node: values read one at a time", row.perNode)
}
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
}
}
@@ -1,7 +1 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the manifests through mesh-sdk's validator (the predefined keys), gitea's and mesh-build-node's.
predefined.json
modules/gitea
modules/mesh-build-node
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)
}

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