Repository navigation
feat(version): honest build identity and an upstream update check - #11
Merged
Merged
Conversation
There was no update documentation at all — only install. Adds a section for a human and one for an AI agent, both built around the three traps this repository actually has: - `up -d` without `--build` reuses the old image, so an "update" silently leaves the previous code running. - `make migrate` on a prod server runs bare `docker compose`, which auto-merges docker-compose.override.yml — the dev overlay is present in every checkout, so it bind-mounts host source over /app and forces DEPLOY_MODE=dev. Every command in the section passes both `-f` files. - `origin` (Gitea) and `github` share no history: `git merge-base origin/main github/main` returns nothing. Pulling from the mirror into a prod checkout fails or makes a mess. The agent section inherits AI-INSTALL.md §0 and adds stop conditions, including a ban on `git pull` without --ff-only: a merge commit created on a production checkout is how these histories diverged in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwX8Ejy7NLT5Cjug2VTYXb
The admin footer printed `getenv('APP_VERSION') ?: 'v0.5.5'`. APP_VERSION was
set nowhere in the repository, so the fallback always won and every deployment
claimed v0.5.5 — a version invented in a template. The same fabricated string
sat in the public layout, so the login page lied too.
Build identity is now baked at image build time, in two distinct kinds:
- release — stamped by CI from the tag it built; both workflows now fail the
build if the tag or revision cannot be resolved.
- source — from `git describe` in a local build, displayed as a source build.
They are never mixed behind one variable, because the nearest *reachable* tag
is not the newest tag in the repository: on this branch `git describe` returns
v0.4.1-85-gb9d3407 (v0.6.0 lives on feat/filters-pixel-country and is not an
ancestor of HEAD). Comparing that to upstream would confidently report "two
minors behind" for a build that has diverged, not fallen behind. A source build
therefore never claims to be current or behind.
There is deliberately no `environment:` entry for these keys in the prod
compose files: a compose environment mapping overrides the image ENV even when
it interpolates to an empty string, so `${APP_VERSION:-}` would erase the
identity of a correctly stamped release image on an ordinary `up -d`. Dev
overrides live in the dev overlay, and cover the cron service too — it declares
only env_file, inherits nothing from app, and is the container that runs the
check.
`version:check` (twice daily) reads GitHub's releases/latest into a typed
single-row core.update_status. Notable choices:
- redirects are disabled; PHP streams follow them by default and would forward
the Authorization header to the target host.
- every response field is validated, tag_name must parse as semver, and
html_url is ignored — all URLs are built locally from a validated owner/repo
slug against constant hosts. The operator picks the repository, never the
host that receives the token.
- last_attempt_at and last_success_at are separate. One timestamp cannot mean
both "when did we last try" and "how old is this answer", and conflating them
is how a checker broken for months keeps asserting a fresh verdict.
- a failed attempt keeps the last good answer but exits non-zero, so
core.cron_runs records the failure; freshness ages out into a `stale` state
instead of being hidden.
- changing UPDATE_CHECK_REPO clears the etag, release data *and*
last_success_at before the next request. Leaving that timestamp behind is a
false-current bug: a repo change plus one failed request would otherwise let
the footer assert a verdict for data it no longer has.
Five states with a strict precedence order; only `behind` is linked, to the
release page plus a "how to update" link pinned to that release. The version
renders whenever the build is known — state only decides the chip. Resolution
never throws: the footer is decorative and must not be able to 500 an admin
page.
Also adds author links (telegram, izzyypizzy.com) to both footers. They sit in
.public-left because .public-right is display:none under 760px, and hidden
links in a mobile-first index are worse than none.
Verified against the live API: v0.6.1 found, second run took a 304, an invalid
slug was rejected before any request. 416 tests pass; PHPStan level 6 clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwX8Ejy7NLT5Cjug2VTYXb
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.
What
The admin footer printed
getenv('APP_VERSION') ?: 'v0.5.5'— andAPP_VERSIONis set nowhere in the repository, so the fallback always won and every deployment claimed a version invented in a template. The same fabricated string sat in the public layout, so the login page lied too.This replaces it with a real build identity and an upstream update check.
Build identity
Baked at image build time, in two distinct kinds:
git describein a local build, displayed as a source build.They are never mixed behind one variable, because the nearest reachable tag is not the newest tag in the repository. On the branch this was developed from,
git describereturnsv0.4.1-85-gb9d3407whilev0.6.1is the latest release — comparing those would confidently report "behind" for a build that had diverged, not fallen behind. A source build therefore never claims to be current or behind.There is deliberately no
environment:entry for these keys in the prod compose files: a compose environment mapping overrides the imageENVeven when it interpolates to an empty string, so${APP_VERSION:-}would erase the identity of a correctly stamped release image on an ordinaryup -d. Dev overrides live in the dev overlay, and cover thecronservice too — it declares onlyenv_file, inherits nothing fromapp, and is the container that runs the check.The check
version:check, twice daily, reads GitHub'sreleases/latestinto a typed single-rowcore.update_status.Authorizationheader to the target host.tag_namemust parse as semver, andhtml_urlis ignored — all URLs are built locally from a validatedowner/reposlug against constant hosts. The operator picks the repository, never the host that receives the token.last_attempt_atandlast_success_atare separate. One timestamp cannot mean both "when did we last try" and "how old is this answer", and conflating them is how a checker broken for months keeps asserting a fresh verdict.core.cron_runsrecords the failure. Freshness ages out into astalestate instead of being hidden.UPDATE_CHECK_REPOclears the etag, release data andlast_success_atbefore the next request. Leaving that timestamp behind is a false-current bug: a repo change plus one failed request would otherwise let the footer assert a verdict for data it no longer has.UI
Five states with a strict precedence order, so overlapping conditions cannot produce two answers. Only
behindis linked — to the release page, plus a "how to update" link pinned to that same release so the steps are the ones that shipped with it. The version renders whenever the build is known; state only decides the chip.Resolution never throws: the footer is decorative and must not be able to 500 an admin page.
Also
up -dwithout--buildreuses the old image;make migrateon prod auto-merges the dev overlay)..public-leftbecause.public-rightisdisplay:noneunder 760px and hidden links in a mobile-first index are worse than none.Verification
Against the live API:
v0.6.1found, a second run took a 304, an invalid slug was rejected before any request was made. Full suite green, PHPStan level 6 clean on all new code.🤖 Generated with Claude Code
https://claude.ai/code/session_01UwX8Ejy7NLT5Cjug2VTYXb