Skip to content

(feat): bump the downstream base image after a release - #94

Open
abnegate wants to merge 11 commits into
mainfrom
feat/downstream-base-bump
Open

abnegate wants to merge 11 commits into
mainfrom
feat/downstream-base-bump

Conversation

@abnegate

@abnegate abnegate commented Aug 21, 2026

Copy link
Copy Markdown
Member

Extends the weekly automation past its own release: open a base bump in appwrite/appwrite, wait for CI there, merge it, and tag the merge commit.

Why

A base release was only half the job. appwrite/appwrite pins the image by exact version — FROM appwrite/base:2.0.0 AS base, plus a -xdebug reference and a comment — so every release we published left the consumer behind until someone edited that Dockerfile by hand.

The flow

Four steps appended to dependencies.yml, each gated on the one before:

  1. Propose — read Dockerfile at the head of the downstream branch, rewrite every appwrite/base:<version> occurrence, commit to automation/base-<version>, open the PR. Emits changed=false and stops if the pin is already current, so a re-run is a no-op.
  2. Wait for downstream CI — poll the PR's statusCheckRollup until every check has concluded. SUCCESS, SKIPPED and NEUTRAL pass; anything else aborts before the merge, naming the failing checks.
  3. Merge and tag — squash-merge pinned to the exact tested head, then read APP_VERSION_STABLE from app/init/constants.php at the merge commit and tag it cl-{version}-{n}.
  4. Summarise — write the tag into the job summary.

Decisions, and the evidence behind them

Target branch is main. main carries appwrite/base:2.0.0; 1.9.x is still on 1.4.3 and has no -xdebug stage, so bumping it would not be a version replacement. cl-1.9.6-1 also peels to a commit that is an ancestor of main, so that is where these tags are cut. Configurable via DOWNSTREAM_BRANCH.

Tag pattern matches what is already there — cl-1.8.0-2..4, cl-1.9.0-1..5, cl-1.9.5-1, cl-1.9.6-1. The sub-version is the next unused integer for that application version, so a bump of APP_VERSION_STABLE restarts at -1. Unrelated cl- tags such as cl-usage-poc-4 and cl-shared-tables-zdt-6 are ignored.

The merge goes through branch protection first, and bypasses it only for the review. It used to pass --admin unconditionally, which made the check verification advisory: --match-head-commit binds the head SHA, not the check state, so anything that changed between the poll and the merge was merged past. GitHub's branch-protection gate is the only thing that evaluates required checks atomically at merge time.

So the merge is now attempted without --admin. If GitHub accepts it, the checks were enforced atomically. If GitHub refuses, the refusal is classified first: Downstream\Refusal reads the message the merge endpoint returns — the only place GitHub says which requirements were unmet — and only a refusal naming a review requirement and nothing else is eligible for the bypass. Any phrase naming another protection (status check, deployment, signature, conversation, branch restriction, merge queue, linear history, out-of-date, conflict) disqualifies it outright, even when a review is named alongside, and an unrecognised message rethrows too; it fails closed. For an eligible refusal the checks are then re-read, and a pending or red required check rethrows too, so a bypass needs both: GitHub said review, and the checks are green at that moment.

The review requirement is singled out because it is the only protection a single automation identity structurally cannot satisfy — the same DOWNSTREAM_TOKEN opens the pull request and GitHub forbids self-approval.

wait and release share one settle() that polls until every required check has concluded green. It does not wait out BLOCKED, because the review requirement holds a pull request there forever and the merge is what resolves it; it fails fast on BEHIND, DIRTY and DRAFT, and keeps polling UNKNOWN while GitHub computes mergeability.

Required before this can run

A DOWNSTREAM_TOKEN secret with write access to appwrite/appwrite; github.token from this repository has no access there at all. Without it the propose step fails immediately and the base release is unaffected.

