.DEFAULT_GOAL := help SHELL := /bin/bash # node@22 is keg-only on this host; ensure it is on PATH for pnpm export PATH := /opt/homebrew/opt/node@22/bin:$(PATH) .PHONY: help help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' .PHONY: bootstrap bootstrap: ## First-time dev setup ./scripts/bootstrap.sh # Separate from bootstrap because bootstrap installs a toolchain and this does # not: an up-to-date clone with the hook uninstalled needs this one command and # nothing else. `git config core.hooksPath` is repo-local and repo-local config # is never committed, so without it a clone carries .githooks/pre-push and git # never runs it - the push to main is permitted exactly as it was on 2026-08-12. # # A checkout that PREDATES this target prints "No rule to make target" and # installs nothing. That clone needs `git pull` first. # # THREE THINGS HERE ARE NOT STYLE, each measured wrong before: # 1. core.hooksPath is set to an ABSOLUTE path derived from --git-common-dir. # A relative one is resolved by git against whichever working tree runs the # hook, so a linked worktree - or a checkout of a commit that predates the # hook - silently runs no hook while config keeps reading "installed". # 2. The hook is COPIED into the common hooks directory rather than config # pointing at the tracked .githooks, because checking out an older commit # deletes that directory out from under a config still naming it. # 3. The destination is REMOVED before the copy. `cp` follows a symlink and # writes through it, so a pre-push symlinked in from a dotfiles repo or # another checkout had that FOREIGN FILE overwritten with this hook's text # - measured, in a fresh clone. The copy-aside above does not prevent it: # it saves the contents and `cp` then clobbers the target anyway. `rm -f` # makes the new file replace the LINK instead. # So re-run this after editing .githooks/pre-push; `make hooks-status` says # whether the installed copy is still current. .PHONY: hooks hooks: ## Install the committed git hooks (pre-push refuses a push that lands on main) @set -eu; \ hooks_dir="$$(cd "$$(git rev-parse --git-common-dir)" && pwd)/hooks"; \ mkdir -p "$$hooks_dir"; \ if [ -e "$$hooks_dir/pre-push" ] && ! cmp -s .githooks/pre-push "$$hooks_dir/pre-push"; then \ aside="$$hooks_dir/pre-push.replaced-$$(date +%Y%m%d%H%M%S)"; \ cp "$$hooks_dir/pre-push" "$$aside"; \ if [ -L "$$hooks_dir/pre-push" ]; then \ echo "NOTE: a DIFFERENT pre-push hook was already installed, as a SYMLINK to:"; \ echo " $$(readlink "$$hooks_dir/pre-push")"; \ echo " That file is left untouched; the LINK is replaced. Its contents were copied aside to:"; \ else \ echo "NOTE: a DIFFERENT pre-push hook was already installed. Copied aside to:"; \ fi; \ echo " $$aside"; \ echo " Merge anything you need from it back into .githooks/pre-push."; \ fi; \ rm -f "$$hooks_dir/pre-push"; \ cp .githooks/pre-push "$$hooks_dir/pre-push"; \ chmod +x "$$hooks_dir/pre-push"; \ git config core.hooksPath "$$hooks_dir"; \ echo "pre-push INSTALLED: $$hooks_dir/pre-push" # Exits NON-ZERO when the hook is not actually live, so it is usable as a check # and cannot report success over its own absence. It compares the installed copy # against the tracked one: "core.hooksPath is set" alone is not installed. # # Both paths resolve against `git rev-parse --show-toplevel`, never against the # cwd. Git resolves a relative core.hooksPath against the working-tree root, so # resolving it against the cwd answered a question nobody asked: run from # apps/api it reported a live hook as NOT INSTALLED, and a correctly installed # absolute one as STALE, because ".githooks/pre-push" did not exist there either. .PHONY: hooks-status hooks-status: ## Report whether the pre-push hook is actually running in this checkout @hp="$$(git config --get core.hooksPath || true)"; \ if [ -z "$$hp" ]; then \ echo "pre-push NOT INSTALLED: core.hooksPath is unset, so git ignores .githooks/. Run 'make hooks'."; \ exit 1; \ fi; \ root="$$(git rev-parse --show-toplevel)"; \ case "$$hp" in /*) ;; *) hp="$$root/$$hp";; esac; \ if [ ! -x "$$hp/pre-push" ]; then \ echo "pre-push NOT INSTALLED: core.hooksPath=$$hp has no executable pre-push. Run 'make hooks'."; \ exit 1; \ fi; \ if cmp -s "$$root/.githooks/pre-push" "$$hp/pre-push"; then \ echo "pre-push INSTALLED and current: $$hp/pre-push"; \ else \ echo "pre-push INSTALLED but STALE: $$hp/pre-push differs from .githooks/pre-push. Run 'make hooks'."; \ exit 1; \ fi .PHONY: quickstart quickstart: ## One-command self-host bootstrap: write .env + generate secrets ./scripts/init-env.sh COMPOSE := docker compose -f infra/docker-compose.yml COMPOSE_DEV := $(COMPOSE) -f infra/docker-compose.dev.yml .PHONY: dev dev: ## Run full stack for local development $(COMPOSE_DEV) up .PHONY: up up: ## Run the production-style stack (built images) $(COMPOSE) up -d .PHONY: down down: ## Stop the local stack $(COMPOSE_DEV) down .PHONY: observability observability: ## Run the stack with the observability profile (otel-lgtm) $(COMPOSE) --profile observability up -d .PHONY: docker-build docker-build: ## Build the api + web container images docker build -f infra/Dockerfile.api -t wpmgr-api:dev . docker build -f infra/Dockerfile.web -t wpmgr-web:dev . .PHONY: build build: build-api build-web ## Build everything .PHONY: build-api build-api: ## Build the Go API binary cd apps/api && go build -o bin/wpmgr ./cmd/wpmgr .PHONY: build-web build-web: ## Build the web SPA pnpm --filter @wpmgr/web build .PHONY: test test: test-api test-web ## Run all tests .PHONY: test-api # -timeout 45m, not the default. apps/api/tests stands up an ephemeral Postgres # per test and sits right on Go's 10 minute default: measured 522s for that # package alone and 601s under the parallel load of `go test ./...`, which # panicked with "test timed out after 10m0s". The tests were fine; the limit # was not. Without this flag the full run flakes on a busy machine. # # -count=1 (GH #464): this target's own help text calls it "unit plus # integration", i.e. the thing you run to know whether apps/api is actually # green, not a fast iteration loop. Go caches a successful `go test` result # keyed on inputs, so without -count=1 a second run after an unrelated change # elsewhere in the repo prints "ok ... (cached)" and executes nothing. test-api: ## Run Go tests (unit plus integration; needs Docker for the integration package; never cached, see -count=1) cd apps/api && go test -count=1 -timeout 45m ./... .PHONY: test-integration # The integration package on its own, which is where the security proofs live: # the m112 RLS tests that stop a collaborator invited to one site reaching the # whole organisation's email credentials. # # THIS IS NOT RUN BY CI. .github/workflows/ci.yml excludes the package and # .github/workflows/api-integration.yml is manual-only, because it takes about # 18 minutes on a standard runner and that toll on every PR is not worth paying # yet. So this command is the only thing standing in front of a regression in # it. Run it before merging anything that touches RLS, the email domain or # tenant scoping. Needs a working Docker daemon (testcontainers). # # -count=1 (GH #464): without it, Go caches a pass and a re-run after an # unrelated change reports "ok ... (cached)" having executed nothing — for the # one target CI deliberately never runs, that is a silent pass over the RLS # proofs above. This target is a gate, not an iteration loop, so it is never # cached. (test-api, above, has the same flag for the same reason; the other # test targets in this file — test-web, check-versions-test — do not invoke # `go test` at all, so Go's result cache does not apply to them.) # SERIALISED MACHINE-WIDE (GH #565). Every test in this package starts a fresh # Postgres testcontainer, and several agents work in parallel git worktrees on # one machine. Two suites running at once contend for ports: measured, two # overlapping runs at 14m36s and 11m39s from two worktrees, and #565 records # this suite failing 2 of 3 runs in one slice, one with `port "5432/tcp" not # found`. The wasted minutes are not the problem. The problem is that a # contention failure and a real regression LOOK IDENTICAL, so a genuine RLS # regression gets waved through as "that flake again" — in the one package CI # never runs, where this command is the only gate. # # So a second invocation now WAITS for the first instead of racing it. It says # who it is waiting for and for how long, it gives up after an hour with exit # 75 rather than hanging, and it reclaims (noisily) a lock whose holder died. # The suite's own exit status passes through untouched — see the long comment # in the script about why that is not routed through a pipe. # # scripts/with-machine-lock_test.sh is its regression suite; `make test-lock`. # To run without the lock (single worktree, or debugging the lock itself): # cd apps/api && go test -count=1 -timeout 45m ./tests/... test-integration: ## Run the Go integration tests, serialised machine-wide (Docker required; never cached, see -count=1) scripts/with-machine-lock.sh api-integration -- sh -c 'cd apps/api && go test -count=1 -timeout 45m ./tests/...' .PHONY: test-lock test-lock: ## Run the machine-wide lock's regression suite (seconds; no Docker, no integration run) scripts/with-machine-lock_test.sh .PHONY: test-web test-web: ## Run frontend tests pnpm run test .PHONY: lint lint: ## Lint everything cd apps/api && go vet ./... pnpm run lint # The same check CI runs, so a drifted install pin, hero badge or agent version # is a five second answer here instead of a red build later. check-versions-test # is the guard's own regression suite; run it after editing the guard. .PHONY: check-versions check-versions: ## Check every version-naming surface (docs, marketing, agent) scripts/check-version-surfaces.sh .PHONY: check-versions-test check-versions-test: ## Run the version surface guard's regression suite scripts/check-version-surfaces_test.sh # GH #819: the integration tests' S3 store is one digest-pinned const in # apps/api/tests, and api-integration.yml pre-pulls whatever this reads out of # it. The same two commands CI runs, suite first. .PHONY: check-s3-image check-s3-image: ## Check the integration tests' S3 image is digest-pinned, named once, and pre-pulled from the const scripts/test-s3-image.sh --check .PHONY: check-s3-image-test check-s3-image-test: ## Run the S3 image guard's regression suite scripts/test-s3-image_test.sh # The agent's generated block markup, validated by the real @wordpress/blocks # on the pinned WP 6.2 and latest package sets (CI job page-blocks). .PHONY: check-page-blocks check-page-blocks: ## Validate the page builder's block markup per WordPress version scripts/check-page-blocks.sh .PHONY: check-page-blocks-test check-page-blocks-test: ## Run the page block guard's regression suite scripts/check-page-blocks_test.sh # The same generated markup, run through the save filters of the pinned # WordPress cores as a user without unfiltered_html (CI job page-kses). The # first run downloads and sha256-verifies each core into a cache dir; the # guard's own regression suite is the second target. Run it after editing. .PHONY: check-page-kses check-page-kses: ## Run the page builder's markup through core kses per WordPress version scripts/check-page-kses.sh .PHONY: check-page-kses-test check-page-kses-test: ## Run the kses guard's regression suite scripts/check-page-kses_test.sh # The agent's Elementor trees, saved by a real Elementor as the service user and # rendered, and the agent's own create path (precheck, write, undo) run on that # Elementor, per pinned Elementor version and per layout (CI job # elementor-roundtrip). It runs a real WordPress under the Playground CLI (needs # node 22 and network on a first run; downloads are sha256-pinned and cached). # RT_VERSIONS and RT_LAYOUTS narrow a run. The second target is the guard's own # regression suite; run it after editing the guard or the agent's create path. .PHONY: check-elementor-roundtrip check-elementor-roundtrip: ## Save and render the agent's Elementor trees, and run its create path, under each pinned Elementor scripts/elementor-roundtrip/run.sh .PHONY: check-elementor-roundtrip-test check-elementor-roundtrip-test: ## Run the Elementor round trip's regression suite scripts/elementor-roundtrip_test.sh # Plants a defect in a render probe's tree, once for each plant in # scripts/elementor-roundtrip/plants.txt and on every pinned Elementor, and # requires every run to come back red because of the plant (CI runs it after the # round trip). RT_VERSIONS narrows a run to some pins. .PHONY: check-elementor-roundtrip-plants check-elementor-roundtrip-plants: ## Plant defects in the Elementor render probes and require every one to turn the run red scripts/elementor-roundtrip/plants.sh # The load-balancer url-map. Twice in one day a route shipped, deployed and was # unreachable because the API mounted it and the LB did not route it — POST # /mcp, then very nearly the OAuth discovery documents. Both answer 200 # text/html from the web SPA, so no status-code smoke test can see them. # check-urlmap proves every mounted route has a path rule and needs NO cloud # credentials, which is why it is the one wired into ci.yml. check-urlmap-drift # compares the committed file against live GCP and does need credentials, so it # is a separate target and not a CI gate. check-urlmap-test is the guard's own # regression suite; run it after editing the guard. .PHONY: check-urlmap check-urlmap: ## Check every API route is routed by infra/urlmap.yaml (no cloud access) scripts/check-urlmap-routes.sh .PHONY: check-urlmap-test check-urlmap-test: ## Run the url-map route-coverage guard's regression suite scripts/check-urlmap-routes_test.sh .PHONY: check-urlmap-drift check-urlmap-drift: ## Compare infra/urlmap.yaml against the live GCP url-map (needs gcloud) scripts/check-urlmap-drift.sh # GH #650: a PHP fatal error inside an agent test ends PHPUnit partway through # the suite, and the red named that one error instead of the tests that never # ran. scripts/check-phpunit-complete.sh compares `phpunit --list-tests-xml` # with the run's `--log-junit` report; ci.yml's PHP (agent) job runs it after # the tests. check-phpunit-complete-test is the guard's own regression suite. .PHONY: check-phpunit-complete-test check-phpunit-complete-test: ## Run the PHPUnit completeness guard's regression suite scripts/check-phpunit-complete_test.sh # GH #547: the agent declared MIT in its plugin header and GPLv2 or later in # the wp.org readme.txt at the same time. This reconciles every place in # apps/agent (plus the repo-root LICENSE-AGENT carve-out) that names the # agent's own license, by name, and fails if any is missing or any two # disagree. check-licenses-test is the guard's own regression suite; run it # after editing the guard. .PHONY: check-licenses check-licenses: ## Check every license-naming surface in the agent plugin scripts/check-license-surfaces.sh .PHONY: check-licenses-test check-licenses-test: ## Run the license surface guard's regression suite scripts/check-license-surfaces_test.sh # scripts/check-rls-cross-tenant.sh (GH #470) reconciles every cross-tenant RLS # policy against apps/api/db/rls-cross-tenant-policies.txt, the ledger that # says which access mode each one is MEANT to have. Run this before merging # anything that adds or narrows an RLS policy: it needs a database (Docker for # a throwaway postgres, or $WPMGR_RLS_DATABASE_URL for one you already have) # because extraction reads pg_policies live. check-rls-cross-tenant-test is the # guard's own regression suite and needs neither; run it after editing the # guard itself. .PHONY: check-rls-cross-tenant check-rls-cross-tenant: ## Audit cross-tenant RLS policies against the ledger (Docker or $WPMGR_RLS_DATABASE_URL required) scripts/check-rls-cross-tenant.sh .PHONY: check-rls-cross-tenant-test check-rls-cross-tenant-test: ## Run the RLS cross-tenant guard's regression suite (hermetic, no DB) scripts/check-rls-cross-tenant_test.sh # scripts/check-schema-sync.sh (GH #759) replays every migration into one # throwaway postgres, loads apps/api/db/schema.sql into a second, and compares # what the catalogs say: tables, columns, indexes, constraints, RLS flags and # policies, functions. It also checks atlas.sum names exactly the migration # files, in order, with the hashes Atlas would write. Needs Docker (the # postgres:16-alpine image, no network, nothing published) and openssl; run it # before merging anything that touches a migration or schema.sql. # check-schema-sync-test is the guard's own regression suite; it needs the same # Docker and builds small trees, so it is unaffected by the real tree's state. # Run it after editing the guard. .PHONY: check-schema-sync check-schema-sync: ## Check db/schema.sql and atlas.sum are in step with the migrations (Docker required) scripts/check-schema-sync.sh .PHONY: check-schema-sync-test check-schema-sync-test: ## Run the schema sync guard's regression suite (Docker required) scripts/check-schema-sync_test.sh # ADR-061 A11 item 4: the containment test. No handler on the assistant surface # may take a site id from a request and pass it anywhere but the ONE audited # chokepoint, mcp.Repo.ResolveScopeSites. The chokepoint shipped; nothing # stopped the next handler going round it, and ADR-060's freeze clause calls # that an open auth-boundary item. This is what closes it. # # It reads the mcp.Store and requestStore interfaces through `go doc`, i.e. # Go's own parser rather than a grep over repo.go, so it needs the Go toolchain # but NO database and NO cloud credentials — which is why it runs on every PR. # `go doc` parses and does not type-check, so the guard fires on a bypass in # progress before the branch compiles. The reviewed surface is # infra/mcp-site-containment-allowlist.txt and it is checked in both # directions: an unreviewed call site, uuid parameter or tool-argument binding # is a violation, and so is an allowlist entry that no longer matches anything. # check-mcp-containment-test is the guard's own regression suite (hermetic, no # Go toolchain needed); run it after editing the guard itself. .PHONY: check-mcp-containment check-mcp-containment: ## Check no MCP handler can pass a request site id round the chokepoint (ADR-061 A11) scripts/check-mcp-site-containment.sh .PHONY: check-mcp-containment-test check-mcp-containment-test: ## Run the MCP site-containment guard's regression suite (hermetic) scripts/check-mcp-site-containment_test.sh # scripts/check-assistant-ship-gate.sh scores ADR-061's nine-item pre-ship gate # -- the section headed "What has to exist before v1 ships." Until this existed # that gate had never been evaluated once, while the surface it gates was live # in production. It needs no toolchain, no database and no network, so it runs # on a developer machine in under a second. # # EXPECT IT TO FAIL. Most of the nine are open, which is the finding and not a # defect in the guard. Each unmet line names the specific thing that closes it. # Items are labelled by kind: [code] was verified against the tree, [artefact] # means only that a written document exists and is current, and [manual] means # the item cannot be scored from a repository and is therefore counted as unmet # rather than skipped. Exit 2 is GUARD BROKEN and never means "clean". # # check-assistant-gate-test is the guard's own regression suite; it builds # fixture trees, so it is hermetic and unaffected by the real tree's state. Run # it after editing the guard. .PHONY: check-assistant-gate check-assistant-gate: ## Score ADR-061's nine-item assistant pre-ship gate (no toolchain, no DB) scripts/check-assistant-ship-gate.sh .PHONY: check-assistant-gate-test check-assistant-gate-test: ## Run the assistant ship-gate guard's regression suite (hermetic) scripts/check-assistant-ship-gate_test.sh # ---- Agent harness (.claude) ------------------------------------------------ # The shell guards that used to live in scripts/claude/ are gone: deciding what # a shell command will write by parsing its text is undecidable (eval, bash -c, # a path built at run time), and four rounds of fixes closed real bypasses while # each following round found more. What actually caught this project's real # defects - a privilege escalation, a check-then-act race, an unclamped API-key # role - was the review process in CLAUDE.md and review.md, which is kept. # # .githooks/pre-push is kept and is different in kind: git hands it # already-resolved refs, so eval, quoting and exotic refspecs are gone before it # runs. It is not guessing. `make hooks` installs it; `make hooks-status` says # whether it is live. Those two are above, next to bootstrap. # reproducible_zip: package $2 (a directory) inside $1 into $3, byte for byte # identically every time the same tree is packaged. # # WHY THIS EXISTS (GitHub issue #322). A plain `zip -r` records each entry's # mtime and walks the tree in filesystem order, so the same source produced a # DIFFERENT archive on every build. agent-vendor deletes and reinstalls vendor/ # each time, which restamps thousands of files, so the drift was guaranteed # rather than occasional. # # That mattered because the agent version deliberately does NOT track the repo # version (it only changes when the agent changes), so a web-only release # republished the SAME version string with different bytes. A self-hoster # mirroring our releases correctly refused it: "upstream republished the same # version with different bytes". Four artifacts on GitHub claimed 0.61.114, # each with a different sha256, and every mirror kept refusing until the # version moved. # # The mirror's assumption, that one version string means one set of bytes # forever, is right. This makes it true. # # touch pins every mtime to a fixed instant. It must be >= 1980 because the # zip format cannot store anything earlier, and on an even second # because DOS timestamps have two-second granularity. # sort fixes entry order independent of how the filesystem enumerates. # -X drops platform extra fields (uid, gid, and the macOS-only ones), so # a Linux CI runner and a macOS workstation agree. # # $(1) working dir, $(2) directory to package, $(3) output archive name. # The wordpress.org distribution slug, and the zip that carries it. THIS IS THE # ONE PLACE EITHER NAME IS WRITTEN. The slug is also the top-level directory # inside the archive, which is what WordPress names the installed plugin folder, # so a rename moves the zip path and the install path together. # # Consumers outside this file must NOT restate the literal — they ask make: # PLUGIN_ZIP="$$(make -s print-agent-wporg-zip)" # `.github/workflows/e2e-agent.yml` and `apps/agent/tests-e2e/run.sh` both do # that. They used to hard-code a previous slug; the rename to the current one # updated this file and not them, and the nightly E2E workflow then failed 40 # runs in a row on a missing zip while `make` kept working locally. AGENT_WPORG_SLUG := fleet-agent-site-manager AGENT_WPORG_ZIP := release/$(AGENT_WPORG_SLUG).zip .PHONY: print-agent-wporg-zip print-agent-wporg-zip: ## Print the absolute path of the wp.org agent zip (machine-readable; use with `make -s`) @echo "$(PWD)/$(AGENT_WPORG_ZIP)" .PHONY: print-agent-wporg-slug print-agent-wporg-slug: ## Print the wp.org agent slug (machine-readable; use with `make -s`) @echo "$(AGENT_WPORG_SLUG)" define reproducible_zip find $(1)/$(2) -exec touch -h -t 200001010000.00 {} + cd $(1) && find $(2) -print | LC_ALL=C sort | zip -X -q -@ $(3) endef .PHONY: agent-vendor agent-vendor: ## Build a clean prod-only vendor/ for the agent (no-dev, stripped) # ADR-033 / M5.6: agent has ZERO production composer deps (we dropped phpbu # and ifsnop/mysqldump-php after the mysqli rewrite). composer install is # still needed to generate vendor/autoload.php's classmap for includes/. # Composer 2 in a container (no host PHP requirement). --no-dev drops dev # tooling; --ignore-platform-reqs skips ext-* runtime checks (the build # container doesn't ship ext-mysqli/zip/zlib; those checks happen on the # actual WP host at runtime, where the extensions are present). cd apps/agent && rm -rf vendor composer.lock # Run composer as the HOST user (not the container's root) so every extracted # vendor file is owned by the invoking user. Otherwise, on a Linux CI runner, # the bind-mounted files are created root-owned and the host-side strip step # below fails with "Permission denied" on read-only package files (e.g. a # dependency's README.md / phpstan.neon). COMPOSER_HOME points at a writable # cache dir since the mapped UID has no entry in the container's /etc/passwd. docker run --rm --user "$$(id -u):$$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$(PWD)/apps/agent:/app" -w /app composer:2 install --no-dev --optimize-autoloader --classmap-authoritative --ignore-platform-reqs # Strip non-runtime files from the runtime vendors. Be conservative: only # drop directories named exactly tests/Tests/doc/docs/examples/.git, plus # CHANGELOG/UPGRADING/README .md files. Never touch *.php. cd apps/agent/vendor && find . -type d \( -name tests -o -name Tests -o -name doc -o -name docs -o -name examples -o -name .git -o -name .github \) -prune -exec rm -rf {} + cd apps/agent/vendor && find . -type f \( -name 'CHANGELOG*.md' -o -name 'UPGRADING*.md' -o -name 'README*.md' -o -name 'CONTRIBUTING*.md' -o -name '.gitignore' -o -name 'phpunit.xml*' -o -name 'phpstan.neon*' -o -name '.editorconfig' \) -delete @echo "agent vendor size: $$(du -sh apps/agent/vendor | cut -f1)" .PHONY: agent-zip agent-zip: agent-vendor ## Package the WordPress agent plugin as a zip (with ifsnop vendor/) mkdir -p release # Rebuild (not update) the zip — without this, `zip -r` appends to the # existing archive, leaving stale entries from prior plugin versions (e.g. # old phpbu/ vendor tree, deleted files). Removing the target file forces # a clean rebuild every run. rm -f release/wpmgr-agent.zip rm -rf release/wpmgr-agent # Sweep dev-only files (tests, caches, macOS resource forks, nested # archives someone may have unzipped here for debugging) before packaging. cd apps/agent && rm -f Archive.zip .DS_Store .phpunit.result.cache && find . -name ".DS_Store" -delete # Stage the plugin under a STABLE top-level folder (wpmgr-agent/) before # zipping. WordPress derives a plugin's install folder (its slug) from the # archive's top-level directory — or, when files sit at the archive root, # from the .zip FILENAME. Packaging the bare contents (the old `zip -r . `) # meant a versioned filename like wpmgr-agent-0.10.5.zip extracted to # plugins/wpmgr-agent-0.10.5/ — a DIFFERENT slug from plugins/wpmgr-agent/, # so WordPress saw each release as a brand-new plugin instead of an update # (forcing a deactivate/delete that wipes the agent's wp-cron events). # Staging under wpmgr-agent/ pins the slug regardless of the .zip filename, # so every upload is recognised as an in-place update of the same plugin. rsync -a --delete \ --exclude 'tests/' --exclude 'tests-e2e/' --exclude 'tools/' --exclude '*.dist' --exclude '.phpunit.cache/' \ --exclude '.phpunit.result.cache' --exclude 'composer.lock' \ --exclude '.DS_Store' --exclude '*.zip' \ --exclude 'patchwork.json' \ apps/agent/ release/wpmgr-agent/ # VERSION override: when VERSION is provided (e.g. from the release tag), # strip any leading 'v' and stamp ONLY the staged copy — the source file is # never modified. Two precise in-place sed replacements target exactly the # plugin header "Version:" line and the WPMGR_AGENT_VERSION constant, leaving # all other lines unchanged. When VERSION is unset the staged copy carries the # source baseline unchanged, making this step a no-op. @if [ -n "$(VERSION)" ]; then \ _v=$$(echo "$(VERSION)" | sed 's/^v//'); \ case "$$_v" in \ [0-9]*.[0-9]*.[0-9]*) \ case "$$_v" in \ *[!0-9A-Za-z.+\-]*) \ echo "agent-zip: refusing unsafe VERSION '$(VERSION)' — only digits, letters, dots, hyphens, and plus signs are allowed" >&2; exit 1 ;; \ esac ;; \ *) echo "agent-zip: refusing VERSION '$(VERSION)' — must be MAJOR.MINOR.PATCH (with optional leading v)" >&2; exit 1 ;; \ esac; \ _v_esc=$$(printf '%s' "$$_v" | sed -e 's/[\/&|]/\\&/g'); \ echo "agent-zip: stamping staged copy with version $$_v"; \ sed -i.bak -E "s/^( \* Version:[ \t]+)[0-9]+\.[0-9]+\.[0-9].*/\1$$_v_esc/" release/wpmgr-agent/wpmgr-agent.php; \ sed -i.bak -E "s/^(define\('WPMGR_AGENT_VERSION', *')[^']+(')/\1$$_v_esc\2/" release/wpmgr-agent/wpmgr-agent.php; \ rm -f release/wpmgr-agent/wpmgr-agent.php.bak; \ fi $(call reproducible_zip,release,wpmgr-agent,wpmgr-agent.zip) rm -rf release/wpmgr-agent @echo "agent zip: $$(du -sh release/wpmgr-agent.zip | cut -f1)" .PHONY: agent-zip-wporg agent-zip-wporg: agent-vendor ## Package the wp.org-distributable plugin zip ($(AGENT_WPORG_SLUG) identity; self-hosted identity untouched) mkdir -p release rm -f $(AGENT_WPORG_ZIP) rm -rf release/$(AGENT_WPORG_SLUG) # Stage under $(AGENT_WPORG_SLUG)/ — the permanent wp.org slug. The self-updater # (class-update-checker.php) is physically excluded so PCP cannot match the # site_transient_update_plugins hook (B2 / G8). NOTICE.md and README.md are # excluded because wp.org rejects unexpected Markdown files (B4 / C8). # Dev-only files mirror the existing agent-zip excludes. # vendor/bin/ and vendor/*/bin/ are excluded because wp.org does not permit # CLI entrypoints (minifyjs, minifycss) — the matthiasmullie/minify library is # used via its PHP API only (no shell-out to the bin scripts anywhere in the # agent). vendor/*/LICENSE files are excluded because wp.org flags them as # unexpected non-code files; the library licences are already referenced in the # plugin's own LICENSE / readme.txt. # vendor/**/data-scripts/ and *.py files are excluded because wp.org does not # permit build tooling scripts (e.g. bjeavons/zxcvbn-php/data-scripts/build_*.py). # composer.json ships with vendor/ so Plugin Check does not warn about a vendored # directory without a composer.json. composer.lock is excluded (not needed at runtime). rsync -a --delete \ --exclude 'tests/' --exclude 'tests-e2e/' --exclude 'tools/' --exclude '*.dist' --exclude '.phpunit.cache/' \ --exclude '.phpunit.result.cache' --exclude 'composer.lock' \ --exclude '.DS_Store' --exclude '*.zip' \ --exclude '.distignore' --exclude '.gitignore' --exclude '.gitattributes' \ --exclude 'phpstan.neon' --exclude 'phpstan-baseline.neon' \ --exclude 'NOTICE.md' --exclude 'README.md' \ --exclude 'patchwork.json' \ --exclude 'includes/support/class-update-checker.php' \ apps/agent/ release/$(AGENT_WPORG_SLUG)/ # Remove CLI entrypoints, LICENSE files, data-scripts dirs, and Python files # that wp.org does not permit. rsync --exclude patterns for vendor/*/bin/ do not # recurse into arbitrary depth (e.g. vendor/matthiasmullie/minify/bin/ is 3 levels # deep), so a post-stage find+delete is more reliable than path-based excludes. find release/$(AGENT_WPORG_SLUG)/vendor/bin -mindepth 0 -maxdepth 0 -type d -exec rm -rf {} + 2>/dev/null || true find release/$(AGENT_WPORG_SLUG)/vendor -mindepth 2 -type d -name bin -exec rm -rf {} + 2>/dev/null || true find release/$(AGENT_WPORG_SLUG)/vendor -mindepth 2 -type f -name LICENSE -delete 2>/dev/null || true find release/$(AGENT_WPORG_SLUG)/vendor -type d -name data-scripts -exec rm -rf {} + 2>/dev/null || true find release/$(AGENT_WPORG_SLUG)/vendor -type f -name "*.py" -delete 2>/dev/null || true # Rename the main plugin file to match the wp.org slug. WordPress derives the # plugin's displayed name, slug, and update identity from the top-level .php # filename inside the archive folder — renaming is mandatory for the wp.org slug. mv release/$(AGENT_WPORG_SLUG)/wpmgr-agent.php \ release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php # VERSION override: same mechanism as agent-zip — stamp ONLY the staged copy. # Two lines: the plugin header "Version:" and the WPMGR_AGENT_VERSION constant. @if [ -n "$(VERSION)" ]; then \ _v=$$(echo "$(VERSION)" | sed 's/^v//'); \ case "$$_v" in \ [0-9]*.[0-9]*.[0-9]*) \ case "$$_v" in \ *[!0-9A-Za-z.+\-]*) \ echo "agent-zip-wporg: refusing unsafe VERSION '$(VERSION)' — only digits, letters, dots, hyphens, and plus signs are allowed" >&2; exit 1 ;; \ esac ;; \ *) echo "agent-zip-wporg: refusing VERSION '$(VERSION)' — must be MAJOR.MINOR.PATCH (with optional leading v)" >&2; exit 1 ;; \ esac; \ _v_esc=$$(printf '%s' "$$_v" | sed -e 's/[\/&|]/\\&/g'); \ echo "agent-zip-wporg: stamping staged copy with version $$_v"; \ sed -i.bak -E "s/^( \* Version:[ \t]+)[0-9]+\.[0-9]+\.[0-9].*/\1$$_v_esc/" release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php; \ sed -i.bak -E "s/^(define\('WPMGR_AGENT_VERSION', *')[^']+(')/\1$$_v_esc\2/" release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php; \ rm -f release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php.bak; \ fi # Stamp readme.txt Stable tag to match the plugin header Version. Mirrors the # VERSION block above; reads the stamped Version from the staged main file so # the two values always agree regardless of the VERSION variable. @_stamped_v=$$(grep -E '^ \* Version:' release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php | sed -E 's/.*Version:[ \t]+//'); \ echo "agent-zip-wporg: stamping readme.txt Stable tag: $$_stamped_v"; \ sed -i.bak -E "s/^(Stable tag:[ \t]+).*/\1$$_stamped_v/" release/$(AGENT_WPORG_SLUG)/readme.txt; \ rm -f release/$(AGENT_WPORG_SLUG)/readme.txt.bak # Rewrite plugin-identity header fields in the staged main file: # Plugin Name -> Fleet Agent Site Manager (reviewer-accepted display name; no "WP" prefix) # Text Domain -> $(AGENT_WPORG_SLUG) (matches new slug) # WPMGR_AGENT_DISPLAY_NAME -> Fleet Agent Site Manager (admin menu/page # title constant; keeps the wp.org admin screen name consistent with the # listing identity above instead of showing the self-hosted "WPMgr Agent") # # License / License URI are DELIBERATELY NOT rewritten here (GH #547). This # target used to force the header to "GPLv2 or later" on the theory that # wp.org expects it, while the source plugin header, composer.json, # NOTICE.md and LICENSE-AGENT all say MIT (the owner's actual ruling, and # MIT is fully GPL-compatible for a wp.org listing). That rewrite is what # shipped the live bug #547 was filed against: readme.txt said GPLv2 while # the header said MIT. Fixing readme.txt alone would have flipped the # mismatch, not closed it — this sed line would still overwrite the # freshly-agreed MIT header back to GPLv2 on every build. The header now # carries whatever the source declares, unchanged, so it and readme.txt # (and every other surface scripts/check-license-surfaces.sh reads) stay # in agreement without the build pipeline being a 10th, unwatched place a # license value could live. sed -i.bak \ -e "s|^ \* Plugin Name:.*| * Plugin Name: Fleet Agent Site Manager|" \ -e "s|^ \* Text Domain:.*| * Text Domain: $(AGENT_WPORG_SLUG)|" \ -e "s|define('WPMGR_AGENT_DISPLAY_NAME', 'WPMgr Agent');|define('WPMGR_AGENT_DISPLAY_NAME', 'Fleet Agent Site Manager');|" \ release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php rm -f release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php.bak # Inject the WPMGR_WPORG_BUILD constant immediately after the WPMGR_AGENT_VERSION # define line. This guards the self-updater boot hook (class-plugin.php:522) so # it never binds in the wp.org build, satisfying G8 / B2 (the file exclusion # above satisfies PCP static-analysis; the constant satisfies the runtime guard). # Use awk to insert the line immediately after the WPMGR_AGENT_VERSION define, # avoiding the multi-line sed /a\ syntax which is non-portable across BSD/GNU sed. awk "/^define\('WPMGR_AGENT_VERSION',/{print; print \"define('WPMGR_WPORG_BUILD', true);\"; next}1" \ release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php \ > release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php.tmp mv release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php.tmp \ release/$(AGENT_WPORG_SLUG)/$(AGENT_WPORG_SLUG).php # Rewrite the text-domain literal 'wpmgr-agent' -> '$(AGENT_WPORG_SLUG)' across # all staged PHP files. This covers both __()/__e() text-domain args AND the # plugin-identity constants (PAGE_SLUG, exclude-dir lists) that reference the # plugin folder name — in the wp.org install the folder IS $(AGENT_WPORG_SLUG), # so all references must agree. class-update-checker.php is already excluded above # and is never present in the staged tree. # GREP FIRST — surface every occurrence so the caller can audit non-text-domain hits. @echo "--- grep of 'wpmgr-agent' in staged tree (before rewrite) ---"; \ grep -rn "'wpmgr-agent'" release/$(AGENT_WPORG_SLUG)/ --include="*.php" || true; \ echo "--- end grep ---" find release/$(AGENT_WPORG_SLUG) -name "*.php" -print0 | \ xargs -0 sed -i.bak "s/'wpmgr-agent'/'$(AGENT_WPORG_SLUG)'/g" find release/$(AGENT_WPORG_SLUG) -name "*.php.bak" -delete # ASSERT the rewrite above reached the self-target guard's own constant. # tests/ is excluded from the staged tree, so nothing else checks that the # wp.org build recognises its OWN slug as the agent; a refactor of that # constant to double quotes or string concatenation would silently leave the # wp.org build guarding the self-hosted slug only. @if ! grep -q "SELF_PLUGIN_FOLDER = '$(AGENT_WPORG_SLUG)';" \ release/$(AGENT_WPORG_SLUG)/includes/commands/class-update-command.php; then \ echo "agent-zip-wporg: FAILED. SELF_PLUGIN_FOLDER in the staged tree is not '$(AGENT_WPORG_SLUG)', so the self-target guard would not recognise the wp.org slug. Check the 'wpmgr-agent' rewrite above and the constant in apps/agent/includes/commands/class-update-command.php" >&2; \ exit 1; \ fi @echo "agent-zip-wporg: self-target guard constant OK (SELF_PLUGIN_FOLDER = '$(AGENT_WPORG_SLUG)')" # ASSERT no staged file resolves the (now absent) self-updater class outside a # guard. includes/class-plugin.php SURVIVES the rsync above and holds two hard # class fetches, `new UpdateChecker(...)` and `UpdateChecker::HOOK_APPLY`, each # safe only because it sits inside a guard. Both sit among a dozen visually # identical unconditional calls, so dedenting one out is a plausible refactor, # and it would raise # Fatal error: Uncaught Error: Class "WPMgr\Agent\Support\UpdateChecker" not found # on every request of every wp.org install. `wp plugin check` is static analysis # and never executes the plugin, so it cannot catch that. This assert runs # against the artifact that actually ships. Mirrors the SELF_PLUGIN_FOLDER # assertion above. tools/ is excluded from the staged tree, so the checker is # invoked from the source tree. php apps/agent/tools/assert-wporg-updatechecker-guard.php release/$(AGENT_WPORG_SLUG) $(call reproducible_zip,release,$(AGENT_WPORG_SLUG),$(AGENT_WPORG_SLUG).zip) rm -rf release/$(AGENT_WPORG_SLUG) @echo "agent wporg zip: $$(du -sh $(AGENT_WPORG_ZIP) | cut -f1)" .PHONY: agent-check agent-check: ## Fast phpcs pass over apps/agent (committed phpcs.xml.dist). NOT the authoritative gate. cd apps/agent && (composer install --no-interaction --quiet --ignore-platform-reqs 2>/dev/null \ || composer update --no-interaction --quiet --ignore-platform-reqs) cd apps/agent && vendor/bin/phpcs -d memory_limit=1G # The static analysis gate for the plugin, and the same one CI runs. # # It is a script rather than a `vendor/bin/phpstan` line because the decision is # not the exit code. PHPStan exits 1 both when it analysed everything and found # things and when it aborted in the parser having analysed almost nothing, and # in JSON mode the only signal telling those apart is on stderr. Answering the # second case by widening phpstan-baseline.neon turns the build green and leaves # the analyser blind, so the guard reports it as its own outcome with its own # exit code. See the header of the script. # # agent-phpstan-test is the guard's own regression suite; run it after editing # the guard. Needs a vendor tree: run `composer install` in apps/agent first. # This target is STRICTER than CI on purpose. CI passes --allow-findings while # the backlog is triaged; locally you want the truth, so this one fails on # findings too. Add --allow-findings by hand to see exactly what CI sees. .PHONY: agent-phpstan agent-phpstan: ## PHPStan static analysis over apps/agent (fails on findings AND on an incomplete analysis) scripts/check-agent-phpstan.sh .PHONY: agent-phpstan-test agent-phpstan-test: ## Run the agent PHPStan guard's regression suite scripts/check-agent-phpstan_test.sh .PHONY: agent-format agent-format: ## phpcbf auto-fix over apps/agent, then re-lint cd apps/agent && vendor/bin/phpcbf -d memory_limit=1G --report-summary --report-source || true cd apps/agent && vendor/bin/phpcs -d memory_limit=1G .PHONY: agent-plugincheck agent-plugincheck: agent-zip-wporg ## AUTHORITATIVE: `wp plugin check` on real WordPress via Docker (mariadb + wordpress:cli) # Always tests the wp.org-identity build ($(AGENT_WPORG_SLUG)) so the META # trademark/updater/readme checks key off the right slug. Exits non-zero on any ERROR row. cd tools/plugincheck && PLUGIN_ZIP="$(PWD)/$(AGENT_WPORG_ZIP)" ./run.sh .PHONY: agent-plugincheck-name-test agent-plugincheck-name-test: ## Run the regression suite for the plugin check's per-checkout compose project name (no Docker) scripts/plugincheck-project-name_test.sh .PHONY: agent-e2e-objectcache agent-e2e-objectcache: agent-zip-wporg ## Run the object-cache E2E harness (Docker; Redis + WordPress + phpredis) # Spins a full Docker environment: WordPress 6.8 + MariaDB 11 + Redis 7 + phpredis. # Exercises provision → assert-cli → cross-request persistence (FIX A net) → # freshness guard → cron-check → negative-check → disable. # Requires Docker. Depends on agent-zip-wporg because the zip passed below IS # the wp.org build; the old `agent-zip` dependency built release/wpmgr-agent.zip # and then handed the harness a path that target never produces. chmod +x apps/agent/tests-e2e/run.sh PLUGIN_ZIP="$(PWD)/$(AGENT_WPORG_ZIP)" apps/agent/tests-e2e/run.sh .PHONY: agent-release agent-release: agent-zip ## Publish the agent release (zip + latest.json) to object storage for CP-driven self-update (ADR-042) # Uploads the versioned package FIRST, then latest.json LAST, so the CP # manifest never points at a package that is not yet in place. Override the # bucket/prefix via WPMGR_RELEASE_BUCKET / WPMGR_RELEASE_PREFIX. Use # `make agent-release-dry-run` to preview latest.json without uploading. ./scripts/release-agent.sh .PHONY: agent-release-dry-run agent-release-dry-run: agent-zip ## Preview the agent release (build zip + print latest.json) without uploading ./scripts/release-agent.sh --dry-run .PHONY: agent-release-test agent-release-test: ## Run scripts/release-agent.sh's regression suite (GH #515: the tested/requires/requires_php defaults) scripts/release-agent_test.sh .PHONY: gen gen: ## Regenerate OpenAPI clients (Go + TS) from packages/openapi/openapi.yaml # Until GH #511 this target ran a script that echoed "wired in Phase 4" and # exited 0 while both generators had been wired for months, so editing the # spec and running `make gen` produced a stale tree that looked fresh. The # script now fails if either generator is missing or writes nothing. ./scripts/gen-openapi.sh .PHONY: gen-check gen-check: ## Fail if the committed generated trees differ from a fresh generation ./scripts/gen-openapi.sh --check .PHONY: gen-test gen-test: ## Test suite for scripts/gen-openapi.sh (shims only, no toolchain needed) ./scripts/gen-openapi_test.sh .PHONY: gen-secrets gen-secrets: ## Print the boot-critical self-host secrets as ready-to-paste env lines # Self-verifying generator: each secret is decoded back through the server's # own boot parsers before it is printed (see apps/api/cmd/wpmgr-cli). cd apps/api && go run ./cmd/wpmgr-cli gen-secrets .PHONY: validate-env validate-env: ## Check the environment config and list every problem at once cd apps/api && go run ./cmd/wpmgr-cli validate-env .PHONY: init-env init-env: ## Copy .env.example -> .env and inject fresh secrets (preserves existing .env) ./scripts/init-env.sh