Skip to content

Issue backup to Cloudflare R2 — a per-repo Actions snapshot of every issue and comment, so a GitHub account ban cannot take the audit chain with it (maintainer, 2026-09-13) #17989

Description

@claude

Filed by the domain:skills execution seat (session session_01DAcomhvR9kKizeYgg89Vo8) on the maintainer's direct word, live chat 2026-09-13T09:4xZ–09:5xZ, verbatim and untranslated:

「先讨论issue 清单,我本来就想备份issue 清单,防止GitHub被封账户。你帮我综合评估结合现在的场景最佳的方案。」
「那如果我单独开一个github 仓库备份issue呢?目前被封号只会丢 issues,不会导致仓库丢」
「那如果推 s3 呢?」
「Cloudflare R2」

Routed and graded under the maintainer's direct-dispatch channel (the words above are the authorization): domain:skills (PM tooling under scripts/pm/**; the workflow's SUBJECT is the PM board's data, not code quality), priority:p1 (the maintainer's own data-safety ask; every ruling, review record and landing record of this board lives in issue comments), type Task.

Threat model and why R2

An account-level ban removes access to the organisation's issues; the code survives in every clone, the issues do not. A backup inside GitHub (a second repository) survives an account ban only under a different owner and never a platform-level action; object storage outside GitHub survives both. The maintainer chose Cloudflare R2 (S3-compatible API, negligible cost). ⚠️ R2 has no bucket versioning: history is kept by the daily tarball below, not by object versions.

Deliverables

D1 — scripts/pm/issue-snapshot.mjs (non-governed, this lane). Incremental: GET /repos/{o}/{r}/issues?state=all&since=<cursor>&per_page=100 (pull requests come back through the same endpoint and are kept, flagged by their pull_request key) and the repo-level GET /repos/{o}/{r}/issues/comments?since=<cursor>&per_page=100 (one endpoint for every new or edited comment, no per-issue reads). Writes a directory: index.json (number, title, state, labels, assignee, type, created/updated/closed timestamps, author — small enough to grep), issues/NNNNN.json (the issue object plus its comments, merged on every run), state.json (the since cursor of the last complete run; a run that stops on 403/429 keeps the previous cursor). Flags: --repo OWNER/NAME, --out DIR, --full (ignore the cursor: the bootstrap), --self-test (offline). Rate reading printed before and after; stop on the first 403/429. ⛔ No new dependency in the repository: the script uses fetch only; the upload is the runner's AWS CLI (see D2).

D2 — .github/workflows/issue-snapshot.yml in objectstack and objectui. Cron every 30 minutes plus workflow_dispatch; steps: checkout → node scripts/pm/issue-snapshot.mjs --repo <this repo> --out snapshot/ (the previous state.json and index.json are first pulled down from the bucket so the run is incremental across runners) → aws s3 sync snapshot/ s3://$BUCKET/<repo>/ --endpoint-url https://<account>.r2.cloudflarestorage.com → once a day (a second schedule or a date check) tar the directory to s3://$BUCKET/<repo>/snapshots/YYYY-MM-DD.tar.gz. Uses GITHUB_TOKEN for GitHub reads (its own 1 000/h quota, ⛔ none of the seats' identities) and the R2 credentials from repository secrets (R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET) — an API token scoped to that one bucket, object read/write only. The bootstrap (--full; objectstack has ~18 000 issues) is one workflow_dispatch run with a PAT secret, or a run on the maintainer's machine; after it every run is a handful of requests.

D3 — restore (documented now, written when needed): scripts/pm/issue-restore.mjs recreates issues and comments from the snapshot on a fresh repository in number order so numbers are preserved (placeholders for gaps), with a dry-run and a resume cursor.

Patrol: one line in this lane's standing patrol — each fire reads the snapshot workflow's last successful run (GET /repos/{o}/{r}/actions/workflows/issue-snapshot.yml/runs?status=success&per_page=1, one request); older than 24 h ⇒ report on the seat post and a comment here. ⛔ No new label, no new sweep.

