The build log prints a count per source, and CI now declines to fail on a page that states its own emptiness — which leaves that source showing an unexplained 0, reading as exactly the fault the gate just decided it was not. The zero we are content with is the one that has to say why. It is also the only line that would ever prompt anyone to ask whether a lane quiet for a month is a game between patches or a wiki that reworded the sentence a statesNoEvents check is still matching. Nothing else in the pipeline can tell those apart. The test count in the layout block was stale at 898 and moves with these two commits, so it is corrected here. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
209 lines
7.4 KiB
YAML
209 lines
7.4 KiB
YAML
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
|
|
|
|
# `lastConfirmedAt` — the "we asked the wiki and it said this is still
|
|
# current" half of the freshness badge — lives in the gitignored
|
|
# snapshots/*.state.json, so without this the feed built here has only
|
|
# `contentChangedAt` to go on and every source reads as stale two days
|
|
# after its bytes last moved, which for a wiki page is most of the time.
|
|
# Restore-only: refresh.yml owns writing this cache, and a miss just
|
|
# returns the pre-existing fallback behaviour.
|
|
- name: Restore refresh bookkeeping
|
|
uses: actions/cache/restore@v4
|
|
with:
|
|
path: snapshots/*.state.json
|
|
key: refresh-state-
|
|
restore-keys: refresh-state-
|
|
|
|
- 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`);
|
|
for (const s of feed.sources) {
|
|
console.log(` ${s.sourceId.padEnd(24)} ${String(s.eventCount).padStart(3)}`);
|
|
}
|
|
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");
|
|
}
|
|
// Per source, not just in total: nine healthy sources hide a tenth
|
|
// that has gone to zero, and the total stays comfortably over the
|
|
// floor while one game shows an empty calendar.
|
|
//
|
|
// Which zero it is decides whether this build should fail, and
|
|
// the rule lives in shared/feed.ts rather than here. It was
|
|
// inline, and a test did pin it — by grepping this file for the
|
|
// string. That proved the check existed, never that it was right,
|
|
// and it was not: it read eventCount, which is counted after
|
|
// expiry, so a page whose events had all simply ended reddened the
|
|
// build. Behaviour belongs where behaviour can be exercised.
|
|
const { brokenSources, quietSources, staleSources } = await import("./src/shared/feed.ts");
|
|
for (const s of staleSources(feed.sources)) {
|
|
console.log(
|
|
` note: ${s.sourceId} parsed ${s.parsedCount} events, all of them ended — stale page, not a fault`,
|
|
);
|
|
}
|
|
for (const s of quietSources(feed.sources)) {
|
|
console.log(
|
|
` note: ${s.sourceId} parsed nothing and the page says so itself — between versions, not a fault`,
|
|
);
|
|
}
|
|
const broken = brokenSources(feed.sources);
|
|
if (broken.length > 0) {
|
|
throw new Error(
|
|
`sources parsing to nothing: ${broken.map((s) => s.sourceId).join(", ")}`,
|
|
);
|
|
}
|
|
'
|
|
|
|
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
|
|
|
|
# This is the job whose output is deployed, so this is the one that must
|
|
# see the refresh bookkeeping; see the same step in `check`.
|
|
- name: Restore refresh bookkeeping
|
|
uses: actions/cache/restore@v4
|
|
with:
|
|
path: snapshots/*.state.json
|
|
key: refresh-state-
|
|
restore-keys: refresh-state-
|
|
|
|
- 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
|
|
|
|
- name: Lowercase image name
|
|
id: img
|
|
# GHCR rejects any uppercase in a repository name, and
|
|
# github.repository preserves the owner's casing verbatim.
|
|
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
|
|
|
|
- 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: |
|
|
${{ steps.img.outputs.name }}:latest
|
|
${{ steps.img.outputs.name }}:${{ github.sha }}
|
|
cache-from: type=gha
|
|
cache-to: type=gha,mode=max
|
|
|
|
# Deploying to Pages needs two one-time steps that CI cannot do for itself:
|
|
#
|
|
# 1. Settings → Pages → Source: "GitHub Actions".
|
|
# The default GITHUB_TOKEN cannot do this. Creating a Pages site needs
|
|
# `administration: write`, which is not a permission a workflow can grant
|
|
# GITHUB_TOKEN, so `configure-pages` with enablement: true fails with
|
|
# "Resource not accessible by integration".
|
|
# 2. Settings → Secrets and variables → Actions → Variables:
|
|
# set DEPLOY_PAGES to "true".
|
|
#
|
|
# Gated on that variable so the pipeline stays green for anyone who does not
|
|
# want Pages, rather than failing on every push forever.
|
|
pages:
|
|
name: Deploy to Pages
|
|
runs-on: ubuntu-latest
|
|
needs: build
|
|
if: >-
|
|
github.ref == 'refs/heads/main' &&
|
|
github.event_name != 'pull_request' &&
|
|
vars.DEPLOY_PAGES == 'true'
|
|
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
|