From 6ea43bb120dff5cf470ab208874c5db97c3414bb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 08:47:42 +0100 Subject: [PATCH] test: phase 33 gate script - scripts/check-phase33.sh with the stages self-test, go, plugins, layers, consumers, docker, admin, docs, marker, additive and compat, and --all; no argument prints the usage and exits 2 - engine taken from check-phase32.sh (go test -json and vitest detectors, scratch sibling farm, apisurface additive comparison) - marker diffs from v0.6.0 and adds sitemap, JSON-LD and the application names; additive compares against the newest tag not containing HEAD - a missing repository fails with "missing "; the self-test plants an API removal, an interface method, a marker hit, a failing go test and a missing repository --- scripts/check-phase33.sh | 1196 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1196 insertions(+) create mode 100755 scripts/check-phase33.sh diff --git a/scripts/check-phase33.sh b/scripts/check-phase33.sh new file mode 100755 index 00000000..21003ac4 --- /dev/null +++ b/scripts/check-phase33.sh @@ -0,0 +1,1196 @@ +#!/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 and vitest 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 and go test -short; 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, npm run build:playground and the +# layer's SSR smoke; node testing/guard-check.mjs in +# vue-seo-layer (the build-guard fixtures). +# 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). +# docker Docker host only: the Postgres suites (go test ./... without +# -short) of the three Go plugins. +# 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 (build)"), or fails at the +# newest tag too ("compat: skipped (fails at too)"). +# +# A stage whose repository or directory is missing fails with +# "missing ". 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: :. + +# 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 +) + +# 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 : 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 "). +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:-}, 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" +} + +# 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 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 + 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 + npm_vitest "$layer" + npm_script "$layer" build:playground + done + npm_script "$SEO_LAYER" smoke:ssr + node_run "$JOURNAL_LAYER" testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures/acme --expect testing/expect-acme.txt + npm_script "$MENU_LAYER" smoke:ssr + npm_script "$FAQ_LAYER" smoke:ssr + node_run "$SEO_LAYER" testing/guard-check.mjs + 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 + echo "phase33 consumers passed" +} + +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" + for dir in "$SITEMAP_PLUGIN" "$SEO_PLUGIN" "$JOURNAL_PLUGIN"; do + workspace_check "$dir" + go_json "$dir" -count=1 -timeout 30m ./... + done + echo "phase33 docker passed" +} + +# 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/). 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:-}, 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