From 70b078a0906f2b4ec1bd336c515fb0be4b94222d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 10:17:01 +0000 Subject: [PATCH] ci(board-snapshot): mirror the archive to Cloudflare R2 after the branch push The archive branch survives an account suspension but not an action against the repository itself. Add one step after the archive commit that mirrors the archive checkout to an R2 prefix with --delete, and writes one tarball per UTC day outside that prefix, since R2 has no bucket versioning and a --delete sync keeps no history. Until the four repository secrets exist the step prints one notice and exits 0; a configured upload that fails goes red. pull_request runs never upload, the same guard the archive commit already carries. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude --- .github/workflows/board-snapshot.yml | 109 +++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/.github/workflows/board-snapshot.yml b/.github/workflows/board-snapshot.yml index 29ac126476..2d5380abc1 100644 --- a/.github/workflows/board-snapshot.yml +++ b/.github/workflows/board-snapshot.yml @@ -274,6 +274,113 @@ jobs: echo "committed=${files}" >> "$GITHUB_OUTPUT" echo "pushed ${files} changed file(s) to board-archive" + # ## The copy that outlives the platform, not just the account + # + # The archive branch above survives an ACCOUNT suspension, because a + # branch belongs to the repository rather than to a user. It does not + # survive an action against the organisation or the repository itself, + # and that is the remaining hole. The maintainer's answer, verbatim and + # untranslated, in chat on 2026-09-13: + # + # 「那如果推 s3 呢?」 「Cloudflare R2」 + # + # So this step mirrors the archive CHECKOUT — the same tree the commit + # above just pushed — into one R2 prefix. Two things follow from R2 + # having NO bucket versioning: + # + # - `--delete` makes the prefix equal to the branch tip and nothing + # else, so a restore never reads a file the board no longer has; + # - a mirror therefore keeps no HISTORY at all, so once per UTC day the + # tree is also written as `snapshots/YYYY-MM-DD.tar.gz`. That key + # lives OUTSIDE the mirrored prefix on purpose: inside it, the very + # next `--delete` sync would remove it as an object with no local + # counterpart, and the history would be exactly one day long. + # + # The daily tarball is decided by asking the bucket for today's key + # rather than by the clock, so a missed 02:07 run, a re-run and a + # `workflow_dispatch` all converge on one tarball per day. + # + # ## Unset secrets are a notice, not a failure + # + # The bucket and its scoped API token are the maintainer's to create. + # Until all four secrets exist this step prints one notice and exits 0, + # so this workflow keeps archiving to the branch and nothing here has to + # be merged in the same hour as the bucket. A CONFIGURED upload that then + # fails is the opposite case and goes red: a backup that silently stops + # copying is the failure mode this whole file exists to prevent. + # + # ⛔ The four secrets are read, never echoed, and never written to the + # summary; the run below also never touches the board, `main`, or the + # archive branch — it only reads the checkout that is already on disk. + - name: Upload the archive to R2 + id: r2 + if: github.event_name != 'pull_request' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + # R2 is S3-compatible with one fixed pseudo-region. + AWS_DEFAULT_REGION: auto + # S3-compatible endpoints other than S3 itself are the documented + # case for this setting: the CLI then sends an integrity checksum + # only where the API requires one, instead of on every request. + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + run: | + set -euo pipefail + + if [ -z "${AWS_ACCESS_KEY_ID:-}" ] || [ -z "${AWS_SECRET_ACCESS_KEY:-}" ] \ + || [ -z "${R2_ACCOUNT_ID:-}" ] || [ -z "${R2_BUCKET:-}" ]; then + echo "status=skipped — no R2 credentials configured in this repository" >> "$GITHUB_OUTPUT" + echo "::notice::R2 upload skipped: R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are not all set as repository secrets; this run is archived on the board-archive branch only." + exit 0 + fi + + endpoint="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + mirror="s3://${R2_BUCKET}/objectstack/board/" + + # `archive/` is a linked worktree, so its `.git` is a FILE and the + # pattern `.git/*` does not match it; both spellings are excluded. + # No pipe between the command and `$?` — see the snapshot step. + set +e + aws s3 sync archive/ "$mirror" \ + --endpoint-url "$endpoint" \ + --delete --no-progress \ + --exclude '.git' --exclude '.git/*' \ + > "$RUNNER_TEMP/r2-sync.log" 2>&1 + code=$? + set -e + if [ "$code" != "0" ]; then + cat "$RUNNER_TEMP/r2-sync.log" >&2 + echo "status=FAILED — aws s3 sync exited $code" >> "$GITHUB_OUTPUT" + echo "::error::aws s3 sync to R2 exited $code. This run IS archived on board-archive; the out-of-GitHub copy is now behind and every later run will stay behind until this is fixed." + exit 1 + fi + # One line per object moved. The first configured run moves the whole + # board and every later run moves a handful, so the log is truncated + # here and the counts below are taken over all of it. + transfers=$(wc -l < "$RUNNER_TEMP/r2-sync.log") + head -n 50 "$RUNNER_TEMP/r2-sync.log" + if [ "$transfers" -gt 50 ]; then + echo "... and $((transfers - 50)) further transfer line(s), not printed." + fi + uploaded=$(grep -c '^upload:' "$RUNNER_TEMP/r2-sync.log" || true) + deleted=$(grep -c '^delete:' "$RUNNER_TEMP/r2-sync.log" || true) + + day=$(date -u +%Y-%m-%d) + tarball="s3://${R2_BUCKET}/objectstack/snapshots/${day}.tar.gz" + if aws s3 ls "$tarball" --endpoint-url "$endpoint" > /dev/null 2>&1; then + tarred="already written for ${day}" + else + tar -czf "$RUNNER_TEMP/${day}.tar.gz" --exclude=.git -C archive . + aws s3 cp "$RUNNER_TEMP/${day}.tar.gz" "$tarball" \ + --endpoint-url "$endpoint" --no-progress + tarred="written for ${day}" + fi + + echo "status=synced ${uploaded} object(s), deleted ${deleted}; daily tarball ${tarred}" >> "$GITHUB_OUTPUT" + echo "R2: synced ${uploaded} object(s), deleted ${deleted}; daily tarball ${tarred}" + - name: Publish the run to the summary if: always() run: | @@ -282,6 +389,8 @@ jobs: echo echo "Committed: ${{ steps.commit.outputs.committed || 'nothing (the run stopped before the commit step)' }}" echo + echo "R2: ${{ steps.r2.outputs.status || 'not attempted (a pull_request run, or the job stopped before the upload step)' }}" + echo echo '```' cat "$RUNNER_TEMP/snapshot.md" 2>/dev/null || echo '(no report produced)' echo '```'