ci: migrate Gitea workflows to .gitea and restore GitHub Pages in .github

Move the scheduled refresh and container-registry CI workflows into
.gitea/workflows/ so Gitea Actions runs them natively. Update
.github/workflows/ci.yml to build with BASE_PATH and deploy to GitHub
Pages upon push from the mirror sync, removing the Gitea-specific
container registry step. Update Dockerfile, .dockerignore, and test
harness to test and copy workflows from .gitea/.
This commit is contained in:
Lucas Winther
2026-09-17 09:59:32 +02:00
parent 93a6513149
commit 0826f24db9
6 changed files with 261 additions and 94 deletions
+1 -1
View File
@@ -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
+219
View File
@@ -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
# /<repo>/ 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
+30 -92
View File
@@ -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
# /<repo>/ for GitHub Pages, which this pipeline no longer deploys to.
# Pages serves from /<repo>/ 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
+1
View File
@@ -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
+10 -1
View File
@@ -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", () => {