Write no merge-check.sh in a new repository's first commit and announce none: tests run by convention (issue 1296, E1b)

This commit is contained in:
2026-10-23 15:11:37 +02:00
parent e4ec060ac7
commit 2038943c3e
8 changed files with 32 additions and 110 deletions
+6 -8
View File
@@ -22,8 +22,7 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
minutes, or when the search fails.
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's
`mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` with none of them for ten minutes since it first saw it so is a delivery held past its bound
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
@@ -153,19 +152,18 @@ listed repository:
- one that is not in the organisation and not under the setup account is made there, **empty and under the setup
account**, where neither the tools' team nor the agents' team (both on every repository of the organisation)
reaches it. Main gets a rule letting the setup login alone push; main's first commit is written with it: a README
naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit
tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on
a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
naming the description and nothing else (the checker runs a repository's tests by their language's convention,
ADR 0445, ADR 0529 §5). The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation.
There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge
list a user without access to the repository, and under the setup account `mesh-tools` has none;
- one found still under the setup account is finished: the steps it lacks are done, main is never written twice;
- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of
`mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires
`mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296)
and keeps it; `mesh/check` is never dropped.
`mesh/repo-check` alone (hq's did, until issue 1296) is given `mesh/check` in its place too, since nothing writes
`mesh/repo-check` once build-agent is gone (ADR 0527 §2); `mesh/check` is never dropped.
Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped
Every other repository of the organisation whose main requires `mesh/merge-gate` or `mesh/repo-check` has the old statuses swapped
for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with
no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a
repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
+4 -29
View File
@@ -2,34 +2,9 @@ package main
import "strings"
// MergeCheck is the first commit's merge-check.sh (ADR 0428 §4): it declares the Go toolchain (ADR 0238 §4, read
// among a script's first lines by the build seat), and runs the unit tests, then with -tags integration the
// integration tests, of every Go module in the tree; on a tree with none it says so and passes.
const MergeCheck = `#!/bin/sh
# mesh-check-toolchain: go
#
# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4):
# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration).
# A tree with no Go module has nothing to test, and the check says so and passes.
set -eu
cd "$(dirname "$0")"
modules=$(find . -name go.mod -not -path '*/vendor/*' -not -path './.git/*' | sort)
if [ -z "$modules" ]; then
echo "This tree holds no Go module, so there are no tests to run; the check passes."
exit 0
fi
for mod in $modules; do
dir=$(dirname "$mod")
echo "== $dir: go vet and the unit tests"
(cd "$dir" && go vet ./... && go test ./...)
echo "== $dir: the integration tests (-tags integration)"
(cd "$dir" && go vet -tags integration ./... && go test -tags integration ./...)
done
`
// FirstCommit is main's first commit of a listed repository: its README, naming the repository's purpose, and
// its own check.
// nothing else: the checker runs a repository's tests by their language's convention (ADR 0445), so no script is written
// (ADR 0529 §5).
func FirstCommit(name string, r Repo) map[string]string {
purpose := strings.TrimSpace(r.Description)
if purpose == "" {
@@ -38,6 +13,6 @@ func FirstCommit(name string, r Repo) map[string]string {
readme := "# " + name + "\n\n" + purpose + "\n\n" +
"Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " +
"a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " +
"main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n"
return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck}
"main, its tests run by their language's convention (novox/hq ADR 0447, ADR 0445).\n"
return map[string]string{"README.md": readme}
}
@@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -53,7 +52,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
if repo.Private || repo.Description != "the mesh's delivery" {
t.Errorf("made as %+v, want public with its description", repo)
}
if repo.Commits != 1 || len(repo.Files) != 2 || repo.Files["README.md"] == "" || repo.Files["merge-check.sh"] == "" {
if repo.Commits != 1 || len(repo.Files) != 1 || repo.Files["README.md"] == "" {
t.Errorf("first commit: %d commits, files %v", repo.Commits, keysOf(repo.Files))
}
if !strings.Contains(repo.Files["README.md"], "the mesh's delivery") {
@@ -409,66 +408,19 @@ func TestReadValue(t *testing.T) {
}
}
// 1124/AC4: the first commit's merge-check.sh declares its toolchain in its first lines, runs the tests of a Go
// module in the tree, and on a tree with none says so and passes.
func TestFirstCommitMergeCheck(t *testing.T) {
// 1296/E1b (ADR 0527 §2, ADR 0529 §5): the first commit holds a README naming the purpose and no merge-check.sh: the
// checker runs a repository's tests by their language's convention and no script is written for it.
func TestFirstCommitHasNoMergeCheck(t *testing.T) {
files := FirstCommit("mesh-delivery", Repo{Description: "the mesh's delivery"})
script := files["merge-check.sh"]
lines := strings.SplitN(script, "\n", 4)
if lines[0] != "#!/bin/sh" || lines[1] != "# mesh-check-toolchain: go" {
t.Fatalf("first lines %q", lines[:2])
if _, has := files["merge-check.sh"]; has || len(files) != 1 {
t.Fatalf("first commit files %v, want the README alone", keysOf(files))
}
if !strings.Contains(files["README.md"], "mesh-delivery") || !strings.Contains(files["README.md"], "the mesh's delivery") {
t.Errorf("README %q", files["README.md"])
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("no go on PATH to run the script with")
if strings.Contains(files["README.md"], "merge-check") {
t.Errorf("the README names a merge-check.sh: %q", files["README.md"])
}
tree := t.TempDir()
mod := filepath.Join(tree, "sub")
_ = os.MkdirAll(mod, 0o755)
_ = os.WriteFile(filepath.Join(tree, "merge-check.sh"), []byte(script), 0o755)
_ = os.WriteFile(filepath.Join(mod, "go.mod"), []byte("module tiny\n\ngo 1.21\n"), 0o644)
_ = os.WriteFile(filepath.Join(mod, "tiny_test.go"), []byte(`package tiny
import (
"os"
"testing"
)
func TestRan(t *testing.T) { _ = os.WriteFile("ran", []byte("yes"), 0o644) }
`), 0o644)
cmd := exec.Command("sh", "merge-check.sh")
cmd.Dir = tree
cmd.Env = append(os.Environ(), "GOFLAGS=", "GOTOOLCHAIN=local", "PATH="+filepath.Dir(goBin)+":"+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("on a tree with a Go module: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(mod, "ran")); err != nil || !strings.Contains(string(out), "integration") {
t.Fatalf("its tests did not run, or not with -tags integration:\n%s", out)
}
// A failing test fails the check.
_ = os.WriteFile(filepath.Join(mod, "fail_test.go"), []byte("package tiny\n\nimport \"testing\"\n\nfunc TestFails(t *testing.T) { t.Fatal(\"no\") }\n"), 0o644)
if out, err := cmd2(tree, cmd.Env).CombinedOutput(); err == nil {
t.Fatalf("a failing test passed the check:\n%s", out)
}
empty := t.TempDir()
_ = os.WriteFile(filepath.Join(empty, "merge-check.sh"), []byte(script), 0o755)
out, err = cmd2(empty, cmd.Env).CombinedOutput()
if err != nil || !strings.Contains(string(out), "no Go module") {
t.Fatalf("on an empty tree: %v\n%s", err, out)
}
}
func cmd2(dir string, env []string) *exec.Cmd {
c := exec.Command("sh", "merge-check.sh")
c.Dir, c.Env = dir, env
return c
}
func keysOf(m map[string]string) []string {
+4 -4
View File
@@ -13,8 +13,8 @@
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main
// requires `mesh/repo-check` alone (ADR 0503 §5).
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since
// ADR 0527 §2, so these are written only for a revert.
//
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
@@ -257,8 +257,8 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires
// mesh/repo-check alone; a rule that cannot be read is said pending, as before.
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's;
// a rule that cannot be read is said pending, as before.
const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined);
if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha);
return true;
+5 -4
View File
@@ -7,7 +7,7 @@
//
// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's,
// whose line names the trunk it judged ("pass <head12> on <trunk12>"); where a base does not require it, the controller's
// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is
// status it still requires (only on a revert), as before. A mesh/check of another trunk is refused and nothing is
// asked: the checker sets it out of date on the trunk's move and checks it again.
import { announce } from "./announce.js";
@@ -37,7 +37,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
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 " +
"and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " +
"and every status its base branch's protection requires succeeded there (mesh/check), " +
"judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " +
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
@@ -131,7 +131,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
`ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`);
}
}
// Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a
// Where the base still requires the controller's status (mesh/merge-gate, only on a
// revert), the first it requires judges, as before (ADR 0323 §5).
const kept = maintained(required);
const judge = kept[0] ?? CHECK_CONTEXT;
@@ -161,7 +161,8 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
// announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather
// than wait for ever on a check nobody asked.
// The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone.
// The statuses the base requires of the controller's (issue 1301): the one that judges, and the repository's own
// check beside it where a revert has the base require both.
const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT]));
// Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new
// check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older.
+1 -1
View File
@@ -172,7 +172,7 @@
},
"repositories": {
"type": "object",
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone is swapped too, ADR 0527 §2). Every other repository whose main requires mesh/merge-gate or mesh/repo-check has the old statuses swapped for mesh/check; nothing else of a repository out of the value is touched.",
"trusted": true,
"default": {}
}
+1 -1
View File
@@ -1,5 +1,5 @@
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`,
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) —
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1, hq's too since ADR 0527 §2) —
// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
import { TOOLS_ACCOUNT } from "./tools-account.js";
+3 -7
View File
@@ -14,8 +14,8 @@
//
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's
// mesh/repo-check, pulls.ts maintained).
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (a revert,
// pulls.ts maintained).
//
// Pure functions and two small files of the module's own state, so this is tested without a forge.
@@ -234,14 +234,11 @@ export async function announcementOf(client: GiteaClient, owner: string, name: s
const files = await client.listPullFiles(owner, name, pull.number);
const head = String(pull.head_sha);
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as amended): which
// changed directories hold a module at the head — the merge's rule (issue 278) — and whether the head holds the
// repository's own merge-check.sh. Not said when it could not be read; the controller then reads the change as
// changed directories hold a module at the head — the merge's rule (issue 278). Not said when it could not be read; the controller then reads the change as
// touching everything built from the repository.
let moduleDirs: string[] | null = null;
let mergeCheck: boolean | null = null;
try {
if (!files.truncated) moduleDirs = await client.moduleDirsAt(owner, name, head, files.paths);
mergeCheck = await client.holdsFile(owner, name, head, "merge-check.sh");
} catch (err) {
console.error(`[gitea] ${owner}/${name}#${pull.number}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
}
@@ -264,6 +261,5 @@ export async function announcementOf(client: GiteaClient, owner: string, name: s
paths_truncated: files.truncated,
removed: files.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
};
}