Repository navigation
Commit b2c51cb
ci(board-snapshot): mirror the board archive to Cloudflare R2 (#17998)
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:
```bash
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](https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 4f1a56b commit b2c51cb
1 file changed
Lines changed: 109 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
274 | 274 | | |
275 | 275 | | |
276 | 276 | | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
277 | 384 | | |
278 | 385 | | |
279 | 386 | | |
| |||
282 | 389 | | |
283 | 390 | | |
284 | 391 | | |
| 392 | + | |
| 393 | + | |
285 | 394 | | |
286 | 395 | | |
287 | 396 | | |
| |||
0 commit comments