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
6 changes: 6 additions & 0 deletions .github/workflows/alint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: alint
on:
pull_request:
jobs:
alint:
uses: zyx1121/.github/.github/workflows/alint.yml@main
48 changes: 23 additions & 25 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<tree>. promote makes it tree-<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"]'
71 changes: 12 additions & 59 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,69 +1,22 @@
name: release

# Ships only an image 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/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
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<APP>_VERSION` (`sha-<commit>`), 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.
Expand Down
11 changes: 7 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` `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 |
Expand Down
7 changes: 6 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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/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)
Expand Down
35 changes: 35 additions & 0 deletions deploy/smoke.sh
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
# The commit in the version corner: UI_VERSION is sha-<commit> on Carrel,
# and anything else (a local build) reads dev.
map "${UI_VERSION}" $build_sha {
~^sha-(?<commit>[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;
}
Expand Down
24 changes: 17 additions & 7 deletions registry/winlab/blocks/app-shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +62,7 @@ function AppShell({
account,
layout = "column",
corners,
build,
children,
}: {
breadcrumb?: Crumb[]
Expand All @@ -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()
Expand Down Expand Up @@ -138,7 +144,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 +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 <span className={cornerLinkClassName}>dev</span>
}
Expand Down Expand Up @@ -337,4 +346,5 @@ function CornerLink({
return <Link className={cn(cornerLinkClassName, className)} {...props} />
}

export type { Build }
export { AppShell, BuildVersion, CornerLink, CornerTip, cornerLinkClassName }
Loading