feat(canvas): add /buildinfo endpoint exposing the build SHA (core#2235) #2253

Merged
hongming merged 1 commits from feat/core2235-canvas-buildinfo into main 2026-06-04 21:25:45 +00:00
5 changed files with 67 additions and 12 deletions
@@ -208,6 +208,11 @@ jobs:
build-args: |
NEXT_PUBLIC_PLATFORM_URL=${{ steps.build_args.outputs.platform_url }}
NEXT_PUBLIC_WS_URL=${{ steps.build_args.outputs.ws_url }}
# Bake the merge SHA into the image so /api/buildinfo reports the
# served canvas SHA (core#2235). Mirrors how the platform image
# surfaces GIT_SHA at /buildinfo. Full 40-char SHA (not the
# 7-char tag) so the fleet redeploy verification can match exactly.
BUILD_SHA=${{ github.sha }}
# Ordered deploy (core#2226): the build job pushes the immutable
# per-commit tag + the build-tracking staging-latest + the legacy
# back-compat :sha-<sha> tag. It does NOT push :latest — :latest is
+11
View File
@@ -24,6 +24,17 @@ COPY --from=builder /app/public ./public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Git SHA the image was built from, surfaced at /api/buildinfo so canvas
# deploys are verifiable by the served SHA the same way workspace-server's
# /buildinfo is (core#2235). Wired from `${{ github.sha }}` in
# publish-canvas-image.yml. Server-only (not NEXT_PUBLIC_) — the route
# handler reads it at runtime on the standalone Node server, so it stays
# out of the client bundle. Set on the final stage (not the builder) so it
# lives in the runtime env that force-dynamic reads per request. Default
# "dev" matches the route + workspace-server sentinel: an unwired build
# fails the SHA comparison closed instead of looking deployed.
ARG BUILD_SHA=dev
ENV BUILD_SHA=$BUILD_SHA
# Non-root runtime — use addgroup/adduser without fixed GID/UID to avoid conflicts with base image
RUN addgroup canvas 2>/dev/null || true && adduser -G canvas -s /bin/sh -D canvas 2>/dev/null || true
USER canvas
@@ -1,12 +1,17 @@
/**
* Canvas /api/buildinfo — version-display endpoint mirroring
* workspace-server's /buildinfo. Lets `curl <url>/api/buildinfo`
* confirm which git SHA is live on a canvas deployment.
* confirm which git SHA is live on a canvas deployment (core#2235).
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { GET } from "../route";
const ENV_KEYS = ["VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_REF", "VERCEL_ENV"];
const ENV_KEYS = [
"BUILD_SHA",
"VERCEL_GIT_COMMIT_SHA",
"VERCEL_GIT_COMMIT_REF",
"VERCEL_ENV",
];
describe("GET /api/buildinfo", () => {
let saved: Record<string, string | undefined>;
@@ -23,13 +28,24 @@ describe("GET /api/buildinfo", () => {
}
});
it("returns dev sentinel when Vercel env vars are unset", async () => {
it("returns dev sentinel when no SHA source is set", async () => {
const res = await GET();
const body = await res.json();
expect(body).toEqual({ git_sha: "dev", git_ref: "", vercel_env: "local" });
});
it("reports the SHA Vercel injected at build time", async () => {
it("reports BUILD_SHA baked into the Docker image (fleet deploy path)", async () => {
// BUILD_SHA is the authoritative source for the ECR-image fleet deploy,
// which never runs on Vercel. It must win even when a Vercel var is also
// present in the environment.
process.env.BUILD_SHA = "deadbeefcafe";
process.env.VERCEL_GIT_COMMIT_SHA = "should-not-win";
const res = await GET();
const body = await res.json();
expect(body.git_sha).toBe("deadbeefcafe");
});
it("falls back to the SHA Vercel injected when BUILD_SHA is unset", async () => {
process.env.VERCEL_GIT_COMMIT_SHA = "abc1234567890";
process.env.VERCEL_GIT_COMMIT_REF = "main";
process.env.VERCEL_ENV = "production";
+27 -8
View File
@@ -1,17 +1,36 @@
import { NextResponse } from "next/server";
// Mirror of workspace-server's GET /buildinfo (PR #2398). Lets a developer
// confirm which git SHA is live on a canvas deployment with the same
// `curl <url>/buildinfo` flow they use against tenant workspaces.
// or the fleet redeploy workflow confirm which git SHA is live on a canvas
// deployment with the same `curl <url>/api/buildinfo` flow used against
// tenant workspaces (core#2235; cross-ref core#2226).
//
// Vercel injects VERCEL_GIT_COMMIT_SHA / _REF / VERCEL_ENV at build time
// from the deploying commit; outside Vercel (local `next dev`, harness)
// these are unset and the endpoint reports `git_sha: "dev"`. Same sentinel
// the workspace-server uses pre-ldflags-injection so both surfaces speak
// the same vocabulary.
// SHA source, in priority order:
// 1. BUILD_SHA — server-only env baked into the canvas Docker image at
// build time (Dockerfile `ARG BUILD_SHA` → `ENV BUILD_SHA`, wired
// from `${{ github.sha }}` in publish-canvas-image.yml). This is the
// authoritative source for the fleet's ECR-image deploy path, which
// does NOT run on Vercel. Read server-side here (App Router route
// handler runs on the standalone Node server, `output: "standalone"`),
// so it is intentionally NOT a NEXT_PUBLIC_ var — keeping it out of
// the client bundle.
// 2. VERCEL_GIT_COMMIT_SHA — Vercel injects this at build time when the
// canvas is deployed via Vercel rather than the Docker image.
// 3. "dev" — local `next dev` / test harness, where neither is set. Same
// sentinel workspace-server uses pre-ldflags-injection, so both
// surfaces speak the same vocabulary and an unconfigured deploy
// fails the SHA comparison closed instead of round-tripping "".
//
// force-dynamic so the response is evaluated at request time against the
// runtime env of the standalone server (where ENV BUILD_SHA lives), not
// frozen into a static asset at `next build`.
export const dynamic = "force-dynamic";
export async function GET() {
const sha =
process.env.BUILD_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "dev";
return NextResponse.json({
git_sha: process.env.VERCEL_GIT_COMMIT_SHA ?? "dev",
git_sha: sha,
git_ref: process.env.VERCEL_GIT_COMMIT_REF ?? "",
vercel_env: process.env.VERCEL_ENV ?? "local",
});
+4
View File
@@ -188,6 +188,10 @@ services:
NEXT_PUBLIC_PLATFORM_URL: ${NEXT_PUBLIC_PLATFORM_URL:-http://localhost:${PLATFORM_PUBLISH_PORT:-8080}}
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:${PLATFORM_PUBLISH_PORT:-8080}/ws}
NEXT_PUBLIC_ADMIN_TOKEN: ${ADMIN_TOKEN:-}
# SHA surfaced at /api/buildinfo (core#2235). CI passes the real merge
# SHA via the publish-canvas-image workflow build-args; local compose
# builds default to "dev" (the route's unwired sentinel).
BUILD_SHA: ${BUILD_SHA:-dev}
depends_on:
platform:
condition: service_healthy