diff --git a/.dockerignore b/.dockerignore index a0d07f9..9bc3203 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,5 @@ .git -# .github is NOT ignored: the test suite asserts on the refresh workflows, and +# .github and .gitea are NOT ignored: the test suite asserts on the refresh workflows, and # the image build runs that suite as its gate. It reaches the build stage only — # the runtime stage copies nothing but public/ and serve.ts. .gitlab-ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8c4c48d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,219 @@ +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: https://github.com/actions/checkout@v4 + + - uses: https://github.com/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: https://github.com/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: https://github.com/actions/checkout@v4 + - uses: https://github.com/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: https://github.com/actions/cache/restore@v4 + with: + path: snapshots/*.state.json + key: refresh-state- + restore-keys: refresh-state- + + - name: Build + # Built at the domain root, because the container image is what gets + # deployed and `serve.ts` serves from `/`. A BASE_PATH belongs here only + # if this artefact is ever hosted under a subpath again — it was set to + # // for GitHub Pages, which this pipeline no longer deploys to. + run: bun run build + - uses: https://github.com/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 + # Kept as a statement of intent, but it does not grant what this job + # needs: on Gitea the registry does not accept the Actions token at all, + # so `packages: write` on it authorises nothing. See the login step. + packages: write + steps: + - uses: https://github.com/actions/checkout@v4 + + - uses: https://github.com/docker/setup-buildx-action@v3 + + - name: Lowercase image name + id: img + # Gitea's container registry, like GHCR before it, rejects any uppercase + # in a repository name, and github.repository preserves the owner's + # casing verbatim. The host is read from github.server_url rather than + # hardcoded, so a fork on another Gitea instance pushes to its own + # registry instead of this one. + env: + SERVER_URL: ${{ github.server_url }} + run: | + host="${SERVER_URL#https://}" + host="${host#http://}" + host="${host%%/*}" + # Two outputs on purpose: login wants the bare host, the tags want the + # full path. Passing the full path as the registry authenticates + # against a host that does not exist. + echo "host=${host}" >> "$GITHUB_OUTPUT" + echo "name=${host}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + + # Why not `secrets.GITHUB_TOKEN`, which is what this was and what the + # GHCR version used: a Gitea package belongs to a **user or org owner, + # not to a repository**, so the ephemeral repo-scoped Actions token + # carries no authority over it and the registry refuses it. GitLab hands + # out working per-job registry credentials ($CI_REGISTRY_PASSWORD, which + # .gitlab-ci.yml uses) and Gitea deliberately does not, so this needs a + # real access token with package write scope, stored as REGISTRY_TOKEN. + # + # The username must be the token's owner, hence repository_owner rather + # than github.actor: the image is pushed into the owner's namespace, and + # actor is whoever happened to trigger the run — a different person on a + # dispatch, and the wrong account entirely on a fork. + - name: Check the registry credential exists + # Without this the failure is a bare `unauthorized` from the daemon, + # which says nothing about what to do. It also tells us something we do + # not currently know: whether the old token was rejected or was simply + # never set. + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + if [ -z "${REGISTRY_TOKEN:-}" ]; then + echo "::error::REGISTRY_TOKEN is not set. Create an access token at" \ + "${{ github.server_url }}/user/settings/applications with the" \ + "package read+write scope, then add it to this repository under" \ + "Settings → Actions → Secrets as REGISTRY_TOKEN." + exit 1 + fi + echo "REGISTRY_TOKEN is set (${#REGISTRY_TOKEN} chars)" + + - uses: https://github.com/docker/login-action@v3 + with: + registry: ${{ steps.img.outputs.host }} + username: ${{ github.repository_owner }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - uses: https://github.com/docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ steps.img.outputs.name }}:latest + ${{ steps.img.outputs.name }}:${{ github.sha }} + # Registry cache rather than `type=gha`. Gitea does expose the Actions + # cache API, but buildx's gha backend is the least reliable corner of + # that compatibility surface, and we are already authenticated to a + # registry that can hold the layers. + cache-from: type=registry,ref=${{ steps.img.outputs.name }}:buildcache + cache-to: type=registry,ref=${{ steps.img.outputs.name }}:buildcache,mode=max diff --git a/.github/workflows/refresh.yml b/.gitea/workflows/refresh.yml similarity index 100% rename from .github/workflows/refresh.yml rename to .gitea/workflows/refresh.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c4c48d..e746fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ jobs: name: Typecheck, test, feed runs-on: ubuntu-latest steps: - - uses: https://github.com/actions/checkout@v4 + - uses: actions/checkout@v4 - - uses: https://github.com/oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" @@ -33,10 +33,10 @@ jobs: # 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. + # Restore-only: Gitea's refresh workflow owns writing this cache, and a + # miss just returns the pre-existing fallback behaviour. - name: Restore refresh bookkeeping - uses: https://github.com/actions/cache/restore@v4 + uses: actions/cache/restore@v4 with: path: snapshots/*.state.json key: refresh-state- @@ -105,8 +105,8 @@ jobs: runs-on: ubuntu-latest needs: check steps: - - uses: https://github.com/actions/checkout@v4 - - uses: https://github.com/oven-sh/setup-bun@v2 + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3" - run: bun install --frozen-lockfile @@ -114,106 +114,44 @@ jobs: # 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: https://github.com/actions/cache/restore@v4 + uses: actions/cache/restore@v4 with: path: snapshots/*.state.json key: refresh-state- restore-keys: refresh-state- - name: Build - # Built at the domain root, because the container image is what gets - # deployed and `serve.ts` serves from `/`. A BASE_PATH belongs here only - # if this artefact is ever hosted under a subpath again — it was set to - # // for GitHub Pages, which this pipeline no longer deploys to. + # Pages serves from // by default, so the app is built with a + # matching base href unless overridden by vars.BASE_PATH (e.g. for + # custom domains). Built at the domain root it would 404 on every asset. + env: + BASE_PATH: ${{ vars.BASE_PATH || format('/{0}/', github.event.repository.name) }} run: bun run build - - uses: https://github.com/actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: site path: public/ retention-days: 7 - image: - name: Container image + pages: + name: Deploy to Pages 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. + needs: build if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' permissions: - contents: read - # Kept as a statement of intent, but it does not grant what this job - # needs: on Gitea the registry does not accept the Actions token at all, - # so `packages: write` on it authorises nothing. See the login step. - packages: write + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} steps: - - uses: https://github.com/actions/checkout@v4 - - - uses: https://github.com/docker/setup-buildx-action@v3 - - - name: Lowercase image name - id: img - # Gitea's container registry, like GHCR before it, rejects any uppercase - # in a repository name, and github.repository preserves the owner's - # casing verbatim. The host is read from github.server_url rather than - # hardcoded, so a fork on another Gitea instance pushes to its own - # registry instead of this one. - env: - SERVER_URL: ${{ github.server_url }} - run: | - host="${SERVER_URL#https://}" - host="${host#http://}" - host="${host%%/*}" - # Two outputs on purpose: login wants the bare host, the tags want the - # full path. Passing the full path as the registry authenticates - # against a host that does not exist. - echo "host=${host}" >> "$GITHUB_OUTPUT" - echo "name=${host}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - # Why not `secrets.GITHUB_TOKEN`, which is what this was and what the - # GHCR version used: a Gitea package belongs to a **user or org owner, - # not to a repository**, so the ephemeral repo-scoped Actions token - # carries no authority over it and the registry refuses it. GitLab hands - # out working per-job registry credentials ($CI_REGISTRY_PASSWORD, which - # .gitlab-ci.yml uses) and Gitea deliberately does not, so this needs a - # real access token with package write scope, stored as REGISTRY_TOKEN. - # - # The username must be the token's owner, hence repository_owner rather - # than github.actor: the image is pushed into the owner's namespace, and - # actor is whoever happened to trigger the run — a different person on a - # dispatch, and the wrong account entirely on a fork. - - name: Check the registry credential exists - # Without this the failure is a bare `unauthorized` from the daemon, - # which says nothing about what to do. It also tells us something we do - # not currently know: whether the old token was rejected or was simply - # never set. - env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - if [ -z "${REGISTRY_TOKEN:-}" ]; then - echo "::error::REGISTRY_TOKEN is not set. Create an access token at" \ - "${{ github.server_url }}/user/settings/applications with the" \ - "package read+write scope, then add it to this repository under" \ - "Settings → Actions → Secrets as REGISTRY_TOKEN." - exit 1 - fi - echo "REGISTRY_TOKEN is set (${#REGISTRY_TOKEN} chars)" - - - uses: https://github.com/docker/login-action@v3 + - uses: actions/download-artifact@v4 with: - registry: ${{ steps.img.outputs.host }} - username: ${{ github.repository_owner }} - password: ${{ secrets.REGISTRY_TOKEN }} - - - uses: https://github.com/docker/build-push-action@v6 + name: site + path: public + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 with: - context: . - push: true - tags: | - ${{ steps.img.outputs.name }}:latest - ${{ steps.img.outputs.name }}:${{ github.sha }} - # Registry cache rather than `type=gha`. Gitea does expose the Actions - # cache API, but buildx's gha backend is the least reliable corner of - # that compatibility surface, and we are already authenticated to a - # registry that can hold the layers. - cache-from: type=registry,ref=${{ steps.img.outputs.name }}:buildcache - cache-to: type=registry,ref=${{ steps.img.outputs.name }}:buildcache,mode=max + path: public + - id: deploy + uses: actions/deploy-pages@v4 diff --git a/Dockerfile b/Dockerfile index 06fe86d..5500f97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY test ./test # and fail silently — so the files have to be here for `bun test` below to mean # the same thing it means in CI. COPY .github ./.github +COPY .gitea ./.gitea # Fail the image on a type error or a failing test rather than shipping it. RUN bun run typecheck && bun test diff --git a/test/refresh.test.ts b/test/refresh.test.ts index c4d4552..246a792 100644 --- a/test/refresh.test.ts +++ b/test/refresh.test.ts @@ -993,7 +993,7 @@ describe("the workflows that drive the refresh", () => { // the site just quietly carries wrong or stale data. Asserting on the file // is the only offline way to keep them fixed. const read = (name: string) => - Bun.file(new URL(`../.github/workflows/${name}`, import.meta.url)).text(); + Bun.file(new URL(`../.gitea/workflows/${name}`, import.meta.url)).text(); test("ci.yml restores the refresh bookkeeping before it builds the feed", async () => { // lastConfirmedAt lives only in the gitignored snapshots/*.state.json. If @@ -1107,6 +1107,15 @@ describe("the workflows that drive the refresh", () => { expect(ci).toContain("staleSources"); expect(ci).toContain("quietSources"); }); + + test("GitHub ci.yml builds with BASE_PATH and deploys to Pages", async () => { + const githubCi = await Bun.file( + new URL("../.github/workflows/ci.yml", import.meta.url), + ).text(); + expect(githubCi).toContain("BASE_PATH"); + expect(githubCi).toContain("deploy-pages"); + expect(githubCi).toContain("pages:"); + }); }); describe("flags", () => {