diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd2d245..b27155c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 < /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-. promote makes them 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"]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb8b6a2..468b10c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,96 +1,22 @@ name: release +# Ships only images ci passed: retags tree- 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/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 diff --git a/Dockerfile b/Dockerfile index b998436..37e8b2f 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 @@ -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 diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 809001b..33a4b73 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 @@ -60,6 +63,7 @@ function AppShell({ account, layout = "column", corners, + build, children, }: { breadcrumb?: Crumb[] @@ -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() @@ -138,7 +145,7 @@ function AppShell({
- {corners?.bottomLeft ?? } + {corners?.bottomLeft ?? }
@@ -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 dev } @@ -337,4 +347,5 @@ function CornerLink({ return } +export type { Build } export { AppShell, BuildVersion, CornerLink, CornerTip, cornerLinkClassName } diff --git a/components/gallery-shell.tsx b/components/gallery-shell.tsx index c043ca9..c823c7d 100644 --- a/components/gallery-shell.tsx +++ b/components/gallery-shell.tsx @@ -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 @@ -44,7 +45,7 @@ export async function GalleryShell({ )} - + ), }} diff --git a/compose.yaml b/compose.yaml index eb40bb9..be9da9b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 @@ -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 diff --git a/deploy/carrel-start.sh b/deploy/carrel-start.sh index 32ed897..cca51ae 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/gallery/compose.yaml.new + mv /opt/gallery/compose.yaml.new /opt/gallery/compose.yaml +fi cd /opt/gallery tmp=$(mktemp .env.XXXXXX) diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100755 index 0000000..54da878 --- /dev/null +++ b/deploy/smoke.sh @@ -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 < /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" diff --git a/lib/build.ts b/lib/build.ts new file mode 100644 index 0000000..fa2ace1 --- /dev/null +++ b/lib/build.ts @@ -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-) 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 +}