Skip to content

auto-deploy: stop tag pushes hijacking branch hosts; fix pull order + deploy-on-failure - #1422

Merged
jonfroehlich merged 3 commits into
masterfrom
fix-autodeploy-tag-hijack-and-pull-order
Jul 27, 2026
Merged

auto-deploy: stop tag pushes hijacking branch hosts; fix pull order + deploy-on-failure#1422
jonfroehlich merged 3 commits into
masterfrom
fix-autodeploy-tag-hijack-and-pull-order

Conversation

@jonfroehlich

@jonfroehlich jonfroehlich commented Jul 22, 2026

Copy link
Copy Markdown
Member

What & why

Hardens the GitLab/GitHub auto-deploy webhook (auto-deploy/index.php).

1. Tag pushes hijacking branch hosts (the headline bug)

The condition ($incomingOp == "tags" && $OPERATION = "TAG") used = (assignment) instead of ==. That made the clause true for every tag push regardless of host config, and reassigned $OPERATION to "TAG" for the rest of the request — so a tag push drove the branch-tracking test server down the tag code path, leaving its checkout detached at that tag. Fixed to ==.

2. Pull/checkout order

git pull aborts from a detached HEAD ("You are not currently on a branch"). For branch hosts we now checkout first, then pull; for tag hosts we fetch first (the tag usually isn't local yet). Prod's tag path is unchanged in the happy case.

3. Deploy-on-failure gate (from code review)

The container build ran unconditionally after checkout/pull, so any pull failure still rebuilt from stale source while looking freshly deployed. Now we confirm the checked-out HEAD equals the pushed commit ($req['after']) before building; abort + log on mismatch. Fail-safe: if the payload has no usable SHA, deploy as before (never blocks a legit deploy).

4. Same =/== footgun, twice more

_trace()/_debug() guarded on if ($x = TRUE) — always true, never disableable. Fixed to a defined() toggle (default on; silence via define('AUTODEPLOY_TRACE'/'AUTODEPLOY_DEBUG', false) in config.php).

5. determine_branch_name() typo

Guarded on misspelled $reqest → always returned null (branch fast-path was dead code). Fixed to $request.

6. Deploy gate: peel annotated tags (would have blocked every prod release)

Follow-up to #3. $req['after'] is the ref's new value, which for an
annotated tag is the tag object's sha, while git checkout refs/tags/X
leaves HEAD at the commit. Tags 2.27.0 / 2.27.2 / 2.27.3 are all annotated,
so the gate as first written would have aborted every production deploy. The
expected sha is now peeled with ^{commit} before comparing, and the gate is
skipped entirely (deploy proceeds, as before) if the sha can't be resolved
locally. Verified against real git: annotated tag object e109d61… peels to
commit 3c746b3…, which is what HEAD reads after the tag checkout.

7. Validate the incoming ref before it reaches a shell

$req['ref'] was interpolated straight into git checkout <ref> via
shell_exec() on an unauthenticated endpoint (no webhook-secret /
signature check anywhere in this script), so a crafted payload naming our
public repo URL could smuggle shell metacharacters onto the deploy host. The
ref must now match refs/(heads|tags)/[A-Za-z0-9._/-]+; anything else is
logged and refused. Normal refs (refs/heads/master,
refs/heads/dependabot/pip/pypdf-6.14.2, refs/tags/2.27.3) are unaffected.

Testing

  • php -l clean (no local PHP runtime; linted in a throwaway php:8-cli-alpine container).
  • No automated harness exists for this PHP webhook; the deploy gate is written fail-safe (falls back to prior behavior on any ambiguity) precisely because it can't be exercised end-to-end off-server.

🤖 Generated with Claude Code

jonfroehlich and others added 3 commits July 22, 2026 14:15
Two related bugs that together froze makeabilitylab-test for four days
(Jul 18-22) while making it look freshly deployed.

1. `$OPERATION = "TAG"` was an assignment, not a comparison. Every tag
   push therefore satisfied the condition on EVERY configured host, and
   reassigned $OPERATION to "TAG" for the remainder of the request. A
   branch-tracking host (the test server) was thus driven down the tag
   path -- `git fetch` + `git checkout <tag>` -- leaving its checkout
   detached at that tag. Confirmed: tag 2.27.0 points at 578d643, which
   is exactly the commit the test host was frozen on.

2. Once detached, it could not recover. `git pull` aborts from a
   detached HEAD, and do_clone_or_pull() ran BEFORE do_checkout_branch(),
   so the pull failed, the local branch never advanced, and the checkout
   stayed behind. For branch deploys we now check out the branch first
   and pull second, which both fixes the failure and self-heals an
   already-detached checkout. Tag deploys keep the original order, since
   a tag generally isn't present locally until after the fetch.

Not fixed here, but worth noting: index.php calls deploy_container()
regardless of whether the pull or checkout succeeded, so a failed update
still produces a successful-looking rebuild. That is what hid this --
/version.json reported built_at advancing on every push while git_sha sat
at a four-day-old commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lings

Follow-up hardening from code review of the tag-hijack/pull-order fix:

- Deploy gate: the container build ran unconditionally after checkout/pull, so
  any pull failure (merge conflict, network/ssh error, or a detached HEAD the
  reorder doesn't cover) rebuilt from stale source while looking freshly
  deployed. Now confirm the checked-out HEAD equals the pushed commit
  ($req['after']) before building; abort and log loudly on mismatch. Fail-safe:
  if the payload carries no usable SHA, deploy as before (never blocks a legit
  deploy).

- _trace()/_debug(): `if ($x = TRUE)` was an assignment, not a comparison --
  the same =/== footgun this branch fixes in the deploy condition -- so they
  always logged and could never be disabled. Default on (preserves behavior),
  disable via define('AUTODEPLOY_TRACE'/'AUTODEPLOY_DEBUG', false) in config.php.

- determine_branch_name(): guarded on misspelled $reqest, so it always returned
  null and the branch-name fast path was dead code. Fixed to $request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oming ref

Two fixes to the deploy gate added in the previous commit:

1. The gate compared `git rev-parse HEAD` to the raw `$req['after']`. For an
   ANNOTATED tag push, `after` is the ref's new value -- the tag OBJECT's sha --
   while `git checkout refs/tags/X` leaves HEAD at the COMMIT. Our last three
   releases (2.27.0, 2.27.2, 2.27.3) are annotated, so as written the gate would
   have aborted every production deploy. The expected sha is now peeled with
   `^{commit}` before comparison, and the check is skipped (deploy proceeds, as
   it always did) whenever the sha can't be resolved locally.

2. `$req['ref']` is interpolated into `git checkout <ref>` via shell_exec on an
   unauthenticated endpoint. It is now required to match
   `refs/(heads|tags)/[A-Za-z0-9._/-]+` before any of it reaches a shell.

Verified: `php -l` clean; peel/detached-HEAD semantics confirmed against real
git; ref and sha patterns exercised over accept/refuse cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich
jonfroehlich merged commit d72dea2 into master Jul 27, 2026
3 checks passed
jonfroehlich added a commit that referenced this pull request Jul 27, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich
jonfroehlich deleted the fix-autodeploy-tag-hijack-and-pull-order branch July 27, 2026 19:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant