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
59 changes: 20 additions & 39 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,23 @@ jobs:
- run: bun run actions:check
- run: bun run build

# The image as deployed: compose up with Postgres, migrations, then probe
# sign-in, 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/gallery:ci .
- name: Start the stack
run: |
cat > .env <<ENV
GALLERY_VERSION=ci
GALLERY_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
NEXTCLOUD_URL=https://nextcloud.example.com
NEXTCLOUD_USER=ci
NEXTCLOUD_APP_PASSWORD=ci
ENV
docker compose up -d --wait
- name: Probe
run: |
docker compose logs migrate | grep -q "migrations applied"
docker compose exec -T web ffprobe -version > /dev/null
docker compose exec -T web node -e "require('sharp')"
test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/media/00000000-0000-0000-0000-000000000000/thumb)" = 307
test "$(curl -s -o /dev/null -w '%{http_code}' -X PUT localhost:3000/api/upload --data-binary x)" = 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/gallery"}, {"name": "ghcr.io/nycu-winlab/gallery-embed", "context": "embed"}]'
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/gallery", "ghcr.io/nycu-winlab/gallery-embed"]'
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/gallery
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

embed:
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/gallery-embed
tags: |
type=sha,prefix=sha-
type=raw,value=main
- uses: docker/build-push-action@v6
with:
context: embed
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=embed
cache-to: type=gha,mode=max,scope=embed

# 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/gallery.tar.gz" -C release .
- uses: NYCU-WinLab/carrel-deploy@v1
with:
tenant: winfra
app: gallery
artifact: ${{ runner.temp }}/gallery.tar.gz
with:
images: '["ghcr.io/nycu-winlab/gallery", "ghcr.io/nycu-winlab/gallery-embed"]'
tenant: winfra
app: gallery
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 @@ -25,6 +20,10 @@ ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 \
MEDIA_CACHE_DIR=/data/cache
# EXIF times carry no zone; the lab's cameras are 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 GALLERY_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 @@ -60,6 +63,7 @@ function AppShell({
account,
layout = "column",
corners,
build,
children,
}: {
breadcrumb?: Crumb[]
Expand All @@ -77,6 +81,9 @@ function AppShell({
>
>
layout?: "column" | "spotlight" | "wide"
/** 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 @@ -138,7 +145,7 @@ function AppShell({
</div>

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

<div className="fixed right-6 bottom-6 z-50 flex items-center gap-4">
Expand Down Expand Up @@ -301,12 +308,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 @@ -337,4 +347,5 @@ function CornerLink({
return <Link className={cn(cornerLinkClassName, className)} {...props} />
}

export type { Build }
export { AppShell, BuildVersion, CornerLink, CornerTip, cornerLinkClassName }
3 changes: 2 additions & 1 deletion components/gallery-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { GalleryCorners } from "@/components/gallery-corners"
import { LiveRefresh } from "@/components/live-refresh"
import { getSession } from "@/lib/auth/session"
import { runningBuild } from "@/lib/build"

// The gallery's corners: the lab and the page in the breadcrumb, the pages
// then search and upload in the top right (both open over the page), the
Expand Down Expand Up @@ -44,7 +45,7 @@ export async function GalleryShell({
</CornerLink>
</CornerTip>
)}
<BuildVersion />
<BuildVersion build={runningBuild()} />
</>
),
}}
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/gallery:${GALLERY_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://gallery:${POSTGRES_PASSWORD}@postgres:5432/gallery
Expand All @@ -46,6 +49,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

# Search by meaning (EmbeddingGemma 2 on CPU, about 3 GB of memory).
# Starts with COMPOSE_PROFILES=search; set EMBED_URL=http://embed:8000 to
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/gallery/compose.yaml.new
mv /opt/gallery/compose.yaml.new /opt/gallery/compose.yaml
fi
cd /opt/gallery

tmp=$(mktemp .env.XXXXXX)
Expand Down
34 changes: 34 additions & 0 deletions deploy/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# The image as deployed: compose up with Postgres, migrations, then probe
# sign-in, OAuth discovery and the MCP challenge.
# ci runs it with the image loaded as ghcr.io/nycu-winlab/gallery:ci.
set -euo pipefail
trap 'docker compose logs' EXIT

cat > .env <<ENV
GALLERY_VERSION=ci
GALLERY_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
NEXTCLOUD_URL=https://nextcloud.example.com
NEXTCLOUD_USER=ci
NEXTCLOUD_APP_PASSWORD=ci
ENV
docker compose up -d --wait

docker compose logs migrate | grep -q "migrations applied"
docker compose exec -T web ffprobe -version > /dev/null
docker compose exec -T web node -e "require('sharp')"
test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/media/00000000-0000-0000-0000-000000000000/thumb)" = 307
test "$(curl -s -o /dev/null -w '%{http_code}' -X PUT localhost:3000/api/upload --data-binary x)" = 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
// GALLERY_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.GALLERY_VERSION?.match(/^sha-([0-9a-f]{7,})$/)?.[1]
return sha
? { sha, time: process.env.BUILD_TIME, url: process.env.BUILD_URL }
: undefined
}
Loading