From 3cf80781dfaf725123217223aaaa36d45b7f266b Mon Sep 17 00:00:00 2001 From: Loki Date: Tue, 3 Nov 2026 16:48:56 +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 | 59 +++++++-------------- .github/workflows/release.yml | 98 +++++------------------------------ Dockerfile | 11 ++-- components/app-shell.tsx | 25 ++++++--- components/gallery-shell.tsx | 3 +- compose.yaml | 5 ++ deploy/carrel-start.sh | 6 +++ deploy/smoke.sh | 34 ++++++++++++ lib/build.ts | 11 ++++ 9 files changed, 113 insertions(+), 139 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 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 +}