Still worth configuring. appwrite/appwrite main requires one approving review and GitHub forbids approving your own pull request, so the single automation identity cannot satisfy it and the flow takes the --admin fallback on every release. That works unattended, but a second approving identity for DOWNSTREAM_TOKEN's pull requests would remove the bypass entirely and with it the last one-round-trip window.

Verification

composer verify: Pint, PHPStan max, 215 tests / 1924 assertions, 91 parity contracts. 41 new tests cover the new domain:

  • rewriting all three reference forms together; no-op when current; conflicting versions rejected; missing reference rejected; inexact target rejected.
  • next sub-version selection, including the semantic maximum (-10 beats -9), restart at -1 for a new application version, and rejection of a non-canonical -01.
  • unrelated and prefixed tags ignored (cl-1.9.6-1-rc1, cl-1.9.60-4, cl-shared-tables-zdt-6).
  • waiting across rounds until checks settle, and aborting with the failing check names.
  • a mergeable pull request is never admin-merged; a missing review falls back to the bypass only after the checks are re-read green; a required deployment, and an unrecognised refusal, are never bypassed and leave nothing tagged; a check that went red or pending at the merge is rethrown; a DIRTY pull request is refused outright; plus the refusal classification across twelve message shapes — including a review named alongside a deployment, unresolved conversations, or a branch restriction — and the merge-state classification itself.
  • the merge-then-tag call order, asserting the tag lands on the merge commit rather than the tested head.

Closed risk

The earlier revision accepted a race: a required downstream check changing state between release() reading the rollup and the --admin merge was merged past. That is closed. Even on the bypass path, a check that goes red or pending between the last poll and the merge is caught by the re-read after GitHub's refusal, and the release aborts untagged.

Direction

#100 proposes replacing this whole orchestrator with Renovate, which would delete the 98 custom PHP automation files this PR extends. The two are alternatives, not complements — whichever direction the owner picks, the other should be closed rather than merged alongside.

Not verified

  • Nothing here has run against appwrite/appwrite. Every downstream interaction is covered by a fake repository, not the real API. The first live run merges and tags in the main product repository.
  • Two prerequisites are unmet, and the flow cannot run until both exist: a DOWNSTREAM_TOKEN secret with admin rights on the downstream repository, and required status-check contexts configured on its main branch. The wait refuses outright if the branch declares none.
  • Downstream CI length is unknown to me. The wait uses a 7200s deadline inside a job whose own timeout is 300 minutes; if the required checks take longer than two hours the wait aborts with the PR already open.
  • The propose step commits through the Contents API, so the commit is unsigned. If the downstream branch requires signed commits it will be rejected.
  • Nothing local guards against PHP syntax newer than the 8.3 target — that was caught here only by CI, incidentally, via the runner's PHP version.

A base release was only half the job: appwrite/appwrite pins the image by
exact version, so every release left the consumer behind until someone
edited the Dockerfile by hand. Carry the release through — open the bump,
wait for its checks, merge it, and tag the merge commit.

The tag reads APP_VERSION_STABLE from the downstream constants rather than
anything in this repo, and takes the next unused sub-version for that
application version, matching the cl-{version}-{n} tags already in use.

Merging bypasses the downstream review requirement because that branch
requires an approving review and GitHub forbids approving your own pull
request. It does not bypass the checks: a failing check aborts before the
merge is attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR has no remaining blocking correctness finding, though implementation-coupled tests still warrant cleanup.

Summary

The PR extends the base-image release workflow to update the downstream Appwrite repository, wait for required checks, merge the tested commit, and create a downstream release tag.

  • Adds downstream Dockerfile rewriting, pull-request creation, CI polling, protected merging, recovery, and tagging.
  • Adds refusal classification intended to restrict branch-protection bypasses to unmet review requirements.
  • The latest revision rejects combined refusal messages containing recognized non-review protections and replaces one implementation-call transcript with observable tag-target assertions.

Reviews (8) · Last reviewed commit: "fix(downstream): never bypass a refusal ..."