Charter text (governed — rides the charter-revision PR, chain head #17942, ⛔ not this card's PR): the backup is a read-only derivative — 「GitHub 之外永不维护任何跟踪状态」 is untouched, the authority stays the issue itself; the dedupe cache the triage seat keeps in its own container is a separate, session-local thing and is not this backup.

Maintainer actions (the seat cannot do these)

  1. Create the R2 bucket (one bucket, one prefix per repository) and an API token scoped to it (object read/write).
  2. Add the four secrets above to objectstack-ai/objectstack and objectstack-ai/objectui.
  3. Approve the workflow PR (.github/workflows/** is not a governed surface here, but a new scheduled workflow with secrets is the maintainer's to see once — this card asks for that one look).

Acceptance

  1. --self-test covers: cursor round-trip; a comment newer than the cursor updates its issue file; a 429 mid-batch keeps the previous cursor and exits non-zero with the cursor printed; --dry-run writes nothing.
  2. One dry run against objectstack in CI with the request count and the rate reading printed; the bootstrap run's object count equals the board's issue count read from GET /repos/{o}/{r} (open_issues plus closed via search is not available — use the listing's last page).
  3. Two consecutive scheduled runs: the second uploads only changed objects (aws s3 sync output shows the count).
  4. The daily tarball appears under snapshots/ and unpacks to the same index.json.

Dedupe

Open objectstack issues (6 pages, read 2026-09-13T09:5xZ) grepped for backup / snapshot / R2 / 封号 / mirror-of-issues: no existing card. The related-but-different cards: #17374 / #11742 (the shared identity's rate-limit discipline) and the triage-side dedupe cache (a charter item, no card yet).


Generated by Claude Code

Activity

  1. added theissue type on Sep 13, 2026
  2. claude commented on Sep 13, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round 1 — the R2 sink only; the snapshot producer already exists
    Session: session_01DAcomhvR9kKizeYgg89Vo8 (GitHub os-project-manager, skills seat), claimed at 2026-09-13T10:09Z
    Branch: claude/issue-17989-board-archive-to-r2
    Worktree: objectstack-issue-17989
    Domain: domain:skills (maintainer direct ask, verbatim in the card body; p1 Task)
    Premise correction (the card's dedupe missed the tree): scripts/pm/board-snapshot.mjs (#17390, 1616 lines) and .github/workflows/board-snapshot.yml (cron 7 2,8,14,20 * * *) already produce exactly the snapshot D1/D2 describe — issues, comments, PR reviews, a manifest, --restore — onto the orphan branch board-archive of this repository, read-only, no second credential. What is missing is the out-of-GitHub copy: a branch survives an account suspension but not a platform-level action, and the maintainer chose Cloudflare R2. D1 and D2 as written in the card are therefore ⛔ not built; the deliverable is one step.
    File surface: .github/workflows/board-snapshot.yml ONLY — after the archive push, a step 「Upload the archive to R2」: aws s3 sync <archive checkout> s3://$R2_BUCKET/objectstack/board/ --endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com --delete (the runner's AWS CLI; region auto; credentials from secrets.R2_ACCESS_KEY_ID / secrets.R2_SECRET_ACCESS_KEY), plus a once-a-day tarball snapshots/YYYY-MM-DD.tar.gz (R2 has no bucket versioning; the tarballs are the history); when the four secrets are absent the step prints a notice and exits 0, so the PR can land before the maintainer sets them; the run summary names the object count synced. ⛔ board-snapshot.mjs untouched. ⛔ No secret values anywhere. objectui has no board-snapshot workflow or archive branch — its copy (workflow + the ported script through that repo's upstream-port ledger) is a separate objectui card this seat files after this lands, ⛔ not this PR.
    Container & model: S, mode:subagent, model: opus; landing = non-governed (.github/workflows/**, PM-data subject) ⇒ in-seat review → the maintainer's one look at the workflow diff (a job that will hold secrets) → ready → auto-merge SQUASH
    Clause-②: no
    Thread-read: none — no comments on the card at this claim; body read in full
    Serial constraints cleared: no open PR touches .github/workflows/board-snapshot.yml (read by name at 2026-09-13T10:08Z); H17 index ∩ this face = ∅. Also for the maintainer's dedupe-cache line (ruling ③): git fetch origin board-archive hands the triage seat the whole board with zero REST — the cold-start source the seat post will name.


    Generated by Claude Code

  3. claude commented on Sep 13, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 17989,
      "status": "done",
      "branch": "claude/issue-17989-board-archive-to-r2",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/17998",
      "premise_still_valid": true,
      "summary": "One step, 'Upload the archive to R2', added to .github/workflows/board-snapshot.yml after 'Commit and push the archive': it mirrors the archive checkout to s3://R2_BUCKET/objectstack/board/ with --delete through the ubuntu-latest runner's own AWS CLI at the R2 endpoint, and once per UTC day writes the same tree as objectstack/snapshots/YYYY-MM-DD.tar.gz. With any of the four secrets unset it prints exactly one notice line and exits 0; a configured upload that fails prints the sync log and exits 1. pull_request runs never upload (the same guard the commit step carries). The existing summary step now names whether the upload ran and how many objects synced. scripts/pm/board-snapshot.mjs is untouched, no dependency is added, and no secret value appears in the diff, the job log or the summary.",
      "files_changed": [
        ".github/workflows/board-snapshot.yml"
      ],
      "tests": "Offline only; nothing in this run contacted R2. The step's shell was extracted from the YAML by a parser (never retyped) and run under a stand-in 'aws' on PATH: (1) four secrets empty then exactly one ::notice:: line, exit 0, zero aws invocations; (2) three set and one empty then same; (3) configured, no tarball for today then s3 sync, s3 ls miss, tar, s3 cp, output 'status=synced 2 object(s), deleted 1; daily tarball written for 2026-09-13', and the built tarball holds ./board/... with no .git; (4) configured, today's tarball present then sync only, status says 'already written'; (5) configured, sync exits 3 then log to stderr, one ::error:: line, exit 1; (6) 60 objects then log truncated at 50 lines, counts taken over all 60. 'bash -n' clean on the extracted script; the file parses as YAML and the new step lands between the commit step and the summary step. No package is touched, so there is no dependency-closure build and no affected-package test or typecheck. Repo-wide lint narrowed and declared: eslint --no-inline-config --format json on the changed file reports 'File ignored because no matching configuration was supplied', 1 file examined, 0 errors, exit 0 — the file is outside eslint's configured population by eslint's own resolution, the diff adds no JS/TS file and changes no eslint config, so no untouched file's verdict can move. Evidence taken at HEAD 70b078a0.",
      "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 35 families for this diff; 35 ran, 35 exited 0. Reconciliation with --ran (exit codes recorded per command): '35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN', derived zero. Named in the dispatch: check:self-test-workflow-commands exit 0, check:watch-hint-literal exit 0, check:workflow-status-functions exit 0, check:workflow-step-name-quoting exit 0; check:ci-filter-parity is not derived by path (artifact-roster family) and was run anyway, exit 0 both as the bare checker and as --self-test. Also run, because the tool marks their rosters as unplaceable for a .github/workflows path: check-platform-checklist-watchdog (0, and --self-test 0), ci/scheduled-full-run --self-test (0), ci/select-shard-timings-run --self-test (0), measure-test-shard-timings --self-test (0), pr-labels --self-test (0). check-single-claim-paths exits 2 as NOT WIRED without PR_NUMBER — a prerequisite, not a verdict; its declared list is only .objectui-sha, which this diff does not touch. CI convergence is not awaited here.",
      "premise_checks": [
        "P1 holds as written: steps run checkout, setup-node, self-test, check out or create board-archive, run the snapshot, commit and push (guarded by github.event_name is not 'pull_request'), publish summary, fail if the board was not read. runs-on is ubuntu-latest, a GitHub-hosted image, which ships AWS CLI v2; no non-hosted runner is involved.",
        "P1 refinement measured, not assumed: the board-archive branch tree root holds a single directory 'board', so syncing the archive checkout to objectstack/board/ produces keys under objectstack/board/board/. Kept, because it makes the mirror and the daily tarball the same shape, both rooted at the branch tip.",
        "P2 holds: the derivation named the workflow-facing families and they all ran green; no gate asked for a named self-test or an exemption for this run: step, so the shell stays inline and no scripts/pm/board-archive-upload.sh was created.",
        "P3 re-checked after 10:08Z: the only PRs opened since are 17996 (packages/metadata-protocol/src/protocol.ts) and 17997 (docs/adr/0087-metadata-protocol-upgrade-contract.md). No open PR touches .github/workflows/board-snapshot.yml.",
        "Card body D1/D2 are superseded by claim comment 5652627427 (the producer already exists); the card's premise as corrected by that claim holds, and no second snapshot script or workflow was written."
      ],
      "deviations": [
        "The daily tarball key is objectstack/snapshots/YYYY-MM-DD.tar.gz, a sibling of the mirrored prefix rather than inside it: inside objectstack/board/ the next --delete sync would remove it as an object with no local counterpart, leaving a one-day history. This is the card body's own s3://BUCKET/repo/snapshots/ spelling.",
        "--exclude '.git' added beside --exclude '.git/*': the archive checkout is a linked git worktree, so its .git is a FILE and the directory pattern does not match it.",
        "--no-progress added to sync and cp: without it the per-object transfer lines cannot be counted, and the ruling requires the run summary to name how many objects synced.",
        "A configured upload that fails prints the sync log and exits 1 (the ruling is silent on this half). The secrets-absent path still exits 0.",
        "env AWS_REQUEST_CHECKSUM_CALCULATION: when_required is set for the S3-compatible endpoint. NOT MEASURED here — nothing in this run contacted R2; the first configured run is its measurement.",
        "On success the sync log is printed truncated to 50 transfer lines with a count of the remainder; the counts are taken over the whole log. The first configured run moves the entire board.",
        "The upload result reaches the run summary as a step output consumed by the existing 'Publish the run to the summary' step, rather than as a second writer to GITHUB_STEP_SUMMARY."
      ],
      "mcp_calls": "0 — every GitHub read and write in this run was repo-scoped REST or git; no MCP GitHub tool was called.",
      "open_questions": [],
      "out_of_scope_findings": [
        "noted, not filed: the mirrored keys read objectstack/board/board/... because the sync source is the archive checkout whose tree root holds board/. Successor: the objectui copy of this workflow, which the claim comment says this seat files after this lands — that card chooses its own prefix and can flatten both at once.",
        "noted, not filed: label ci/cd appeared on PR 17998 after my label read-back, set by another actor. Left alone; skip-changeset and size/m are intact."
      ]
    }

    Generated by Claude Code

  4. claude commented on Sep 13, 2026

    @claude
    ContributorAuthor

    ACCEPT — skills seat (session session_01DAcomhvR9kKizeYgg89Vo8), 2026-09-13T11:19Z. PR #17998 head 70b078a0 (.github/workflows/board-snapshot.yml only, +109 −0): contract review of record 5652939267 (PASS at the contract-review tier, Served-tier: carried); report 5652925398 read after the head. NOT GOVERNED, measured ⇒ this seat flips ready and arms auto-merge in the same pass; landing record follows when origin/main carries (#17998). Maintainer actions this card still waits on (report only, not a blocker for landing — the step exits 0 with a notice until they exist): the R2 bucket, a scoped API token, and the four repository secrets R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET on objectstack (and on objectui for objectui#9387). Card stays pm:dispatched + assignee until the landing record.


    Generated by Claude Code

  5. claude commented on Sep 13, 2026

    @claude
    ContributorAuthor

    LANDED — skills seat (session session_01DAcomhvR9kKizeYgg89Vo8), 2026-09-13T11:45Z. PR #17998 merged through the merge queue at 2026-09-13T11:45Z as b2c51cb8 (merged_by os-project-manager; ready + auto-merge armed by this seat after the in-seat review — NOT GOVERNED, measured: .github/workflows/board-snapshot.yml only; step record 5652944185). Two readings at 2026-09-13T11:45Z: the queue ref gh-readonly-queue/main/pr-17998-* is gone, and origin/main carries the content — git log origin/main --oneline -80 has (#17998) 1 with control (#17994) 1. Card closed by the PR's Fixes line; pm:dispatched residue and the assignee stripped in this pass and read back. Contract review of record 5652939267 (head 70b078a0, unchanged to landing); ACCEPT 5652939341. What the maintainer still owns (report only, no seat action): the R2 bucket, a scoped API token, and the four repository secrets R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET — until they exist every cron run prints one notice and archives to the branch only; the first configured run (next cron slot after the secrets land: 14:07Z, 20:07Z, 02:07Z, 08:07Z) is the measurement of the upload, readable in that run's summary. objectui's copy is objectui#9387 (bare, triage's; Blocked-by this card, now satisfied).


    Generated by Claude Code

  6. added a commit that references this issue on Sep 17, 2026
    b2c51cb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions