From e179cbf48a4e00fc9e60de4c521babe6a21102c3 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Thu, 16 Jul 2026 13:52:36 +0200 Subject: [PATCH] fix(status): show the release version on the admin status page Two coupled changes so /ui/admin/status reports the deployed release instead of "1.0.0+no-version-set" / branch "main": - The profiling image (the prod hot-deploy target) is now built only on a tag push or manual dispatch, not on every main-push. Both runs pushed the same :profiling- tag, so a main-push build could stamp ref=main over the release tag's build (last-writer-wins). Tag/dispatch-only makes the deployed image's APP_BUILD_REF deterministically the release tag. - The Application version field now prefers that baked APP_BUILD_REF (a version tag, e.g. v6.3.4 -> 6.3.4) over Composer's root version, which reads 1.0.0+no-version-set in a CI build with no tag/VCS context. Signed-off-by: Sebastian Mendel --- .github/workflows/docker-publish.yml | 12 +++-- src/Controller/Admin/GetStatusAction.php | 58 ++++++++++++++++-------- tests/Controller/GetStatusActionTest.php | 24 ++++++++++ 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4d1edd8b9..f94adf2ec 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -93,9 +93,13 @@ jobs: # Prod-like image with the Symfony profiler (admin-gated). Published for # operators to switch to on demand — never the default deployment. - name: Build and push profiling image - # Skip on PRs entirely (#624 QW10): the profiling image is never deployed - # from a PR, so its breakage can surface on the main-push build instead. - if: github.event_name != 'pull_request' + # Build ONLY on a tag push (release) or manual dispatch. The prod hot-deploy + # runs :profiling-, and /ui/admin/status reads its version from that + # image's baked APP_BUILD_REF. Building it on main-push too made both runs + # write the same :profiling- tag (last-writer-wins), so a release image + # could end up stamped ref=main instead of its tag. Tag/dispatch-only keeps + # the deployed image's ref deterministically the release tag (e.g. v6.3.4). + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 env: GIT_SHA: ${{ github.sha }} @@ -105,7 +109,7 @@ jobs: source: . targets: app-profiling files: docker-bake.hcl - # The step's `if:` already excludes PRs, so this only ever runs on a push. + # The step's `if:` restricts this to tag/dispatch runs, never a PR. push: true set: | *.cache-from=type=gha diff --git a/src/Controller/Admin/GetStatusAction.php b/src/Controller/Admin/GetStatusAction.php index c0ff6761f..0a0b8ab6d 100644 --- a/src/Controller/Admin/GetStatusAction.php +++ b/src/Controller/Admin/GetStatusAction.php @@ -76,9 +76,7 @@ public function __invoke(): JsonResponse 'env' => $this->kernel->getEnvironment(), 'debug' => $this->kernel->isDebug(), 'locale' => $this->param('app_locale'), - 'version' => InstalledVersions::isInstalled('netresearch/timetracker') - ? InstalledVersions::getPrettyVersion('netresearch/timetracker') - : null, + 'version' => $this->appVersion(), ], 'build' => $this->buildInfo(), 'php' => [ @@ -120,25 +118,13 @@ private function param(string $key): mixed */ private function buildInfo(): array { - $env = static function (string $key): ?string { - // getenv() reads the real process environment regardless of PHP's - // variables_order (where the APP_BUILD_* Docker env land); fall back - // to the superglobals for SAPIs/setups that only populate those. - $value = getenv($key); - if (false === $value || '' === $value) { - $value = $_SERVER[$key] ?? $_ENV[$key] ?? null; - } - - return is_string($value) && '' !== $value ? $value : null; - }; - - $revision = $env('APP_BUILD_REVISION'); - $ref = $env('APP_BUILD_REF'); + $revision = $this->buildEnv('APP_BUILD_REVISION'); + $ref = $this->buildEnv('APP_BUILD_REF'); return [ 'revision' => $revision, 'ref' => $ref, - 'date' => $env('APP_BUILD_DATE'), + 'date' => $this->buildEnv('APP_BUILD_DATE'), 'repositoryUrl' => self::REPOSITORY_URL, 'commitUrl' => null !== $revision ? self::REPOSITORY_URL . '/commit/' . $revision : null, 'refUrl' => null !== $ref ? self::REPOSITORY_URL . '/tree/' . rawurlencode($ref) : null, @@ -146,6 +132,42 @@ private function buildInfo(): array ]; } + /** + * Read a build-provenance env var (APP_BUILD_*), null when unset. + * + * getenv() reads the real process environment regardless of PHP's + * variables_order (where the APP_BUILD_* Docker env land); fall back to the + * superglobals for SAPIs/setups that only populate those. + */ + private function buildEnv(string $key): ?string + { + $value = getenv($key); + if (false === $value || '' === $value) { + $value = $_SERVER[$key] ?? $_ENV[$key] ?? null; + } + + return is_string($value) && '' !== $value ? $value : null; + } + + /** + * The release version for the "Application" section. The profiling image is + * built only on tag pushes, so a deployed release carries its tag (e.g. + * "v6.3.4") in APP_BUILD_REF — prefer that, stripped of the leading "v". + * Falls back to Composer's root version, which reads "1.0.0+no-version-set" + * in a CI build that has no tag/VCS context. + */ + private function appVersion(): ?string + { + $ref = $this->buildEnv('APP_BUILD_REF'); + if (null !== $ref && 1 === preg_match('/^v?\d+\.\d+\.\d+/', $ref)) { + return ltrim($ref, 'v'); + } + + return InstalledVersions::isInstalled('netresearch/timetracker') + ? InstalledVersions::getPrettyVersion('netresearch/timetracker') + : null; + } + /** @return array */ private function packageVersions(): array { diff --git a/tests/Controller/GetStatusActionTest.php b/tests/Controller/GetStatusActionTest.php index 15303e324..da201c0c5 100644 --- a/tests/Controller/GetStatusActionTest.php +++ b/tests/Controller/GetStatusActionTest.php @@ -95,6 +95,30 @@ public function testSubsystemCardsCoverExpectedStorage(): void self::assertStringNotContainsStringIgnoringCase('password', json_encode($json, JSON_THROW_ON_ERROR)); } + public function testVersionPrefersTheBuildRefTag(): void + { + // The profiling image is built only on tag pushes, so a deployed release + // carries its tag in APP_BUILD_REF. The "Application → Version" field + // surfaces it (stripped of the leading "v") rather than Composer's + // unversioned root default ("1.0.0+no-version-set"). + putenv('APP_BUILD_REF=v9.9.9'); + $_SERVER['APP_BUILD_REF'] = 'v9.9.9'; + + try { + $this->getJson('/admin/status'); + $this->assertStatusCode(200); + + $json = $this->getJsonResponse($this->client->getResponse()); + self::assertIsArray($json['app']); + self::assertSame('9.9.9', $json['app']['version']); + self::assertIsArray($json['build']); + self::assertSame('v9.9.9', $json['build']['ref']); + } finally { + putenv('APP_BUILD_REF'); + unset($_SERVER['APP_BUILD_REF']); + } + } + public function testForbiddenForNonAdmin(): void { $this->logInSession('developer'); // type DEV — not an admin