Comment thread .github/scripts/src/Downstream/Checks.php Outdated
Comment thread .github/scripts/src/Downstream/Orchestrator.php
Two holes in the downstream flow, both of which end badly in the main
product repository.

The waiter accepted the first non-empty rollup in which everything had
concluded. A single fast check can finish before the heavy workflows have
registered theirs, so the admin merge could land a pull request whose real
CI had not started. Require the check set to be unchanged across two
consecutive polls and a grace period to have passed.

A run that died between the merge and the tag left the downstream pin in
place with no release tag, and the next run read the Dockerfile as already
current and skipped forever. Recover that state before proposing anything,
bounded to the downstream tip so a merge main has moved past is left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

Both Greptile P1s fixed in 6036b14. Both were right, and both would have ended badly in the main product repository.

Incomplete check set passes. settled() accepted the first non-empty rollup in which everything had concluded. On a fresh PR a single fast check can finish before the heavy workflows have registered theirs, so the admin merge could have landed a pull request whose real CI never started. This is the exact trap I had been handling by hand while shepherding these PRs and failed to encode in the product code.

The wait now requires three things together: every check concluded, the check-name/conclusion signature unchanged across two consecutive polls, and a 120s grace period elapsed since the wait began. A workflow that registers late changes the signature and resets the requirement.

Merged release cannot recover. A run dying between merge() and tag() left the downstream pin in place with no release tag; the next run read the Dockerfile as already current, emitted changed=false, and skipped forever. Same class as the recovery hole fixed in #92, and I built this flow without recovery at all.

A recover step now runs before propose. It finds the merged automation pull request for the version, and tags its merge commit if no cl- tag already points there. It is bounded to the downstream tip, so a merge that main has moved past is left alone rather than resurrected with a fresh sub-version — the lesson from #92.

Verification — each confirmed red before the fix and green after:

  • test_waits_out_a_late_registering_workflow feeds one completed check, then a second check appearing in progress, then both complete. Fails with the stability requirement removed.
  • test_tags_a_merge_that_never_got_its_tag fails with recovery disabled.
  • test_does_not_recover_a_merge_already_tagged and test_does_not_recover_a_merge_main_has_moved_past bound it.

composer verify: PHPStan max, 182 tests / 1866 assertions, 91 parity contracts.

Worth a look on re-review: the 120s grace plus two-poll stability adds a floor of roughly two and a half minutes to every downstream wait, and Repository::tags() now returns name/target pairs rather than names, which assumes our tags are lightweight refs — they are, since tag() creates them via git/refs with the commit sha.

new DateTimeImmutable(...)->modify() omits the parentheses that PHP only
made optional in 8.4. Local PHP is 8.5 so Pint and PHPStan both parsed it,
while the runner and the declared composer platform are 8.3, where it is a
parse error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread .github/scripts/src/Downstream/Orchestrator.php Outdated
Comment thread .github/scripts/src/Downstream/Orchestrator.php Outdated
Downstream CI expands a dynamic matrix into dozens of checks that register
minutes apart, so no view of the currently visible rollup distinguishes a
finished run from one whose matrix has not been generated yet. Two attempts
to infer it — all-complete, then all-complete plus stability plus a grace
window — were both wrong for the same reason, and the third would have been
too.

Read the branch's required status-check contexts and wait for exactly those
to conclude. That set is declared rather than inferred, so a check that
registers late is still waited for.

Refuse to merge when the branch declares no required contexts. --admin
bypasses branch protection, so without this the automation would merge
having verified nothing.

Recovery no longer requires the merge to be the downstream tip, only that
it is reachable from the branch. The tip rule made a release unrecoverable
as soon as anyone else merged, and the version-scoped lookup already
prevents resurrecting an unrelated merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

Redesigned the merge gate in f4d721b rather than patching the heuristic a third time. Both remaining P1s are addressed.

