Files
summercms/scripts/check-phase33.sh
Jakub Zych fbe9816a50 test: phase 33 gate, layer, consumer and admin stages
- layers: node --test runs checked with a TAP detector (self-tested), every
  vue-seo-layer smoke, guard:check and purge:check, all five journal
  expectation files (postTitleTag h1 and p), the journal purge checks and
  check-git-install.sh --self-test
- consumers: the 33-12 and 33-13 app patches pass git apply --check, and
  the 33-14 demo locale patches apply on top of the 33-12 ones in scratch
  worktrees
2026-11-04 20:25:36 +01:00

1396 lines
56 KiB
Bash
Executable File
Raw Permalink 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
# Phase 33 verification gate: the framework's additive content facts and
# per-request field type options (plan 33-01), the sitemap, SEO and Journal
# plugins, the four Nuxt layers and the applications that consume them.
#
# Stages:
# self-test the go test -json, vitest and TAP detectors on planted output, a
# failing go test in a planted module, the marker rule on a
# planted repository, the missing-repository failure, the docker
# NOT RUN rule on a faked host, and the additive comparison on
# planted API pairs (a removal and an interface method added
# are refused).
# go go vet ./..., go test -short ./... and the Docker-free
# framework tests of the phase by required name (GO_TESTS).
# plugins sm-sitemap-plugin, sm-seo-plugin and sm-journal-plugin: the
# workspace check (the framework resolves to this checkout),
# go vet, go test -short and the unit tests of plan 33-14 by
# required name (*_UNIT_TESTS); npm run test (vitest) in
# sm-seo-plugin and sm-journal-plugin.
# layers vue-seo-layer, vue-journal-layer, vue-menu-layer and
# vue-faq-layer: npm run test (node --test, an all-passing TAP
# summary), npm run build:playground and the layer's SSR
# smokes; in vue-seo-layer guard:check (the build-guard
# fixtures) and purge:check; in vue-journal-layer every
# expectation file (expect-acme-title-h1 and -title-p with
# NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG), the purge checks and
# check-git-install.sh --self-test.
# consumers sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app and
# sm-bm-app: the workspace check and go build ./...; npm run
# build in each Nuxt app (vue-*/ with a build script); the
# 33-12 and 33-13 app patches pass git apply --check, and the
# 33-14 demo locale patches apply on top of the 33-12 ones.
# docker Docker host only: the Postgres suites (go test ./... without
# -short) of the three Go plugins with the phase's suites by
# required name (*_DOCKER_TESTS; TestChromeRenderer too when a
# Chrome or Chromium binary is on the host, else it is printed
# as NOT RUN), and the framework's modules/lagoon/attach/...
# admin scripts/check-admin-openapi.sh --check and
# scripts/check-admin-dist.sh.
# docs TestDocsTree and summer docs:build --check.
# marker D-90: the lines added since the release before the phase
# (PHASE33_BASE, v0.6.0) to non-test Go files, READMEs and docs
# under modules/ and docs/ name neither SEO, a sitemap, JSON-LD
# nor a consuming application.
# 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 that does
# not contain HEAD, 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.
# compat existing consumers build unchanged against this checkout,
# each through a scratch go.work in a scratch sibling layout:
# sm-sitemap-plugin and sm-journal-plugin at their releases
# before the phase and sm-demo-app at its commit before the
# phase with its pinned submodules (build, 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 stage whose repository or directory is missing fails with
# "missing <path>". 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, layers, consumers, admin, docs and marker, then
# docker when a Docker daemon answers (NOT RUN docker otherwise, a failure
# with PHASE33_REQUIRE_DOCKER=1), then additive and compat. Without an
# argument the script prints its usage and exits 2.
#
# Environment overrides (default: the sibling checkouts next to this
# repository): PHASE33_ROOT, SITEMAP_PLUGIN, SEO_PLUGIN, JOURNAL_PLUGIN,
# SEO_LAYER, JOURNAL_LAYER, MENU_LAYER, FAQ_LAYER, DEMO_APP, GRZYBY_APP,
# SUMMERCMSIO_APP, BM_APP, PHASE33_BASE (the framework release before the
# phase), PHASE33_SITEMAP_BASE, PHASE33_JOURNAL_BASE and PHASE33_DEMO_BASE
# (the consumer revisions before the phase, for compat),
# PHASE33_REQUIRE_DOCKER, PHASE33_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="${PHASE33_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PARENT="$(dirname "$ROOT")"
SITEMAP_PLUGIN="${SITEMAP_PLUGIN:-$PARENT/sm-sitemap-plugin}"
SEO_PLUGIN="${SEO_PLUGIN:-$PARENT/sm-seo-plugin}"
JOURNAL_PLUGIN="${JOURNAL_PLUGIN:-$PARENT/sm-journal-plugin}"
SEO_LAYER="${SEO_LAYER:-$PARENT/vue-seo-layer}"
JOURNAL_LAYER="${JOURNAL_LAYER:-$PARENT/vue-journal-layer}"
MENU_LAYER="${MENU_LAYER:-$PARENT/vue-menu-layer}"
FAQ_LAYER="${FAQ_LAYER:-$PARENT/vue-faq-layer}"
DEMO_APP="${DEMO_APP:-$PARENT/sm-demo-app}"
GRZYBY_APP="${GRZYBY_APP:-$PARENT/sm-grzybyfunkcjonalne-app}"
SUMMERCMSIO_APP="${SUMMERCMSIO_APP:-$PARENT/sm-summercmsio-app}"
BM_APP="${BM_APP:-$PARENT/sm-bm-app}"
# The framework release before the phase (33-01, Jakub 2026-11-04: the phase
# delivers v0.6.1 on top of v0.6.0).
PHASE33_BASE="${PHASE33_BASE:-v0.6.0}"
# The consumer revisions before the phase, for the compat stage: the
# sitemap and Journal releases and the demo's HEAD when 33-01 ran.
PHASE33_SITEMAP_BASE="${PHASE33_SITEMAP_BASE:-v0.1.2}"
PHASE33_JOURNAL_BASE="${PHASE33_JOURNAL_BASE:-v0.3.1}"
PHASE33_DEMO_BASE="${PHASE33_DEMO_BASE:-da481c997062adcd156d8ae9c0ae8e01feb93d17}"
MCP_TAG="summercms_mcp"
# ---------------------------------------------------------------------------
# Required test names: <package>:<TestName>.
# go stage: Docker-free framework tests (-short) of plan 33-01.
GO_TESTS=(
./modules/signpost:TestContentDescriptorFields ./modules/signpost:TestContentLookups
./modules/cabana:TestFieldTypeRequestOptions ./modules/cabana:TestFormExtensionUnextendedSchemaUnchanged
./modules/cabana:TestMergeRequestOptions ./modules/cabana:TestMergeRequestOptionsNoFields
./modules/pact:TestContentKindAndToolValues ./modules/pact:TestContentRecordZeroFacts ./modules/pact:TestContentValueTypes
./modules/lagoon/attach:TestVariantKeyTable ./modules/lagoon/attach:TestVariantKeyOfThumbFilename
./modules/lagoon/attach/webp:TestEncodeImageKinds ./modules/lagoon/attach/webp:TestEncodeConstants
./modules/lagoon/attach/webp:TestVariantStoredWithContentType ./modules/lagoon/attach/webp:TestVariantBrokenAndRefused
)
# plugins stage: the Docker-free unit tests of plan 33-14 by required name
# (-short), per plugin.
SITEMAP_UNIT_TESTS=(
./classes:TestWriteURLSet ./classes:TestWriteIndex ./classes:TestWriteImageURLSet ./classes:TestFileNamesRoundTrip ./classes:TestValidSlug
./classes:TestAssignSlots ./classes:TestAssignSlotsEdges ./classes:TestRealChanges ./classes:TestLastmodIgnore ./classes:TestRebuildDelay
./classes:TestNewsLimits ./classes:TestParseNewsSources ./classes:TestNewsSourcesMatch ./classes:TestNewsLanguage ./classes:TestWriteNewsURLSet
./classes:TestIndexNowBatches ./classes:TestIndexNowOutcome ./classes:TestIndexNowKeys ./classes:TestSiteURLs
./classes:TestHTMLGroupOrder ./classes:TestTreeOrder ./classes:TestNestPaging ./classes:TestToEntries
./classes:TestDefaultSlug ./classes:TestEffectiveType ./classes:TestMarkListedExtras
)
SEO_UNIT_TESTS=(
./classes/schema:TestDefaultAndAllowedTypes ./classes/schema:TestPageIDs ./classes/schema:TestBuildArticle ./classes/schema:TestBuildPerKind
./classes/schema:TestPublisherNode ./classes/schema:TestValidateNodeRules ./classes/schema:TestDecodeCustom ./classes/schema:TestNormalize ./classes/schema:TestMerge
./classes:TestParsePage ./classes:TestPageLocation ./classes:TestAlternates ./classes:TestPrimaryParent ./classes:TestCandidatePath
./classes:TestValidPagePath ./classes:TestApplyPageFields ./classes:TestPageGroupAndMissing ./classes:TestPageBeforeSaveUTC ./classes:TestValidAutolinkURL
./classes/crawl:TestParseFacts ./classes/crawl:TestCheckStatus ./classes/crawl:TestCheckHead ./classes/crawl:TestCheckHeadings ./classes/crawl:TestCheckJSONLD
./classes/crawl:TestCheckImages ./classes/crawl:TestCheckContentCMS ./classes/crawl:TestRoute ./classes/crawl:TestRouteEveryKind
./classes/crawl:TestCompareRendered ./classes/crawl:TestAllowRequest ./classes/crawl:TestResolverRules ./classes/crawl:TestNewRenderer
)
JOURNAL_UNIT_TESTS=(
./classes:TestConvertH1 ./classes:TestConvertContentH1 ./classes:TestConvertTranslationH1 ./classes:TestSetImageAlt ./classes:TestSetImageAltEdges
./classes:TestParseContentFacts ./classes:TestParseContentFactsEmpty ./classes:TestTopHeading ./classes:TestReadRelatedSettings
)
# docker stage: the Postgres suites of the phase by required name, per
# plugin (each plugin's go test ./... must pass them). TestChromeRenderer is
# required when a Chrome or Chromium binary is on this host.
SITEMAP_DOCKER_TESTS=(TestSitemapIndex TestExtraDuplicates TestImageSitemap TestNewsSitemap TestIndexNow TestHTMLGroups TestFilterExcludes TestHreflangAlternates TestLastmod TestMigrateDefinitions TestSitemapTools TestRebuildCommand)
SEO_DOCKER_TESTS=(TestRegistry TestRegistryTimestampsUTC TestRouteReport TestSiteEndpoint TestSitemapFilterTracer TestFilterReasons TestGraphTracer TestGraphPerKind TestPagination TestPagedAlternates TestCustomJSONLD TestCrawlerTracer TestCrawlerChecks TestRenderSkipped TestAutolinks TestLinkSuggestions)
JOURNAL_DOCKER_TESTS=(TestHeadingConversionTracer TestHeadingConversion TestFixHeadingsCommand TestSetImageAlt TestJournalDescriptorFields TestImageVariants TestImageVariantsCommandCLI TestJournalAPIShowNeighbors)
# marker stage: SEO words and the names of consuming applications (split so
# this file does not match itself).
MARKER_WORDS=("\\bs""eo\\b" "site""map" "json-?l""d")
APP_NAMES=("p[lł]yta""rium" "fono""teka" "wave""path" "horo""skopia" "bm\\.jakub""zych" "grzy""by" "summercms""io" "demo\\.summer""cms" "stu""dia")
usage() {
cat >&2 <<'EOF'
usage:
check-phase33.sh --self-test
check-phase33.sh [--go] [--plugins] [--layers] [--consumers] [--docker] [--admin] [--docs] [--marker] [--additive] [--compat]
check-phase33.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 PATH WHAT refuses a repository or directory that is missing,
# naming it ("missing <path>").
require_dir() {
[[ -d "$1" ]] || {
echo "refuse: missing $1 ($2)" >&2
return 1
}
}
# require_file PATH WHAT refuses a file that is missing.
require_file() {
[[ -f "$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/ or docs/ that
# names SEO, a sitemap, JSON-LD or a consuming application (D-90).
marker_hits() {
local dir="$1" base="$2" pattern
pattern="$(IFS='|'; echo "${MARKER_WORDS[*]}")|$(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
'
}
# npm_vitest DIR runs npm run test (vitest) in DIR and checks its output.
npm_vitest() {
local dir="$1" log rc=0 dc=0
require_script "$dir" test
log="$(mktemp)"
npm --prefix "$dir" run test >"$log" 2>&1 || rc=$?
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 $dir (test=$rc detect=$dc)" >&2
return 1
fi
echo "vitest: $dir $(grep -E '^\s*Tests\s' "$log" | tr -s ' ')"
rm -f "$log"
}
# detect_tap reads a node --test --test-reporter=tap run. Exit 1 a failed
# test ("not ok", "# fail" or "# cancelled" above 0), 2 skipped or todo
# tests, 3 no all-passing summary.
detect_tap() {
python3 - "$1" <<'PY'
import re, sys
text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
text = re.sub(r"\x1b\[[0-9;]*m", "", text)
counts = {k: int(v) for k, v in re.findall(r"^# (tests|pass|fail|skipped|todo|cancelled) (\d+)\s*$", text, re.M)}
if re.search(r"^\s*not ok\b", text, re.M) or counts.get("fail", 0) > 0 or counts.get("cancelled", 0) > 0:
print("refuse: TAP reports failed tests", file=sys.stderr)
sys.exit(1)
if counts.get("skipped", 0) > 0 or counts.get("todo", 0) > 0 or re.search(r"^\s*ok .*# (SKIP|TODO)\b", text, re.M | re.I):
print("refuse: TAP reports skipped or todo tests", file=sys.stderr)
sys.exit(2)
if "fail" not in counts or counts.get("pass", 0) == 0 or counts.get("tests", -1) != counts.get("pass"):
print("refuse: no all-passing TAP summary", file=sys.stderr)
sys.exit(3)
PY
}
# node_tap DIR runs npm run test (node --test with the TAP reporter) in DIR
# and requires an all-passing TAP summary.
node_tap() {
local dir="$1" log rc=0 dc=0
require_script "$dir" test
log="$(mktemp)"
npm --prefix "$dir" run test >"$log" 2>&1 || rc=$?
detect_tap "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
grep -E '^\s*not ok|# (fail|skipped|todo)|Error' "$log" | head -n 40 >&2 || true
tail -n 20 "$log" >&2
rm -f "$log"
echo "refuse: npm run test in $dir (test=$rc detect=$dc)" >&2
return 1
fi
echo "tap: $dir $(grep -E '^# (tests|pass|fail) ' "$log" | tr '\n' ' ')"
rm -f "$log"
}
# require_script DIR NAME refuses DIR without node_modules or without the
# npm script NAME in its package.json.
require_script() {
local dir="$1" name="$2"
require_file "$dir/package.json" "npm package"
require_dir "$dir/node_modules" "npm dependencies; run npm ci in $dir"
if ! python3 -c 'import json, sys; sys.exit(0 if sys.argv[2] in json.load(open(sys.argv[1])).get("scripts", {}) else 1)' "$dir/package.json" "$name"; then
echo "refuse: missing npm script $name in $dir/package.json" >&2
return 1
fi
}
# npm_script DIR NAME runs npm run NAME in DIR; the last lines of its output
# are printed on failure.
npm_script() {
local dir="$1" name="$2" log rc=0
require_script "$dir" "$name"
log="$(mktemp)"
npm --prefix "$dir" run "$name" >"$log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]]; then
tail -n 40 "$log" >&2
rm -f "$log"
echo "refuse: npm run $name in $dir (exit $rc)" >&2
return 1
fi
rm -f "$log"
echo "npm: $name in $dir"
}
# node_run DIR SCRIPT ARGS... runs a node script of DIR from DIR.
node_run() {
local dir="$1" script="$2" log rc=0
shift 2
require_file "$dir/$script" "node script"
log="$(mktemp)"
(cd "$dir" && node "$script" "$@") >"$log" 2>&1 || rc=$?
if [[ "$rc" -ne 0 ]]; then
tail -n 40 "$log" >&2
rm -f "$log"
echo "refuse: node $script in $dir (exit $rc)" >&2
return 1
fi
rm -f "$log"
echo "node: $script in $dir"
}
# require_all WHAT DIR... checks every directory and names all missing ones.
require_all() {
local what="$1" dir missing=0
shift
for dir in "$@"; do
require_dir "$dir" "$what" || missing=1
done
return "$missing"
}
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":"TestContentDescriptorFields"}'
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestFieldTypeRequestOptions"}'
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":"TestB"}'
expect_detector detect_go subtest-skip 2 '{"Action":"skip","Package":"p","Test":"TestA/sub"}
{"Action":"pass","Package":"p","Test":"TestA"}'
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="TestA TestB" expect_detector detect_go missing-named 5 '{"Action":"pass","Package":"p","Test":"TestA"}'
REQUIRE_TESTS="TestA" expect_detector detect_go subtest-is-not-parent 5 '{"Action":"pass","Package":"p","Test":"TestOuter/TestA"}
{"Action":"pass","Package":"p","Test":"TestOther"}'
local ok=' ✓ tests/a.test.ts (12 tests) 15ms
Test Files 1 passed (1)
Tests 12 passed (12)'
expect_detector detect_vitest pass 0 "$ok"
REQUIRE_FILES="tests/b.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/a.test.ts (12 tests) 15ms'
local tap='TAP version 13
ok 1 - a
ok 2 - b
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0'
expect_detector detect_tap pass 0 "$tap"
expect_detector detect_tap not-ok 1 "${tap/ok 2 - b/not ok 2 - b}"
expect_detector detect_tap fail-count 1 "${tap/\# fail 0/\# fail 1}"
expect_detector detect_tap skipped 2 "${tap/\# skipped 0/\# skipped 1}"
expect_detector detect_tap skip-directive 2 "${tap/ok 2 - b/ok 2 - b # SKIP not here}"
expect_detector detect_tap no-summary 3 'TAP version 13
ok 1 - a'
expect_detector detect_tap zero 3 "$(printf '# tests 0\n# pass 0\n# fail 0')"
local tmp
tmp="$(mktemp -d)"
# A failing go test in a planted module is refused; the passing one is
# not.
mkdir -p "$tmp/gomod"
printf 'module selftest\n\ngo 1.22\n' >"$tmp/gomod/go.mod"
printf 'package selftest\n\nimport "testing"\n\nfunc TestPlanted(t *testing.T) {}\n' >"$tmp/gomod/p_test.go"
if ! GOWORK=off GOFLAGS=-mod=mod go_json "$tmp/gomod" -count=1 ./... >/dev/null 2>&1; then
rm -rf -- "${tmp:?}"
echo "refuse: self-test go_json refused a passing planted test" >&2
return 1
fi
printf 'package selftest\n\nimport "testing"\n\nfunc TestPlanted(t *testing.T) { t.Fatal("planted") }\n' >"$tmp/gomod/p_test.go"
if GOWORK=off GOFLAGS=-mod=mod go_json "$tmp/gomod" -count=1 ./... >/dev/null 2>&1; then
rm -rf -- "${tmp:?}"
echo "refuse: self-test go_json accepted a failing planted test" >&2
return 1
fi
echo "PASS self-test go_json failing-test"
# The marker rule on a planted repository: an added SEO word, sitemap,
# JSON-LD 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.
mkdir -p "$tmp/repo"
git -C "$tmp/repo" init -q
mkdir -p "$tmp/repo/modules/pact" "$tmp/repo/docs"
printf 'package pact\n\n// Old mentions a site''map from before the phase.\nfunc Old() {}\n' >"$tmp/repo/modules/pact/old.go"
git -C "$tmp/repo" add -A
git -C "$tmp/repo" -c user.name=t -c user.email=t@example.test commit -q -m base
local base
base="$(git -C "$tmp/repo" rev-parse HEAD)"
printf 'package pact\n\n// Content describes records.\nfunc Content() {}\n' >"$tmp/repo/modules/pact/content.go"
printf 'package pact\n\n// The site''map test.\nfunc helper() {}\n' >"$tmp/repo/modules/pact/content_test.go"
git -C "$tmp/repo" add -A
git -C "$tmp/repo" -c user.name=t -c user.email=t@example.test commit -q -m clean
if [[ -n "$(marker_hits "$tmp/repo" "$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 pact\n\n// Content feeds the site''map.\nfunc Content() {}\n' >"$tmp/repo/modules/pact/content.go"
printf '# Content\n\nRendered as JSON''-LD for grzy''byfunkcjonalne.\n' >"$tmp/repo/docs/content.md"
git -C "$tmp/repo" add -A
git -C "$tmp/repo" -c user.name=t -c user.email=t@example.test commit -q -m dirty
local hits
hits="$(marker_hits "$tmp/repo" "$base")"
if ! grep -q '^modules/pact/content.go:3:' <<<"$hits" || ! grep -q '^docs/content.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 stage whose repository is missing fails naming it.
expect_mode missing-plugin 1 "missing $tmp/nope-plugin" --plugins "SITEMAP_PLUGIN=$tmp/nope-plugin"
expect_mode missing-layer 1 "missing $tmp/nope-layer" --layers "SEO_LAYER=$tmp/nope-layer"
expect_mode missing-app 1 "missing $tmp/nope-app" --consumers "BM_APP=$tmp/nope-app"
expect_mode missing-compat 1 "missing $tmp/nope-compat" --compat "DEMO_APP=$tmp/nope-compat"
# 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" PHASE33_REQUIRE_DOCKER=0
expect_mode docker-required 1 "FAIL docker" --docker-gate "PATH=$tmp/bin:$PATH" PHASE33_REQUIRE_DOCKER=1
expect_mode docker-stage-needs-daemon 1 "needs a reachable Docker daemon" --docker "PATH=$tmp/bin:$PATH"
rm -rf -- "${tmp:?}"
# No argument prints the usage and exits 2.
local out rc=0
out="$(bash "${BASH_SOURCE[0]}" 2>&1)" || rc=$?
if [[ "$rc" -ne 2 ]] || ! grep -qF -- "--compat" <<<"$out"; then
echo "refuse: self-test usage: exit $rc, want 2 with the usage" >&2
return 1
fi
echo "PASS self-test usage"
self_test_additive
echo "phase33 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 "phase33 go passed"
}
run_plugins() {
local dir
require_all "Go plugin" "$SITEMAP_PLUGIN" "$SEO_PLUGIN" "$JOURNAL_PLUGIN"
for dir in "$SITEMAP_PLUGIN" "$SEO_PLUGIN" "$JOURNAL_PLUGIN"; do
workspace_check "$dir"
go_vet "$dir" ./...
go_short "$dir"
done
go_pkg_named "$SITEMAP_PLUGIN" "-short" "${SITEMAP_UNIT_TESTS[@]}"
go_pkg_named "$SEO_PLUGIN" "-short" "${SEO_UNIT_TESTS[@]}"
go_pkg_named "$JOURNAL_PLUGIN" "-short" "${JOURNAL_UNIT_TESTS[@]}"
npm_vitest "$SEO_PLUGIN"
npm_vitest "$JOURNAL_PLUGIN"
echo "phase33 plugins passed"
}
run_layers() {
local layer
require_all "Nuxt layer" "$SEO_LAYER" "$JOURNAL_LAYER" "$MENU_LAYER" "$FAQ_LAYER"
for layer in "$SEO_LAYER" "$JOURNAL_LAYER" "$MENU_LAYER" "$FAQ_LAYER"; do
node_tap "$layer"
npm_script "$layer" build:playground
done
# vue-seo-layer: every smoke expectation (the layer on, off, the CMS
# down, without the 404 token, with drift), the build-guard fixtures
# and the purge checks.
npm_script "$SEO_LAYER" smoke:ssr
npm_script "$SEO_LAYER" guard:check
npm_script "$SEO_LAYER" purge:check
# vue-journal-layer: the acme expectations, the post title forced to h1
# and to p (postTitleTag), the link hint and the breadcrumbs off, the
# purge checks and the git-install lockfile reading.
local journal_smoke=(testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme)
node_run "$JOURNAL_LAYER" "${journal_smoke[@]}" --expect testing/expect-acme.txt
node_run "$JOURNAL_LAYER" "${journal_smoke[@]}" --expect testing/expect-acme-title-h1.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=h1
node_run "$JOURNAL_LAYER" "${journal_smoke[@]}" --expect testing/expect-acme-title-p.txt --env NUXT_PUBLIC_JOURNAL_POST_TITLE_TAG=p
node_run "$JOURNAL_LAYER" "${journal_smoke[@]}" --expect testing/expect-acme-hint-off.txt --env NUXT_PUBLIC_SEO_LINKS_NEW_TAB_HINT=false
node_run "$JOURNAL_LAYER" "${journal_smoke[@]}" --expect testing/expect-acme-breadcrumbs-off.txt --env NUXT_PUBLIC_JOURNAL_BREADCRUMBS=false
node_run "$JOURNAL_LAYER" testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --kind post --slug hello-acme --path /blog/hello-acme
node_run "$JOURNAL_LAYER" testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures/acme --path /blog/hello-acme
(cd "$JOURNAL_LAYER" && bash scripts/check-git-install.sh --self-test)
npm_script "$MENU_LAYER" smoke:ssr
npm_script "$FAQ_LAYER" smoke:ssr
echo "phase33 layers passed"
}
run_consumers() {
local app pkg
require_all "application" "$DEMO_APP" "$GRZYBY_APP" "$SUMMERCMSIO_APP" "$BM_APP"
for app in "$DEMO_APP" "$GRZYBY_APP" "$SUMMERCMSIO_APP" "$BM_APP"; do
workspace_check "$app"
(cd "$app" && go build ./...)
echo "build: $app"
for pkg in "$app"/vue-*/package.json; do
[[ -f "$pkg" ]] || continue
npm_script "$(dirname "$pkg")" build
done
done
# The app patches of plans 33-12, 33-13 and 33-14 apply to the apps as
# committed; 33-14's demo locale patches apply on top of the 33-12 demo
# patches (checked in scratch worktrees).
patch_check "$DEMO_APP" 33-12-sm-demo-app.patch
patch_check "$DEMO_APP/vue-demo-app" 33-12-vue-demo-app.patch
patch_check "$SUMMERCMSIO_APP" 33-12-sm-summercmsio-app.patch
patch_check "$SUMMERCMSIO_APP/vue-summercmsio-app" 33-12-vue-summercmsio-app.patch
patch_check "$GRZYBY_APP" 33-13-sm-grzybyfunkcjonalne-app.patch
patch_check "$GRZYBY_APP/vue-grzybyfunkcjonalne-app" 33-13-vue-grzybyfunkcjonalne-app.patch
scratch_init
stacked_patch_check "$DEMO_APP/vue-demo-app" 33-12-vue-demo-app.patch 33-14-vue-demo-app-locale.patch
if [[ -f "$PHASE_DIR/33-14-sm-demo-app-locale.patch" ]]; then
stacked_patch_check "$DEMO_APP" 33-12-sm-demo-app.patch 33-14-sm-demo-app-locale.patch
fi
echo "phase33 consumers passed"
}
# PHASE_DIR holds the app patches.
PHASE_DIR="$ROOT/.planning/phases/33-seo-output-audit-and-content"
# patch_check REPO PATCH runs git apply --check of a phase patch in REPO
# (--allow-empty: an empty patch, such as summercms.io's Go side, passes).
patch_check() {
local repo="$1" patch="$PHASE_DIR/$2"
require_dir "$repo" "patch target"
require_file "$patch" "app patch"
if ! git -C "$repo" apply --check --allow-empty "$patch"; then
echo "refuse: $2 does not apply to $repo" >&2
return 1
fi
echo "patch: $2 applies to $repo"
}
# stacked_patch_check REPO BASE PATCH applies BASE in a scratch worktree of
# REPO's HEAD and runs git apply --check of PATCH on top of it.
stacked_patch_check() {
local repo="$1" base="$PHASE_DIR/$2" patch="$PHASE_DIR/$3" wt
require_file "$base" "app patch"
require_file "$patch" "app patch"
wt="$SCRATCH/stack-$(basename "$repo")-$RANDOM"
add_worktree "$repo" "$wt" HEAD
if ! git -C "$wt" apply --allow-empty "$base" || ! git -C "$wt" apply --check --allow-empty "$patch"; then
echo "refuse: $3 does not apply to $repo with $2" >&2
return 1
fi
echo "patch: $3 applies to $repo on top of $2"
}
docker_up() {
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
}
run_docker() {
local dir
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_all "Go plugin" "$SITEMAP_PLUGIN" "$SEO_PLUGIN" "$JOURNAL_PLUGIN"
local seo_required=("${SEO_DOCKER_TESTS[@]}") seo_skip=()
if chrome_binary >/dev/null; then
seo_required+=(TestChromeRenderer)
echo "chrome: $(chrome_binary) (TestChromeRenderer required)"
else
seo_skip=(-skip '^TestChromeRenderer$')
echo "NOT RUN TestChromeRenderer: no Chrome or Chromium on this host (set SEO_TEST_CHROME)"
fi
workspace_check "$SITEMAP_PLUGIN"
REQUIRE_TESTS="${SITEMAP_DOCKER_TESTS[*]}" go_json "$SITEMAP_PLUGIN" -count=1 -timeout 30m ./...
workspace_check "$SEO_PLUGIN"
REQUIRE_TESTS="${seo_required[*]}" go_json "$SEO_PLUGIN" -count=1 -timeout 30m "${seo_skip[@]}" ./...
workspace_check "$JOURNAL_PLUGIN"
REQUIRE_TESTS="${JOURNAL_DOCKER_TESTS[*]}" go_json "$JOURNAL_PLUGIN" -count=1 -timeout 30m ./...
# The framework's attachment and WebP packages with their Postgres
# suites (33-11).
go_json "$ROOT" -count=1 -timeout 30m ./modules/lagoon/attach/...
echo "phase33 docker passed"
}
# chrome_binary prints the Chrome or Chromium binary TestChromeRenderer
# uses (SEO_TEST_CHROME, else chromium, chromium-browser, google-chrome on
# PATH) and fails when there is none.
chrome_binary() {
if [[ -n "${SEO_TEST_CHROME:-}" ]]; then
printf '%s\n' "$SEO_TEST_CHROME"
return 0
fi
local name
for name in chromium chromium-browser google-chrome; do
if command -v "$name" >/dev/null 2>&1; then
command -v "$name"
return 0
fi
done
return 1
}
# docker_gate is the docker step of --all: run the stage when a daemon
# answers, fail with PHASE33_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 [[ "${PHASE33_REQUIRE_DOCKER:-0}" == "1" ]]; then
echo "FAIL docker: no reachable Docker daemon and PHASE33_REQUIRE_DOCKER=1"
return 1
fi
cat >&2 <<'EOF'
************************************************************************
NOT RUN docker: no reachable Docker daemon on this host.
The Postgres suites of the sitemap, SEO and Journal plugins did NOT run.
Run on a Docker host (through sudo -u golem when the socket is denied)
before /msd-verify-work:
bash scripts/check-phase33.sh --docker
************************************************************************
EOF
echo "NOT RUN docker (Docker-host only; see the notice above)"
}
run_admin() {
(cd "$ROOT" && bash scripts/check-admin-openapi.sh --check)
(cd "$ROOT" && bash scripts/check-admin-dist.sh)
echo "phase33 admin passed"
}
run_docs() {
go_named "$ROOT" ./cmd/summer "" TestDocsTree
(cd "$ROOT" && go run ./cmd/summer docs:build --check)
echo "phase33 docs passed"
}
run_marker() {
if ! git -C "$ROOT" rev-parse --verify -q "$PHASE33_BASE^{commit}" >/dev/null; then
echo "refuse: PHASE33_BASE $PHASE33_BASE is not a commit of $ROOT" >&2
return 1
fi
local hits
hits="$(marker_hits "$ROOT" "$PHASE33_BASE")"
if [[ -n "$hits" ]]; then
printf '%s\n' "$hits" | head -n 40 >&2
echo "refuse: framework lines added since $PHASE33_BASE name SEO, a sitemap, JSON-LD or a consuming application (D-90)" >&2
return 1
fi
echo "marker: no line added since $PHASE33_BASE under modules/ or docs/ names SEO, a sitemap, JSON-LD or a consuming application"
echo "phase33 marker passed"
}
# ---------------------------------------------------------------------------
# additive and compat (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 that does not contain HEAD: the
# previous release, also once HEAD itself carries the new release tag (a
# post-release run must not compare the release with itself).
last_tag() {
git -C "$ROOT" tag --sort=-v:refname --no-contains HEAD | 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 "phase33 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 "${PHASE33_SKIP_TESTS:-}" ]]; then
echo "compat: note $name: tests matching $PHASE33_SKIP_TESTS skipped (PHASE33_SKIP_TESTS)" >&2
consumer_run "$name" "$app" "$framework" go test -count=1 -short -skip "$PHASE33_SKIP_TESTS" ./...
else
consumer_run "$name" "$app" "$framework" go test -count=1 -short ./...
fi
echo "compat: ok $name"
}
# compat_snapshot NAME REPO REV FARM places a read-only snapshot of REPO at
# REV, with its pinned submodules, in a sibling farm and prints its path.
compat_snapshot() {
local name="$1" repo="$2" rev="$3" farm="$4" gd
gd="$(git_dir_of "$repo" || true)"
if [[ -z "$gd" ]] || ! git -c safe.directory='*' --git-dir="$gd" rev-parse --verify -q "$rev^{commit}" >/dev/null; then
echo "refuse: compat: $repo has no revision $rev" >&2
return 1
fi
sibling_farm "$farm" "$name"
snapshot "$gd" "$rev" "$farm/$name"
snapshot_submodules "$repo" "$rev" "$farm/$name"
}
run_compat() {
local farm repo name gd real failed=0 tag skip=()
tag="$(last_tag)"
require_all "compat consumer" "$SITEMAP_PLUGIN" "$JOURNAL_PLUGIN" "$DEMO_APP"
export GOPRIVATE="${GOPRIVATE:-git.golem15.com/*}"
scratch_init
# (a) sm-sitemap-plugin and (b) sm-journal-plugin at their releases
# before the phase.
name="$(basename "$SITEMAP_PLUGIN")"
compat_snapshot "$name" "$SITEMAP_PLUGIN" "$PHASE33_SITEMAP_BASE" "$SCRATCH/a"
compat_tested "$name" "$SCRATCH/a/$name" "$ROOT"
name="$(basename "$JOURNAL_PLUGIN")"
compat_snapshot "$name" "$JOURNAL_PLUGIN" "$PHASE33_JOURNAL_BASE" "$SCRATCH/b"
compat_tested "$name" "$SCRATCH/b/$name" "$ROOT"
# (c) sm-demo-app at its commit before the phase, with the submodules
# pinned there.
name="$(basename "$DEMO_APP")"
compat_snapshot "$name" "$DEMO_APP" "$PHASE33_DEMO_BASE" "$SCRATCH/c"
COMPAT_TAGS="$MCP_TAG" compat_tested "$name" "$SCRATCH/c/$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"; 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 "phase33 compat passed"
}
run_all() {
local stage
for stage in self-test go plugins layers consumers admin docs marker; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
docker_gate || exit 1
for stage in additive compat; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
echo "Phase 33 gate passed"
}
run_stage() {
case "$1" in
--self-test | self-test) run_self_test ;;
--go | go) run_go ;;
--plugins | plugins) run_plugins ;;
--layers | layers) run_layers ;;
--consumers | consumers) run_consumers ;;
--docker | docker) run_docker ;;
--docker-gate) docker_gate ;;
--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
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