diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml
index 3eef3018f..234166339 100644
--- a/.github/workflows/nightly.yaml
+++ b/.github/workflows/nightly.yaml
@@ -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:
+ # -nightly.- (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.-` (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 -nightly.-
- # (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 (-nightly.-, 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"
diff --git a/RELEASING.md b/RELEASING.md
index 60b335cd2..0434d4bf9 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -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 (`-nightly.-`) 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 ` — set all four fields at once, then
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 2fd06aea3..4c6b22123 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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=-nightly.-; 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 ───────────────────────────────
diff --git a/frontend/src/components/workspace/settings/about-content.ts b/frontend/src/components/workspace/settings/about-content.ts
index ca587eceb..247a95994 100644
--- a/frontend/src/components/workspace/settings/about-content.ts
+++ b/frontend/src/components/workspace/settings/about-content.ts
@@ -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**
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
new file mode 100644
index 000000000..5a4f36b6c
--- /dev/null
+++ b/frontend/src/version.ts
@@ -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
+ * `-nightly.-` (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;
diff --git a/frontend/tests/unit/components/workspace/settings/about-content.test.ts b/frontend/tests/unit/components/workspace/settings/about-content.test.ts
new file mode 100644
index 000000000..e1e5f370a
--- /dev/null
+++ b/frontend/tests/unit/components/workspace/settings/about-content.test.ts
@@ -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}]`);
+});
diff --git a/frontend/tests/unit/version.test.ts b/frontend/tests/unit/version.test.ts
new file mode 100644
index 000000000..172340be0
--- /dev/null
+++ b/frontend/tests/unit/version.test.ts
@@ -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);
+});