Skip to content

feat(version): honest build identity and an upstream update check - #11

Merged
izzipizzy merged 2 commits into
mainfrom
feat/version-ticker
Aug 24, 2026
Merged

izzipizzy merged 2 commits into
mainfrom
feat/version-ticker

Conversation

@izzipizzy

Copy link
Copy Markdown
Owner

What

The admin footer printed getenv('APP_VERSION') ?: 'v0.5.5' — and APP_VERSION is 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:

  • release — stamped by CI from the tag it built. The release workflow now fails the build if the tag or revision cannot be resolved: a release image with no identity is a defect, not something to paper over at runtime.
  • 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 the branch this was developed from, git describe returns v0.4.1-85-gb9d3407 while v0.6.1 is 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 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.

The check

version:check, twice daily, reads GitHub's releases/latest into a typed single-row core.update_status.

  • 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.

UI

Five states with a strict precedence order, so overlapping conditions cannot produce two answers. Only behind is 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

  • README gains an "Updating an existing install" section for a human and for an AI agent, built around the traps this repo actually has (up -d without --build reuses the old image; make migrate on prod auto-merges the dev overlay).
  • Author links (telegram, izzyypizzy.com) in both footers, in .public-left because .public-right is display:none under 760px and hidden links in a mobile-first index are worse than none.

Verification

Against the live API: v0.6.1 found, 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

izzypizzy and others added 2 commits August 24, 2026 21:13
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
@izzipizzy
izzipizzy merged commit 870fd22 into main Aug 24, 2026
1 check passed
@izzipizzy
izzipizzy deleted the feat/version-ticker branch August 24, 2026 18:16
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