Late checks still escape stabilization. Correct, and the third attempt would have been wrong too. A recent appwrite/appwrite pull request carries 44 checks, many of them a dynamic matrix (Tests / E2E / PostgreSQL (dedicated) / Account and siblings) that only registers once its generator job finishes. No view of the currently visible rollup can distinguish a finished run from one whose matrix has not been generated yet — all-complete, stability, and grace windows are all guesses about the same unknowable thing.

The waiter now reads the branch protection required_status_checks.contexts for the downstream branch and waits for exactly those to conclude. That set is declared, not inferred, so a required check that registers ten minutes late is still waited for. The grace window and signature stability are gone.

It also refuses to merge when the branch declares no required contexts. --admin bypasses branch protection including required checks, so without that guard the automation would merge having verified nothing. This makes the dependency explicit and fails closed.

This does mean the downstream branch must actually mark its test checks required — that configuration is now load-bearing, not advisory.

Advanced main blocks tag recovery. Also correct. The tip bound made a release permanently unrecoverable as soon as anyone else merged. Recovery now requires only that the merge commit is reachable from the downstream branch, which keeps it recoverable while still refusing a commit that is not on the branch at all. The reason #92 needed a tip bound does not apply here: that selector scanned every automation merge in history, whereas recover here is scoped to one specific base version's branch, so an unrelated old merge cannot be resurrected.

Verification — each confirmed red before the fix:

  • test_waits_for_a_required_check_that_registers_late: a required check appears only on the third poll; fails against the previous all-complete logic.
  • test_refuses_to_merge_when_nothing_is_required.
  • test_recovers_after_main_has_moved_past_the_merge and test_does_not_recover_a_merge_absent_from_the_branch.
  • test_ignores_checks_that_are_not_required confirms an unrelated failing check does not block, and an unrelated pending one does not hold the merge.

composer verify: PHPStan max, 185 tests / 1872 assertions, 91 parity contracts.

Comment thread .github/workflows/dependencies.yml
Waiting for the required checks and merging were separate steps, and the
merge bypasses branch protection, so a check re-run between the wait's last
poll and the merge would be ignored — the very state the wait exists to
prevent, reachable through the gap between them.

Re-read the required contexts and their conclusions immediately before
merging. The window is now a single call rather than however long the merge
step takes to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abnegate

Copy link
Copy Markdown
Member Author

Greptile P1 (check state changes before merge) — fixed in 78241a6.

Correct, and a genuine gap rather than a restatement. Waiting for the required checks and merging were separate workflow steps, and the merge bypasses branch protection, so a required check re-run between the wait's final poll and the merge would simply be ignored. The exact state the wait exists to prevent was reachable through the gap between the two steps.

release() now re-reads the required contexts and their conclusions immediately before calling merge, and refuses if any is pending again or no longer passing. The window is a single call rather than however long the merge step takes to start. It cannot be closed completely — any check state read from GitHub can change a moment later — but it is no longer a multi-minute hole.

Verification, both confirmed red with the re-verification removed:

  • test_refuses_to_merge_a_check_that_went_pending_again
  • test_refuses_to_merge_a_check_that_failed_after_waiting

test_merges_then_tags_the_merge_commit now asserts the full call order — required:main, checks:93, merge, file, tags, tag — so the re-verification cannot be silently dropped later without a test noticing.

composer verify: PHPStan max, 187 tests / 1876 assertions, 91 parity contracts.

Comment thread .github/scripts/src/Downstream/Orchestrator.php Outdated
@abnegate

Copy link
Copy Markdown
Member Author

Decision on the remaining Greptile P1 (check state races the admin merge): accepted as a known residual risk by the repository owner, deliberately not fixed.

Recording it here so the reasoning is not lost:

  • The race is real. A required check that is re-run, or flips to pending or failing, in the window between release() verifying the rollup and the gh pr merge --admin call will be merged past.
  • It cannot be closed by refining this code. GitHub's only atomic merge precondition is --match-head-commit, which covers the head SHA and says nothing about checks. The mechanism that enforces required checks atomically at merge time is branch protection, and --admin exists to bypass branch protection.
  • Closing it entirely would mean obtaining an approving review from a second identity and merging without --admin. That was weighed and declined: the unattended merge is worth more here than eliminating a single-round-trip window.
  • 78241a6 still matters — it narrowed the window from a whole workflow step, potentially minutes, to one API round trip.

