Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 20 additions & 36 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<ENV
MEET_VERSION=ci
MEET_PORT=3000
POSTGRES_PASSWORD=ci-$(openssl rand -hex 8)
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
KEYCLOAK_ISSUER=https://auth.winlab.tw/realms/winlab
KEYCLOAK_CLIENT_ID=ci
KEYCLOAK_CLIENT_SECRET=ci
LIVEKIT_URL=wss://livekit.example.com
LIVEKIT_API_KEY=ci
LIVEKIT_API_SECRET=ci-secret-ci-secret-ci-secret-ci
ENV
docker compose up -d --wait web
- name: Probe
run: |
docker compose logs migrate | grep -q "migrations applied"
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:3000/api/livekit/webhook -d '{}')" = 401
test "$(curl -s -o /dev/null -w "%{http_code}" localhost:3000/sign-in)" = 303
test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/)" = 307
curl -sf localhost:3000/.well-known/oauth-protected-resource/mcp | grep -q '"resource":"http://localhost:3000/mcp"'
curl -sf localhost:3000/.well-known/oauth-authorization-server/api/auth | grep -q '"client_id_metadata_document_supported":true'
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:3000/mcp -H 'content-type: application/json' -d '{}')" = 401
curl -s -D - -o /dev/null -X POST localhost:3000/mcp -H 'content-type: application/json' -d '{}' | grep -qi 'www-authenticate: Bearer resource_metadata='
- if: always()
run: docker compose logs
# Builds the images once, runs deploy/smoke.sh against them and pushes
# them as candidate-<tree>. promote makes them tree-<tree> once every job
# passed, and release ships only tree- images.
image:
uses: zyx1121/.github/.github/workflows/image.yml@main
permissions:
contents: read
packages: write
with:
images: '[{"name": "ghcr.io/nycu-winlab/meet"}, {"name": "ghcr.io/nycu-winlab/meet-summary", "context": "summary"}]'
smoke: bash deploy/smoke.sh

promote:
needs: [check, image]
uses: zyx1121/.github/.github/workflows/promote.yml@main
permissions:
contents: read
packages: write
with:
images: '["ghcr.io/nycu-winlab/meet", "ghcr.io/nycu-winlab/meet-summary"]'
98 changes: 12 additions & 86 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,96 +1,22 @@
name: release

# Ships only images ci passed: retags tree-<tree> as sha-<commit> 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
11 changes: 5 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
25 changes: 18 additions & 7 deletions components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +64,7 @@ function AppShell({
layout = "column",
corners,
footer = true,
build,
children,
}: {
breadcrumb?: Crumb[]
Expand All @@ -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()
Expand Down Expand Up @@ -145,7 +152,7 @@ function AppShell({

{footer && (
<div className="fixed bottom-6 left-6 z-50 flex items-center gap-4">
{corners?.bottomLeft ?? <BuildVersion />}
{corners?.bottomLeft ?? <BuildVersion build={build} />}
</div>
)}

Expand Down Expand Up @@ -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 <span className={cornerLinkClassName}>dev</span>
}
Expand Down Expand Up @@ -347,4 +357,5 @@ function CornerLink({
return <Link className={cn(cornerLinkClassName, className)} {...props} />
}

export type { Build }
export { AppShell, BuildVersion, CornerLink, CornerTip, cornerLinkClassName }
2 changes: 2 additions & 0 deletions components/meet-shell.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -32,6 +33,7 @@ export async function MeetShell({
...(page ? [page] : []),
]}
footer={footer}
build={runningBuild()}
corners={topRight ? { topRight } : undefined}
nav={
session
Expand Down
5 changes: 5 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions deploy/carrel-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions deploy/smoke.sh
Original file line number Diff line number Diff line change
@@ -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 <<ENV
MEET_VERSION=ci
MEET_PORT=3000
POSTGRES_PASSWORD=ci-$(openssl rand -hex 8)
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=$(openssl rand -base64 32)
KEYCLOAK_ISSUER=https://auth.winlab.tw/realms/winlab
KEYCLOAK_CLIENT_ID=ci
KEYCLOAK_CLIENT_SECRET=ci
LIVEKIT_URL=wss://livekit.example.com
LIVEKIT_API_KEY=ci
LIVEKIT_API_SECRET=ci-secret-ci-secret-ci-secret-ci
ENV
docker compose up -d --wait web

docker compose logs migrate | grep -q "migrations applied"
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:3000/api/livekit/webhook -d '{}')" = 401
test "$(curl -s -o /dev/null -w "%{http_code}" localhost:3000/sign-in)" = 303
test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/)" = 307
curl -sf localhost:3000/.well-known/oauth-protected-resource/mcp | grep -q '"resource":"http://localhost:3000/mcp"'
curl -sf localhost:3000/.well-known/oauth-authorization-server/api/auth | grep -q '"client_id_metadata_document_supported":true'
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST localhost:3000/mcp -H 'content-type: application/json' -d '{}')" = 401
curl -s -D - -o /dev/null -X POST localhost:3000/mcp -H 'content-type: application/json' -d '{}' | grep -qi 'www-authenticate: Bearer resource_metadata='
echo "smoke passed"
11 changes: 11 additions & 0 deletions lib/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Build } from "@/components/app-shell"

// The build running, for the version corner. The commit comes from
// MEET_VERSION (sha-<commit>) at run time, since merging only retags the image
// built on the pull request; the time and the CD run are baked into it.
export function runningBuild(): Build | undefined {
const sha = process.env.MEET_VERSION?.match(/^sha-([0-9a-f]{7,})$/)?.[1]
return sha
? { sha, time: process.env.BUILD_TIME, url: process.env.BUILD_URL }
: undefined
}
Loading