Files
summercms/scripts/check-phase32.sh
Jakub Zych 2b300ec03d test(32-06): additive and compat stages in check-phase32
- scripts/apisurface: standard-library printer of a package's exported API
  (signatures without parameter names, struct fields and tags, interface
  method sets, constants with their group index), canonical across
  formatting and comment edits
- additive: compares the API of pact, cabana, signpost and every other
  changed package with the newest tag; refuses removed or changed lines and
  methods added to existing interfaces (D-106)
- compat: sitemap at HEAD, Journal and the demo at their pre-phase commits
  (demo with its pinned submodules) build and pass short tests; every other
  sibling that requires the framework builds, each through a scratch go.work
  in a scratch sibling layout; read-only snapshots for other users' repos
- self-test plants 12 API change cases; PHASE32_SKIP_TESTS for host-only
  test failures
2026-10-27 13:08:49 +01:00

1248 lines
49 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# check-phase32: fail-closed gate for Phase 32, SEO foundation: the
# framework seams (form extensions, record events, content descriptors),
# the Journal plugin's descriptors and events, the SEO plugin
# (sm-seo-plugin) and the demo application that mounts it.
#
# Stages:
# self-test the go test -json and vitest detectors on planted output,
# the marker rule on a planted repository, the route-drift
# comparison, the mount detection and the docker NOT RUN rule
# on a faked host.
# go go vet ./..., go test -short ./... and the Docker-free
# framework tests of the phase by required name (GO_TESTS).
# plugins sm-seo-plugin and sm-journal-plugin: the workspace check (the
# framework resolves to this checkout), go vet, go test -short
# and the Docker-free tests by required name.
# consumers the demo application: the workspace check, go build ./...
# untagged and with -tags summercms_mcp, go test -short ./...
# both ways, the SEO mount (NOT MOUNTED golem15.seo fails unless
# PHASE32_ALLOW_UNMOUNTED=1: the mount waits for the plugin tag,
# plan 32-06) and the route-drift check (each Journal pattern of
# config/menu_items.yaml equals the route the demo's Nuxt app
# configures for the Journal layer, else the layer default;
# strict URLs 301 valid pages when the two drift).
# docker Docker host only: the Postgres suites by required name in the
# framework, both plugins and, once mounted, the demo (tagged).
# admin the FormView vitest file, scripts/check-admin-openapi.sh
# --check, and scripts/check-admin-dist.sh when admin/src
# changed since the phase base.
# docs TestDocsTree, summer docs:build --check, the grobotstxt
# decision note and its STACK row, and the SEO plugin's
# element typecheck and dist check.
# marker D-90: the lines this phase added to non-test Go files,
# READMEs and docs under modules/ and docs/ name neither SEO
# nor a consuming application; the Journal plugin imports
# neither the SEO nor the sitemap plugin; the framework go.mod
# does not require grobotstxt.
# additive D-106 (the release is a patch tag): the exported API of
# modules/pact, modules/cabana, modules/signpost and every other
# package under modules/ changed since the newest tag, printed
# by scripts/apisurface for a worktree of that tag and for this
# checkout, keeps every line of the tag (no removed
# declaration, field or method, no changed signature, field
# type, struct tag, constant value or underlying type) and adds
# no method or embed to an interface that exists at the tag (a
# new capability goes on a new optional interface).
# compat existing consumers build unchanged against this checkout,
# each through a scratch go.work in a scratch sibling layout:
# sm-sitemap-plugin at HEAD (no change since the phase base;
# build and short tests), sm-journal-plugin at its commit
# before the phase and sm-demo-app at its commit before the
# phase with its pinned submodules (builds, the demo also with
# -tags summercms_mcp, and short tests); every other sibling
# that requires the framework builds at HEAD with its pinned
# submodules ("compat: ok <repo> (build)"), or fails at the
# newest tag too ("compat: skipped <repo> (fails at <tag> too)").
#
# A Go stage exits non-zero on a failing command, or on a go test -json run
# that fails, skips a test (a subtest included), matches zero tests, prints
# "no tests to run" or misses a required name.
#
# Several stages may be given at once (--go --plugins ...). --all runs
# self-test, go, plugins, consumers, admin, docs and marker, then docker
# when a Docker daemon answers (NOT RUN docker otherwise, a failure with
# PHASE32_REQUIRE_DOCKER=1), then additive and compat.
#
# Environment overrides (default: the sibling checkouts next to this
# repository): PHASE32_ROOT, SEO_PLUGIN, JOURNAL_PLUGIN, DEMO_APP,
# SITEMAP_PLUGIN, JOURNAL_LAYER, PHASE32_BASE (the commit before the phase,
# plan 32-01's start), PHASE32_JOURNAL_BASE and PHASE32_DEMO_BASE (the
# Journal and demo commits before the phase, for compat),
# PHASE32_ALLOW_UNMOUNTED, PHASE32_REQUIRE_DOCKER, PHASE32_SKIP_TESTS (a go
# test -skip pattern for the compat stage's short tests, for a test that
# cannot run on this host; each use is printed).
set -euo pipefail
unset FORCE_COLOR
export NO_COLOR=1
ROOT="${PHASE32_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
SEO_PLUGIN="${SEO_PLUGIN:-$ROOT/../sm-seo-plugin}"
JOURNAL_PLUGIN="${JOURNAL_PLUGIN:-$ROOT/../sm-journal-plugin}"
DEMO_APP="${DEMO_APP:-$ROOT/../sm-demo-app}"
JOURNAL_LAYER="${JOURNAL_LAYER:-$ROOT/../vue-journal-layer}"
SITEMAP_PLUGIN="${SITEMAP_PLUGIN:-$ROOT/../sm-sitemap-plugin}"
# The framework commit before the phase (plan 32-01's start).
PHASE32_BASE="${PHASE32_BASE:-9868838fe3d01e07d578f2b2dd557d4296c48e10}"
# Journal's commit before plan 32-02 and the demo's commit before the phase
# (plan 32-05 did not commit to the demo), for the compat stage.
PHASE32_JOURNAL_BASE="${PHASE32_JOURNAL_BASE:-7fc07d88226cb99091e7fdc1422ee5f4a4509132}"
PHASE32_DEMO_BASE="${PHASE32_DEMO_BASE:-8fcdbdd034ac349431e3775c33cb9ff1826e3e32}"
MCP_TAG="summercms_mcp"
SEO_ID="golem15.seo"
# ---------------------------------------------------------------------------
# Required test names: <package>:<TestName> for the framework, names for the
# plugins and the demo (package .).
# go stage: Docker-free framework tests (-short).
GO_TESTS=(
./modules/cabana:TestFormExtensionPlacement ./modules/cabana:TestFormExtensionBootErrors
./modules/cabana:TestFormExtensionUnextendedSchemaUnchanged ./modules/cabana:TestFormExtensionTwoPluginsSameAnchor
./modules/cabana:TestFormExtensionOnePluginTwoControllers ./modules/cabana:TestFormExtensionWidgetsAndPartials
./modules/signpost:TestContentLookups ./modules/signpost:TestContentRecordsOptional
./modules/signpost:TestContentBootErrors ./modules/signpost:TestContentMatchPattern
./modules/signpost:TestContentMatchPath
)
# docker stage: Postgres tests in the framework.
DOCKER_TESTS=(
./modules/cabana:TestFormExtensionTracer ./modules/cabana:TestFormExtensionSave
./modules/cabana:TestRecordEvents
)
# plugins stage: Docker-free tests by required name (<package>:<TestName>).
SEO_SHORT_TESTS=(.:TestEveryAdminOperationHasTool .:TestMCPReadToolsAreGET .:TestLangKeysComplete .:TestNormalize)
JOURNAL_SHORT_TESTS=(.:TestJournalMCP ./classes:TestMenuItemTypesPatterns)
# docker stage: the plugins' and the demo's Postgres suites.
SEO_TESTS=(
TestSEOTabTracer TestSEOTabPermission TestSEOTab TestRuleFormPreview TestHistoryRecordsAdminWrite
TestEntryDeletedWithRecord TestGenerateEntries TestResolverPrecedence
TestSlugChange301 TestRedirectEngine TestLoopRefused TestNotFoundLog TestAddressChangeFromEvent
TestStrictURL TestOneHop TestRobotsServed TestRobotsTester TestRobotsScreen TestVerificationFile
TestMCPSetURLSeoTracer TestManagementAPI TestIssues TestBatch TestUndo TestCSVTransfer
)
JOURNAL_TESTS=(TestContentDescriptors TestJournalTagType TestAPIRecordEvents TestRecordSavedDescribesNewURL TestTranslatedSlug)
DEMO_TESTS=(TestSEOTabMounted TestSEOToolsCatalog)
# admin stage: vitest files that must pass.
ADMIN_FILES=(tests/form/FormView.test.ts)
# docs stage: the decision note and the STACK row of the plugin-only
# dependency.
DECISION_NOTE=".planning/notes/2026-10-27-robots-txt-grobotstxt.md"
STACK_NAMES=("github.com/jimsmart/grobotstxt")
# marker stage: names of consuming applications (split so this file does
# not match itself; the list check-phase19.7.sh uses).
APP_NAMES=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "bm\\.jakub""zych" "grzyby""funkcjonalne" "stu""dia")
usage() {
cat >&2 <<'EOF'
usage:
check-phase32.sh --self-test
check-phase32.sh [--go] [--plugins] [--consumers] [--docker] [--admin] [--docs] [--marker] [--additive] [--compat]
check-phase32.sh --all
EOF
exit 2
}
# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
# 4 non-JSON, 5 missing required name (REQUIRE_TESTS, space-separated).
detect_go() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
passed = set()
failed = []
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
print("refuse: non-json test output", file=sys.stderr)
sys.exit(4)
action = ev.get("Action")
test = ev.get("Test") or ""
pkg = ev.get("Package") or ev.get("ImportPath") or ""
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
print(f"refuse: build failed {pkg}", file=sys.stderr)
sys.exit(1)
text = ev.get("Output") or ""
if action == "output" and "no tests to run" in text:
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
sys.exit(3)
if action == "skip" and test:
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
sys.exit(2)
if action == "fail":
failed.append(f"{pkg} {test}".strip())
if action == "pass" and test:
passed.add(test)
if failed:
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
sys.exit(1)
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
missing = [n for n in require if n not in passed]
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# detect_vitest reads a vitest run's output. Exit 1 failed, skipped or todo
# tests, 3 no all-passing summary, 5 a required file (REQUIRE_FILES) did
# not pass.
detect_vitest() {
python3 - "$1" <<'PY'
import os, re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
need = os.environ.get("REQUIRE_FILES", "").split()
summary = re.findall(r"^\s*(Test Files|Tests)\s+(.*)$", text, re.M)
if any(re.search(r"\b(failed|skipped|todo)\b", rest) for _, rest in summary) or re.search(r"^\s*(FAIL|×)\s", text, re.M):
print("refuse: vitest reports failed, skipped or todo tests", file=sys.stderr)
sys.exit(1)
m = re.search(r"^\s*Tests\s+(\d+) passed \((\d+)\)\s*$", text, re.M)
if not m or m.group(1) != m.group(2) or m.group(1) == "0":
print("refuse: no all-passing vitest summary", file=sys.stderr)
sys.exit(3)
missing = [f for f in need if not re.search(r"^\s*✓ (?:\|\w+\| )?" + re.escape(f) + r" \(\d+ tests?\)", text, re.M)]
if missing:
print("refuse: did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
# go_json DIR ARGS... runs go test -json ARGS in DIR and checks the stream
# with detect_go.
go_json() {
local dir="$1"
shift
local log err rc=0 dc=0
log="$(mktemp)"
err="$(mktemp)"
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
detect_go "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
python3 -c 'import json,sys
for raw in open(sys.argv[1], errors="replace"):
try: ev = json.loads(raw)
except ValueError: continue
out = ev.get("Output", "")
if ev.get("Action") == "output" and ("FAIL" in out or "SKIP" in out or "_test.go:" in out):
sys.stderr.write(out)' "$log" 2>&1 | tail -n 40 >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
return 1
fi
echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)"
rm -f "$log" "$err"
}
# go_named DIR PACKAGE FLAGS NAME... runs exactly the named top-level tests
# and requires every one to pass. FLAGS is word-split.
go_named() {
local dir="$1" pkg="$2" flags="$3"
shift 3
if [[ "$#" -eq 0 ]]; then
echo "refuse: go_named $pkg in $dir without required names" >&2
return 1
fi
local names=("$@") regex
regex="^($(IFS='|'; echo "${names[*]}"))\$"
# shellcheck disable=SC2086
REQUIRE_TESTS="${names[*]}" go_json "$dir" $flags -count=1 -run "$regex" "$pkg"
}
# go_pkg_named DIR FLAGS ENTRY... groups <package>:<TestName> entries by
# package and runs go_named once per package.
go_pkg_named() {
local dir="$1" flags="$2"
shift 2
local entry pkg name pkgs=()
declare -A by=()
for entry in "$@"; do
pkg="${entry%%:*}"
name="${entry#*:}"
if [[ -z "$pkg" || -z "$name" || "$pkg" == "$entry" ]]; then
echo "refuse: malformed required test entry $entry" >&2
return 1
fi
if [[ -z "${by[$pkg]+x}" ]]; then
pkgs+=("$pkg")
by[$pkg]="$name"
else
by[$pkg]="${by[$pkg]} $name"
fi
done
for pkg in "${pkgs[@]}"; do
# shellcheck disable=SC2086
go_named "$dir" "$pkg" "$flags" ${by[$pkg]}
done
}
# go_short DIR [FLAGS] runs go test -short ./... in DIR.
go_short() {
local dir="$1" flags="${2:-}" out
# shellcheck disable=SC2086
if ! out="$(cd "$dir" && go test $flags -count=1 -short ./... 2>&1)"; then
printf '%s\n' "$out" | grep -v '^ok ' | tail -n 60 >&2
echo "refuse: go test -short ./... $flags in $dir" >&2
return 1
fi
echo "short: $dir ($flags)"
}
go_vet() {
local dir="$1"
shift
(cd "$dir" && go vet "$@")
echo "vet: $dir ($*)"
}
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: $2 $1 not found" >&2
return 1
}
}
# workspace_check DIR refuses a repository that does not resolve the
# framework to this checkout (no trust in local go.work files).
workspace_check() {
local dir="$1" got rgot rwant
got="$( (cd "$dir" && go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{else}}{{.Dir}}{{end}}' git.golem15.com/golem15/summercms 2>/dev/null) || true)"
rgot="$(realpath -e -- "$got" 2>/dev/null || true)"
rwant="$(realpath -e -- "$ROOT" 2>/dev/null || true)"
if [[ -z "$got" || -z "$rgot" || "$rgot" != "$rwant" ]]; then
echo "refuse: $dir resolves git.golem15.com/golem15/summercms to ${got:-<nothing>}, not $ROOT" >&2
return 1
fi
echo "workspace: $dir resolves the framework to $rwant"
}
# seo_mounted MANIFEST reports whether an application's summer.yaml lists
# the SEO plugin.
seo_mounted() {
grep -qE "^[[:space:]]*-[[:space:]]*id:[[:space:]]*${SEO_ID//./\\.}[[:space:]]*$" "$1"
}
# mount_verdict DIR prints the mount state of an application and refuses
# an unmounted one unless PHASE32_ALLOW_UNMOUNTED=1.
mount_verdict() {
local dir="$1"
if seo_mounted "$dir/summer.yaml"; then
echo "mount: $dir mounts $SEO_ID"
return 0
fi
echo "NOT MOUNTED $SEO_ID in $dir (the mount waits for the plugin tag: .planning/phases/32-seo-foundation/32-05-demo-mount.patch, plan 32-06)"
if [[ "${PHASE32_ALLOW_UNMOUNTED:-0}" == "1" ]]; then
return 0
fi
echo "refuse: $SEO_ID is not mounted in $dir; set PHASE32_ALLOW_UNMOUNTED=1 before plan 32-06" >&2
return 1
}
# route_drift MENU_ITEMS NUXT_CONFIG LAYER_ROUTES compares each Journal
# pattern of menu_items.yaml (journal-post, journal-category, journal-tag
# when set) with the route the Nuxt app configures for the Journal layer,
# else the layer default; placeholder names do not count. It prints one
# refuse: line per drift and exits 1 on any.
route_drift() {
python3 - "$1" "$2" "$3" <<'PY'
import re, sys
menu_path, nuxt_path, layer_path = sys.argv[1:4]
def norm(p):
return re.sub(r":[A-Za-z_][A-Za-z0-9_]*", ":*", p.strip().strip("'\"")) if isinstance(p, str) else p
menu = {}
for line in open(menu_path, encoding="utf-8"):
m = re.match(r"^\s+(journal-[a-z]+):\s*(\S+)\s*$", line)
if m:
menu[m.group(1)] = m.group(2).strip("'\"")
def routes_of(text, block):
m = re.search(block, text, re.S)
out = {}
if not m:
return out
for k, v in re.findall(r"(\w+):\s*('[^']*'|\"[^\"]*\"|false)", m.group(1)):
out[k] = False if v == "false" else v.strip("'\"")
return out
layer = {}
try:
layer = routes_of(open(layer_path, encoding="utf-8").read(), r"DEFAULT_JOURNAL_ROUTES[^{]*\{(.*?)\}")
except OSError:
pass
nuxt = {}
try:
nuxt = routes_of(open(nuxt_path, encoding="utf-8").read(), r"journal:\s*\{\s*routes:\s*\{(.*?)\}")
except OSError:
pass
drift = 0
for item, key in (("journal-post", "post"), ("journal-category", "category"), ("journal-tag", "tag")):
if item not in menu:
continue
want = nuxt.get(key, layer.get(key))
if want is None:
print(f"refuse: no Nuxt route for {key} (menu_items {item} {menu[item]})", file=sys.stderr)
drift += 1
continue
if want is False or norm(want) != norm(menu[item]):
print(f"refuse: route drift: menu_items {item} {menu[item]} but the Nuxt {key} route is {want}", file=sys.stderr)
drift += 1
continue
print(f"route: {item} {menu[item]} matches the Nuxt {key} route {want}")
sys.exit(1 if drift else 0)
PY
}
# marker_hits DIR BASE prints file:line for every line added since BASE to
# a non-test Go file, a README or a docs page under modules/ or docs/ that
# names SEO or a consuming application (D-90).
marker_hits() {
local dir="$1" base="$2" pattern
pattern="\\bseo\\b|$(IFS='|'; echo "${APP_NAMES[*]}")"
git -C "$dir" diff -U0 "$base" HEAD -- 'modules/*.go' 'modules/**/*.go' 'modules/**/README.md' 'docs' ':(exclude)*_test.go' ':(exclude)**/*_test.go' |
PATTERN="$pattern" python3 -c '
import os, re, sys
pat = re.compile(os.environ["PATTERN"], re.I)
path, line = "", 0
for raw in sys.stdin:
if raw.startswith("+++ "):
path = raw[6:].rstrip("\n") if raw.startswith("+++ b/") else ""
continue
m = re.match(r"^@@ -\S+ \+(\d+)", raw)
if m:
line = int(m.group(1))
continue
if raw.startswith("+") and not raw.startswith("+++"):
if path and pat.search(raw[1:]):
print(f"{path}:{line}: {raw[1:].strip()[:120]}")
line += 1
'
}
expect_detector() {
local detector="$1" name="$2" want="$3" payload="$4" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
"$detector" "$log" >/dev/null 2>&1 || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test $detector $name: exit $dc, want $want" >&2
return 1
fi
echo "PASS self-test $detector $name"
}
# expect_mode NAME WANT_EXIT WANT_TEXT MODE ENV... runs this script in MODE
# with ENV and checks its exit status and output.
expect_mode() {
local name="$1" want="$2" text="$3" mode="$4"
shift 4
local out rc=0
out="$(env "$@" bash "${BASH_SOURCE[0]}" "$mode" 2>&1)" || rc=$?
if [[ "$rc" -ne "$want" ]] || ! grep -qF -- "$text" <<<"$out"; then
printf '%s\n' "$out" | tail -n 10 >&2
echo "refuse: self-test $name: exit $rc (want $want), output must carry \"$text\"" >&2
return 1
fi
echo "PASS self-test $name"
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestBatch"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestUndo"}'
expect_detector detect_go build 1 '{"Action":"build-fail","ImportPath":"p"}'
expect_detector detect_go skip 2 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"skip","Package":"p","Test":"TestSEOTabMounted"}'
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestLoopRefused/mcp"}
{"Action":"pass","Package":"p","Test":"TestLoopRefused"}'
expect_detector detect_go zero 3 '{"Action":"pass","Package":"p"}'
expect_detector detect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
{"Action":"pass","Package":"p"}'
expect_detector detect_go nonjson 4 '{"Action":"pass",'
REQUIRE_TESTS="TestBatch TestUndo" expect_detector detect_go missing-named 5 '{"Action":"pass","Package":"p","Test":"TestBatch"}'
REQUIRE_TESTS="TestLoopRefused" expect_detector detect_go subtest-is-not-parent 5 '{"Action":"pass","Package":"p","Test":"TestOuter/TestLoopRefused"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local ok=' ✓ tests/form/FormView.test.ts (12 tests) 15ms
Test Files 1 passed (1)
Tests 12 passed (12)'
REQUIRE_FILES="tests/form/FormView.test.ts" expect_detector detect_vitest pass 0 "$ok"
REQUIRE_FILES="tests/form/Other.test.ts" expect_detector detect_vitest missing-file 5 "$ok"
expect_detector detect_vitest failed 1 ' Test Files 1 failed | 1 passed (2)
Tests 1 failed | 16 passed (17)'
expect_detector detect_vitest no-summary 3 ' ✓ tests/form/FormView.test.ts (12 tests) 15ms'
local tmp
tmp="$(mktemp -d)"
# The marker rule on a planted repository: an added SEO name or
# application name in a module source or a docs page is found; a test
# file, and a line that was there before the base, are not.
git -C "$tmp" init -q
mkdir -p "$tmp/modules/cabana" "$tmp/docs"
printf 'package cabana\n\n// Old mentions SEO from before the phase.\nfunc Old() {}\n' >"$tmp/modules/cabana/old.go"
git -C "$tmp" add -A
git -C "$tmp" -c user.name=t -c user.email=t@example.test commit -q -m base
local base
base="$(git -C "$tmp" rev-parse HEAD)"
printf 'package cabana\n\n// Extension adds fields to a form.\nfunc Extension() {}\n' >"$tmp/modules/cabana/ext.go"
printf 'package cabana\n\n// The SEO tab test.\nfunc helper() {}\n' >"$tmp/modules/cabana/ext_test.go"
git -C "$tmp" add -A
git -C "$tmp" -c user.name=t -c user.email=t@example.test commit -q -m clean
if [[ -n "$(marker_hits "$tmp" "$base")" ]]; then
rm -rf "$tmp"
echo "refuse: self-test marker_hits flagged a clean change or a test file" >&2
return 1
fi
printf 'package cabana\n\n// Extension adds the SEO tab.\nfunc Extension() {}\n' >"$tmp/modules/cabana/ext.go"
printf '# Forms\n\nUsed by fono''teka.\n' >"$tmp/docs/forms.md"
git -C "$tmp" add -A
git -C "$tmp" -c user.name=t -c user.email=t@example.test commit -q -m dirty
local hits
hits="$(marker_hits "$tmp" "$base")"
if ! grep -q '^modules/cabana/ext.go:3:' <<<"$hits" || ! grep -q '^docs/forms.md:3:' <<<"$hits"; then
rm -rf "$tmp"
echo "refuse: self-test marker_hits missed a planted name: $hits" >&2
return 1
fi
echo "PASS self-test marker_hits"
# The route-drift comparison: placeholder names do not count, a moved
# route and a disabled one do.
mkdir -p "$tmp/rd"
printf 'patterns:\n journal-post: /blog/:slug\n journal-category: /blog/category/:slug\n' >"$tmp/rd/menu.yaml"
printf "export default {\n journal: {\n routes: {\n post: '/blog/:slug',\n category: '/blog/category/:category',\n tag: false,\n },\n },\n}\n" >"$tmp/rd/nuxt.ts"
printf "export const DEFAULT_JOURNAL_ROUTES = Object.freeze({\n post: '/blog/:slug',\n category: '/blog/category/:category',\n tag: '/blog/tag/:tag',\n})\n" >"$tmp/rd/routes.ts"
if ! route_drift "$tmp/rd/menu.yaml" "$tmp/rd/nuxt.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1; then
rm -rf "$tmp"
echo "refuse: self-test route_drift refused matching routes" >&2
return 1
fi
printf 'patterns:\n journal-post: /news/:slug\n' >"$tmp/rd/moved.yaml"
printf 'patterns:\n journal-tag: /blog/tag/:slug\n' >"$tmp/rd/tag.yaml"
if route_drift "$tmp/rd/moved.yaml" "$tmp/rd/nuxt.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1 ||
route_drift "$tmp/rd/tag.yaml" "$tmp/rd/nuxt.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1; then
rm -rf "$tmp"
echo "refuse: self-test route_drift accepted a moved or disabled route" >&2
return 1
fi
if ! route_drift "$tmp/rd/menu.yaml" "$tmp/rd/missing.ts" "$tmp/rd/routes.ts" >/dev/null 2>&1; then
rm -rf "$tmp"
echo "refuse: self-test route_drift did not fall back to the layer defaults" >&2
return 1
fi
echo "PASS self-test route_drift"
# The mount detection and the NOT MOUNTED rule.
printf 'plugins:\n - id: golem15.sitemap\n module: x\n - id: golem15.seo\n module: y\n' >"$tmp/mounted.yaml"
printf 'plugins:\n - id: golem15.sitemap\n module: x\n - id: golem15.seox\n module: y\n' >"$tmp/unmounted.yaml"
if ! seo_mounted "$tmp/mounted.yaml" || seo_mounted "$tmp/unmounted.yaml"; then
rm -rf "$tmp"
echo "refuse: self-test seo_mounted misread a manifest" >&2
return 1
fi
mkdir -p "$tmp/app"
cp "$tmp/unmounted.yaml" "$tmp/app/summer.yaml"
expect_mode not-mounted 1 "NOT MOUNTED golem15.seo" --mount-verdict "DEMO_APP=$tmp/app" PHASE32_ALLOW_UNMOUNTED=0
expect_mode not-mounted-allowed 0 "NOT MOUNTED golem15.seo" --mount-verdict "DEMO_APP=$tmp/app" PHASE32_ALLOW_UNMOUNTED=1
# The docker NOT RUN rule: a docker that does not answer.
mkdir -p "$tmp/bin"
printf '#!/bin/sh\nexit 1\n' >"$tmp/bin/docker"
chmod +x "$tmp/bin/docker"
expect_mode docker-not-run 0 "NOT RUN docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE32_REQUIRE_DOCKER=0
expect_mode docker-required 1 "FAIL docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE32_REQUIRE_DOCKER=1
expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker "PATH=$tmp/bin:$PATH"
rm -rf "$tmp"
self_test_additive
echo "phase32 self-test passed"
}
# additive_case TOOL NAME WANT BASE_SRC HEAD_SRC runs a planted package pair
# through scripts/apisurface and additive_compare; WANT is the line the
# comparison must print ("removed or changed", "method added to existing
# interface" or "ok").
additive_case() {
local tool="$1" name="$2" want="$3" dir out rc=0 ok=0
dir="$(mktemp -d)"
mkdir -p "$dir/base" "$dir/head"
printf '%s\n' "$4" >"$dir/base/p.go"
printf '%s\n' "$5" >"$dir/head/p.go"
if api_surface "$tool" "$dir/base" "$dir/base.api" && api_surface "$tool" "$dir/head" "$dir/head.api"; then
out="$(additive_compare "selftest/$name" "$dir/base.api" "$dir/head.api" 2>&1)" || rc=$?
case "$want" in
ok) [[ "$rc" -eq 0 ]] && grep -q '^additive: ok selftest/' <<<"$out" && ok=1 ;;
*) [[ "$rc" -ne 0 ]] && grep -q "^additive: $want in selftest/$name: " <<<"$out" && ok=1 ;;
esac
fi
rm -rf -- "${dir:?}"
if [[ "$ok" -ne 1 ]]; then
printf '%s\n' "${out:-}" >&2
echo "refuse: self-test additive $name (want $want, exit $rc)"
return 1
fi
echo "PASS self-test additive $name"
}
# self_test_additive plants the refused and the accepted API changes.
self_test_additive() {
local tmp tool failed=0
tmp="$(mktemp -d)"
tool="$tmp/apisurface"
(cd "$ROOT" && go build -o "$tool" ./scripts/apisurface)
mkdir -p "$tmp/empty"
local rc=0
"$tool" "$tmp/empty" >/dev/null 2>&1 || rc=$?
if [[ "$rc" -ne 2 ]]; then
rm -rf -- "${tmp:?}"
echo "refuse: self-test apisurface: exit $rc for a directory without Go files, want 2" >&2
return 1
fi
echo "PASS self-test apisurface no-go-files"
local p=$'package p\n\n'
additive_case "$tool" removed-func "removed or changed" "${p}func A() {}"$'\n\n'"func B() {}" "${p}func A() {}" || failed=1
additive_case "$tool" changed-param "removed or changed" "${p}func F(n int) {}" "${p}func F(n int64) {}" || failed=1
additive_case "$tool" multiline-param-added "removed or changed" \
"${p}"$'func F(\n\ta int,\n\tb string,\n) error {\n\treturn nil\n}' \
"${p}"$'func F(\n\ta int,\n\tb string,\n\tc bool,\n) error {\n\treturn nil\n}' || failed=1
additive_case "$tool" interface-method-added "method added to existing interface" \
"${p}"$'// HasThing is an optional capability.\ntype HasThing interface {\n\tThing() string\n}' \
"${p}"$'// HasThing is an optional capability.\ntype HasThing interface {\n\tThing() string\n\tOther() error\n}' || failed=1
additive_case "$tool" field-type-changed "removed or changed" "${p}"$'type S struct {\n\tA int\n}' "${p}"$'type S struct {\n\tA int64\n}' || failed=1
additive_case "$tool" const-value-changed "removed or changed" "${p}const C = 1" "${p}const C = 2" || failed=1
additive_case "$tool" func-added ok "${p}func A() {}" "${p}func A() {}"$'\n\n'"func B(s string) error { return nil }" || failed=1
additive_case "$tool" struct-field-added ok \
"${p}"$'type S struct {\n\tA int\n\tB string `json:"b"`\n}' \
"${p}"$'type S struct {\n\tA int\n\tB string `json:"b"`\n\tLonger bool\n}' || failed=1
additive_case "$tool" optional-interface-added ok \
"${p}"$'type HasThing interface {\n\tThing() string\n}' \
"${p}"$'type HasThing interface {\n\tThing() string\n}\n\n// HasOther is a new optional capability.\ntype HasOther interface {\n\tOther() error\n\tMore() int\n}' || failed=1
additive_case "$tool" method-added-to-struct ok \
"${p}"$'type S struct{}\n\nfunc (S) A() {}' \
"${p}"$'type S struct{}\n\nfunc (S) A() {}\n\nfunc (s *S) B(n int) error { return nil }' || failed=1
additive_case "$tool" param-renamed ok "${p}func F(a int, b string) {}" "${p}func F(count int, label string) {}" || failed=1
additive_case "$tool" comment-edited ok "${p}"$'// F does x.\nfunc F() {}' "${p}"$'// F does y,\n// in two lines.\nfunc F() {}' || failed=1
rm -rf -- "${tmp:?}"
if [[ "$failed" -ne 0 ]]; then
return 1
fi
}
run_go() {
go_vet "$ROOT" ./...
go_short "$ROOT"
go_pkg_named "$ROOT" "-short" "${GO_TESTS[@]}"
echo "phase32 go passed"
}
run_plugins() {
require_dir "$SEO_PLUGIN" "SEO plugin"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
workspace_check "$SEO_PLUGIN"
workspace_check "$JOURNAL_PLUGIN"
go_vet "$SEO_PLUGIN" ./...
go_short "$SEO_PLUGIN"
go_pkg_named "$SEO_PLUGIN" "-short" "${SEO_SHORT_TESTS[@]}"
go_vet "$JOURNAL_PLUGIN" ./...
go_short "$JOURNAL_PLUGIN"
go_pkg_named "$JOURNAL_PLUGIN" "-short" "${JOURNAL_SHORT_TESTS[@]}"
echo "phase32 plugins passed"
}
run_consumers() {
require_dir "$DEMO_APP" "demo application"
workspace_check "$DEMO_APP"
(cd "$DEMO_APP" && go build ./...)
echo "build: $DEMO_APP"
(cd "$DEMO_APP" && go build -tags "$MCP_TAG" ./...)
echo "build: $DEMO_APP (-tags $MCP_TAG)"
go_short "$DEMO_APP"
go_short "$DEMO_APP" "-tags $MCP_TAG"
mount_verdict "$DEMO_APP"
route_drift "$DEMO_APP/config/menu_items.yaml" "$DEMO_APP/vue-demo-app/nuxt.config.ts" "$JOURNAL_LAYER/app/utils/routes.ts"
echo "phase32 consumers passed"
}
docker_up() {
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
}
run_docker() {
if ! docker_up; then
echo "refuse: the docker stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2
return 1
fi
require_dir "$SEO_PLUGIN" "SEO plugin"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
require_dir "$DEMO_APP" "demo application"
workspace_check "$SEO_PLUGIN"
workspace_check "$JOURNAL_PLUGIN"
go_pkg_named "$ROOT" "-timeout 20m" "${DOCKER_TESTS[@]}"
go_named "$SEO_PLUGIN" . "-timeout 20m" "${SEO_TESTS[@]}"
go_named "$JOURNAL_PLUGIN" . "-timeout 20m" "${JOURNAL_TESTS[@]}"
if seo_mounted "$DEMO_APP/summer.yaml"; then
workspace_check "$DEMO_APP"
go_named "$DEMO_APP" . "-timeout 20m -tags $MCP_TAG" "${DEMO_TESTS[@]}"
else
mount_verdict "$DEMO_APP"
fi
echo "phase32 docker passed"
}
# docker_gate is the docker step of --all: run the stage when a daemon
# answers, fail with PHASE32_REQUIRE_DOCKER=1, print NOT RUN otherwise.
docker_gate() {
if docker_up; then
if bash "${BASH_SOURCE[0]}" --docker; then
echo "PASS docker"
return 0
fi
echo "FAIL docker"
return 1
fi
if [[ "${PHASE32_REQUIRE_DOCKER:-0}" == "1" ]]; then
echo "FAIL docker: no reachable Docker daemon and PHASE32_REQUIRE_DOCKER=1"
return 1
fi
cat >&2 <<'EOF'
************************************************************************
NOT RUN docker: no reachable Docker daemon on this host.
The Postgres suites of the framework, the SEO and Journal plugins and the
demo did NOT run. Run on a Docker host (through sudo -u golem when the
socket is denied) before /msd-verify-work:
bash scripts/check-phase32.sh --docker
************************************************************************
EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
}
run_admin() {
local log rc=0 dc=0
log="$(mktemp)"
npm --prefix "$ROOT/admin" run test >"$log" 2>&1 || rc=$?
REQUIRE_FILES="${ADMIN_FILES[*]}" detect_vitest "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E '(FAIL|×|Error|expected)' "$log" | head -n 40 >&2 || true
tail -n 20 "$log" >&2
rm -f "$log"
echo "refuse: npm run test in admin (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest (admin): $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
(cd "$ROOT" && bash scripts/check-admin-openapi.sh --check)
if git -C "$ROOT" diff --quiet "$PHASE32_BASE" HEAD -- admin/src; then
echo "admin: admin/src unchanged since $PHASE32_BASE, dist check not needed"
else
(cd "$ROOT" && bash scripts/check-admin-dist.sh)
fi
echo "phase32 admin passed"
}
run_docs() {
go_named "$ROOT" ./cmd/summer "" TestDocsTree
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
if [[ ! -f "$ROOT/$DECISION_NOTE" ]] || ! grep -qF "grobotstxt" "$ROOT/$DECISION_NOTE"; then
echo "refuse: the decision note $DECISION_NOTE is missing or does not name grobotstxt" >&2
return 1
fi
echo "note: $DECISION_NOTE"
local name
for name in "${STACK_NAMES[@]}"; do
if ! grep -qF -- "| \`$name\`" "$ROOT/.planning/research/STACK.md"; then
echo "refuse: .planning/research/STACK.md has no row for $name" >&2
return 1
fi
done
echo "stack: rows for ${STACK_NAMES[*]}"
require_dir "$SEO_PLUGIN" "SEO plugin"
npm --prefix "$SEO_PLUGIN" run typecheck >/dev/null
echo "typecheck: $SEO_PLUGIN elements"
bash "$SEO_PLUGIN/scripts/check-dist.sh"
echo "phase32 docs passed"
}
run_marker() {
if ! git -C "$ROOT" rev-parse --verify -q "$PHASE32_BASE^{commit}" >/dev/null; then
echo "refuse: PHASE32_BASE $PHASE32_BASE is not a commit of $ROOT" >&2
return 1
fi
local hits imports
hits="$(marker_hits "$ROOT" "$PHASE32_BASE")"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | head -n 40 >&2
echo "refuse: framework lines added since $PHASE32_BASE name SEO or a consuming application (D-90)" >&2
return 1
fi
echo "marker: no line added since ${PHASE32_BASE:0:7} under modules/ or docs/ names SEO or a consuming application"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
imports="$(grep -rnE '"git\.golem15\.com/golem15/sm-(seo|sitemap)-plugin' --include='*.go' "$JOURNAL_PLUGIN" || true)"
if [[ -n "$imports" ]]; then
printf '%s\n' "$imports" >&2
echo "refuse: the Journal plugin imports the SEO or the sitemap plugin (D-90: content plugins import nothing)" >&2
return 1
fi
echo "journal: imports neither sm-seo-plugin nor sm-sitemap-plugin"
if grep -q 'jimsmart/grobotstxt' "$ROOT/go.mod"; then
echo "refuse: the framework go.mod requires grobotstxt (plugin-only dependency)" >&2
return 1
fi
echo "go.mod: the framework does not require grobotstxt"
echo "phase32 marker passed"
}
# ---------------------------------------------------------------------------
# additive and compat (plan 32-06, D-106: the release is a patch tag, so the
# framework change must be strictly additive and existing consumers must
# build unchanged).
# scratch_init creates the stage's scratch directory and the exit trap that
# removes it and every worktree registered with add_worktree.
SCRATCH=""
CLEANUP_WORKTREES=()
scratch_cleanup() {
local entry
for entry in "${CLEANUP_WORKTREES[@]}"; do
git -c safe.directory='*' -C "${entry%%|*}" worktree remove --force "${entry#*|}" >/dev/null 2>&1 || true
git -c safe.directory='*' -C "${entry%%|*}" worktree prune >/dev/null 2>&1 || true
done
if [[ -n "$SCRATCH" && -d "$SCRATCH" ]]; then
chmod -R u+w -- "${SCRATCH:?}" 2>/dev/null || true
rm -rf -- "${SCRATCH:?}"
fi
}
scratch_init() {
SCRATCH="$(mktemp -d)"
trap scratch_cleanup EXIT
}
# add_worktree REPO PATH REV adds a detached worktree (git worktree add
# --detach) that the exit trap removes again.
add_worktree() {
git -c safe.directory='*' -C "$1" worktree add --detach -q "$2" "$3"
CLEANUP_WORKTREES+=("$1|$2")
}
# snapshot GITDIR REV DEST writes the tree of REV into DEST through a
# temporary index: nothing is written to the repository, so read-only
# checkouts (other users' repositories, submodule git dirs) work too.
snapshot() {
local gitdir="$1" rev="$2" dest="$3" index rc=0
index="${SCRATCH:?}/index.$RANDOM$RANDOM"
mkdir -p "$dest"
GIT_INDEX_FILE="$index" git -c safe.directory='*' --git-dir="$gitdir" read-tree "$rev" >/dev/null 2>&1 &&
GIT_INDEX_FILE="$index" git -c safe.directory='*' --git-dir="$gitdir" --work-tree="$dest" checkout-index -a -f >/dev/null 2>&1 || rc=$?
rm -f -- "$index" "$index.lock"
return "$rc"
}
# git_dir_of DIR prints the absolute git dir of the checkout in DIR.
git_dir_of() {
git -c safe.directory='*' -C "$1" rev-parse --absolute-git-dir 2>/dev/null
}
# snapshot_submodules REPO REV DEST fills every gitlink of REV under DEST
# with a snapshot of its pinned commit, read from REPO's submodule checkout
# (or REPO/.git/modules/<path>). A gitlink without a local copy of the
# pinned commit is reported and left empty; the build then shows it.
snapshot_submodules() {
local repo="$1" rev="$2" dest="$3" mode type sha path gd
while read -r mode type sha path; do
[[ "$mode" == "160000" ]] || continue
gd=""
if [[ -e "$repo/$path/.git" ]]; then
gd="$(git_dir_of "$repo/$path" || true)"
elif [[ -d "$repo/.git/modules/$path" ]]; then
gd="$repo/.git/modules/$path"
fi
if [[ -z "$gd" ]] || ! snapshot "$gd" "$sha" "$dest/$path"; then
echo "compat: note $(basename "$repo"): submodule $path at ${sha:0:12} is not available locally" >&2
fi
done < <(git -c safe.directory='*' -C "$repo" ls-tree -r "$rev")
}
# sibling_farm DIR SKIP creates DIR with a symlink to every sibling of this
# checkout except SKIP (the consumer placed there), so relative references
# of the consumer (go.mod and go.work replaces, tests reading
# ../summercms.go) resolve as they do next to the real checkouts.
sibling_farm() {
local farm="$1" skip="$2" parent entry
parent="$(dirname "$ROOT")"
mkdir -p "$farm"
for entry in "$parent"/*; do
[[ "$(basename "$entry")" == "$skip" ]] && continue
ln -s "$entry" "$farm/$(basename "$entry")"
done
}
# scratch_gowork APP FRAMEWORK prints a go.work for APP (a checkout placed in
# a sibling farm): APP's own go.work (or "use ." when it has none) with every
# use and replace path made absolute, any replace of the framework dropped,
# a use of this checkout pointed at FRAMEWORK, and the framework replaced by
# FRAMEWORK.
scratch_gowork() {
python3 - "$1" "$2" "$ROOT" <<'PY'
import os, re, sys
app, framework, root = sys.argv[1:4]
module = "git.golem15.com/golem15/summercms"
real_root = os.path.realpath(root)
used = False
def absolute(path):
global used
full = path if path.startswith("/") else os.path.normpath(os.path.join(app, path))
if os.path.realpath(full) == real_root:
used = True
return framework
return full
def replace(spec):
left, right = (part.strip() for part in spec.split("=>", 1))
if left.split()[0] == module:
return None
target = right.split()
if len(target) == 1 and target[0].startswith((".", "/")):
right = absolute(target[0])
return f"{left} => {right}"
src = os.path.join(app, "go.work")
if os.path.exists(src):
text = open(src, encoding="utf-8").read()
else:
gover = "1.27.0"
for line in open(os.path.join(app, "go.mod"), encoding="utf-8"):
m = re.match(r"^go\s+(\S+)", line)
if m:
gover = m.group(1)
break
text = f"go {gover}\n\nuse .\n"
out, block = [], None
for line in text.splitlines():
code = line.split("//", 1)[0].strip()
if block is not None:
if code == ")":
block = None
out.append(")")
elif code and block == "use":
out.append("\t" + absolute(code))
elif code:
rewritten = replace(code)
if rewritten is not None:
out.append("\t" + rewritten)
continue
if code in ("use (", "replace ("):
block = code.split()[0]
out.append(code)
continue
m = re.fullmatch(r"use\s+(\S+)", code)
if m:
out.append("use " + absolute(m.group(1)))
continue
m = re.fullmatch(r"replace\s+(.+)", code)
if m:
rewritten = replace(m.group(1))
if rewritten is not None:
out.append("replace " + rewritten)
continue
out.append(line)
if not used:
out += ["", f"replace {module} => {framework}"]
print("\n".join(out))
PY
}
# consumer_run NAME APP FRAMEWORK CMD... writes APP's scratch go.work outside
# APP, checks that the framework resolves to FRAMEWORK and runs CMD in APP
# with that workspace. The output goes to a log whose last lines are printed
# on failure unless COMPAT_QUIET=1.
consumer_run() {
local name="$1" app="$2" framework="$3" work got rc=0
shift 3
work="${SCRATCH:?}/work/$name.$(basename "$framework")"
mkdir -p "$work"
if [[ ! -f "$work/go.work" ]]; then
scratch_gowork "$app" "$framework" >"$work/go.work"
fi
got="$( (cd "$app" && GOWORK="$work/go.work" go list -m -f '{{if .Replace}}{{.Replace.Dir}}{{else}}{{.Dir}}{{end}}' git.golem15.com/golem15/summercms 2>/dev/null) || true)"
if [[ -z "$got" || "$(realpath -e -- "$got" 2>/dev/null || true)" != "$(realpath -e -- "$framework")" ]]; then
echo "refuse: compat: $name resolves git.golem15.com/golem15/summercms to ${got:-<nothing>}, not $framework" >&2
return 1
fi
(cd "$app" && GOWORK="$work/go.work" "$@") >"$work/log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 && "${COMPAT_QUIET:-0}" != "1" ]]; then
grep -v '^ok ' "$work/log" | tail -n 40 >&2 || true
echo "refuse: compat: $name: $* (exit $rc)" >&2
fi
return "$rc"
}
# last_tag prints the newest release tag of this checkout.
last_tag() {
git -C "$ROOT" tag --sort=-v:refname | head -n 1
}
# base_worktree creates the last tag's worktree once (BASE_TREE).
BASE_TREE=""
base_worktree() {
if [[ -z "$BASE_TREE" ]]; then
BASE_TREE="${SCRATCH:?}/base"
add_worktree "$ROOT" "$BASE_TREE" "$(last_tag)"
fi
}
# additive_compare PKG BASE_API HEAD_API refuses every API line of the base
# missing from HEAD (removed or changed) and every interface method or embed
# HEAD adds to an interface that exists in the base; it prints
# "additive: ok PKG (+N lines)" otherwise.
additive_compare() {
local pkg="$1" base="$2" head="$3" line name refused=0 added
while IFS= read -r line; do
[[ -n "$line" ]] || continue
echo "additive: removed or changed in $pkg: $line" >&2
refused=1
done < <(LC_ALL=C comm -23 "$base" "$head")
while IFS= read -r line; do
[[ "$line" =~ ^(imethod|iembed)\ ([A-Za-z0-9_]+)\. ]] || continue
name="${BASH_REMATCH[2]}"
if grep -qE "^type ${name}(\[.*\])? interface\$" "$base"; then
echo "additive: method added to existing interface in $pkg: $line" >&2
refused=1
fi
done < <(LC_ALL=C comm -13 "$base" "$head")
if [[ "$refused" -ne 0 ]]; then
return 1
fi
added="$(LC_ALL=C comm -13 "$base" "$head" | grep -c . || true)"
echo "additive: ok $pkg (+$added lines)"
}
# api_surface TOOL DIR OUT writes DIR's sorted API to OUT; a directory that
# does not exist or has no Go files has an empty API.
api_surface() {
local tool="$1" dir="$2" out="$3" rc=0
if [[ ! -d "$dir" ]]; then
: >"$out"
return 0
fi
"$tool" "$dir" >"$out.raw" 2>"$out.err" || rc=$?
if [[ "$rc" -eq 2 ]]; then
: >"$out.raw"
elif [[ "$rc" -ne 0 ]]; then
cat "$out.err" >&2
echo "refuse: apisurface $dir (exit $rc)" >&2
return 1
fi
LC_ALL=C sort "$out.raw" >"$out"
}
# additive_packages TAG BASE prints modules/pact, modules/cabana,
# modules/signpost and every other directory under modules/ holding a
# non-test .go file changed since TAG (uncommitted changes included) that
# exists at TAG.
additive_packages() {
local tag="$1" base="$2" dir
{
printf '%s\n' modules/pact modules/cabana modules/signpost
git -C "$ROOT" diff --name-only "$tag" -- modules |
grep -E '\.go$' | grep -vE '_test\.go$' | xargs -r -n1 dirname
} | LC_ALL=C sort -u | while IFS= read -r dir; do
if [[ -d "$base/$dir" ]]; then
echo "$dir"
fi
done
}
run_additive() {
local tag dir name failed=0 pkgs=()
tag="$(last_tag)"
if [[ -z "$tag" ]]; then
echo "refuse: additive: $ROOT has no release tag" >&2
return 1
fi
scratch_init
base_worktree
(cd "$ROOT" && go build -o "$SCRATCH/apisurface" ./scripts/apisurface)
mapfile -t pkgs < <(additive_packages "$tag" "$BASE_TREE")
mkdir -p "$SCRATCH/api"
for dir in "${pkgs[@]}"; do
name="${dir//\//_}"
api_surface "$SCRATCH/apisurface" "$BASE_TREE/$dir" "$SCRATCH/api/$name.base"
api_surface "$SCRATCH/apisurface" "$ROOT/$dir" "$SCRATCH/api/$name.head"
additive_compare "$dir" "$SCRATCH/api/$name.base" "$SCRATCH/api/$name.head" || failed=1
done
if [[ "$failed" -ne 0 ]]; then
echo "refuse: additive: the framework API changed incompatibly since $tag (D-106: a patch release is strictly additive)" >&2
return 1
fi
echo "phase32 additive passed (${#pkgs[@]} packages against $tag)"
}
# compat_tested NAME APP FRAMEWORK builds APP (also with each build tag in
# COMPAT_TAGS) and runs its short tests.
compat_tested() {
local name="$1" app="$2" framework="$3" tags
consumer_run "$name" "$app" "$framework" go build ./...
for tags in ${COMPAT_TAGS:-}; do
consumer_run "$name" "$app" "$framework" go build -tags "$tags" ./...
done
if [[ -n "${PHASE32_SKIP_TESTS:-}" ]]; then
echo "compat: note $name: tests matching $PHASE32_SKIP_TESTS skipped (PHASE32_SKIP_TESTS)" >&2
consumer_run "$name" "$app" "$framework" go test -count=1 -short -skip "$PHASE32_SKIP_TESTS" ./...
else
consumer_run "$name" "$app" "$framework" go test -count=1 -short ./...
fi
echo "compat: ok $name"
}
run_compat() {
local parent farm repo name gd real failed=0 base_date tag skip=()
parent="$(dirname "$ROOT")"
tag="$(last_tag)"
require_dir "$SITEMAP_PLUGIN" "sitemap plugin"
require_dir "$JOURNAL_PLUGIN" "Journal plugin"
require_dir "$DEMO_APP" "demo application"
export GOPRIVATE="${GOPRIVATE:-git.golem15.com/*}"
scratch_init
# (a) sm-sitemap-plugin, which the phase must not touch, at HEAD.
base_date="$(git -C "$ROOT" log -1 --format=%cI "$PHASE32_BASE")"
if [[ -n "$(git -c safe.directory='*' -C "$SITEMAP_PLUGIN" status --porcelain)" ]] ||
[[ -n "$(git -c safe.directory='*' -C "$SITEMAP_PLUGIN" log --oneline --since="$base_date" HEAD)" ]]; then
echo "refuse: compat: $SITEMAP_PLUGIN changed since the phase base ($base_date); it must build unchanged" >&2
return 1
fi
name="$(basename "$SITEMAP_PLUGIN")"
farm="$SCRATCH/a"
sibling_farm "$farm" "$name"
snapshot "$(git_dir_of "$SITEMAP_PLUGIN")" HEAD "$farm/$name"
compat_tested "$name" "$farm/$name" "$ROOT"
# (b) sm-journal-plugin at its commit before the phase.
name="$(basename "$JOURNAL_PLUGIN")"
farm="$SCRATCH/b"
sibling_farm "$farm" "$name"
add_worktree "$JOURNAL_PLUGIN" "$farm/$name" "$PHASE32_JOURNAL_BASE"
compat_tested "$name" "$farm/$name" "$ROOT"
# (c) sm-demo-app at its commit before the phase, with the submodules
# pinned there.
name="$(basename "$DEMO_APP")"
farm="$SCRATCH/c"
sibling_farm "$farm" "$name"
add_worktree "$DEMO_APP" "$farm/$name" "$PHASE32_DEMO_BASE"
snapshot_submodules "$DEMO_APP" "$PHASE32_DEMO_BASE" "$farm/$name"
COMPAT_TAGS="$MCP_TAG" compat_tested "$name" "$farm/$name" "$ROOT"
# (d) every other sibling that requires the framework, at HEAD with its
# pinned submodules: it builds, or it fails at the last tag too.
for repo in "$ROOT" "$SITEMAP_PLUGIN" "$JOURNAL_PLUGIN" "$DEMO_APP" "$SEO_PLUGIN"; do
skip+=("$(realpath -m -- "$repo")")
done
for repo in "$parent"/*; do
[[ -f "$repo/go.mod" ]] || continue
grep -qE '^[[:space:]]*(require[[:space:]]+)?git\.golem15\.com/golem15/summercms[[:space:]]+v' "$repo/go.mod" || continue
real="$(realpath -m -- "$repo")"
if printf '%s\n' "${skip[@]}" | grep -qxF -- "$real"; then
continue
fi
name="$(basename "$repo")"
gd="$(git_dir_of "$repo" || true)"
if [[ -z "$gd" ]]; then
echo "compat: skipped $name (not a git checkout)"
continue
fi
farm="$SCRATCH/d/$name"
sibling_farm "$farm" "$name"
if ! snapshot "$gd" HEAD "$farm/$name"; then
echo "refuse: compat: cannot read $name at HEAD" >&2
failed=1
continue
fi
snapshot_submodules "$repo" HEAD "$farm/$name"
if COMPAT_QUIET=1 consumer_run "$name" "$farm/$name" "$ROOT" go build ./...; then
echo "compat: ok $name (build)"
continue
fi
base_worktree
if COMPAT_QUIET=1 consumer_run "$name" "$farm/$name" "$BASE_TREE" go build ./...; then
grep -v '^ok ' "$SCRATCH/work/$name.$(basename "$ROOT")/log" | tail -n 40 >&2 || true
echo "refuse: compat: $name builds at $tag but not at HEAD" >&2
failed=1
else
echo "compat: skipped $name (fails at $tag too)"
fi
done
if [[ "$failed" -ne 0 ]]; then
return 1
fi
echo "phase32 compat passed"
}
run_all() {
local stage
for stage in self-test go plugins consumers admin docs marker additive compat; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
docker_gate || exit 1
echo "Phase 32 gate passed"
}
run_stage() {
case "$1" in
--self-test | self-test) run_self_test ;;
--go | go) run_go ;;
--plugins | plugins) run_plugins ;;
--consumers | consumers) run_consumers ;;
--docker | docker) run_docker ;;
--docker-gate) docker_gate ;;
--mount-verdict) mount_verdict "$DEMO_APP" ;;
--admin | admin) run_admin ;;
--docs | docs) run_docs ;;
--marker | marker) run_marker ;;
--additive | additive) run_additive ;;
--compat | compat) run_compat ;;
--all | all) run_all ;;
*) usage ;;
esac
}
if [[ "$#" -eq 0 ]]; then
run_all
exit 0
fi
if [[ "$#" -eq 1 ]]; then
run_stage "$1"
exit 0
fi
for arg in "$@"; do
if bash "${BASH_SOURCE[0]}" "$arg"; then
echo "PASS ${arg#--}"
else
echo "FAIL ${arg#--}"
exit 1
fi
done