From 3faf185ee2bfe97419b80dc1a6bf6bb09601c73c Mon Sep 17 00:00:00 2001 From: Loki Date: Tue, 3 Nov 2026 16:49:01 +0800 Subject: [PATCH] ci: build, promote and ship through the shared workflows The image is built on the pull request and only retagged on merge, so the version corner reads the commit from the running version. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 56 +++++++------------- .github/workflows/release.yml | 98 +++++------------------------------ Dockerfile | 11 ++-- components/app-shell.tsx | 25 ++++++--- components/meet-shell.tsx | 2 + compose.yaml | 5 ++ deploy/carrel-start.sh | 6 +++ deploy/smoke.sh | 31 +++++++++++ lib/build.ts | 11 ++++ 9 files changed, 110 insertions(+), 135 deletions(-) create mode 100755 deploy/smoke.sh create mode 100644 lib/build.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7899e3c..049ed73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,39 +20,23 @@ jobs: - run: bun run build - run: cd summary && bun install --frozen-lockfile && bun run typecheck - # The image as deployed: compose up with Postgres, migrations, then probe - # sign-in, the webhook, OAuth discovery and the MCP challenge. - smoke: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build the image under the tag compose uses - run: docker build -t ghcr.io/nycu-winlab/meet:ci . - - name: Start the stack - run: | - cat > .env < as sha- and main, +# then carrel-deployd on the Carrel machine runs deploy/carrel-start.sh and +# rolls back if web is unhealthy. When ci has not passed these files yet, +# it waits for ci on main inside this run. on: push: branches: [main] -concurrency: - group: release-${{ github.ref }} - -permissions: - contents: read - packages: write - jobs: - image: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - id: build - run: | - echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - echo "time=$(TZ=Asia/Taipei date '+%Y-%m-%d %H:%M')" >> "$GITHUB_OUTPUT" - - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/nycu-winlab/meet - tags: | - type=sha,prefix=sha- - type=raw,value=main - - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - build-args: | - BUILD_SHA=${{ steps.build.outputs.sha }} - BUILD_TIME=${{ steps.build.outputs.time }} - BUILD_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - summary: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/nycu-winlab/meet-summary - tags: | - type=sha,prefix=sha- - type=raw,value=main - - uses: docker/build-push-action@v6 - with: - context: summary - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=summary - cache-to: type=gha,mode=max,scope=summary - - # Ships the new tag to Carrel: carrel-deployd on the machine runs - # deploy/carrel-start.sh and rolls back if web is unhealthy. - deploy: - needs: image - runs-on: ubuntu-latest + ship: + uses: zyx1121/.github/.github/workflows/ship.yml@main permissions: contents: read + packages: write + actions: read id-token: write - steps: - - uses: actions/checkout@v4 - - run: | - mkdir release - cp deploy/carrel-start.sh release/ - echo "sha-${GITHUB_SHA::7}" > release/VERSION - tar czf "$RUNNER_TEMP/meet.tar.gz" -C release . - - uses: NYCU-WinLab/carrel-deploy@v1 - with: - tenant: winfra - app: meet - artifact: ${{ runner.temp }}/meet.tar.gz + with: + images: '["ghcr.io/nycu-winlab/meet", "ghcr.io/nycu-winlab/meet-summary"]' + tenant: winfra + app: meet diff --git a/Dockerfile b/Dockerfile index 15009a2..58e602c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,12 +4,7 @@ WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . -# The build corner shows the commit, links its CD run and tips the time. -ARG BUILD_SHA BUILD_TIME BUILD_URL -ENV NEXT_PUBLIC_BUILD_SHA=$BUILD_SHA \ - NEXT_PUBLIC_BUILD_TIME=$BUILD_TIME \ - NEXT_PUBLIC_BUILD_URL=$BUILD_URL \ - NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_TELEMETRY_DISABLED=1 RUN bun run build \ && bun build scripts/migrate.ts --target node --external pg-native --outfile migrate.mjs @@ -18,6 +13,10 @@ WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 # The lab is in Taipei. ENV TZ=Asia/Taipei +# The build corner tips the time and links the run. Last, so a new run +# reuses every layer above; the commit comes from MEET_VERSION at run time. +ARG BUILD_TIME BUILD_URL +ENV BUILD_TIME=$BUILD_TIME BUILD_URL=$BUILD_URL COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 58be507..66e8ef3 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -33,6 +33,9 @@ type NavItem = { label: string; href: string; tip: string } // name, then items such as 登出 (DropdownMenuItem children). type AccountMenu = { label: string; tip: string; menu: React.ReactNode } +// The build running: the short commit, the build time and the CD run. +type Build = { sha?: string; time?: string; url?: string } + // The chrome of every WinLab app is its four corners. By default each corner // has one job, filled from the props below; an app that needs something else // in a corner passes it in `corners`, built from CornerLink and CornerTip so @@ -61,6 +64,7 @@ function AppShell({ layout = "column", corners, footer = true, + build, children, }: { breadcrumb?: Crumb[] @@ -81,6 +85,9 @@ function AppShell({ /** false drops both bottom corners: a page that fills the screen, such as * a meeting, keeps its own controls there. */ footer?: boolean + /** The build running, read on the server at run time (see BuildVersion). + * Without it the corner falls back to NEXT_PUBLIC_BUILD_*. */ + build?: Build children: React.ReactNode }) { const pathname = usePathname() @@ -145,7 +152,7 @@ function AppShell({ {footer && (
- {corners?.bottomLeft ?? } + {corners?.bottomLeft ?? }
)} @@ -311,12 +318,15 @@ const cornerLinkClassName = "relative inline-flex h-6 items-center text-muted-foreground transition-colors duration-state outline-none after:absolute after:-inset-2 hover:text-foreground focus-visible:text-foreground" // Which build is running: the short commit, linking to the CD run that built -// it, with the build time as its tip. CD sets these at build time; without -// them (local dev) the corner says "dev". -function BuildVersion() { - const sha = process.env.NEXT_PUBLIC_BUILD_SHA - const time = process.env.NEXT_PUBLIC_BUILD_TIME - const url = process.env.NEXT_PUBLIC_BUILD_URL +// it, with the build time as its tip. CI builds the image on the pull +// request and merging only retags it, so the commit is not known at build +// time: a server app reads it at run time and passes `build`. A static site +// builds with NEXT_PUBLIC_BUILD_* instead. Without a commit (local dev) the +// corner says "dev". +function BuildVersion({ build }: { build?: Build }) { + const sha = build?.sha ?? process.env.NEXT_PUBLIC_BUILD_SHA + const time = build?.time ?? process.env.NEXT_PUBLIC_BUILD_TIME + const url = build?.url ?? process.env.NEXT_PUBLIC_BUILD_URL if (!sha) { return dev } @@ -347,4 +357,5 @@ function CornerLink({ return } +export type { Build } export { AppShell, BuildVersion, CornerLink, CornerTip, cornerLinkClassName } diff --git a/components/meet-shell.tsx b/components/meet-shell.tsx index cf591c9..a13ac35 100644 --- a/components/meet-shell.tsx +++ b/components/meet-shell.tsx @@ -1,6 +1,7 @@ import { AppShell } from "@/components/app-shell" import { LiveRefresh } from "@/components/live-refresh" import { getSession } from "@/lib/auth/session" +import { runningBuild } from "@/lib/build" // Meet's corners: the lab and the page in the breadcrumb, the dashboard in // the top right (登入 when signed out), the build in the bottom left. @@ -32,6 +33,7 @@ export async function MeetShell({ ...(page ? [page] : []), ]} footer={footer} + build={runningBuild()} corners={topRight ? { topRight } : undefined} nav={ session diff --git a/compose.yaml b/compose.yaml index 8d3f9cb..576651a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -28,6 +28,9 @@ services: web: image: ghcr.io/nycu-winlab/meet:${MEET_VERSION:-main} restart: unless-stopped + init: true + # Next waits for open event streams on SIGTERM; cut them short. + stop_grace_period: 3s env_file: .env environment: DATABASE_URL: postgres://meet:${POSTGRES_PASSWORD}@postgres:5432/meet @@ -44,6 +47,8 @@ services: - fetch('http://localhost:3000/.well-known/oauth-protected-resource').then(r=>process.exit(r.ok?0:1),()=>process.exit(1)) interval: 10s retries: 10 + start_period: 60s + start_interval: 1s # Running summary: joins every room as a hidden participant, sends each # microphone to the lab's transcribe service in 60 s chunks (at most 2 jobs in diff --git a/deploy/carrel-start.sh b/deploy/carrel-start.sh index 451b32e..014f8a5 100755 --- a/deploy/carrel-start.sh +++ b/deploy/carrel-start.sh @@ -6,6 +6,12 @@ set -eu version=$(cat VERSION) +# The release carries the stack's compose.yaml, so the repo is its source. +# An older release (a rollback) has none and keeps the one in place. +if [ -f compose.yaml ]; then + cp compose.yaml /opt/meet/compose.yaml.new + mv /opt/meet/compose.yaml.new /opt/meet/compose.yaml +fi cd /opt/meet tmp=$(mktemp .env.XXXXXX) diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100755 index 0000000..3a6bcfb --- /dev/null +++ b/deploy/smoke.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# The image as deployed: compose up with Postgres, migrations, then probe +# sign-in, the webhook, OAuth discovery and the MCP challenge. +# ci runs it with the image loaded as ghcr.io/nycu-winlab/meet:ci. +set -euo pipefail +trap 'docker compose logs' EXIT + +cat > .env <