name: Search index maintenance # indexbuild decides what to do from the index's own state, so every # trigger below runs the same command: # # no completed import -> build (first run, or resume a partial one) # import older than 6mo -> rebuild (re-import from the newest dump) # otherwise -> refresh (fold in new incremental listens) # # **There is deliberately no `push` trigger, and restoring one is a # decision rather than a cleanup.** A refresh is individually cheap, so # running it on every push to main looked free; what it actually does is # put an unattended job that mutates the only copy of a ~205 GB catalog # on the same trigger as an ordinary code change, on a runner with # capacity 1. # # That is not hypothetical. On 2026-08-17 `fix(database): retire a table # whose shape the schema moved past` landed on main, green — the CI # database is deliberately in the older encoding, so the stale-shape # repair judged its `explore_index` stale and dropped it, and this job # fell back to a full import from the dumps. `fix(database): never # retire the catalog the index build derives` stops that specific repair # and cannot undo it. Every push to main then booked another `budget` # (3h) of the one runner while ordinary CI queued behind it. # # So the rule this file is an instance of: **a job that mutates state # which cannot be rebuilt in ten minutes is triggered deliberately, not # by a push.** The weekly cron keeps the catalog current, and # workflow_dispatch resumes or forces a build — indexbuild picks up from # its checkpoint either way, so nothing is lost by not running on every # merge. See docs/index-cache.md for the snapshot and the restore. on: schedule: # Weekly update pass. The 6-month rebuild is triggered by the same # command when it notices the import has aged out. - cron: '0 4 * * 1' workflow_dispatch: inputs: mode: description: 'auto | build | refresh | rebuild' required: false default: 'auto' budget: description: 'Max build time this run' required: false default: '3h' artists: description: 'Top artists in the core artifact' required: false default: '50000' # Runs share one persistent working directory, so they must not overlap. # A push landing mid-build waits rather than corrupting the checkpoint. # # That directory holds the only copy of a catalog nothing can cheaply # re-derive: see docs/index-cache.md for the snapshot it takes and the # restore, which is minutes against the hours a rebuild costs. concurrency: group: search-index cancel-in-progress: false jobs: maintain-index: runs-on: ubuntu-latest container: # CGO is not needed: the project uses the pure-Go modernc sqlite # driver, and neither command imports the Wails app — which is a # claim with a test behind it now (cmd/indexbuild/deps_test.go), # because the v3 migration quietly broke it and this job was where # that surfaced. image: golang:1.25 # This host path must exist on the runner and be listed verbatim in # act_runner's container.valid_volumes. It holds explore-staging/ # (counts.bin + state.json) and yj.db — the checkpoint that makes # resuming possible. Losing it means re-downloading ~205GB. volumes: - /srv/yellowjacket/index-cache:/cache env: YJ_HOME: /cache CGO_ENABLED: '0' PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} SERVER_URL: ${{ github.server_url }} OWNER: ${{ github.repository_owner }} REPO: ${{ github.repository }} SHA: ${{ github.sha }} MODE: ${{ inputs.mode || 'auto' }} BUDGET: ${{ inputs.budget || '3h' }} ARTISTS: ${{ inputs.artists || '50000' }} steps: # Cloned by hand rather than with actions/checkout: that is a JS # action and needs node inside the job container, which the golang # image does not carry. Same approach as arch-package.yml. - name: Clone repo at this commit run: | set -eu git clone --quiet \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \ /src git -C /src checkout --quiet --detach "$SHA" git -C /src log --oneline -1 - name: Verify the cache volume run: | set -eu mkdir -p /cache # A RAM-backed cache would defeat the point: the checkpoint has # to outlive the job, and the import wants real disk headroom. fstype=$(stat -f -c %T /cache || echo unknown) echo "cache fstype: $fstype" case "$fstype" in tmpfs|ramfs) echo "::error::/cache is RAM-backed; use a disk-backed host path." exit 1 ;; esac df -h /cache - name: Build tools working-directory: /src # The dump importer is behind the `indexbuild` tag so it is not # linked into the app binary; cmd/indexbuild carries the same tag # and will not build without it. run: | go build -tags indexbuild -o /usr/local/bin/ ./cmd/indexbuild go build -o /usr/local/bin/ ./cmd/indexexport - name: Maintain index id: maintain run: | set +e indexbuild -mode "$MODE" -budget "$BUDGET" code=$? set -e case "$code" in 0) ;; 3) echo "::notice::Build checkpointed with work remaining — rerun to continue." ;; *) exit "$code" ;; esac # Publishing only on `changed` keeps identical artifacts from # accumulating when a refresh finds nothing new. - name: Export core artifact if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true' run: | set -eu command -v zstd >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq zstd; } indexexport -o /tmp/core-index.db -artists "$ARTISTS" zstd -19 -T0 -q -f /tmp/core-index.db -o /tmp/core-index.db.zst sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256 ls -lh /tmp/core-index.db.zst - name: Publish to the Gitea package registry if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true' run: | set -eu pkg="${SERVER_URL}/api/packages/${OWNER}/generic/yellowjacket-core-index" # Published twice: under a dated version for history, and under # the fixed "latest" version the client fetches. Clients cannot # discover the newest dated version on their own — the package # listing API requires a token, while a plain file GET does not # — so "latest" is what makes an anonymous first run possible. # # A generic package rejects re-uploading a filename that already # exists, so "latest" is deleted before being rewritten. It is # absent on the very first publish, hence the tolerated 404. curl --silent --show-error --user "${OWNER}:${PACKAGE_TOKEN}" \ --request DELETE "${pkg}/latest" || true for version in "$(date -u +%Y%m%d)" latest; do for f in core-index.db.zst core-index.db.zst.sha256; do echo "Uploading $f -> $version" curl --fail-with-body --user "${OWNER}:${PACKAGE_TOKEN}" \ --upload-file "/tmp/$f" "${pkg}/${version}/${f}" done done - name: Summary if: always() run: | echo "complete=${{ steps.maintain.outputs.complete }}" echo "changed=${{ steps.maintain.outputs.changed }}" if [ "${{ steps.maintain.outputs.complete }}" != "true" ]; then echo "Build incomplete — rerun to continue from the checkpoint." echo "Progress lives in /cache/data/explore-staging." elif [ "${{ steps.maintain.outputs.changed }}" != "true" ]; then echo "Nothing new to publish." fi