feat(frontend): show real project version on About page (#4126)

* feat(frontend): show real project version on About page

The About page heading was hardcoded "About DeerFlow 2.0" while the real
version is 2.1.0, kept in lockstep across backend/pyproject.toml,
frontend/package.json, and Chart.yaml by scripts/verify_versions.sh.
With the nightly workflow shipping unreleased main daily, a hardcoded
version drifts further from reality every day.

Wire the heading to the real version, nightly-aware:
- src/version.ts reads NEXT_PUBLIC_APP_VERSION (nightly override) with a
  package.json fallback for release/local builds.
- about-content.ts interpolates APP_VERSION into the heading; the
  "DeerFlow 1.0 and 2.0" milestone copy in acknowledgments is left as-is.
- Dockerfile adds ARG APP_VERSION / ENV NEXT_PUBLIC_APP_VERSION in the
  builder stage so nightly CI can stamp the version.
- nightly.yaml computes the nightly string once in prepare
  (<base>-nightly.<date>-<sha>, mirroring the chart scheme) and feeds it
  to both the frontend image build-arg and the chart publish (refactored
  to consume the shared output, so the displayed version and chart
  version can't drift). Release builds (container.yaml) are untouched
  and fall back to package.json.

Tests cover the version branches and the heading interpolation;
RELEASING.md notes the About page as a derived version consumer.

* test(frontend): assert positive About-page version value

Address review feedback on #4126: the unset-env assertion was negative-only
(checked the stale "2.0" literal was gone), so it would still pass if
APP_VERSION interpolated to "" or undefined. Mirror version.test.ts by
asserting the heading carries the real resolved APP_VERSION.

* ci(nightly): fail loudly on empty base version

Address review feedback on #4126: the prepare step's BASE (parsed from
Chart.yaml) had no empty-guard. A malformed/missing version would yield
`-nightly.<date>-<sha>` (invalid semver) that now flows into both the
chart publish and the frontend About-page version. Add `set -euo pipefail`
plus a `test -n "$BASE"` guard mirroring publish-chart. `pipefail` matters
- without it the pipeline's exit code is awk's, so `set -e` alone wouldn't
catch a grep miss.
This commit is contained in:
Zheng Feng
2026-07-12 22:54:03 +08:00
committed by GitHub
parent 2730ee1f7b
commit a9145e426c
7 changed files with 141 additions and 12 deletions
+42 -11
View File
@@ -42,12 +42,37 @@ jobs:
runs-on: ubuntu-latest
outputs:
date: ${{ steps.date.outputs.date }}
nightly_version: ${{ steps.nightly_version.outputs.nightly_version }}
steps:
- name: Set nightly date (UTC)
id: date
# Shared by build-images (image tag) and publish-chart (chart version)
# so the two stay in sync for a given run.
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Checkout repository
# Needed to read Chart.yaml's base version for the nightly version
# string computed below.
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Compute nightly version
id: nightly_version
# Single source of truth for the nightly version string:
# <base>-nightly.<YYYYMMDD>-<short_sha> (mirrors the chart's nightly
# scheme). build-images injects it into the frontend image (About-page
# version) and publish-chart stamps it on Chart.yaml, so the version
# users see and the chart version can't drift apart.
run: |
set -euo pipefail
BASE=$(grep -m1 '^version:' deploy/helm/deer-flow/Chart.yaml | awk '{print $2}')
# A malformed/missing Chart.yaml version would yield an empty BASE ->
# `-nightly.<date>-<sha>` (invalid semver) that now flows into both
# the chart publish and the frontend About-page version. Fail loudly.
# `pipefail` matters: without it the pipeline's exit code is awk's,
# so `set -e` alone wouldn't catch a grep miss.
test -n "$BASE" || { echo "::error::empty base version from Chart.yaml"; exit 1; }
SHORT_SHA="${GITHUB_SHA::7}"
NIGHTLY="${BASE}-nightly.${{ steps.date.outputs.date }}-${SHORT_SHA}"
echo "nightly_version=${NIGHTLY}" >> "$GITHUB_OUTPUT"
echo "Nightly version: ${NIGHTLY}"
validate-chart:
if: github.repository == 'bytedance/deer-flow'
@@ -132,7 +157,13 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.build-args }}
# APP_VERSION is consumed only by the frontend Dockerfile (it stamps
# the About-page version); backend/provisioner Dockerfiles don't
# declare it. Passed via build-arg because build-push-action doesn't
# forward host env into the BuildKit build.
build-args: |
${{ matrix.build-args }}
${{ matrix.component == 'frontend' && format('APP_VERSION={0}', needs.prepare.outputs.nightly_version) || '' }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be #v2.4.0
with:
@@ -151,25 +182,25 @@ jobs:
contents: read
packages: write
env:
DATE: ${{ needs.prepare.outputs.date }}
OWNER: ${{ github.repository_owner }}
NIGHTLY: ${{ needs.prepare.outputs.nightly_version }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
- name: Patch chart to a nightly version + nightly image defaults
# Bumps Chart.yaml version/appVersion to <base>-nightly.<date>-<sha>
# (valid semver prerelease; the short SHA makes each dispatch's version
# unique, so a same-day re-dispatch re-publishes cleanly - OCI chart
# versions are immutable and otherwise can't be overwritten). Repoints
# the chart's default image registry/tag at the nightly build. Patches
# are in-workflow only - nothing is committed back.
# Bumps Chart.yaml version/appVersion to the nightly version computed in
# the prepare job (<base>-nightly.<date>-<sha>, a valid semver
# prerelease; the short SHA makes each dispatch's version unique, so a
# same-day re-dispatch re-publishes cleanly - OCI chart versions are
# immutable and otherwise can't be overwritten). The same string is
# injected into the frontend image, so the About-page version and the
# chart version match. Repoints the chart's default image registry/tag
# at the nightly build. Patches are in-workflow only - nothing is
# committed back.
run: |
set -eu
CHART=deploy/helm/deer-flow
BASE=$(grep -m1 '^version:' "$CHART/Chart.yaml" | awk '{print $2}')
SHORT_SHA="${GITHUB_SHA::7}"
NIGHTLY="${BASE}-nightly.${DATE}-${SHORT_SHA}"
echo "Nightly chart version: ${NIGHTLY}"
sed -i "s|^version:.*|version: ${NIGHTLY}|" "$CHART/Chart.yaml"
sed -i "s|^appVersion:.*|appVersion: \"${NIGHTLY}\"|" "$CHART/Chart.yaml"
+7
View File
@@ -23,6 +23,13 @@ Container images are tagged from the git tag (not from these files), and the
Helm chart version is validated against the tag — so if any source lags the
tag, the release is blocked (see [Version gate](#version-gate)).
The frontend's in-app About page (Settings ▸ About) is a *derived* consumer, not
a fifth source: it reads `frontend/package.json`'s version at build time, so it
tracks the table above automatically with no bump needed. Nightly builds override
it with the chart's nightly string (`<base>-nightly.<YYYYMMDD>-<short_sha>`) via
the `APP_VERSION` build-arg in `nightly.yaml`, so a nightly image's About page
distinguishes it from a release.
## Helper scripts
- `scripts/bump_version.sh <version>` — set all four fields at once, then
+5
View File
@@ -32,6 +32,11 @@ EXPOSE 3000
FROM base AS builder
RUN cd /app/frontend && pnpm install --frozen-lockfile
# Skip env validation — runtime vars are injected by nginx/container
# App version stamp for the About page. Nightly CI passes
# APP_VERSION=<base>-nightly.<date>-<sha>; release/local builds leave it empty
# and the frontend falls back to package.json's version.
ARG APP_VERSION=""
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
RUN cd /app/frontend && SKIP_ENV_VALIDATION=1 pnpm build
# ── Prod: minimal runtime with pre-built output ───────────────────────────────
@@ -2,7 +2,9 @@
* About DeerFlow markdown content. Inlined to avoid raw-loader dependency
* (Turbopack cannot resolve raw-loader for .md imports).
*/
export const aboutMarkdown = `# 🦌 [About DeerFlow 2.0](https://github.com/bytedance/deer-flow)
import { APP_VERSION } from "@/version";
export const aboutMarkdown = `# 🦌 [About DeerFlow ${APP_VERSION}](https://github.com/bytedance/deer-flow)
> **From Open Source, Back to Open Source**
+15
View File
@@ -0,0 +1,15 @@
/**
* App version shown in the UI (About page, etc.). Prefers the build-time
* NEXT_PUBLIC_APP_VERSION, which nightly CI sets to
* `<base>-nightly.<YYYYMMDD>-<short_sha>` (matching the Helm chart's nightly
* scheme). Falls back to package.json's version for local dev and tag-driven
* release builds, where package.json is verified to match the git tag
* (see scripts/verify_versions.sh + .github/workflows/verify-versions.yml).
*/
import pkg from "../package.json";
// `||` (not `??`) is intentional: the frontend Dockerfile sets
// NEXT_PUBLIC_APP_VERSION="" for release/local builds, and that empty string
// must fall through to pkg.version just like an unset var. `??` would keep "".
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || pkg.version;
@@ -0,0 +1,34 @@
import { afterEach, expect, test, rs } from "@rstest/core";
const original = process.env.NEXT_PUBLIC_APP_VERSION;
afterEach(() => {
rs.resetModules();
if (original === undefined) {
delete process.env.NEXT_PUBLIC_APP_VERSION;
} else {
process.env.NEXT_PUBLIC_APP_VERSION = original;
}
});
test("aboutMarkdown heading interpolates the app version", async () => {
process.env.NEXT_PUBLIC_APP_VERSION = "9.9.9-test";
const { aboutMarkdown } =
await import("@/components/workspace/settings/about-content");
// The heading link text carries the version stamp.
expect(aboutMarkdown).toContain("[About DeerFlow 9.9.9-test]");
// Milestone copy in the acknowledgments refers to the 1.0/2.0 product
// generations and must NOT be parameterized.
expect(aboutMarkdown).toContain("DeerFlow 1.0 and 2.0");
});
test("aboutMarkdown heading reflects the package version when env is unset", async () => {
delete process.env.NEXT_PUBLIC_APP_VERSION;
const { APP_VERSION } = await import("@/version");
const { aboutMarkdown } =
await import("@/components/workspace/settings/about-content");
// Positive: the heading carries the real resolved version. This catches an
// empty or undefined APP_VERSION interpolation (`About DeerFlow ]` /
// `About DeerFlow undefined]`), not just removal of the old literal.
expect(aboutMarkdown).toContain(`[About DeerFlow ${APP_VERSION}]`);
});
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, expect, test, rs } from "@rstest/core";
import pkg from "../../package.json";
const original = process.env.NEXT_PUBLIC_APP_VERSION;
afterEach(() => {
rs.resetModules();
if (original === undefined) {
delete process.env.NEXT_PUBLIC_APP_VERSION;
} else {
process.env.NEXT_PUBLIC_APP_VERSION = original;
}
});
test("APP_VERSION uses NEXT_PUBLIC_APP_VERSION when set (nightly build-arg)", async () => {
process.env.NEXT_PUBLIC_APP_VERSION = "2.1.0-nightly.20260712-abc1234";
const { APP_VERSION } = await import("@/version");
expect(APP_VERSION).toBe("2.1.0-nightly.20260712-abc1234");
});
test("APP_VERSION falls back to package.json version when env is unset (local dev)", async () => {
delete process.env.NEXT_PUBLIC_APP_VERSION;
const { APP_VERSION } = await import("@/version");
expect(APP_VERSION).toBe(pkg.version);
});
test("APP_VERSION treats an empty NEXT_PUBLIC_APP_VERSION as unset (release Docker build)", async () => {
// The frontend Dockerfile sets ENV NEXT_PUBLIC_APP_VERSION="" when nightly CI
// doesn't pass APP_VERSION; the empty string must fall through to the
// package.json version just like an unset var.
process.env.NEXT_PUBLIC_APP_VERSION = "";
const { APP_VERSION } = await import("@/version");
expect(APP_VERSION).toBe(pkg.version);
});