diff --git a/.github/workflows/alint.yml b/.github/workflows/alint.yml new file mode 100644 index 0000000..46a5947 --- /dev/null +++ b/.github/workflows/alint.yml @@ -0,0 +1,6 @@ +name: alint +on: + pull_request: +jobs: + alint: + uses: zyx1121/.github/.github/workflows/alint.yml@main diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a211b17..586fe74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,34 +21,13 @@ jobs: - run: bun run format:check - run: bun run build - smoke: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - - name: Start the image with compose - run: docker compose up -d --build --wait - - name: Serve the site and registry - run: | - curl -sf localhost:8080/ | grep -q "WinLab UI" - curl -sf localhost:8080/button | grep -q "npx shadcn@latest add @winlab/button" - curl -sf localhost:8080/colors | grep -q "primary" - curl -sf localhost:8080/r/registry.json | grep -q '"name": "winlab"' - curl -sfI localhost:8080/r/base.json | grep -qi "content-type: application/json" - test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/missing)" = 404 - curl -s localhost:8080/missing | grep -q '找不到這個頁面' - test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/404)" = 404 - test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/404.html)" = 404 - curl -s localhost:8080/404 | grep -q '找不到這個頁面' - ! curl -sI localhost:8080/ | grep -qi "server: nginx/" - # Install the base into a fresh project, the same way a WinLab app would. # Items depend on each other by absolute URL; point those at the files - # inside this image so unreleased items resolve. + # just built so unreleased items resolve. - name: Smoke test shadcn init run: | mkdir -p "$RUNNER_TEMP/registry" - docker compose cp web:/usr/share/nginx/html/r "$RUNNER_TEMP/registry/r" + cp -r out/r "$RUNNER_TEMP/registry/r" sed -i 's#https://ui.winlab.tw/r/\([a-z-]*\)\.json#http://localhost:8099/r/\1.json#g' "$RUNNER_TEMP"/registry/r/*.json (cd "$RUNNER_TEMP/registry" && python3 -m http.server 8099 > /dev/null 2>&1 &) for i in $(seq 1 10); do curl -sf localhost:8099/r/base.json > /dev/null && break; sleep 1; done @@ -132,5 +111,24 @@ jobs: items=$(python3 -c 'import json; print(" ".join(f"http://localhost:8099/r/{i["name"]}.json" for i in json.load(open("../../registry/r/registry.json"))["items"] if i["type"] in ("registry:ui", "registry:component", "registry:block")))') bunx shadcn@latest add $items -y -o bun run build - - if: always() - run: docker compose logs + + # Builds the image once, runs deploy/smoke.sh against it and pushes it + # as candidate-. promote makes it tree- once every job passed, + # and release ships only a tree- image. + image: + uses: zyx1121/.github/.github/workflows/image.yml@main + permissions: + contents: read + packages: write + with: + images: '[{"name": "ghcr.io/nycu-winlab/ui"}]' + 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/ui"]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bacd3eb..0967b87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,69 +1,22 @@ name: release +# Ships only an image 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/ui - 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 - - # 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/ui.tar.gz" -C release . - - uses: NYCU-WinLab/carrel-deploy@v1 - with: - tenant: winfra - app: ui - artifact: ${{ runner.temp }}/ui.tar.gz + with: + images: '["ghcr.io/nycu-winlab/ui"]' + tenant: winfra + app: ui diff --git a/DESIGN.md b/DESIGN.md index 034eeba..ccbd817 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -15,7 +15,7 @@ Every WinLab app is framed by four fixed corners, 24px in from the viewport. `ap - Actions are not navigation: a "新增訂單" button goes in the page header next to the content it changes, never in a corner. - There is no theme toggle: the theme follows the system (`theme-provider`). D switches themes, outside text fields, so a page can be checked in both. -- CD passes `BUILD_SHA`, `BUILD_TIME` and `BUILD_URL` to the image build (`NEXT_PUBLIC_BUILD_*`); without them the version reads `dev`. +- CI builds the image on the pull request and merging only retags it, so the commit is read at run time: a server app passes `build` to `AppShell` from its `_VERSION` (`sha-`), with `BUILD_TIME` and `BUILD_URL` baked into the image. A static site builds with `NEXT_PUBLIC_BUILD_*`. Without a commit the version reads `dev`. - A corner with nothing to show stays empty. - Every corner item has a tip (`tip` is required in `app-shell`, so a missing one fails the type check) that says what its label leaves out (where a link goes, what a page holds, who owns the site). A tip never repeats the label. Tips open at once, toward the page, lined up with the corner's outer edge; touch never opens them, so nothing depends on one. - Corner text is `text-body` in `muted-foreground`, links turn `foreground` on hover and when current. diff --git a/Dockerfile b/Dockerfile index d7a381f..7641028 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,16 +3,19 @@ WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . -# CD passes the build identity; app-shell shows it in the bottom-left corner. -ARG BUILD_SHA= +# app-shell shows the build in the bottom-left corner. The image is built on +# the pull request and only retagged on merge, so the commit is a +# placeholder that nginx replaces with UI_VERSION at run time. ARG BUILD_TIME= ARG BUILD_URL= -ENV NEXT_PUBLIC_BUILD_SHA=$BUILD_SHA \ +ENV NEXT_PUBLIC_BUILD_SHA=__BUILD_SHA__ \ NEXT_PUBLIC_BUILD_TIME=$BUILD_TIME \ NEXT_PUBLIC_BUILD_URL=$BUILD_URL RUN bun run build FROM nginxinc/nginx-unprivileged:1.29-alpine -COPY nginx.conf /etc/nginx/conf.d/default.conf +# The image's entrypoint fills ${UI_VERSION} in templates/ into conf.d/. +COPY nginx.conf /etc/nginx/templates/default.conf.template +ENV UI_VERSION=dev COPY --from=build /app/out /usr/share/nginx/html EXPOSE 8080 diff --git a/README.md b/README.md index 524a34c..2315391 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Moving an existing shadcn (Radix) app over: see [MIGRATING.md](MIGRATING.md) for | `number-ticker` | `registry:ui` | A whole number whose digits roll to their new value | | `progress` | `registry:ui` | How much of a whole is used: an 8px muted track with a primary fill | | `chart` | `registry:ui` | Recharts in WinLab colors: series take `primary`, `muted-foreground` or a state color; inverted tooltip and a legend | -| `app-shell` | `registry:block` | The four corners of every WinLab app (pages fold into a menu on narrow screens; the account can be a link or a menu) and the page layouts `column`, `spotlight` and `wide`; set `NEXT_PUBLIC_BUILD_SHA`, `_TIME` and `_URL` at build for the version corner | +| `app-shell` | `registry:block` | The four corners of every WinLab app (pages fold into a menu on narrow screens; the account can be a link or a menu) and the page layouts `column`, `spotlight` and `wide`; pass `build` (read at run time) for the version corner | | `theme-provider` | `registry:block` | Wrap the app in it: the theme follows the system (no toggle), D switches themes while checking a page; give `` `suppressHydrationWarning` | | `confirm-dialog` | `registry:block` | Asks before an action runs; the confirm button names the verb, both buttons lock while it runs, and it closes only when the action finishes | | `form-dialog` | `registry:block` | A short form in a dialog; `FormField` puts a label over its control, fields and buttons lock while it submits, and it closes only when the submit finishes | diff --git a/compose.yaml b/compose.yaml index 9cf1f23..9d78d8e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,9 +5,14 @@ services: ports: - "${UI_BIND:-127.0.0.1:8080}:8080" restart: unless-stopped + init: true + stop_grace_period: 3s + environment: + UI_VERSION: ${UI_VERSION:-dev} healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/r/base.json"] interval: 30s timeout: 3s retries: 3 - start_period: 5s + start_period: 30s + start_interval: 1s diff --git a/deploy/carrel-start.sh b/deploy/carrel-start.sh index b495415..198001d 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/winlab-ui/compose.yaml.new + mv /opt/winlab-ui/compose.yaml.new /opt/winlab-ui/compose.yaml +fi cd /opt/winlab-ui tmp=$(mktemp .env.XXXXXX) diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100755 index 0000000..64d1f7a --- /dev/null +++ b/deploy/smoke.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# The image as deployed: serve the site and the registry, and fill the +# commit into the version corner at run time. ci runs it with the image +# loaded as ghcr.io/nycu-winlab/ui:ci. +set -euo pipefail +trap 'docker compose logs' EXIT + +printf 'UI_VERSION=ci\n' > .env +docker compose up -d --no-build --wait + +curl -sf localhost:8080/ | grep -q "WinLab UI" +curl -sf localhost:8080/button | grep -q "npx shadcn@latest add @winlab/button" +curl -sf localhost:8080/colors | grep -q "primary" +curl -sf localhost:8080/r/registry.json | grep -q '"name": "winlab"' +curl -sfI localhost:8080/r/base.json | grep -qi "content-type: application/json" +test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/missing)" = 404 +curl -s localhost:8080/missing | grep -q '找不到這個頁面' +test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/404)" = 404 +test "$(curl -s -o /dev/null -w '%{http_code}' localhost:8080/404.html)" = 404 +curl -s localhost:8080/404 | grep -q '找不到這個頁面' +! curl -sI localhost:8080/ | grep -qi "server: nginx/" + +# Not a sha- version: the corner reads dev, and no placeholder is left. +curl -sf localhost:8080/ | grep -q '>dev<' +! curl -sf localhost:8080/ | grep -q __BUILD_SHA__ + +# A release: the corner reads the commit, in the page and in its scripts. +docker compose run --rm -d -e UI_VERSION=sha-abc1234 -p 127.0.0.1:8081:8080 web >/dev/null +for i in $(seq 1 20); do curl -sf localhost:8081/ >/dev/null && break; sleep 0.5; done +curl -sf localhost:8081/ | grep -q '>abc1234<' +! curl -sf localhost:8081/ | grep -q __BUILD_SHA__ +for js in $(curl -sf localhost:8081/ | grep -o '/_next/static/[^"]*\.js' | sort -u); do + ! curl -sf "localhost:8081$js" | grep -q __BUILD_SHA__ +done +echo "smoke passed" diff --git a/nginx.conf b/nginx.conf index f80a139..d0d263d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,8 +1,19 @@ +# The commit in the version corner: UI_VERSION is sha- on Carrel, +# and anything else (a local build) reads dev. +map "${UI_VERSION}" $build_sha { + ~^sha-(?[0-9a-f]+)$ $commit; + default dev; +} + server { listen 8080; server_tokens off; root /usr/share/nginx/html; + sub_filter __BUILD_SHA__ $build_sha; + sub_filter_once off; + sub_filter_types application/javascript text/javascript text/plain text/x-component; + location / { try_files $uri $uri.html $uri/ =404; } diff --git a/registry/winlab/blocks/app-shell/app-shell.tsx b/registry/winlab/blocks/app-shell/app-shell.tsx index 3933266..b59d0ac 100644 --- a/registry/winlab/blocks/app-shell/app-shell.tsx +++ b/registry/winlab/blocks/app-shell/app-shell.tsx @@ -32,6 +32,8 @@ type NavItem = { label: string; href: string; tip: string } // Signed in, the account can open a menu instead of a page: the member's // 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 @@ -60,6 +62,7 @@ function AppShell({ account, layout = "column", corners, + build, children, }: { breadcrumb?: Crumb[] @@ -77,6 +80,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 +144,7 @@ function AppShell({
- {corners?.bottomLeft ?? } + {corners?.bottomLeft ?? }
@@ -301,12 +307,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 +346,5 @@ function CornerLink({ return } +export type { Build } export { AppShell, BuildVersion, CornerLink, CornerTip, cornerLinkClassName }