The guidance in here is not Claude-specific — it is what this project is, what it refuses to do, and the rules that are invisible from the code. Any agent working here needs it, and AGENTS.md is the name they all look for. CLAUDE.md stays as a pointer so Claude Code still finds it, and holds no guidance of its own; two copies would drift and the wrong one would be read. Every cross-reference in the source comments, docs, workflow and skills now names AGENTS.md, and the image ignores both. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
184 lines
7.5 KiB
YAML
184 lines
7.5 KiB
YAML
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: ""
|
|
|
|
# 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. Same shape as the
|
|
# DEPLOY_PAGES gate in ci.yml: 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 == 'StereotypicalCat/gacha-event-tracker'
|
|
permissions:
|
|
# Commit the refreshed snapshots.
|
|
contents: write
|
|
# Dispatch ci.yml afterwards: a push made with GITHUB_TOKEN deliberately
|
|
# does not trigger other workflows, so without this the fresh data would
|
|
# sit in the repo undeployed until someone pushed by hand.
|
|
actions: write
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: 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: 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 }}
|
|
run: |
|
|
args=()
|
|
if [ "$DRY_RUN" = "true" ]; then
|
|
args+=(--dry-run)
|
|
fi
|
|
if [ -n "$ONLY" ]; then
|
|
args+=(--only "$ONLY")
|
|
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: 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 "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
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, the image and the
|
|
# Pages deploy. Dispatching it is how the refreshed data reaches the site
|
|
# without any of that logic being copied here.
|
|
- name: Publish the refreshed feed
|
|
if: steps.diff.outputs.changed == 'true' && inputs.dry_run != true
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: gh workflow run ci.yml --ref "${{ github.ref_name }}"
|
|
|
|
# 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"
|