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:
+30
-92
@@ -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,228 +0,0 @@
|
||||
name: Refresh sources
|
||||
|
||||
# Fetch each source at most twice a day and commit the raw snapshots when — and
|
||||
# only when — the bytes actually changed. Everything downstream (parse, merge,
|
||||
# feed, build, deploy) is CI's existing job; this workflow does not duplicate
|
||||
# any of it, it just hands CI fresher input.
|
||||
#
|
||||
# Twelve hours apart is deliberately well clear of the six-hour-per-source floor
|
||||
# in AGENTS.md § Scraping conduct, and the runner enforces that floor itself, so
|
||||
# a manual dispatch on top of a scheduled run cannot double up on a wiki.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "27 5,17 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Plan only — no requests, no writes"
|
||||
type: boolean
|
||||
default: false
|
||||
only:
|
||||
description: "Refresh a single source id (blank = all)"
|
||||
type: string
|
||||
default: ""
|
||||
# `force` is refused on the scheduled run and only works here, on a
|
||||
# dispatch, because a dispatch is a person and the forge records which one
|
||||
# — a schedule that forces every cycle is just a shorter interval with
|
||||
# extra steps. Prefer pairing it with `only`.
|
||||
force:
|
||||
description: "Ask sources before their 6h floor is up (one run only)"
|
||||
type: boolean
|
||||
default: false
|
||||
# The scheduled run passes this one anyway (see the Refresh step), so this
|
||||
# input exists to turn it OFF for a dispatch — which is how you see which
|
||||
# hosts genuinely cannot be read, rather than which ones we assumed.
|
||||
assume_robots_on_403:
|
||||
description: "Treat a challenged robots.txt 403 as the recorded permission"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Never two refreshes at once: they would both fetch, and the second would race
|
||||
# the first's commit. Queue instead of cancelling — a half-finished refresh that
|
||||
# has already written snapshots should be allowed to finish and push.
|
||||
concurrency:
|
||||
group: refresh
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Fetch sources and rebuild the feed
|
||||
runs-on: ubuntu-latest
|
||||
# A fork must not point this at the wikis on a schedule: off by default for
|
||||
# anyone but this repo, while a fork owner can still dispatch it by hand and
|
||||
# take responsibility.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.repository == 'lucasw89/gacha-event-tracker'
|
||||
permissions:
|
||||
# Commit the refreshed snapshots.
|
||||
contents: write
|
||||
# Dispatch ci.yml afterwards; see that step for why it is still here on a
|
||||
# forge that does not suppress triggers on a token-made push.
|
||||
actions: write
|
||||
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
|
||||
|
||||
# When each source was last checked. Gitignored on purpose (committing it
|
||||
# would mean a commit every cycle saying nothing changed), so it rides in
|
||||
# the actions cache instead. A cache miss only means the runner has no
|
||||
# record of the last check — the twelve-hour schedule still keeps us well
|
||||
# inside the etiquette floor.
|
||||
- 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: Refresh
|
||||
id: refresh
|
||||
env:
|
||||
# Identify the crawler with a contact URL, per AGENTS.md.
|
||||
REFRESH_CONTACT_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
# Passed through the environment rather than interpolated into the
|
||||
# run script, so a dispatch input cannot become shell.
|
||||
ONLY: ${{ inputs.only }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
# Empty on a scheduled run, so --force is never passed by the cron.
|
||||
FORCE: ${{ inputs.force }}
|
||||
# Also empty on a schedule, which is why EVENT_NAME decides there. The
|
||||
# cron stands on the permission recorded in AGENTS.md § Scraping
|
||||
# conduct; a dispatch can set the input to false to see the real state.
|
||||
ASSUME_ROBOTS_ON_403: ${{ inputs.assume_robots_on_403 }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
args=()
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
args+=(--dry-run)
|
||||
fi
|
||||
if [ -n "$ONLY" ]; then
|
||||
args+=(--only "$ONLY")
|
||||
fi
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
args+=(--force)
|
||||
fi
|
||||
if [ "$ASSUME_ROBOTS_ON_403" = "true" ] || [ "$EVENT_NAME" = "schedule" ]; then
|
||||
args+=(--assume-robots-on-403)
|
||||
fi
|
||||
bun run refresh "${args[@]}"
|
||||
|
||||
# The key must differ every time this step runs. `run_id` is stable across
|
||||
# re-runs, so a re-run's save hits an existing key, is skipped, and the
|
||||
# next run restores the bookkeeping from before the re-run — records of
|
||||
# requests we did make, lost. `run_attempt` increments per attempt.
|
||||
- name: Save refresh bookkeeping
|
||||
if: always()
|
||||
uses: https://github.com/actions/cache/save@v4
|
||||
with:
|
||||
path: snapshots/*.state.json
|
||||
key: refresh-state-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
# git is the authority on "did anything change" — a 304, an unchanged
|
||||
# body, or a rejected parse all leave the working tree clean.
|
||||
- name: Detect changes
|
||||
id: diff
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain -- snapshots)" ]; then
|
||||
git status --porcelain -- snapshots
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "no source changed"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# A human push landing between the checkout and this push makes the push
|
||||
# non-fast-forward. Failing there would throw away pages we have already
|
||||
# fetched while the bookkeeping above (saved with `if: always()`) has
|
||||
# already spent their six-hour budget — the wikis would be asked again for
|
||||
# nothing. So rebase onto whatever landed and try again. Never force: this
|
||||
# commit is only ever new files under snapshots/, so it has nothing to say
|
||||
# about anyone else's work.
|
||||
- name: Commit refreshed snapshots
|
||||
if: steps.diff.outputs.changed == 'true' && inputs.dry_run != true
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@noreply.lucaswinther.info"
|
||||
git add -- snapshots
|
||||
git commit -m "chore(data): refresh source snapshots" \
|
||||
-m "Automated fetch from ${{ github.workflow }} run ${{ github.run_id }}."
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin "HEAD:$BRANCH"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "push rejected (attempt $attempt); rebasing onto origin/$BRANCH"
|
||||
git fetch origin "$BRANCH"
|
||||
if ! git rebase "origin/$BRANCH"; then
|
||||
git rebase --abort || true
|
||||
echo "::error::snapshot commit conflicts with $BRANCH; not force-pushing"
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
echo "::error::could not push refreshed snapshots after 3 attempts"
|
||||
exit 1
|
||||
|
||||
# ci.yml owns typecheck, tests, the feed sanity check and the image.
|
||||
# Dispatching it is how the refreshed data reaches the deployed container
|
||||
# without any of that logic being copied here.
|
||||
#
|
||||
# There is no `gh` on a Gitea runner, so this is the REST dispatch the CLI
|
||||
# would have made. Note the difference from GitHub that makes this step
|
||||
# look redundant: Gitea does *not* suppress workflow triggers on a push
|
||||
# made with the Actions token, so the commit above will usually have
|
||||
# started ci.yml already. Dispatching anyway is deliberate — it is the only
|
||||
# thing that guarantees publication if that behaviour is ever configured
|
||||
# off, and a duplicate run is harmless because ci.yml's concurrency group
|
||||
# is per-ref with cancel-in-progress, so the second supersedes the first.
|
||||
- name: Publish the refreshed feed
|
||||
if: steps.diff.outputs.changed == 'true' && inputs.dry_run != true
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sS --fail-with-body -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API/repos/$REPO/actions/workflows/ci.yml/dispatches" \
|
||||
-d "{\"ref\":\"$BRANCH\"}"
|
||||
|
||||
# A source that has failed three cycles running is broken, not down: that
|
||||
# game's calendar has been built from a checked-in fixture for a day and a
|
||||
# half while every run showed a green tick. `bun run refresh` exits 0 on
|
||||
# this so the steps above still commit and publish what did work; turning
|
||||
# the run red is this step's job, and it is last for that reason.
|
||||
#
|
||||
# `always()` so it still reports when an earlier step failed — but note it
|
||||
# cannot report when the Refresh step itself hard-failed, since the output
|
||||
# is then unset and the job is already red on its own account.
|
||||
- name: Report source health
|
||||
if: always()
|
||||
env:
|
||||
BROKEN: ${{ steps.refresh.outputs.broken }}
|
||||
REFRESH_OUTCOME: ${{ steps.refresh.outcome }}
|
||||
run: |
|
||||
if [ "$REFRESH_OUTCOME" != "success" ]; then
|
||||
echo "refresh did not complete ($REFRESH_OUTCOME); no health to report"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$BROKEN" ] && [ "$BROKEN" != "0" ]; then
|
||||
echo "::error::$BROKEN source(s) have stopped answering; see the job summary"
|
||||
exit 1
|
||||
fi
|
||||
echo "every source is answering"
|
||||
Reference in New Issue
Block a user