Skip to content

ci(board-snapshot): mirror the board archive to Cloudflare R2 - #17998

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-17989-board-archive-to-r2
Sep 13, 2026
Merged

os-project-manager merged 1 commit into
mainfrom
claude/issue-17989-board-archive-to-r2

Conversation

@claude

@claude claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #17989

One step in .github/workflows/board-snapshot.yml, right after 「Commit and push the archive」: it mirrors the archive checkout to Cloudflare R2 with --delete and, once per UTC day, writes the tree as a tarball. scripts/pm/board-snapshot.mjs is untouched and so is what the snapshot reads and commits.

Why: the board-archive branch survives an account suspension, because a branch belongs to the repository and not to a user — it does not survive an action against the repository itself. The maintainer's answer, verbatim and untranslated: 「那如果推 s3 呢?」「Cloudflare R2」.

It lands before the bucket exists: with any of R2_ACCOUNT_ID / R2_BUCKET / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY unset the step prints one notice and exits 0, while a CONFIGURED upload that fails goes red. pull_request runs never upload, the same guard the archive commit carries. No secret value is in the diff, the log or the summary, and no dependency is added — aws is the ubuntu-latest runner's own CLI.

Two decisions to overrule if you disagree: the daily tarball goes to objectstack/snapshots/, OUTSIDE the mirrored objectstack/board/ prefix, because inside it the next --delete sync deletes it and the history is one day long; and .git is excluded under two patterns, because in a linked worktree it is a FILE that .git/* does not match.

The step's shell, exactly as it lands:

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}"

Verified offline with a stand-in on PATH in place of aws — nothing in this branch ever contacted R2. Every case ran the shell above as a YAML parser extracted it, never as retyped text:

case result
four secrets empty exactly one ::notice:: line, exit 0, zero aws invocations
three set, one empty same
configured, no tarball for today sync, s3 ls miss, tar, s3 cp; status=synced 2 object(s), deleted 1; daily tarball written for 2026-09-13
configured, today's tarball present sync only; status says already written
configured, sync exits 3 log to stderr, one ::error:: line, exit 1
60 objects moved log truncated at 50 lines, counts taken over all 60

The tarball built in the third case holds ./board/... and no .git.

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 35 families for this diff; all 35 ran, all 35 exited 0, reconciled through --ran (35 accounted, 0 NOT-MEASURED, 0 UNRUN). The nine workflow-roster gates the tool cannot place for a .github/workflows path were run as well: eight exited 0, and check:single-claim-paths exits 2 as NOT WIRED without a PR_NUMBER — its declared list holds only .objectui-sha, which this diff does not touch.

skip-changeset: .github/** publishes nothing from any released package.

Acceptance notes

  • The mirrored keys read objectstack/board/board/..., because the sync source is the archive checkout and that branch's tree root holds a board/ directory. Keeping the ruling's source path is what makes the mirror and the daily tarball the same shape, both rooted at the branch tip.
  • AWS_REQUEST_CHECKSUM_CALCULATION: when_required is set for the S3-compatible endpoint and is NOT measured here — nothing in this run contacted R2. The first configured run is its measurement.

Generated by Claude Code

…nch 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 <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 13, 2026
@github-actions github-actions Bot added the ci/cd label Sep 13, 2026
@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author
  • Served-tier: 707/707 claude-fable-5-1 — harness model stamp counted over this seat's own transcript (non-sidechain assistant messages that a model served; the 11 <synthetic> harness rate-limit notices of 10:36Z–11:04Z carry no model and are not rounds — the first count posted here read 707/718 with them in the denominator, corrected in place) at 2026-09-13T11:19Z; get_session external_metadata.last_served_model read claude-fable-5-1 at 2026-09-13T11:18Z.

Contract review

Head: 70b078a0 (PR #17998, card #17989) — read at 2026-09-13T11:19Z by the skills seat at the contract-review tier. NOT GOVERNED, measured: the diff touches .github/workflows/board-snapshot.yml only (+109 −0), no docs/adr/**, .claude/**, skills/**, AGENTS.md or CLAUDE.md ⇒ in-seat review, then ready + auto-merge by this seat.

① derived judgments — one new step, Upload the archive to R2, after 「Commit and push the archive」, plus one summary line:

  1. Guards. if: github.event_name != 'pull_request' (the commit step's own guard); with any of the four secrets unset the step writes one ::notice:: and exits 0, so the branch archive keeps running until the maintainer creates the bucket, token and secrets; a CONFIGURED upload that fails prints the sync log and exits 1 — the right polarity for a backup (a copy that silently stops is the failure this file exists against). Correct.
  2. What is written where. aws s3 sync archive/ s3://R2_BUCKET/objectstack/board/ --delete --no-progress --exclude '.git' --exclude '.git/*' at https://R2_ACCOUNT_ID.r2.cloudflarestorage.com, then once per UTC day objectstack/snapshots/YYYY-MM-DD.tar.gz decided by asking the bucket (s3 ls) rather than the clock, kept OUTSIDE the mirrored prefix so --delete cannot eat it. Matches the claim's scope (5652627427) and the card's own s3://BUCKET/repo/snapshots/ spelling. The mirror lands keys under objectstack/board/board/… because the archive checkout's root holds board/ — cosmetic, stated, and the objectui copy (objectui#9387) can flatten both.
  3. No secret value anywhere: the diff reads secrets.R2_* into env only; a scan of the diff for key-shaped literals finds none; the endpoint is built from the account id at run time and never echoed. scripts/pm/board-snapshot.mjs untouched; no dependency added (the hosted runner's AWS CLI). Nothing widens; Clause-②: no on both carriers; --pair 17998 exit 0 at 2026-09-13T11:18Z.
  4. Cost / quota floor: the maintainer chose R2 and owns the bucket, token and the four secrets (both repos); the step adds one daily tarball of the board tree — no fleet, model or default changed. Not a floor item.

Seat measurements: 31 checks on 70b078a0, 0 pending, 0 red (check:workflow-step-name-quoting, check:workflow-status-functions, check:self-test-workflow-commands, check:ci-filter-parity among them); the dev's six offline shell cases under a stand-in aws are listed in report 5652925398. NOT MEASURED by anyone yet: a configured run against R2 (AWS_REQUEST_CHECKSUM_CALCULATION: when_required included) — the first configured cron run is its measurement, and the summary line names the result.

② semver: no package touched; skip-changeset is right.

③ boundary flags: open_questions empty. Deviations accepted as stated in the report (tarball outside the prefix; both .git exclude spellings; --no-progress for countable output; a configured failure goes red; output via step output rather than a second summary writer). The dev's turn was cut by a 429 at 10:36Z and resumed at 11:13Z; the head did not move across the cut.

Implemented-by: claude/issue-17989-board-archive-to-r2
Reviewed-by: session_01DAcomhvR9kKizeYgg89Vo8

Verdict: PASS — the R2 sink as ruled (chat 2026-09-13 「Cloudflare R2」, card #17989 narrowed by claim 5652627427). Landing: the seat flips ready and arms auto-merge now; the merge queue's method lands it as one squash commit.


Generated by Claude Code

@claude

claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Landing step (NOT GOVERNED, measured: .github/workflows/board-snapshot.yml only) — skills seat (session session_01DAcomhvR9kKizeYgg89Vo8), 2026-09-13T11:20Z. Head 70b078a0 unchanged since the review of record 5652939267 (PASS) and ACCEPT 5652939341 on #17989. Checks on 70b078a0 at 2026-09-13T11:18Z: 31 runs, 0 pending, 0 red. Flipped ready and armed auto-merge at 2026-09-13T11:20Z (both events read back as this account); the queue's own method lands it as one squash commit. Fixes #17989: the card closes on landing; the landing record and residue strip follow when origin/main carries (#17998).


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants