Files
summercms/scripts/check-phase34.sh
Jakub Zych dfc6127009 test: phase 34 gate requires the summercms.io unit tests
- --site: the site plugin's registry unit tests by required name
- --docker: TestRegistryAPIShapes and TestRegistryIngestEndpoint
2026-10-29 21:35:00 +01:00

909 lines
36 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-phase34: fail-closed gate for Phase 34, plugin marketplace: the
# framework (kiosk, party.Describe, plugin:manifest/install/update, the admin
# Plugins screen), the plugin.yaml rollout in the public sm-*-plugin
# repositories and the registry with /marketplace in the summercms.io
# repositories.
#
# Stages:
# self-test the go test -json, vitest, TAP and unittest detectors on
# planted output, the marker rule on a planted repository, the
# additive comparison on planted packages, a missing repository
# and the docker NOT RUN rule on a faked host.
# go go vet ./..., go test -short ./..., the Docker-free tests of
# the phase by required name (GO_TESTS) and the examples/hello
# workspace module.
# docker Docker host only: go test ./... in the framework, the
# non-short install tests by required name (DOCKER_TESTS), and
# the site plugin's registry suites (SITE_DOCKER_TESTS).
# plugins each sibling sm-*-plugin repository (PLUGIN_REPOS): it
# resolves the framework to this checkout, go vet, go test
# -short, TestPluginManifest by required name in every plugin
# package and `summer plugin:manifest --check` (built from this
# checkout) for every committed plugin.yaml.
# site ../sm-summercmsio-app: the site plugin's go vet, go test
# -short and Docker-free tests by required name; the app's go
# vet and go test; the changelog-post unittest suite;
# scripts/check-deploy.sh when nginx is installed; and in
# vue-summercmsio-app pnpm test, build:changelog,
# test:changelog and test:marketplace.
# admin npm run test (required vitest files ADMIN_FILES), typecheck,
# scripts/check-admin-openapi.sh --check and
# scripts/check-admin-dist.sh.
# docs TestDocsTree, TestCommandsDocumented and summer docs:build
# --check.
# marker the lines this phase added since PHASE34_BASE_TAG to non-test
# Go files, READMEs and docs under modules/, cmd/, internal/ and
# docs/ (testdata excluded) name no consuming application.
# additive scripts/apisurface of every package under modules/ changed
# since PHASE34_BASE_TAG keeps every exported line of the tag
# and adds no method to an interface that exists at the tag
# (kiosk is new; party and cabana only gain elements).
#
# 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. A stage whose repository or
# directory does not exist fails with "missing <path>".
#
# Several stages may be given at once (--go --plugins ...). --all runs
# self-test, go, plugins, site, admin, docs, marker and additive, then docker
# when a Docker daemon answers. Without one docker is NOT RUN, which fails
# --all unless PHASE34_ALLOW_NO_DOCKER=1.
#
# Environment overrides: PHASE34_ROOT (this checkout), PHASE34_SIBLINGS (the
# directory holding the sibling repositories, default the parent of this
# checkout), SITE_APP (default $PHASE34_SIBLINGS/sm-summercmsio-app),
# PHASE34_BASE_TAG (default v0.5.3, the newest tag before the phase),
# PHASE34_ALLOW_NO_DOCKER, PHASE34_PENDING_MANIFESTS (space-separated plugin
# repositories whose manifest checks are reported as pending instead of
# run), PHASE34_PLUGIN_SKIP (space-separated <repo>:<TestName> entries that
# go test -short -skips for a pre-existing failure outside the phase; each
# use is printed).
set -euo pipefail
unset FORCE_COLOR
export NO_COLOR=1
ROOT="${PHASE34_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
SIBLINGS="${PHASE34_SIBLINGS:-$(dirname "$ROOT")}"
SITE_APP="${SITE_APP:-$SIBLINGS/sm-summercmsio-app}"
SITE_PLUGIN="$SITE_APP/plugins/golem15/summercms"
SITE_VUE="$SITE_APP/vue-summercmsio-app"
PHASE34_BASE_TAG="${PHASE34_BASE_TAG:-v0.5.3}"
# sm-user-plugin's TestRegisterCORSPath walks up to an application's
# config/http.yaml and fails in a standalone checkout (34-02 note 1).
PHASE34_PLUGIN_SKIP="${PHASE34_PLUGIN_SKIP-sm-user-plugin:TestRegisterCORSPath}"
# The public plugin repositories that carry a plugin.yaml (plan 34-02).
PLUGIN_REPOS=(
sm-translate-plugin sm-user-plugin sm-chat-plugin sm-faq-plugin sm-menu-plugin
sm-seo-plugin sm-sitemap-plugin sm-mfa-plugin sm-oauthserver-plugin sm-sociallogin-plugin
sm-paymentgateway-plugin sm-paymentgatewaysubscriptions-plugin sm-pgstripe-plugin
sm-journal-plugin sm-chatchannels-plugin sm-chatvideo-plugin sm-forum-plugin
sm-herald-plugin sm-userfriends-plugin sm-chatbots-plugin
)
# ---------------------------------------------------------------------------
# Required test names: <package>:<TestName>.
# go stage: Docker-free framework tests (-short).
GO_TESTS=(
./modules/kiosk:TestManifestSmoke ./modules/kiosk:TestPluginManifest
./internal/build:TestPluginInfoSmoke ./internal/build:TestAddPluginConstID
./modules/cabana:TestPluginsScreenSmoke
# 34-06 unit tests.
./modules/kiosk:TestReadIdentity ./modules/kiosk:TestDerive ./modules/kiosk:TestManifestFormat
./modules/kiosk:TestCheck ./modules/kiosk:TestVersions ./modules/kiosk:TestRegistryClient
./modules/kiosk:TestPackagePath ./modules/kiosk:TestPluginDirs ./modules/kiosk:TestLastElem
./modules/kiosk:ExampleMarshal ./modules/kiosk:ExampleNewestCompatible
./modules/party:TestDescribe
./internal/build:TestPluginDirs ./internal/build:TestEffectiveFramework ./internal/build:TestResolveInstall
./internal/build:TestAskToProceed ./internal/build:TestAppendManifestPlugins
./cmd/summer:TestPluginCommands
./modules/cabana:TestPluginsEndpoints
)
# docker stage: the non-short framework tests (file:// proxies, git
# fixtures, app builds).
DOCKER_TESTS=(./internal/build:TestInstallSmoke)
# site stage: the site plugin's Docker-free tests (-short).
SITE_SHORT_TESTS=(
.:TestPluginIdentity .:TestRoutesAssemble
# 34-06 unit tests.
./registry:TestIngestTags ./registry:TestIngestManifests ./registry:TestGitRunner
./registry:TestCheckIdentity ./registry:TestParseRepositoryURL ./registry:TestIngestHelpers
./registry:TestQueryRules
)
# docker stage: the site plugin's Postgres suites.
SITE_DOCKER_TESTS=(.:TestRegistrySmoke .:TestRegistryAPIShapes .:TestRegistryIngestEndpoint)
# admin stage: vitest files that must pass.
ADMIN_FILES=(tests/views/PluginsView.test.ts tests/state/useSettings.test.ts)
# marker stage: names of consuming applications (split so this file does
# not match itself). summercms.io, the framework's own site and default
# registry, is allowed.
APP_NAMES=("grzy""by" "fono""teka" "p[lł]yta""rium" "summercms""io" "sm-demo""-app" "sm-bm""-app")
usage() {
cat >&2 <<'EOF'
usage:
check-phase34.sh --self-test
check-phase34.sh [--go] [--docker] [--plugins] [--site] [--admin] [--docs] [--marker] [--additive]
check-phase34.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
}
# detect_tap reads node --test TAP output. Exit 1 a "not ok" line or a
# non-zero fail, skipped or todo count, 3 no "# pass N" with N > 0.
detect_tap() {
python3 - "$1" <<'PY'
import re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
counts = dict((k, int(v)) for k, v in re.findall(r"^# (pass|fail|skipped|todo|cancelled) (\d+)\s*$", text, re.M))
if re.search(r"^\s*not ok\b", text, re.M) or any(counts.get(k, 0) for k in ("fail", "skipped", "todo", "cancelled")):
print("refuse: TAP reports failed, skipped, todo or cancelled tests", file=sys.stderr)
sys.exit(1)
if counts.get("pass", 0) == 0:
print("refuse: TAP reports no passing test", file=sys.stderr)
sys.exit(3)
PY
}
# detect_unittest reads python unittest output. Exit 1 a failure, error or
# skip, 3 no "Ran N tests" with N > 0 followed by OK.
detect_unittest() {
python3 - "$1" <<'PY'
import re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
if re.search(r"^FAILED\b", text, re.M) or re.search(r"skipped=\d+", text) or re.search(r"^(FAIL|ERROR):", text, re.M):
print("refuse: unittest reports failed, erroring or skipped tests", file=sys.stderr)
sys.exit(1)
m = re.search(r"^Ran (\d+) tests? in [0-9.]+s\s*\n\s*\nOK\s*$", text, re.M)
if not m or m.group(1) == "0":
print("refuse: no passing unittest run", file=sys.stderr)
sys.exit(3)
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: FAIL 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 [SKIP] runs go test -short ./... in DIR, with go test -skip
# SKIP when given.
go_short() {
local dir="$1" skip="${2:-}" out args=(-count=1 -short)
if [[ -n "$skip" ]]; then
args+=(-skip "$skip")
fi
if ! out="$(cd "$dir" && go test "${args[@]}" ./... 2>&1)"; then
printf '%s\n' "$out" | grep -v '^ok ' | grep -E '^(FAIL|---|panic|\s+\S+_test\.go:)' | tail -n 60 >&2 || true
echo "refuse: FAIL go test -short ./... in $dir" >&2
return 1
fi
echo "short: $dir${skip:+ (skip $skip)}"
}
go_vet() {
local dir="$1"
shift
(cd "$dir" && go vet "$@")
echo "vet: $dir ($*)"
}
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: missing $1 ($2)" >&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"
}
# 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/, cmd/,
# internal/ or docs/ (testdata excluded) that names a consuming application.
marker_hits() {
local dir="$1" base="$2" pattern
pattern="$(IFS='|'; echo "${APP_NAMES[*]}")"
git -C "$dir" diff -U0 "$base" HEAD -- \
'modules/*.go' 'modules/**/*.go' 'cmd/**/*.go' 'internal/**/*.go' \
'modules/**/README.md' 'cmd/**/README.md' 'internal/**/README.md' 'docs' \
':(exclude)*_test.go' ':(exclude)**/*_test.go' ':(exclude)**/testdata/**' |
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]}"
# A failing test, a build failure, skips, zero tests and a missing
# required name are each detected.
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestReadIdentity"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestResolveInstall"}'
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":"TestInstallSmoke"}'
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestInstallSmoke/module"}
{"Action":"pass","Package":"p","Test":"TestInstallSmoke"}'
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="TestManifestSmoke TestPluginManifest" expect_detector detect_go missing-named 5 '{"Action":"pass","Package":"p","Test":"TestManifestSmoke"}'
REQUIRE_TESTS="TestPluginManifest" expect_detector detect_go subtest-is-not-parent 5 '{"Action":"pass","Package":"p","Test":"TestOuter/TestPluginManifest"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local ok=' ✓ tests/views/PluginsView.test.ts (9 tests) 15ms
Test Files 1 passed (1)
Tests 9 passed (9)'
REQUIRE_FILES="tests/views/PluginsView.test.ts" expect_detector detect_vitest pass 0 "$ok"
REQUIRE_FILES="tests/views/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/views/PluginsView.test.ts (9 tests) 15ms'
expect_detector detect_tap pass 0 $'TAP version 13\nok 1 - installCommand\n1..1\n# pass 1\n# fail 0\n# skipped 0\n# todo 0'
expect_detector detect_tap not-ok 1 $'TAP version 13\nnot ok 1 - installCommand\n1..1\n# pass 0\n# fail 1'
expect_detector detect_tap skipped 1 $'TAP version 13\nok 1 - a # SKIP\n1..1\n# pass 1\n# fail 0\n# skipped 1'
expect_detector detect_tap zero 3 $'TAP version 13\n1..0\n# pass 0\n# fail 0'
expect_detector detect_unittest pass 0 $'....\n----------------------------------------------------------------------\nRan 4 tests in 0.012s\n\nOK'
expect_detector detect_unittest failed 1 $'.F..\n----------------------------------------------------------------------\nRan 4 tests in 0.012s\n\nFAILED (failures=1)'
expect_detector detect_unittest skipped 1 $'.s..\n----------------------------------------------------------------------\nRan 4 tests in 0.012s\n\nOK (skipped=1)'
expect_detector detect_unittest zero 3 $'\n----------------------------------------------------------------------\nRan 0 tests in 0.000s\n\nOK'
local tmp
tmp="$(mktemp -d)"
# The marker rule on a planted repository: an added application name in
# a module source, a command source or a docs page is found; a test
# file, a testdata fixture and a line that was there before the base
# are not.
git -C "$tmp" init -q
mkdir -p "$tmp/modules/kiosk/testdata" "$tmp/cmd/summer" "$tmp/docs"
printf 'package kiosk\n\n// Old mentions fono''teka from before the phase.\nfunc Old() {}\n' >"$tmp/modules/kiosk/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 kiosk\n\n// Client asks the registry at summercms.io.\nfunc Client() {}\n' >"$tmp/modules/kiosk/client.go"
printf 'package kiosk\n\n// The grzy''by fixture.\nfunc helper() {}\n' >"$tmp/modules/kiosk/client_test.go"
printf 'package fixture\n\n// sm-demo''-app fixture.\n' >"$tmp/modules/kiosk/testdata/f.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, a test file or a fixture" >&2
return 1
fi
printf 'package kiosk\n\n// Client asks the summercms''io app.\nfunc Client() {}\n' >"$tmp/modules/kiosk/client.go"
printf 'package main\n\n// Used by p''łytarium.\nfunc x() {}\n' >"$tmp/cmd/summer/plugin.go"
printf '# Marketplace\n\nInstalled in sm-bm''-app.\n' >"$tmp/docs/marketplace.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/kiosk/client.go:3:' <<<"$hits" || ! grep -q '^cmd/summer/plugin.go:3:' <<<"$hits" ||
! grep -q '^docs/marketplace.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"
# A missing repository fails the stage that needs it.
expect_mode missing-plugins 1 "missing $tmp/none/sm-translate-plugin" --plugins "PHASE34_SIBLINGS=$tmp/none"
expect_mode missing-site 1 "missing $tmp/none/sm-summercmsio-app" --site "PHASE34_SIBLINGS=$tmp/none" "SITE_APP=$tmp/none/sm-summercmsio-app"
# 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 1 "NOT RUN docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE34_ALLOW_NO_DOCKER=0
expect_mode docker-not-run-allowed 0 "NOT RUN docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE34_ALLOW_NO_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 "phase34 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)
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" removed-field "removed or changed" "${p}"$'type Info struct {\n\tID string\n\tVersion string\n}' "${p}"$'type Info struct {\n\tID string\n}' || failed=1
additive_case "$tool" interface-method-added "method added to existing interface" \
"${p}"$'type HasThing interface {\n\tThing() string\n}' \
"${p}"$'type HasThing interface {\n\tThing() string\n\tOther() error\n}' || 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 SettingsEntry struct {\n\tCode string `json:"code"`\n}' \
"${p}"$'type SettingsEntry struct {\n\tCode string `json:"code"`\n\tPath string `json:"path,omitempty"`\n}' || 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[@]}"
require_dir "$ROOT/examples/hello" "examples/hello"
(cd "$ROOT/examples/hello" && go test -count=1 ./... >/dev/null)
echo "hello: examples/hello go test ./... ok"
echo "phase34 go 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 (sudo -u golem when the socket is denied)" >&2
return 1
fi
require_dir "$SITE_PLUGIN" "site plugin"
local out
if ! out="$(cd "$ROOT" && go test -count=1 -timeout 30m ./... 2>&1)"; then
printf '%s\n' "$out" | grep -v '^ok ' | grep -E '^(FAIL|---|panic|\s+\S+_test\.go:)' | tail -n 60 >&2 || true
echo "refuse: FAIL go test ./... in $ROOT" >&2
return 1
fi
echo "full: $ROOT go test ./..."
go_pkg_named "$ROOT" "-timeout 20m" "${DOCKER_TESTS[@]}"
workspace_check "$SITE_PLUGIN"
go_pkg_named "$SITE_PLUGIN" "-timeout 20m" "${SITE_DOCKER_TESTS[@]}"
echo "phase34 docker passed"
}
# docker_gate is the docker step of --all: run the stage when a daemon
# answers; otherwise print NOT RUN and fail unless PHASE34_ALLOW_NO_DOCKER=1.
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
cat >&2 <<'EOF'
************************************************************************
NOT RUN docker: no reachable Docker daemon on this host.
go test ./... of the framework, TestInstallSmoke's non-short subtests and
the site plugin's TestRegistrySmoke did NOT run. Run on a Docker host
(through sudo -u golem when the socket is denied):
bash scripts/check-phase34.sh --docker
************************************************************************
EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
if [[ "${PHASE34_ALLOW_NO_DOCKER:-0}" != "1" ]]; then
echo "FAIL docker: no reachable Docker daemon; set PHASE34_ALLOW_NO_DOCKER=1 and run --docker on a Docker host"
return 1
fi
}
# plugin_skip REPO prints the go test -skip pattern PHASE34_PLUGIN_SKIP
# names for REPO (empty when none).
plugin_skip() {
local repo="$1" entry names=()
for entry in $PHASE34_PLUGIN_SKIP; do
[[ "${entry%%:*}" == "$repo" ]] || continue
names+=("${entry#*:}")
done
if [[ "${#names[@]}" -gt 0 ]]; then
echo "^($(IFS='|'; echo "${names[*]}"))\$"
fi
}
run_plugins() {
local repo dir skip rel pkgs=() summer failed=0
for repo in "${PLUGIN_REPOS[@]}"; do
require_dir "$SIBLINGS/$repo" "plugin repository"
done
scratch_init
summer="$SCRATCH/summer"
(cd "$ROOT" && go build -o "$summer" ./cmd/summer)
for repo in "${PLUGIN_REPOS[@]}"; do
dir="$SIBLINGS/$repo"
workspace_check "$dir"
go_vet "$dir" ./...
skip="$(plugin_skip "$repo")"
if [[ -n "$skip" ]]; then
echo "plugins: note $repo: go test -short -skip '$skip' (PHASE34_PLUGIN_SKIP, pre-existing failure outside the phase)"
fi
go_short "$dir" "$skip"
mapfile -t pkgs < <(git -C "$dir" ls-files -- 'plugin.yaml' '*/plugin.yaml' | grep -v '/testdata/' | xargs -r -n1 dirname)
if [[ "${#pkgs[@]}" -eq 0 ]]; then
echo "refuse: missing $dir/plugin.yaml (no committed plugin.yaml)" >&2
return 1
fi
for rel in "${pkgs[@]}"; do
go_named "$dir" "./$rel" "-short" TestPluginManifest
if [[ " ${PHASE34_PENDING_MANIFESTS:-} " == *" $repo "* ]]; then
echo "PENDING manifest check $repo/$rel (PHASE34_PENDING_MANIFESTS)"
continue
fi
if ! (cd "$dir" && "$summer" plugin:manifest --check "$rel" >/dev/null); then
echo "refuse: FAIL summer plugin:manifest --check $repo/$rel" >&2
failed=1
continue
fi
echo "manifest: $repo/$rel fresh"
done
done
if [[ "$failed" -ne 0 ]]; then
return 1
fi
echo "phase34 plugins passed (${#PLUGIN_REPOS[@]} repositories)"
}
# run_logged NAME DETECTOR DIR CMD... runs CMD in DIR, keeps its output and
# checks it with DETECTOR (or only the exit status when DETECTOR is -).
run_logged() {
local name="$1" detector="$2" dir="$3" log rc=0 dc=0
shift 3
log="$(mktemp)"
(cd "$dir" && "$@") >"$log" 2>&1 || rc=$?
if [[ "$detector" != "-" ]]; then
"$detector" "$log" || dc=$?
fi
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E '(not ok|FAIL|Error|error|expected|refuse)' "$log" | head -n 40 >&2 || true
tail -n 20 "$log" >&2
rm -f "$log"
echo "refuse: FAIL $name in $dir (exit=$rc detect=$dc)" >&2
return 1
fi
rm -f "$log"
echo "$name: ok"
}
run_site() {
require_dir "$SITE_APP" "summercms.io application"
require_dir "$SITE_PLUGIN" "summercms.io site plugin"
require_dir "$SITE_VUE" "vue-summercmsio-app"
workspace_check "$SITE_PLUGIN"
go_vet "$SITE_PLUGIN" ./...
go_short "$SITE_PLUGIN"
go_pkg_named "$SITE_PLUGIN" "-short" "${SITE_SHORT_TESTS[@]}"
workspace_check "$SITE_APP"
go_vet "$SITE_APP" ./...
run_logged "app go test ./..." - "$SITE_APP" go test -count=1 ./...
run_logged "changelog-post unittest" detect_unittest "$SITE_APP" python3 -B scripts/test_changelog_post.py
if command -v nginx >/dev/null 2>&1; then
run_logged "check-deploy" - "$SITE_APP" bash scripts/check-deploy.sh
else
echo "site: nginx not installed, check-deploy.sh not run"
fi
run_logged "pnpm test" detect_tap "$SITE_VUE" pnpm test
run_logged "pnpm build:changelog" - "$SITE_VUE" pnpm build:changelog
run_logged "pnpm test:changelog" - "$SITE_VUE" pnpm test:changelog
run_logged "pnpm test:marketplace" - "$SITE_VUE" pnpm test:marketplace
echo "phase34 site passed"
}
run_admin() {
require_dir "$ROOT/admin" "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: FAIL 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"
npm --prefix "$ROOT/admin" run typecheck >/dev/null
echo "typecheck: admin"
(cd "$ROOT" && bash scripts/check-admin-openapi.sh --check)
(cd "$ROOT" && bash scripts/check-admin-dist.sh)
echo "phase34 admin passed"
}
run_docs() {
go_named "$ROOT" ./cmd/summer "" TestDocsTree TestCommandsDocumented
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
echo "phase34 docs passed"
}
run_marker() {
if ! git -C "$ROOT" rev-parse --verify -q "$PHASE34_BASE_TAG^{commit}" >/dev/null; then
echo "refuse: missing tag $PHASE34_BASE_TAG in $ROOT" >&2
return 1
fi
local hits
hits="$(marker_hits "$ROOT" "$PHASE34_BASE_TAG")"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | head -n 40 >&2
echo "refuse: framework lines added since $PHASE34_BASE_TAG name a consuming application" >&2
return 1
fi
echo "marker: no line added since $PHASE34_BASE_TAG under modules/, cmd/, internal/ or docs/ names a consuming application"
echo "phase34 marker passed"
}
# ---------------------------------------------------------------------------
# additive: the exported API of every package under modules/ changed since
# the base tag keeps every line of the tag.
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() {
git -c safe.directory='*' -C "$1" worktree add --detach -q "$2" "$3"
CLEANUP_WORKTREES+=("$1|$2")
}
# 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"
}
run_additive() {
local dir name failed=0 pkgs=() base new=()
if ! git -C "$ROOT" rev-parse --verify -q "$PHASE34_BASE_TAG^{commit}" >/dev/null; then
echo "refuse: missing tag $PHASE34_BASE_TAG in $ROOT" >&2
return 1
fi
scratch_init
base="$SCRATCH/base"
add_worktree "$ROOT" "$base" "$PHASE34_BASE_TAG"
(cd "$ROOT" && go build -o "$SCRATCH/apisurface" ./scripts/apisurface)
mapfile -t pkgs < <(git -C "$ROOT" diff --name-only "$PHASE34_BASE_TAG" -- modules |
grep -E '\.go$' | grep -vE '_test\.go$' | grep -v '/testdata/' | xargs -r -n1 dirname | LC_ALL=C sort -u)
mkdir -p "$SCRATCH/api"
for dir in "${pkgs[@]}"; do
if [[ ! -d "$base/$dir" ]]; then
new+=("$dir")
continue
fi
name="${dir//\//_}"
api_surface "$SCRATCH/apisurface" "$base/$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 [[ "${#new[@]}" -gt 0 ]]; then
echo "additive: new since $PHASE34_BASE_TAG: ${new[*]}"
fi
if [[ "$failed" -ne 0 ]]; then
echo "refuse: additive: the framework API changed incompatibly since $PHASE34_BASE_TAG" >&2
return 1
fi
echo "phase34 additive passed ($((${#pkgs[@]} - ${#new[@]})) packages against $PHASE34_BASE_TAG)"
}
run_all() {
local stage
for stage in self-test go plugins site admin docs marker additive; 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 34 gate passed"
}
run_stage() {
case "$1" in
--self-test | self-test) run_self_test ;;
--go | go) run_go ;;
--docker | docker) run_docker ;;
--docker-gate) docker_gate ;;
--plugins | plugins) run_plugins ;;
--site | site) run_site ;;
--admin | admin) run_admin ;;
--docs | docs) run_docs ;;
--marker | marker) run_marker ;;
--additive | additive) run_additive ;;
--all | all) run_all ;;
*) usage ;;
esac
}
if [[ "$#" -eq 0 ]]; then
usage
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