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
This commit is contained in:
2026-10-27 13:08:49 +01:00
parent 49502e8ccb
commit 2b300ec03d
2 changed files with 820 additions and 6 deletions

327
scripts/apisurface/main.go Normal file
View File

@@ -0,0 +1,327 @@
// Command apisurface prints the exported API of the Go package in one
// directory, one element per line, sorted, each line collapsed to single
// spaces. scripts/check-phase32.sh compares its output for the last release
// tag and for HEAD to prove that a patch release is strictly additive.
//
// go run ./scripts/apisurface <package dir>
//
// The files are the non-test files go/build's default context selects for
// the directory. Parameter and result names of every func type are cleared
// and every source position is dropped before printing, so renamed
// parameters, realigned fields, reflowed signatures and edited comments
// print identically.
//
// Line kinds:
//
// func F[<type params>](<param types>) <results>
// method (<receiver type>) M(<param types>) <results>
// type T struct
// field T.Name <type> <tag>
// embed T.<type>
// type T interface
// imethod T.M(<param types>) <results> (unexported methods too)
// iembed T.<type> (embedded interface or type-set term)
// type T <underlying>
// type T = <type>
// const X <type> = <value> @<index in its group>
// var X <type>
//
// It exits 2 when the directory has no Go files and 1 on any other error.
package main
import (
"bytes"
"errors"
"fmt"
"go/ast"
"go/build"
"go/parser"
"go/printer"
"go/token"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: apisurface <package dir>")
os.Exit(1)
}
lines, err := surface(os.Args[1])
if err != nil {
var noGo *build.NoGoError
if errors.As(err, &noGo) {
fmt.Fprintf(os.Stderr, "apisurface: no Go files in %s\n", os.Args[1])
os.Exit(2)
}
fmt.Fprintf(os.Stderr, "apisurface: %v\n", err)
os.Exit(1)
}
for _, line := range lines {
fmt.Println(line)
}
}
// surface returns the sorted API lines of the package in dir.
func surface(dir string) ([]string, error) {
pkg, err := build.Default.ImportDir(dir, 0)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var lines []string
for _, name := range pkg.GoFiles {
file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.SkipObjectResolution)
if err != nil {
return nil, err
}
markAliases(file)
canonicalize(file)
for _, decl := range file.Decls {
lines = append(lines, declLines(decl)...)
}
}
for i, line := range lines {
lines[i] = strings.Join(strings.Fields(line), " ")
}
sort.Strings(lines)
return lines, nil
}
var posType = reflect.TypeOf(token.NoPos)
// canonicalize clears the parameter and result names of every func type
// and sets every position to token.NoPos, so the printer lays each node
// out the same way whatever its source formatting was.
func canonicalize(file *ast.File) {
ast.Inspect(file, func(n ast.Node) bool {
if n == nil {
return false
}
if ft, ok := n.(*ast.FuncType); ok {
ft.Params = unnamed(ft.Params)
ft.Results = unnamed(ft.Results)
}
v := reflect.ValueOf(n)
if v.Kind() == reflect.Pointer && !v.IsNil() {
v = v.Elem()
if v.Kind() == reflect.Struct {
for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
if f.Type() == posType && f.CanSet() {
f.SetInt(0)
}
}
}
}
return true
})
}
// unnamed returns a field list with one unnamed field per name of the
// original (func(a, b int) becomes func(int, int)).
func unnamed(list *ast.FieldList) *ast.FieldList {
if list == nil {
return nil
}
out := &ast.FieldList{}
for _, field := range list.List {
n := len(field.Names)
if n == 0 {
n = 1
}
for i := 0; i < n; i++ {
out.List = append(out.List, &ast.Field{Type: field.Type})
}
}
return out
}
func declLines(decl ast.Decl) []string {
switch d := decl.(type) {
case *ast.FuncDecl:
return funcLines(d)
case *ast.GenDecl:
switch d.Tok {
case token.TYPE:
var out []string
for _, spec := range d.Specs {
out = append(out, typeLines(spec.(*ast.TypeSpec))...)
}
return out
case token.CONST:
return constLines(d)
case token.VAR:
var out []string
for _, spec := range d.Specs {
vs := spec.(*ast.ValueSpec)
for _, name := range vs.Names {
if !name.IsExported() {
continue
}
if vs.Type == nil {
out = append(out, "var "+name.Name)
} else {
out = append(out, "var "+name.Name+" "+node(vs.Type))
}
}
}
return out
}
}
return nil
}
func funcLines(d *ast.FuncDecl) []string {
if !d.Name.IsExported() {
return nil
}
sig := strings.TrimPrefix(node(d.Type), "func")
if d.Recv == nil || len(d.Recv.List) == 0 {
return []string{"func " + d.Name.Name + sig}
}
recv := d.Recv.List[0].Type
if !ast.IsExported(baseTypeName(recv)) {
return nil
}
return []string{"method (" + node(recv) + ") " + d.Name.Name + sig}
}
// baseTypeName returns T for T, *T, T[K] and *T[K, V].
func baseTypeName(expr ast.Expr) string {
for {
switch e := expr.(type) {
case *ast.StarExpr:
expr = e.X
case *ast.IndexExpr:
expr = e.X
case *ast.IndexListExpr:
expr = e.X
case *ast.ParenExpr:
expr = e.X
case *ast.Ident:
return e.Name
default:
return ""
}
}
}
func typeLines(ts *ast.TypeSpec) []string {
if !ts.Name.IsExported() {
return nil
}
name := ts.Name.Name
head := "type " + name + typeParams(ts.TypeParams)
if aliases[ts] {
return []string{head + " = " + node(ts.Type)}
}
switch t := ts.Type.(type) {
case *ast.StructType:
out := []string{head + " struct"}
for _, field := range t.Fields.List {
tag := ""
if field.Tag != nil {
tag = " " + field.Tag.Value
}
if len(field.Names) == 0 {
out = append(out, "embed "+name+"."+node(field.Type)+tag)
continue
}
for _, fn := range field.Names {
if fn.IsExported() {
out = append(out, "field "+name+"."+fn.Name+" "+node(field.Type)+tag)
}
}
}
return out
case *ast.InterfaceType:
out := []string{head + " interface"}
for _, field := range t.Methods.List {
if len(field.Names) == 0 {
out = append(out, "iembed "+name+"."+node(field.Type))
continue
}
sig := strings.TrimPrefix(node(field.Type), "func")
for _, mn := range field.Names {
out = append(out, "imethod "+name+"."+mn.Name+sig)
}
}
return out
}
return []string{head + " " + node(ts.Type)}
}
// aliases records alias specs: canonicalize drops the Assign position, the
// parser's only alias marker, so markAliases runs before it.
var aliases = map[*ast.TypeSpec]bool{}
func markAliases(file *ast.File) {
for _, decl := range file.Decls {
if gd, ok := decl.(*ast.GenDecl); ok && gd.Tok == token.TYPE {
for _, spec := range gd.Specs {
if ts := spec.(*ast.TypeSpec); ts.Assign.IsValid() {
aliases[ts] = true
}
}
}
}
}
func typeParams(list *ast.FieldList) string {
if list == nil || len(list.List) == 0 {
return ""
}
parts := make([]string, 0, len(list.List))
for _, field := range list.List {
names := make([]string, 0, len(field.Names))
for _, n := range field.Names {
names = append(names, n.Name)
}
parts = append(parts, strings.Join(names, ", ")+" "+node(field.Type))
}
return "[" + strings.Join(parts, ", ") + "]"
}
// constLines prints each exported constant with its type and value; a spec
// without them repeats the previous spec's, as Go does, and @index is the
// spec's position in its group (the value of iota).
func constLines(d *ast.GenDecl) []string {
var out []string
var typ ast.Expr
var values []ast.Expr
for i, spec := range d.Specs {
vs := spec.(*ast.ValueSpec)
if len(vs.Values) > 0 {
typ, values = vs.Type, vs.Values
}
for j, name := range vs.Names {
if !name.IsExported() {
continue
}
line := "const " + name.Name
if typ != nil {
line += " " + node(typ)
}
if j < len(values) {
line += " = " + node(values[j])
}
out = append(out, line+" @"+strconv.Itoa(i))
}
}
return out
}
var printFset = token.NewFileSet()
func node(n ast.Node) string {
var buf bytes.Buffer
if err := printer.Fprint(&buf, printFset, n); err != nil {
return fmt.Sprintf("<%v>", err)
}
return buf.String()
}

View File

@@ -35,6 +35,25 @@
# 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
@@ -43,13 +62,16 @@
# 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). Plan 32-06 adds the additive and compat
# stages.
# 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,
# JOURNAL_LAYER, PHASE32_BASE (the commit before the phase, plan 32-01's
# start), PHASE32_ALLOW_UNMOUNTED, PHASE32_REQUIRE_DOCKER.
# 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
@@ -60,8 +82,13 @@ 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"
@@ -117,7 +144,7 @@ usage() {
cat >&2 <<'EOF'
usage:
check-phase32.sh --self-test
check-phase32.sh [--go] [--plugins] [--consumers] [--docker] [--admin] [--docs] [--marker]
check-phase32.sh [--go] [--plugins] [--consumers] [--docker] [--admin] [--docs] [--marker] [--additive] [--compat]
check-phase32.sh --all
EOF
exit 2
@@ -545,9 +572,80 @@ run_self_test() {
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"
@@ -711,9 +809,396 @@ run_marker() {
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; do
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
@@ -737,6 +1222,8 @@ run_stage() {
--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