feat: add GitHub Actions CI and support hosting under a subpath
Mirrors the GitLab pipeline — typecheck, tests, feed sanity — and adds a Pages deploy. Pages serves from /<repo>/, and the app used absolute asset paths, so that job would have shipped a site that 404s on everything. Assets now resolve against a <base href> the build substitutes, the feed URL resolves against document.baseURI so deep links work too, and the service worker derives its paths from its own registration scope. Root-hosted builds are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
517066dc65
commit
b86794a62d
@@ -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 /<repo>/, 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
|
||||||
@@ -148,10 +148,25 @@ so iteration never re-fetches. Every event links back to its source.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.gitlab-ci.yml` runs typecheck, tests and a feed sanity check on every push, then builds and
|
Both `.github/workflows/ci.yml` and `.gitlab-ci.yml` run the same gates on every push — typecheck,
|
||||||
publishes a container image from the default branch. The feed job fails if the event count collapses
|
tests, and a feed sanity check — then build and publish a container image from the default branch.
|
||||||
— a source that quietly stops yielding events is the failure mode a parser-only pipeline is most
|
GitHub Actions additionally deploys to Pages.
|
||||||
prone to, and nothing else surfaces it.
|
|
||||||
|
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 `<base href>` 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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<base href="__BASE__" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>Event Clock — gacha events by what expires next</title>
|
<title>Event Clock — gacha events by what expires next</title>
|
||||||
<meta
|
<meta
|
||||||
@@ -15,13 +16,13 @@
|
|||||||
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=Public+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=Public+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
<link rel="apple-touch-icon" href="icon.svg" />
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/main.js"></script>
|
<script type="module" src="main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"build:js": "bun build src/client/main.tsx --outfile public/main.js --minify",
|
"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",
|
"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",
|
"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"
|
"serve": "bun run serve.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+4
-1
@@ -13,7 +13,10 @@ export type FeedState =
|
|||||||
* worse than one that asks you to reload.
|
* worse than one that asks you to reload.
|
||||||
*/
|
*/
|
||||||
export async function fetchFeed(signal?: AbortSignal): Promise<EventFeed> {
|
export async function fetchFeed(signal?: AbortSignal): Promise<EventFeed> {
|
||||||
const res = await fetch("/data/events.v1.json", { signal: signal ?? null });
|
// Resolved against <base>, 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) {
|
if (!res.ok) {
|
||||||
throw new Error(`Feed request failed (${res.status}).`);
|
throw new Error(`Feed request failed (${res.status}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@ createRoot(root).render(
|
|||||||
// guarded because file:// and older browsers have no service worker at all.
|
// guarded because file:// and older browsers have no service worker at all.
|
||||||
if ("serviceWorker" in navigator && location.protocol.startsWith("http")) {
|
if ("serviceWorker" in navigator && location.protocol.startsWith("http")) {
|
||||||
window.addEventListener("load", () => {
|
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
|
// An unavailable worker costs offline support, nothing else. The app
|
||||||
// works exactly as before.
|
// works exactly as before.
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
"name": "Event Clock",
|
"name": "Event Clock",
|
||||||
"short_name": "Event Clock",
|
"short_name": "Event Clock",
|
||||||
"description": "Live and upcoming gacha events, sorted by what expires next.",
|
"description": "Live and upcoming gacha events, sorted by what expires next.",
|
||||||
"start_url": "/",
|
"start_url": "./",
|
||||||
"scope": "/",
|
"scope": "./",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#12141c",
|
"background_color": "#12141c",
|
||||||
"theme_color": "#12141c",
|
"theme_color": "#12141c",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "icon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
|
|||||||
+6
-2
@@ -15,8 +15,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = "event-clock-v1";
|
const CACHE_VERSION = "event-clock-v1";
|
||||||
const SHELL = ["/", "/index.html", "/styles.css", "/main.js"];
|
// Paths are derived from the registration scope, so the same worker is
|
||||||
const FEED = "/data/events.v1.json";
|
// 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"]);
|
const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user