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); +});