diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b21c2ed0..de991252 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -76,12 +76,21 @@ jobs: fetch-depth: 0 - name: Build local image - run: docker build --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}" --tag edgeever:ci . + run: | + docker build \ + --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}" \ + --build-arg EDGE_EVER_BUILD_CREATED_AT="2026-01-02T03:04:05Z" \ + --build-arg EDGE_EVER_BUILD_TRIGGER=main_push \ + --build-arg EDGE_EVER_BUILD_METHOD=github_actions \ + --tag edgeever:ci . + docker build --tag edgeever:ci-default . - name: Start container run: | docker run --detach --name edgeever-ci \ --env EDGE_EVER_AUTH_PASSWORD=container-ci-password \ + --env EDGE_EVER_DEPLOYMENT_TRIGGER=manual \ + --env EDGE_EVER_DEPLOYMENT_METHOD=docker \ --publish 127.0.0.1:8787:8787 \ edgeever:ci @@ -94,6 +103,8 @@ jobs: sleep 1 done echo "${health}" | jq -e '.ok == true and .runtime == "self-hosted-bun" and .authMode == "required"' + echo "${health}" | jq -e '.buildCreatedAt == "2026-01-02T03:04:05.000Z" and .buildProvenance == {"trigger": "main_push", "method": "github_actions"}' + echo "${health}" | jq -e '.deployment == {"trigger": "manual", "method": "docker"} and .deploymentVersionCreatedAt == null and .containerImageSource == "unknown"' curl --fail --silent --show-error http://127.0.0.1:8787/ | grep -F '' docker exec edgeever-ci test -f /data/edgeever.sqlite docker exec edgeever-ci test -d /data/resources @@ -109,9 +120,29 @@ jobs: docker stop --timeout 30 edgeever-ci docker logs edgeever-ci | grep -F '[self-hosted] shutdown complete' + - name: Verify a default self-built image reports unknown build metadata + run: | + docker run --detach --name edgeever-ci-default \ + --env EDGE_EVER_AUTH_PASSWORD=container-ci-password \ + --env EDGE_EVER_BUILD_TRIGGER=not-a-trigger \ + --env EDGE_EVER_BUILD_CREATED_AT=not-a-date \ + --publish 127.0.0.1:8788:8787 \ + edgeever:ci-default + for attempt in $(seq 1 60); do + if health="$(curl --fail --silent --show-error http://127.0.0.1:8788/api/health)"; then + break + fi + sleep 1 + done + echo "${health}" | jq -e '.build == "unknown" and .buildCreatedAt == null and .buildProvenance == {"trigger": "unknown", "method": "unknown"}' + echo "${health}" | jq -e '.deployment == {"trigger": "unknown", "method": "unknown"} and .deploymentVersionCreatedAt == null' + docker stop --timeout 30 edgeever-ci-default + - name: Show container logs on failure if: failure() - run: docker logs edgeever-ci || true + run: | + docker logs edgeever-ci || true + docker logs edgeever-ci-default || true publish: if: >- @@ -135,6 +166,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.release_tag }} run: | + echo "created_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" if [[ -n "${RELEASE_TAG}" ]]; then if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "release_tag must match vX.Y.Z" >&2 @@ -153,6 +185,7 @@ jobs: exit 1 fi { + echo "build_trigger=github_release" echo "tags<> "${GITHUB_OUTPUT}" else short_sha="${GITHUB_SHA:0:12}" + # A dispatch without release_tag is an ad-hoc rebuild of main. + if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then + build_trigger=main_push + else + build_trigger=manual + fi { + echo "build_trigger=${build_trigger}" echo "tags<' docker exec "${container_name}" test -s /app/scripts/self-hosted-server.js docker exec "${container_name}" test -s /app/scripts/self-hosted-server.mjs diff --git a/Dockerfile b/Dockerfile index 96ba81d7..2aeb415f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,8 +36,17 @@ RUN bun run build:web && bun run build:self-hosted FROM oven/bun:1.3.14-alpine AS runtime WORKDIR /app ARG EDGE_EVER_BUILD_ID=unknown +# Image build metadata for System information. Self-built images leave these +# empty, which the instance reports as unknown. They describe how the image was +# produced, not how it was deployed; set EDGE_EVER_DEPLOYMENT_* at runtime for that. +ARG EDGE_EVER_BUILD_CREATED_AT= +ARG EDGE_EVER_BUILD_TRIGGER= +ARG EDGE_EVER_BUILD_METHOD= ENV NODE_ENV=production \ EDGE_EVER_BUILD_ID=${EDGE_EVER_BUILD_ID} \ + EDGE_EVER_BUILD_CREATED_AT=${EDGE_EVER_BUILD_CREATED_AT} \ + EDGE_EVER_BUILD_TRIGGER=${EDGE_EVER_BUILD_TRIGGER} \ + EDGE_EVER_BUILD_METHOD=${EDGE_EVER_BUILD_METHOD} \ EDGE_EVER_DATA_DIR=/data \ EDGE_EVER_WEB_DIR=/app/apps/web/dist \ PORT=8787 diff --git a/apps/api/src/api-context.ts b/apps/api/src/api-context.ts index 7e1ca33b..7b47e15b 100644 --- a/apps/api/src/api-context.ts +++ b/apps/api/src/api-context.ts @@ -12,6 +12,9 @@ export type Bindings = { EDGE_EVER_CONTAINER_IMAGE?: string; EDGE_EVER_DEPLOYMENT_TRIGGER?: string; EDGE_EVER_DEPLOYMENT_METHOD?: string; + EDGE_EVER_BUILD_CREATED_AT?: string; + EDGE_EVER_BUILD_TRIGGER?: string; + EDGE_EVER_BUILD_METHOD?: string; /** Creation time of the active deployment version, supplied by a runtime adapter. */ deploymentVersionCreatedAt?: string; EDGE_EVER_AUTH_PASSWORD?: string; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7dcd0cc2..41072f69 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -47,7 +47,7 @@ import { import { sha256, sha256Bytes } from "./hash-utils"; import { PUBLIC_PAGE_HEADERS } from "@edgeever/shared/public-page-headers"; import { INSTANCE_BUILD_ID } from "./instance-build"; -import { resolveDeploymentVersionCreatedAt, resolveInstanceDeploymentMetadata } from "./instance-deployment"; +import { resolveDeploymentVersionCreatedAt, resolveImageBuildMetadata, resolveInstanceDeploymentMetadata } from "./instance-deployment"; import type { DatabaseAdapter, PreparedStatementAdapter, @@ -236,7 +236,10 @@ app.get("/api/health", async (c) => { name: "edgeever", runtime, ...(runtime === "self-hosted-bun" - ? { containerImageSource: resolveContainerImageSource(c.env.EDGE_EVER_CONTAINER_IMAGE) } + ? { + containerImageSource: resolveContainerImageSource(c.env.EDGE_EVER_CONTAINER_IMAGE), + ...resolveImageBuildMetadata(c.env), + } : {}), authMode, build: INSTANCE_BUILD_ID.slice(0, 12), diff --git a/apps/api/src/instance-deployment.test.mjs b/apps/api/src/instance-deployment.test.mjs index 6fb46ee2..9aa27fbe 100644 --- a/apps/api/src/instance-deployment.test.mjs +++ b/apps/api/src/instance-deployment.test.mjs @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { resolveDeploymentVersionCreatedAt } from "./instance-deployment"; +import { resolveDeploymentVersionCreatedAt, resolveImageBuildMetadata, resolveInstanceDeploymentMetadata } from "./instance-deployment"; test("normalizes a runtime-supplied deployment version timestamp and rejects missing or invalid values", () => { expect(resolveDeploymentVersionCreatedAt({ deploymentVersionCreatedAt: "2026-10-02T08:00:00+08:00" })) @@ -7,3 +7,29 @@ test("normalizes a runtime-supplied deployment version timestamp and rejects mis expect(resolveDeploymentVersionCreatedAt({})).toBeNull(); expect(resolveDeploymentVersionCreatedAt({ deploymentVersionCreatedAt: "unknown" })).toBeNull(); }); + +test("reports image build metadata only from valid build arguments", () => { + expect(resolveImageBuildMetadata({ + EDGE_EVER_BUILD_CREATED_AT: " 2026-01-02T03:04:05Z ", + EDGE_EVER_BUILD_TRIGGER: "github_release", + EDGE_EVER_BUILD_METHOD: "github_actions", + })).toEqual({ + buildCreatedAt: "2026-01-02T03:04:05.000Z", + buildProvenance: { trigger: "github_release", method: "github_actions" }, + }); + // A default self-build passes empty build arguments. + expect(resolveImageBuildMetadata({ EDGE_EVER_BUILD_CREATED_AT: "", EDGE_EVER_BUILD_TRIGGER: "", EDGE_EVER_BUILD_METHOD: "" })).toEqual({ + buildCreatedAt: null, + buildProvenance: { trigger: "unknown", method: "unknown" }, + }); + expect(resolveImageBuildMetadata({ EDGE_EVER_BUILD_CREATED_AT: "yesterday", EDGE_EVER_BUILD_TRIGGER: "nightly", EDGE_EVER_BUILD_METHOD: "kubernetes" })).toEqual({ + buildCreatedAt: null, + buildProvenance: { trigger: "unknown", method: "unknown" }, + }); +}); + +test("accepts docker as an explicit deployment method without inferring it", () => { + expect(resolveInstanceDeploymentMetadata({ EDGE_EVER_DEPLOYMENT_TRIGGER: "manual", EDGE_EVER_DEPLOYMENT_METHOD: "docker" })) + .toEqual({ trigger: "manual", method: "docker" }); + expect(resolveInstanceDeploymentMetadata({})).toEqual({ trigger: "unknown", method: "unknown" }); +}); diff --git a/apps/api/src/instance-deployment.ts b/apps/api/src/instance-deployment.ts index e77e92d4..98e9edc6 100644 --- a/apps/api/src/instance-deployment.ts +++ b/apps/api/src/instance-deployment.ts @@ -29,3 +29,24 @@ export const resolveDeploymentVersionCreatedAt = ( if (!timestamp || !Number.isFinite(Date.parse(timestamp))) return null; return new Date(timestamp).toISOString(); }; + +const normalizeTimestamp = (value: string | undefined): string | null => { + const timestamp = value?.trim(); + if (!timestamp || !Number.isFinite(Date.parse(timestamp))) return null; + return new Date(timestamp).toISOString(); +}; + +/** + * How and when the container image was produced. This is separate from the + * instance deployment metadata: an image can be deployed long after it was + * built, and by a different mechanism. + */ +export const resolveImageBuildMetadata = ( + environment: Pick, +): { buildCreatedAt: string | null; buildProvenance: DeploymentMetadata } => ({ + buildCreatedAt: normalizeTimestamp(environment.EDGE_EVER_BUILD_CREATED_AT), + buildProvenance: { + trigger: resolveDeploymentTrigger(environment.EDGE_EVER_BUILD_TRIGGER?.trim()), + method: resolveDeploymentMethod(environment.EDGE_EVER_BUILD_METHOD?.trim()), + }, +}); diff --git a/apps/mobile/src/screens/WorkspaceSettingsView.tsx b/apps/mobile/src/screens/WorkspaceSettingsView.tsx index df0e8017..b09808a7 100644 --- a/apps/mobile/src/screens/WorkspaceSettingsView.tsx +++ b/apps/mobile/src/screens/WorkspaceSettingsView.tsx @@ -538,6 +538,7 @@ const getMobileSystemInfoText = (localePreference: MobileLocaleMode) => connectionFailed: "Connection failed", connectionSection: "Connection and sync", containerImageSource: "Container image source", + imageBuiltAt: "Image built at", databaseBackend: "Database backend", databaseVersion: "Database version", description: "View cloud instance and current client diagnostics.", @@ -588,6 +589,7 @@ const getMobileSystemInfoText = (localePreference: MobileLocaleMode) => connectionFailed: "连接失败", connectionSection: "连接与同步", containerImageSource: "容器镜像来源", + imageBuiltAt: "镜像构建时间", databaseBackend: "数据库后端", databaseVersion: "数据库版本", description: "查看云端实例与当前客户端的诊断信息。", @@ -772,7 +774,15 @@ const getMobileSystemInfoGroups = ( ? [{ fullWidth: true, label: copy.existingAttachments, value: copy.existingAttachmentsOriginalStorage }] : []), ...(instance?.health.runtime === "self-hosted-bun" - ? [{ fullWidth: true, label: copy.containerImageSource, value: getMobileContainerImageSource(instance.health.containerImageSource, english) }] + ? [ + { fullWidth: true, label: copy.containerImageSource, value: getMobileContainerImageSource(instance.health.containerImageSource, english) }, + { + label: copy.imageBuiltAt, + value: instance.health.buildCreatedAt && Number.isFinite(Date.parse(instance.health.buildCreatedAt)) + ? new Intl.DateTimeFormat(english ? "en-US" : "zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(instance.health.buildCreatedAt)) + : copy.unknown, + }, + ] : []), ], title: copy.cloudSection, diff --git a/apps/web/src/components/settings/SystemInfoPanel.test.mjs b/apps/web/src/components/settings/SystemInfoPanel.test.mjs index 374782be..d79b30e0 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.test.mjs +++ b/apps/web/src/components/settings/SystemInfoPanel.test.mjs @@ -30,6 +30,17 @@ describe("system information diagnostic fields", () => { expect(syncIssueSource.replaceAll("getShareableWebSystemInfoItems", "")).not.toContain("getWebSystemInfoItems"); }); + test("shows Docker image build metadata separately from the instance deployment", () => { + const selfHosted = source.slice(source.indexOf('diagnostics.instance?.runtime === "self-hosted-bun"')); + expect(selfHosted).toContain('t("systemInfo.imageBuiltAt")'); + expect(selfHosted).toContain("diagnostics.instance.buildCreatedAt"); + expect(selfHosted).toContain('t("systemInfo.imageBuildSource")'); + expect(selfHosted).toContain("resolveSystemInfoDeploymentMetadata(diagnostics.instance.buildProvenance, {}, false)"); + // The deployment time keeps its meaning and never falls back to the image build time. + expect(source).toContain("formatInstanceTimestamp(diagnostics.instance?.deploymentVersionCreatedAt, language)"); + expect(source).not.toContain("deploymentVersionCreatedAt ?? diagnostics.instance?.buildCreatedAt"); + }); + test("includes current device model from client runtime diagnostics", () => { expect(source).toContain('t("systemInfo.deviceModel")'); expect(source).toContain("diagnostics.clientRuntime?.deviceModel"); diff --git a/apps/web/src/components/settings/SystemInfoPanel.tsx b/apps/web/src/components/settings/SystemInfoPanel.tsx index 9daacb24..25ae5b0d 100644 --- a/apps/web/src/components/settings/SystemInfoPanel.tsx +++ b/apps/web/src/components/settings/SystemInfoPanel.tsx @@ -34,7 +34,7 @@ export type SystemInfoItem = { breakAll?: boolean; }; -type InstanceSystemDiagnostics = Pick & { +type InstanceSystemDiagnostics = Pick & { runtime?: string | null; }; @@ -81,6 +81,11 @@ const getDeploymentDescription = ( return `${trigger} · ${method}`; }; +const formatInstanceTimestamp = (value: string | null | undefined, language: string) => + value && Number.isFinite(Date.parse(value)) + ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) + : null; + const getContainerImageSourceTranslationKey = (source: string | null | undefined) => { if (source === "official-ghcr" || source === "official-cn-mirror" || source === "custom") return source; return "unknown"; @@ -166,9 +171,7 @@ const getWebSystemInfoGroups = ( { label: t("systemInfo.instanceBuild"), value: diagnostics.instance?.build ?? t("systemInfo.unknown"), mono: true }, { label: t("systemInfo.deploymentVersionTime"), - value: diagnostics.instance?.deploymentVersionCreatedAt && Number.isFinite(Date.parse(diagnostics.instance.deploymentVersionCreatedAt)) - ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(new Date(diagnostics.instance.deploymentVersionCreatedAt)) - : t("systemInfo.unknown"), + value: formatInstanceTimestamp(diagnostics.instance?.deploymentVersionCreatedAt, language) ?? t("systemInfo.unknown"), }, { label: t("systemInfo.databaseMigration"), value: diagnostics.instance?.migration ?? t("systemInfo.unknown"), mono: true }, { @@ -196,10 +199,20 @@ const getWebSystemInfoGroups = ( value: t(`systemInfo.deploymentPlatforms.${resolveDeploymentPlatform(diagnostics.instance?.runtime)}`), }, ...(diagnostics.instance?.runtime === "self-hosted-bun" - ? [{ - label: t("systemInfo.containerImageSource"), - value: t(`systemInfo.containerImageSources.${getContainerImageSourceTranslationKey(diagnostics.instance.containerImageSource)}`), - }] + ? [ + { + label: t("systemInfo.containerImageSource"), + value: t(`systemInfo.containerImageSources.${getContainerImageSourceTranslationKey(diagnostics.instance.containerImageSource)}`), + }, + { + label: t("systemInfo.imageBuiltAt"), + value: formatInstanceTimestamp(diagnostics.instance.buildCreatedAt, language) ?? t("systemInfo.unknown"), + }, + { + label: t("systemInfo.imageBuildSource"), + value: getDeploymentDescription(t, resolveSystemInfoDeploymentMetadata(diagnostics.instance.buildProvenance, {}, false)), + }, + ] : []), { label: t("systemInfo.deployment"), value: getDeploymentDescription(t, deployment), colSpan: "full" }, ], diff --git a/docs/deploy-docker.md b/docs/deploy-docker.md index 2545834f..32ba3b10 100644 --- a/docs/deploy-docker.md +++ b/docs/deploy-docker.md @@ -188,5 +188,45 @@ docker run --rm -p 8787:8787 \ edgeever:local ``` +### System information metadata + +**Settings → System information** separates how the image was built from how +the instance was deployed. Official images fill in the build fields; self-built +images report them as unknown unless you pass the build arguments below. + +| Field | Source | Meaning | +| ----- | ------ | ------- | +| Instance build | `--build-arg EDGE_EVER_BUILD_ID` | Commit the image was built from | +| Image built at | `--build-arg EDGE_EVER_BUILD_CREATED_AT` (ISO 8601) | When the image was built, not when it was deployed or started | +| Image build source | `--build-arg EDGE_EVER_BUILD_TRIGGER` / `EDGE_EVER_BUILD_METHOD` | How the image was produced, for example `github_release` · `github_actions` for official release images | +| Container image source | runtime `EDGE_EVER_CONTAINER_IMAGE` | Registry the image was pulled from; set by the official `compose.yaml` | +| Deployment source | runtime `EDGE_EVER_DEPLOYMENT_TRIGGER` / `EDGE_EVER_DEPLOYMENT_METHOD` | How you deployed this instance; unknown unless you set it | +| Instance deployment time | Cloudflare only | Docker does not report a deployment time | + +Triggers are `github_release`, `main_push` or `manual`. `EDGE_EVER_DEPLOYMENT_METHOD=docker` +describes a container deployment, while `github_actions`, `cloudflare_workers_builds` +and `local_cli` describe the mechanism that ran a build or deployment. EdgeEver +never infers Docker or the registry on its own, because the same server also runs +directly under Bun and images can be copied between registries. Missing or invalid +values are shown as unknown. + +For a self-built image: + +```sh +docker build \ + --build-arg EDGE_EVER_BUILD_ID="$(git rev-parse HEAD)" \ + --build-arg EDGE_EVER_BUILD_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg EDGE_EVER_BUILD_TRIGGER=manual \ + --build-arg EDGE_EVER_BUILD_METHOD=local_cli \ + --tag edgeever:local . +docker run --rm -p 8787:8787 \ + -e EDGE_EVER_AUTH_PASSWORD='replace-with-a-long-random-password' \ + -e EDGE_EVER_CONTAINER_IMAGE=edgeever:local \ + -e EDGE_EVER_DEPLOYMENT_TRIGGER=manual \ + -e EDGE_EVER_DEPLOYMENT_METHOD=docker \ + -v edgeever-data:/data \ + edgeever:local +``` + PostgreSQL is not supported in the first Docker release. It remains a future storage adapter and will not introduce a separate business-code branch. diff --git a/docs/deploy-docker.zh-CN.md b/docs/deploy-docker.zh-CN.md index 9f58eff2..32ab8aee 100644 --- a/docs/deploy-docker.zh-CN.md +++ b/docs/deploy-docker.zh-CN.md @@ -176,5 +176,42 @@ docker run --rm -p 8787:8787 \ edgeever:local ``` +### 系统信息中的元数据 + +**设置 → 系统信息** 会区分镜像如何构建与实例如何部署。官方镜像会填写构建字段; +自行构建的镜像如未传入下列构建参数,这些字段显示为未知。 + +| 字段 | 来源 | 含义 | +| ---- | ---- | ---- | +| 实例构建 | `--build-arg EDGE_EVER_BUILD_ID` | 构建镜像所用的提交 | +| 镜像构建时间 | `--build-arg EDGE_EVER_BUILD_CREATED_AT`(ISO 8601) | 镜像的构建时间,而不是部署或启动时间 | +| 镜像构建来源 | `--build-arg EDGE_EVER_BUILD_TRIGGER` / `EDGE_EVER_BUILD_METHOD` | 镜像的产出方式,例如官方正式版镜像为 `github_release` · `github_actions` | +| 容器镜像来源 | 运行时 `EDGE_EVER_CONTAINER_IMAGE` | 镜像拉取自哪个仓库;官方 `compose.yaml` 会设置 | +| 部署来源 | 运行时 `EDGE_EVER_DEPLOYMENT_TRIGGER` / `EDGE_EVER_DEPLOYMENT_METHOD` | 你如何部署此实例;未设置时为未知 | +| 实例部署时间 | 仅 Cloudflare | Docker 不提供部署时间 | + +触发方式为 `github_release`、`main_push` 或 `manual`。`EDGE_EVER_DEPLOYMENT_METHOD=docker` +表示容器部署,而 `github_actions`、`cloudflare_workers_builds` 和 `local_cli` +表示执行构建或部署的机制。EdgeEver 不会自行推断 Docker 或镜像仓库,因为同一服务 +也可直接在 Bun 下运行,镜像也可能被复制到其他仓库。缺失或无效的值显示为未知。 + +自行构建镜像时: + +```sh +docker build \ + --build-arg EDGE_EVER_BUILD_ID="$(git rev-parse HEAD)" \ + --build-arg EDGE_EVER_BUILD_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --build-arg EDGE_EVER_BUILD_TRIGGER=manual \ + --build-arg EDGE_EVER_BUILD_METHOD=local_cli \ + --tag edgeever:local . +docker run --rm -p 8787:8787 \ + -e EDGE_EVER_AUTH_PASSWORD='请替换为足够长的随机密码' \ + -e EDGE_EVER_CONTAINER_IMAGE=edgeever:local \ + -e EDGE_EVER_DEPLOYMENT_TRIGGER=manual \ + -e EDGE_EVER_DEPLOYMENT_METHOD=docker \ + -v edgeever-data:/data \ + edgeever:local +``` + Docker 首发版本不支持 PostgreSQL。它仍是未来的存储适配器,不会形成另一套 业务代码分支。 diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 39595019..d3c0ac50 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -140,6 +140,10 @@ export type InstanceHealth = { build?: string | null; deployment?: DeploymentMetadata | null; deploymentVersionCreatedAt?: string | null; + /** Self-hosted images only: when the container image was built. */ + buildCreatedAt?: string | null; + /** Self-hosted images only: how the container image was built, not how it was deployed. */ + buildProvenance?: DeploymentMetadata | null; migration?: string | null; storage?: { database?: "d1" | "sqlite" | string | null; diff --git a/packages/shared/src/deployment-metadata.ts b/packages/shared/src/deployment-metadata.ts index 7e1a1a86..e442013c 100644 --- a/packages/shared/src/deployment-metadata.ts +++ b/packages/shared/src/deployment-metadata.ts @@ -1,5 +1,10 @@ export type DeploymentTrigger = "github_release" | "main_push" | "manual" | "unknown"; -export type DeploymentMethod = "github_actions" | "cloudflare_workers_builds" | "local_cli" | "unknown"; +/** + * How code reached its runtime. `docker` describes a container deployment, while + * `github_actions`, `cloudflare_workers_builds` and `local_cli` describe the + * mechanism that ran the build or deployment. + */ +export type DeploymentMethod = "github_actions" | "cloudflare_workers_builds" | "local_cli" | "docker" | "unknown"; export type DeploymentMetadata = { trigger: DeploymentTrigger; @@ -24,7 +29,7 @@ export const resolveDeploymentTrigger = (trigger: string | undefined): Deploymen }; export const resolveDeploymentMethod = (method: string | undefined): DeploymentMethod => { - if (method === "github_actions" || method === "cloudflare_workers_builds" || method === "local_cli") { + if (method === "github_actions" || method === "cloudflare_workers_builds" || method === "local_cli" || method === "docker") { return method; } return "unknown"; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index dd5ba9fc..9892e793 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1875,6 +1875,8 @@ export const enUS = { unknown: "Unknown", }, containerImageSource: "Container image source", + imageBuiltAt: "Image built at", + imageBuildSource: "Image build source", containerImageSources: { "official-ghcr": "GitHub Container Registry (GHCR)", "official-cn-mirror": "Official mainland China mirror", @@ -1892,6 +1894,7 @@ export const enUS = { github_actions: "via GitHub Actions", cloudflare_workers_builds: "via Cloudflare Workers Builds", local_cli: "via local CLI", + docker: "via Docker", unknown: "Unknown method", }, browser: "Browser", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 3597580f..1039c44c 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -1864,6 +1864,8 @@ export const ja = { unknown: "不明", }, containerImageSource: "コンテナイメージの出典", + imageBuiltAt: "イメージのビルド時刻", + imageBuildSource: "イメージのビルド元", containerImageSources: { "official-ghcr": "GitHub Container Registry (GHCR)", "official-cn-mirror": "中国本土向け公式ミラー", @@ -1881,6 +1883,7 @@ export const ja = { github_actions: "GitHub Actions 経由", cloudflare_workers_builds: "Cloudflare Workers Builds 経由", local_cli: "ローカル CLI 経由", + docker: "Docker 経由", unknown: "不明な方法", }, browser: "ブラウザ", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index faa62ab4..4374bd3c 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -1864,6 +1864,8 @@ export const pl = { unknown: "Nieznana", }, containerImageSource: "Źródło obrazu kontenera", + imageBuiltAt: "Czas zbudowania obrazu", + imageBuildSource: "Źródło budowy obrazu", containerImageSources: { "official-ghcr": "GitHub Container Registry (GHCR)", "official-cn-mirror": "Oficjalny mirror dla Chin kontynentalnych", @@ -1881,6 +1883,7 @@ export const pl = { github_actions: "przez GitHub Actions", cloudflare_workers_builds: "przez Cloudflare Workers Builds", local_cli: "przez lokalne CLI", + docker: "przez Docker", unknown: "Nieznana metoda", }, browser: "Przeglądarka", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 2f1882aa..86ff33a1 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1875,6 +1875,8 @@ export const zhCN = { unknown: "未知", }, containerImageSource: "容器镜像来源", + imageBuiltAt: "镜像构建时间", + imageBuildSource: "镜像构建来源", containerImageSources: { "official-ghcr": "GitHub Container Registry(GHCR)", "official-cn-mirror": "中国大陆官方镜像", @@ -1892,6 +1894,7 @@ export const zhCN = { github_actions: "GitHub Actions 执行", cloudflare_workers_builds: "Cloudflare Workers Builds 执行", local_cli: "本地命令执行", + docker: "Docker 部署", unknown: "执行方式未知", }, browser: "浏览器", diff --git a/scripts/docker-release.test.mjs b/scripts/docker-release.test.mjs index 4142bef2..79b94881 100644 --- a/scripts/docker-release.test.mjs +++ b/scripts/docker-release.test.mjs @@ -98,6 +98,11 @@ describe("Docker release contract", () => { expect(dockerfile).toContain("HEALTHCHECK"); expect(dockerfile).toContain("ARG EDGE_EVER_BUILD_ID=unknown"); expect(dockerfile).toContain("EDGE_EVER_BUILD_ID=${EDGE_EVER_BUILD_ID}"); + for (const name of ["EDGE_EVER_BUILD_CREATED_AT", "EDGE_EVER_BUILD_TRIGGER", "EDGE_EVER_BUILD_METHOD"]) { + // Empty by default so self-built images report unknown build metadata. + expect(runtimeStage).toContain(`ARG ${name}=\n`); + expect(runtimeStage).toContain(`${name}=\${${name}}`); + } expect(runtimeStage).toContain('CMD ["bun", "scripts/self-hosted-server.js"]'); expect(readProjectFile("docs/deploy-docker.md")).toContain( 'Module not found "scripts/self-hosted-server.mjs"', @@ -189,8 +194,23 @@ describe("Docker release contract", () => { expect(workflow).toContain("test ! -e /app/apps/api"); expect(workflow).toContain("test ! -e /app/packages"); expect(workflow).toContain("curl --fail --silent --show-error http://127.0.0.1:8787/"); - expect(workflow).toContain('docker build --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}"'); + expect(workflow).toContain('docker build \\\n --build-arg EDGE_EVER_BUILD_ID="${GITHUB_SHA}"'); expect(workflow).toContain("EDGE_EVER_BUILD_ID=${{ github.sha }}"); + expect(workflow).toContain("EDGE_EVER_BUILD_CREATED_AT=${{ steps.image.outputs.created_at }}"); + expect(workflow).toContain("EDGE_EVER_BUILD_TRIGGER=${{ steps.image.outputs.build_trigger }}"); + expect(workflow).toContain("EDGE_EVER_BUILD_METHOD=github_actions"); + expect(workflow).toContain("org.opencontainers.image.created=${{ steps.image.outputs.created_at }}"); + // Draft releases are prepared by workflow_dispatch with release_tag; published only audits. + const resolveTags = workflow.slice(workflow.indexOf("- name: Resolve image tags"), workflow.indexOf("- name: Set up QEMU")); + const releaseBranch = resolveTags.slice(resolveTags.indexOf('if [[ -n "${RELEASE_TAG}" ]]'), resolveTags.indexOf(" else\n")); + expect(releaseBranch).toContain('echo "build_trigger=github_release"'); + expect(resolveTags).toContain('if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then\n build_trigger=main_push'); + expect(resolveTags).toContain("build_trigger=manual"); + expect(workflow).toContain("docker build --tag edgeever:ci-default ."); + expect(workflow).toContain('.buildProvenance == {"trigger": "main_push", "method": "github_actions"}'); + expect(workflow).toContain('.deployment == {"trigger": "manual", "method": "docker"}'); + expect(workflow).toContain('.build == "unknown" and .buildCreatedAt == null and .buildProvenance == {"trigger": "unknown", "method": "unknown"}'); + expect(workflow).toContain('.buildProvenance == {"trigger": $trigger, "method": "github_actions"}'); expect(workflow).not.toContain("TCR_IMAGE_NAME"); expect(workflow).not.toContain("TENCENT_TCR_USERNAME"); diff --git a/scripts/self-hosted-server.mjs b/scripts/self-hosted-server.mjs index 8ecbfbc5..aab95aa4 100644 --- a/scripts/self-hosted-server.mjs +++ b/scripts/self-hosted-server.mjs @@ -87,6 +87,9 @@ const env = { EDGE_EVER_CONTAINER_IMAGE: runtimeEnvironment.EDGE_EVER_CONTAINER_IMAGE, EDGE_EVER_DEPLOYMENT_TRIGGER: runtimeEnvironment.EDGE_EVER_DEPLOYMENT_TRIGGER, EDGE_EVER_DEPLOYMENT_METHOD: runtimeEnvironment.EDGE_EVER_DEPLOYMENT_METHOD, + EDGE_EVER_BUILD_CREATED_AT: runtimeEnvironment.EDGE_EVER_BUILD_CREATED_AT, + EDGE_EVER_BUILD_TRIGGER: runtimeEnvironment.EDGE_EVER_BUILD_TRIGGER, + EDGE_EVER_BUILD_METHOD: runtimeEnvironment.EDGE_EVER_BUILD_METHOD, EDGE_EVER_AUTH_PASSWORD: runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD, EDGE_EVER_AUTH_PASSWORD_HASH: runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD_HASH, EDGE_EVER_AUTH_PASSWORD_FALLBACK: runtimeEnvironment.EDGE_EVER_AUTH_PASSWORD_FALLBACK, diff --git a/tests/auth-fail-closed.test.ts b/tests/auth-fail-closed.test.ts index a942a63f..d56a1b19 100644 --- a/tests/auth-fail-closed.test.ts +++ b/tests/auth-fail-closed.test.ts @@ -101,6 +101,48 @@ describe("production authentication guard", () => { }); }); + test("keeps Cloudflare health free of image build metadata", async () => { + const response = await fetchApi("/api/health", { + DB: createDatabase({ userId: "owner" }), + RESOURCES: {}, + EDGE_EVER_AUTH_PASSWORD: "configured-secret", + EDGE_EVER_BUILD_CREATED_AT: "2026-01-02T03:04:05Z", + EDGE_EVER_BUILD_TRIGGER: "main_push", + EDGE_EVER_BUILD_METHOD: "github_actions", + }); + + expect(response.status).toBe(200); + const health = await response.json() as Record; + expect(health).not.toHaveProperty("buildCreatedAt"); + expect(health).not.toHaveProperty("buildProvenance"); + expect(health).not.toHaveProperty("containerImageSource"); + expect(health.deploymentVersionCreatedAt).toBeNull(); + }); + + test("reports self-hosted image build metadata separately from deployment metadata", async () => { + const response = await fetchApi("/api/health", { + DB: createDatabase({ userId: "owner" }), + RESOURCES: {}, + EDGE_EVER_AUTH_PASSWORD: "configured-secret", + EDGE_EVER_RUNTIME: "self-hosted-bun", + EDGE_EVER_BUILD_CREATED_AT: "2026-01-02T03:04:05Z", + EDGE_EVER_BUILD_TRIGGER: "github_release", + EDGE_EVER_BUILD_METHOD: "github_actions", + EDGE_EVER_DEPLOYMENT_TRIGGER: "manual", + EDGE_EVER_DEPLOYMENT_METHOD: "docker", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + runtime: "self-hosted-bun", + containerImageSource: "unknown", + buildCreatedAt: "2026-01-02T03:04:05.000Z", + buildProvenance: { trigger: "github_release", method: "github_actions" }, + deployment: { trigger: "manual", method: "docker" }, + deploymentVersionCreatedAt: null, + }); + }); + test("keeps anonymous share responses out of search indexes, caches and Referer headers", async () => { for (const path of ["/api/public/shares/not-a-token", "/api/public/notebook-shares/not-a-token", "/api/public/forms/not-a-token"]) { const response = await fetchApi(path, {