diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..24e7560 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,132 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# A new push supersedes the one before it on the same ref. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege by default; jobs opt into more where they need it. +permissions: + contents: read + +jobs: + check: + name: Typecheck, test, feed + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + + - run: bun install --frozen-lockfile + + - name: Typecheck + run: bun run typecheck + + - name: Test + # Offline by design: no fixture is re-fetched, so a red run always means + # the code changed, never that a source was unreachable. + run: bun test + + - name: Build feed + run: bun run build:feed + + - name: Feed sanity + # A source that quietly stops yielding events is the failure mode a + # parser-only pipeline is most prone to, and nothing else surfaces it. + run: | + bun -e ' + const feed = await Bun.file("public/data/events.v1.json").json(); + const games = new Set(feed.events.map((e) => e.game)); + console.log(`${feed.events.length} events across ${games.size} games`); + if (feed.events.length < 20) { + throw new Error(`feed collapsed to ${feed.events.length} events`); + } + if (feed.events.some((e) => !e.startsAt)) { + throw new Error("events without a start date"); + } + ' + + build: + name: Build site + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3" + - run: bun install --frozen-lockfile + - name: Build + # Pages serves from //, so the app is built with a matching base + # href. Built at the domain root it would 404 on every asset. + env: + BASE_PATH: /${{ github.event.repository.name }}/ + run: bun run build + - uses: actions/upload-artifact@v4 + with: + name: site + path: public/ + retention-days: 7 + + image: + name: Container image + runs-on: ubuntu-latest + needs: check + # Pushing an image for every pull request fills the registry; do it where + # the artefact could actually be deployed. + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + permissions: + contents: read + packages: write + 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 }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + pages: + name: Deploy to Pages + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/download-artifact@v4 + with: + name: site + path: public + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: public + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 183acd9..bd59412 100644 --- a/README.md +++ b/README.md @@ -148,10 +148,25 @@ so iteration never re-fetches. Every event links back to its source. ## CI -`.gitlab-ci.yml` runs typecheck, tests and a feed sanity check on every push, then builds and -publishes a container image from the default branch. The feed job fails if the event count collapses -— a source that quietly stops yielding events is the failure mode a parser-only pipeline is most -prone to, and nothing else surfaces it. +Both `.github/workflows/ci.yml` and `.gitlab-ci.yml` run the same gates on every push — typecheck, +tests, and a feed sanity check — then build and publish a container image from the default branch. +GitHub Actions additionally deploys to Pages. + +The feed job fails if the event count collapses. A source that quietly stops yielding events is the +failure mode a parser-only pipeline is most prone to, and nothing else would surface it. Everything +runs offline against checked-in fixtures, so a red pipeline always means the code changed rather +than a wiki being down. + +### Hosting under a subpath + +Assets resolve against a `` that the build substitutes, so the app works at a domain root +and under a subpath alike: + +```bash +BASE_PATH=/gacha-event-tracker/ bun run build +``` + +The Pages job sets this automatically. Without it, a subpath deploy 404s on every asset. ## Licence diff --git a/index.html b/index.html index 7cc7d69..5e7255e 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + Event Clock — gacha events by what expires next - - - - + + + +
- + diff --git a/package.json b/package.json index f7be3be..14e25a7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build:js": "bun build src/client/main.tsx --outfile public/main.js --minify", "build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:static", "dev": "bun run build && bun run serve.ts", - "build:static": "cp index.html public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg", + "build:static": "mkdir -p public && sed \"s|__BASE__|${BASE_PATH:-/}|g\" index.html > public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg", "serve": "bun run serve.ts" }, "dependencies": { diff --git a/src/client/api.ts b/src/client/api.ts index e7ca3a0..08bc67c 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -13,7 +13,10 @@ export type FeedState = * worse than one that asks you to reload. */ export async function fetchFeed(signal?: AbortSignal): Promise { - const res = await fetch("/data/events.v1.json", { signal: signal ?? null }); + // Resolved against , so this is correct at a domain root, under a + // GitHub Pages subpath, and on a deep link alike. + const feedUrl = new URL("data/events.v1.json", document.baseURI); + const res = await fetch(feedUrl, { signal: signal ?? null }); if (!res.ok) { throw new Error(`Feed request failed (${res.status}).`); } diff --git a/src/client/main.tsx b/src/client/main.tsx index e3b48cc..d215860 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -15,7 +15,8 @@ createRoot(root).render( // guarded because file:// and older browsers have no service worker at all. if ("serviceWorker" in navigator && location.protocol.startsWith("http")) { window.addEventListener("load", () => { - void navigator.serviceWorker.register("/sw.js").catch(() => { + const swUrl = new URL("sw.js", document.baseURI); + void navigator.serviceWorker.register(swUrl).catch(() => { // An unavailable worker costs offline support, nothing else. The app // works exactly as before. }); diff --git a/src/client/manifest.webmanifest b/src/client/manifest.webmanifest index 7873e30..ad43a42 100644 --- a/src/client/manifest.webmanifest +++ b/src/client/manifest.webmanifest @@ -2,15 +2,15 @@ "name": "Event Clock", "short_name": "Event Clock", "description": "Live and upcoming gacha events, sorted by what expires next.", - "start_url": "/", - "scope": "/", + "start_url": "./", + "scope": "./", "display": "standalone", "background_color": "#12141c", "theme_color": "#12141c", "orientation": "portrait-primary", "icons": [ { - "src": "/icon.svg", + "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" diff --git a/src/client/sw.js b/src/client/sw.js index bd83a17..b6ea8b5 100644 --- a/src/client/sw.js +++ b/src/client/sw.js @@ -15,8 +15,12 @@ */ const CACHE_VERSION = "event-clock-v1"; -const SHELL = ["/", "/index.html", "/styles.css", "/main.js"]; -const FEED = "/data/events.v1.json"; +// Paths are derived from the registration scope, so the same worker is +// correct at a domain root and under a subpath (GitHub Pages) alike. +const BASE = new URL("./", self.registration.scope); +const at = (path) => new URL(path, BASE).toString(); +const SHELL = ["", "index.html", "styles.css", "main.js"].map(at); +const FEED = new URL("data/events.v1.json", BASE).pathname; const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]); self.addEventListener("install", (event) => {