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 <