The two open threads are left open rather than resolved, because they accurately describe the shipped design. They are not unaddressed oversights.

abnegate and others added 2 commits September 22, 2026 13:13
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reading the check rollup and then admin-merging can never be atomic:
--match-head-commit binds the head SHA, not the check state, so a rerun
or a newly-pending required check between the two calls was merged and
tagged anyway. The only mechanism GitHub applies atomically at merge
time is branch protection, and --admin exists to bypass it.

The merge no longer passes --admin, so GitHub itself refuses the merge
if a required check is not green at that instant, and a refusal is
surfaced with the gh error rather than swallowed.

Repository::checks is now Repository::status, returning the rollup plus
mergeStateStatus, and wait/release share one settle() that polls until
the required checks have concluded green AND the merge state is
mergeable. BEHIND, DIRTY and DRAFT fail fast; BLOCKED and UNKNOWN keep
polling until the deadline, since those clear on their own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread .github/scripts/src/Downstream/Repository/GitHub.php Outdated
Comment thread .github/scripts/tests/Unit/Downstream/OrchestratorTest.php Outdated
Dropping --admin outright traded a race for a deadlock: the same
DOWNSTREAM_TOKEN opens and merges the pull request, GitHub forbids
self-approval, and appwrite/appwrite main requires one review — so the
merge is refused on every release and settle() sat on BLOCKED until its
two-hour deadline.

The merge is now attempted without --admin first, so GitHub evaluates
the required checks itself, atomically, whenever it can. Only if it
refuses are the checks re-read; a required check that is pending or red
rethrows the refusal, and --admin is used solely when the checks are
still green and the block is therefore the review requirement.

settle() no longer waits for BLOCKED to clear, because nothing clears
it before the merge. It waits for the required checks and fails fast on
BEHIND, DIRTY and DRAFT, which the automation cannot resolve either.

The four new orchestrator tests assert outcomes rather than the
collaborator transcript: the release that comes back, whether the merge
bypassed protection, and that nothing is tagged when a check goes red
between the last poll and the merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread .github/scripts/src/Downstream/Orchestrator.php
Comment thread .github/scripts/tests/Unit/Downstream/OrchestratorTest.php Outdated
abnegate and others added 2 commits September 22, 2026 21:02
The fallback treated every refusal as a missing approval: a required
deployment, signed commits, unresolved conversations or a branch
restriction would all have been admin-merged once the status checks came
back green.

GitHub states the unmet requirement in the merge endpoint's error body
and nowhere else, so that is what is now classified. Only a refusal
recognised as the review requirement is bypassed, and an unrecognised
message fails closed and rethrows, because the review requirement is the
only protection a single automation identity structurally cannot satisfy.

The merge assertions drop the ordered call transcript for the property
they were really about: whether branch protection was bypassed at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Calling a method on `new Foo()` without parentheses is PHP 8.4 syntax and
this package supports 8.3, where both files failed to parse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment thread .github/scripts/src/Downstream/Refusal.php
Comment thread .github/scripts/tests/Unit/Downstream/OrchestratorTest.php
A message naming a missing review alongside a pending deployment, an
unresolved conversation or a branch restriction matched the review
phrases and was treated as review-only, so the bypass could merge past
the protection that actually blocked it.

The classifier now rejects first: any phrase naming a protection other
than review disqualifies the refusal, whatever else the message says.
Only a refusal that names a review and nothing else is bypassable, and
an unrecognised message still fails closed.

The release test that pinned the whole required/status/merge/file/tags/
tag transcript now asserts what the release produced instead: the tag
name, that it landed on the merge commit rather than the tested head,
and that protection was not bypassed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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