release: v1.13.0 — the account that serves a project can read its plugins, and .env can be audited - #160
Merged
Conversation
No code change: `git diff 378cd6f 8496e49` is empty, so this is byte-identical to what v1.9.0 already ships. What changes is whether the pointer SURVIVES. v1.9.0 pinned `378cd6f`, a commit on `fix/portable-alter-table`. That branch has since merged (Let-Migrate PR #8), and a merged branch is a branch someone deletes — at which point `git submodule update` fails for anyone checking out the v1.9.0 tag, with nothing in the kernel to say why. `8496e49` is the tip of `main`, which is not going anywhere. This is the same failure `modules/http` was one branch-deletion away from earlier today, fixed the same way. No CHANGELOG entry and no version bump: a gitlink that resolves to identical content is not something a user of the kernel can observe, and auto-release reads the top heading, which stays `1.9.0`.
Restores the four driver fixes and the Laravel-parity alias that 9ac3a8e undid -- that commit put src/, tests/ and README back byte-for-byte to the state before a day of merged PR work, under a message describing a cleanup -- then carries them forward with what running them against real engines turned up. Verified by execution, not by compiling and reading: SQLite, MariaDB 12.3 and PostgreSQL 18 all run the full DDL lifecycle green. SQL Server is the one gap; no server was reachable and the sqlsrv extension is absent here, so its two fixes are argued from the T-SQL specification. tests/Live runs them the moment LETMIGRATE_DB_SQLSRV points at a server, and skips with the reason until then. docs/guides/18_MIGRATIONS.md: useCurrent() and useCurrentOnUpdate() now exist, so the anti-pattern entry saying they do not is corrected. The ->index() half of it still stands -- an index is declared on the Blueprint, not the column.
# Conflicts: # modules/let-migrate
A commit titled "refactor: remove deprecated methods" had put let-migrate's src/, tests/ and README back byte-for-byte to their state before a day of merged PR work, undoing four driver fixes and deleting the eight tests that covered them. Nothing was failing that the deletion fixed. This restores them and carries them forward with what running the compiler against real engines turned up -- including a PostgreSQL bug where every schema lookup silently matched nothing, because libpq's $1 placeholder is one PDO neither understands nor rejects. Verified by EXECUTION on SQLite, MariaDB 12.3 and PostgreSQL 18. SQL Server is the gap: no server was reachable and the sqlsrv extension is absent, so its two fixes are argued from the T-SQL specification rather than demonstrated. The new tests/Live suite runs them the moment LETMIGRATE_DB_SQLSRV points at a server, and skips with the reason until then -- never counted as a pass.
Picks up the commit-msg hook and the composer wiring that points a clone at it. Without this bump the submodule checkout stays on f13c977, which has neither -- and that is the checkout anyone working in this workspace actually uses, so the hook would protect the standalone clone and nobody else. No functional change to the migration engine.
… serves it
`hkm install --production --owner=` only ever touched var/ and userdata/, so
every directory a request actually reads — app/public_html, src/, vendor/,
plugins/ — kept the deploying user's ownership and whatever mode the clone
arrived with. The pool could write logs it was never going to reach the code
to produce.
Three separate reasons a PHP-FPM boot failed on a tree that runs fine from
the shell:
- the pass covered two directories out of the project. It now covers the
whole tree, and runs LAST — composer install and the plugin fetch both
create files (vendor/, plugins/) as whoever ran the command, so running
at step 3 meant the two largest directories in the project were created
after the permissions were "fixed";
- .env was chmod'd 0600, which no pool running as another account can read
APP_KEY through. It is 0640 now — group-readable, never group-writable,
never world-anything;
- nothing reported that the pool could not TRAVERSE to the project, which
is unfixable from inside it: a home directory is 0700 on a stock Debian
install. The offending parents are named, and only named.
The model is split ownership: code owned by the deploy user and reachable by
the web server through the GROUP (2750/0640), var/ and userdata/
group-writable (2770/0660). Code is never group-writable in either profile —
an FPM pool that can rewrite the PHP it executes turns any file-write bug
into code execution. Every directory carries setgid, code included, so a
file a later deploy lands does not take the deploying account's primary
group and drop out of the share. An already-executable file keeps its exec
bit, re-granted only where the profile grants read, so bin/psp and
vendor/bin/* survive at 0750 rather than 0751.
Separately, the installed kernel was left at whatever the installing
account's umask produced. /opt/hkm-kernel is shared infrastructure — every
pool on the box loads its PHP from that one tree and none of them runs as
the installing account — so umask 027 or 077 left it unreadable to all of
them, while the install reported success because the installer could
obviously read what it had just written. install.sh now normalises the tree
it lays down.
# Conflicts: # CHANGELOG.md
`FileManager` was the one hyphenated plugin missing from the slug override table, so it resolved to hkm-plugin-filemanager — a repository that does not exist. GitHub answers 404 for "does not exist" and "not yours" alike; it will not confirm a private repository to an anonymous request. Git cannot tell the two apart, assumed the second, and stopped to ask for a username and password that no account could have satisfied, on a plugin anyone can clone. Added the override, plus tests pinning every multi-word folder to its real hyphenated slug and round-tripping it back to the PSR-4 folder name, so the next repo added with a hyphen cannot drift the same way. Separately, the fetch inherited the terminal, so an unreachable remote hung the whole install on a password box until somebody killed it — on a deploy box or in CI, indefinitely. Every git invocation now runs with GIT_TERMINAL_PROMPT=0 and SSH BatchMode=yes: the remote fails immediately and the call site names the plugin and the URL, which is the information actually needed. HKM_GIT_INTERACTIVE=1 restores the prompt for a genuinely private remote to authenticate against by hand.
# Conflicts: # CHANGELOG.md
…gins, and .env can be audited --owner stopped at the project boundary. A project's plugins are symlinks into the global store, and both halves of the hardening pass skip symlinks on purpose, so every plugin file kept the deploying user's ownership under a report that said the project was owned by the pool. It now chowns the store versions the project links to, and the traversal check covers the store's own parents — the store defaults under $HOME, which a sudo deploy resolves to /root/.cache. hkm env is new: a .env accumulates duplicate keys that no parser reports, because the loader resolves them silently and the last active assignment wins. It reports them with the live one marked, resolves them one prompt at a time, and groups the file by declaring plugin then by feature. Enabling an already-enabled plugin now tops up its env block, so a plugin that declares a new config[] entry in a later version stops failing the boot on a key nothing wrote; new keys merge into the block the plugin already owns instead of opening a second one. resolveRoot walks up to find proj.json, so every command that takes a project works from anywhere inside it. An explicit path stays exact.
hakeemRash
requested review from
Alshatri and
craftdevscommunity
as code owners
September 2, 2026 23:22
The example read TENANCY_CONTROL_PLANE=admin.example.com, which parses as "the control plane is served from this host". The Tenancy plugin declares the key as type: bool, and any non-empty string is truthy — so uncommenting the documented value switched tenant routing off entirely, which is the opposite of what a multi-tenant deployment is configuring. Adds TENANCY_CENTRAL_DOMAINS, a declared key the example never mentioned, and names the modes. Verified against the plugin's module.json config[], which remains the authority.
Alshatri
approved these changes
Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
## [Unreleased]to## [1.13.0] - 2026-09-03. Merging this pushesv1.13.0and runs the build/publish chain; the Homebrew formula updates itselfafterwards.
Fixed
--owner=left every plugin file owned by the deploying user. A project'splugins are symlinks into the global store, and both halves of the hardening
pass stop at that boundary on purpose —
hardenTreeskips symlinks because achmod would follow one out of the project, and the chown only walked the
project root. So the pool could write logs it was never going to reach the code
to produce, under a report reading
Project owned by deploy:www-data.--ownernow covers the store versions the project links to, plus the directories
between them and the store root. Only the versions this project links to —
the store is shared.
--productioncalled a project reachable while its plugins were not. Thetraversal check walked the project's parents only. The store defaults to
$HOME/.cache, which a sudo deploy resolves to/root/.cache(0700 on everymainstream distro), so the chown succeeded on every entry and the site still
read none of them. Checked now, with its own remedy: relocate the store rather
than widen a home directory.
enablereturnsearly once the wiring is done. Enabling an already-enabled plugin tops up its
block; the seeder still only ever adds keys the file does not mention, so no
secret is rewritten.
it already owns.
Added
hkm env(audit/dedupe/group). Duplicate keys are not an erroranywhere: the loader resolves them silently and the last active assignment
wins, so a key appended at the bottom overrides the one in its proper block.
auditmarks which line is live,dedupeasks per key rather than guessing(
--keep=effectiveis the scriptable form that cannot change behaviour), andgroupreorders by declaring plugin then by feature. The group pass refuses towrite unless every key and every informational comment survives; every write
leaves a 0600
.env.bak.resolveRootwalks up forproj.json, so every command taking a[path|name]works from a subdirectory.An explicit path stays exact — the same resolver backs
install --owner.Verified
zig build test— 179 pass.vendor/bin/phpunit— 389 tests, 723 assertions, green.--ownerexercised against a fixture project and store viaHKM_CHOWN_BINpointed at a recording stub: version dirs chowned recursively, intermediate
dirs once, dangling links skipped, nothing above the store root touched.
hkm envrun against a real 222-line.env: key sets byte-identical beforeand after
group, no informational comment lost, idempotent across three runs.confirmed to still fail cleanly outside any project.