@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
name: Bump engine pin
|
|
2
|
+
|
|
3
|
+
# The public engine moves on its own cadence; this instance takes updates on ITS OWN
|
|
4
|
+
# schedule. The pull lives ENTIRELY on this side: the engine repo knows nothing about
|
|
5
|
+
# this shell (no dispatch wiring, no secrets upstream), and the pin doubles as this
|
|
6
|
+
# instance's release valve — upstream can move fast, updates land only when you say so.
|
|
7
|
+
# Roll back any update by reverting the pin commit.
|
|
8
|
+
#
|
|
9
|
+
# TRACK picks the update channel:
|
|
10
|
+
# release (default) — weekly (or on demand) this job finds the engine's latest
|
|
11
|
+
# GitHub RELEASE, and opens a PR moving the pin to that tag with the release
|
|
12
|
+
# notes in the body. Review the notes, merge, and deploy.yml ships it.
|
|
13
|
+
# Nothing lands without a human reading what changed.
|
|
14
|
+
# main — moves the pin straight to the engine's current main and pushes (no PR).
|
|
15
|
+
# For development instances that dogfood the bleeding edge.
|
|
16
|
+
#
|
|
17
|
+
# Push + trigger use the workflow's own GITHUB_TOKEN. In main mode the push
|
|
18
|
+
# deliberately does NOT retrigger workflows, so deploy.yml is started explicitly.
|
|
19
|
+
# In release mode the PR merge is performed by a human, which triggers deploy.yml
|
|
20
|
+
# through the normal push event.
|
|
21
|
+
#
|
|
22
|
+
# NB GitHub disables cron schedules after ~60 days without repo activity — the manual
|
|
23
|
+
# workflow_dispatch always works.
|
|
24
|
+
#
|
|
25
|
+
# ONE-TIME SETUP for release mode: allow Actions to open PRs on this repo —
|
|
26
|
+
# Settings → Actions → General → Workflow permissions → check "Allow GitHub
|
|
27
|
+
# Actions to create and approve pull requests" (or:
|
|
28
|
+
# gh api -X PUT repos/<owner>/<shell>/actions/permissions/workflow \
|
|
29
|
+
# -f default_workflow_permissions=write -F can_approve_pull_request_reviews=true).
|
|
30
|
+
|
|
31
|
+
on:
|
|
32
|
+
# The engine's own deploy-trigger.yml dispatches `engine-updated` the moment its main
|
|
33
|
+
# moves, to every shell named in its SHELL_REPO variable. Listening for it is what
|
|
34
|
+
# makes a shell update immediately instead of waiting for the next sweep — and
|
|
35
|
+
# without this trigger the dispatch arrives and does nothing, silently, which reads
|
|
36
|
+
# exactly like an instance that simply lags. The cron below stays as the fallback
|
|
37
|
+
# for whenever the dispatch does not arrive (token scope, fork, shell not in the list).
|
|
38
|
+
repository_dispatch:
|
|
39
|
+
types: [engine-updated]
|
|
40
|
+
schedule:
|
|
41
|
+
# Every 6 hours, not weekly. For an instance on the engine's SHELL_REPO list this
|
|
42
|
+
# is only a safety net — but a SELF-HOSTED instance is never on that list, so this
|
|
43
|
+
# cron is its ONLY automatic update, and a weekly sweep meant a security patch
|
|
44
|
+
# could sit unapplied for six days. Auto-update is the default because it is how
|
|
45
|
+
# fixes reach instances nobody is watching; detaching is a deliberate act (disable
|
|
46
|
+
# this workflow, or pin TRACK to a tag), never an accident of scheduling.
|
|
47
|
+
- cron: "17 */6 * * *" # 06:17 past every 6th hour UTC
|
|
48
|
+
workflow_dispatch:
|
|
49
|
+
|
|
50
|
+
env:
|
|
51
|
+
# SET BEFORE USE — this is an instance choice, not a default to leave alone:
|
|
52
|
+
# release follow tagged releases. A human cuts the tag, so an update never
|
|
53
|
+
# lands unread. Costs a lag, and it fails QUIETLY if tagging stops:
|
|
54
|
+
# an instance once followed tags, none were cut for months, and its
|
|
55
|
+
# pin drifted 87 commits behind the engine while looking healthy.
|
|
56
|
+
# main follow the engine's main. Always current, nothing to remember to
|
|
57
|
+
# cut, no silent stall — at the price of taking changes unread.
|
|
58
|
+
# Pick deliberately, and if you pick `release`, make sure something actually
|
|
59
|
+
# cuts tags (see the engine's own release cadence).
|
|
60
|
+
TRACK: release
|
|
61
|
+
|
|
62
|
+
permissions:
|
|
63
|
+
contents: write # push the pin-bump commit / branch
|
|
64
|
+
pull-requests: write # open the release-update PR
|
|
65
|
+
actions: write # start deploy.yml via workflow_dispatch (main mode)
|
|
66
|
+
|
|
67
|
+
jobs:
|
|
68
|
+
bump:
|
|
69
|
+
runs-on: ubuntu-latest
|
|
70
|
+
steps:
|
|
71
|
+
- uses: actions/checkout@v4
|
|
72
|
+
with:
|
|
73
|
+
submodules: false
|
|
74
|
+
|
|
75
|
+
- name: Move the engine pin
|
|
76
|
+
env:
|
|
77
|
+
GH_TOKEN: ${{ github.token }}
|
|
78
|
+
run: |
|
|
79
|
+
set -euo pipefail
|
|
80
|
+
ENGINE_URL="$(git config -f .gitmodules submodule.engine.url)"
|
|
81
|
+
ENGINE_REPO="$(echo "$ENGINE_URL" | sed -E 's#(git@github.com:|https://github.com/)##; s#\.git$##')"
|
|
82
|
+
CUR_SHA="$(git ls-tree HEAD engine | awk '{print $3}')"
|
|
83
|
+
git config user.name "augur-bot"
|
|
84
|
+
git config user.email "augur-bot@users.noreply.github.com"
|
|
85
|
+
|
|
86
|
+
if [ "$TRACK" = "main" ]; then
|
|
87
|
+
NEW_SHA="$(git ls-remote "$ENGINE_URL" refs/heads/main | cut -f1)"
|
|
88
|
+
[ -n "$NEW_SHA" ] || { echo "::error::could not resolve the engine's main"; exit 1; }
|
|
89
|
+
[ "$NEW_SHA" != "$CUR_SHA" ] || { echo "Engine pin already at $NEW_SHA — nothing to do."; exit 0; }
|
|
90
|
+
git update-index --cacheinfo "160000,$NEW_SHA,engine"
|
|
91
|
+
git commit -m "Bump engine pin → ${NEW_SHA:0:12}"
|
|
92
|
+
# RETRY ON A LOST RACE, because losing one silently strands the instance.
|
|
93
|
+
# Anyone pushing to this shell while the bump runs — a doc edit, a config
|
|
94
|
+
# change — makes the push non-fast-forward. That failure is not "try again
|
|
95
|
+
# later": nothing tries again until the next engine push or the next cron
|
|
96
|
+
# sweep, so the instance sits on a stale engine and nothing says so. Observed
|
|
97
|
+
# on a live shell, one second apart.
|
|
98
|
+
#
|
|
99
|
+
# The retry RE-APPLIES the pin onto whatever landed, rather than rebasing:
|
|
100
|
+
# this checkout is shallow (actions/checkout fetches one commit), so a rebase
|
|
101
|
+
# has no common ancestor to work from and fails for a reason that has nothing
|
|
102
|
+
# to do with the conflict. Re-applying needs no history at all, and it is
|
|
103
|
+
# exactly as correct — the commit sets ONE value, a submodule pointer that was
|
|
104
|
+
# resolved before the loop.
|
|
105
|
+
pushed=""
|
|
106
|
+
for attempt in 1 2 3; do
|
|
107
|
+
if git push origin HEAD:main; then pushed=1; break; fi
|
|
108
|
+
echo "push rejected (attempt $attempt) — someone else pushed; re-applying the pin"
|
|
109
|
+
git fetch origin main
|
|
110
|
+
git reset --hard FETCH_HEAD
|
|
111
|
+
if [ "$(git ls-tree HEAD engine | awk '{print $3}')" = "$NEW_SHA" ]; then
|
|
112
|
+
echo "Another run already pinned ${NEW_SHA:0:12} — nothing to do."; exit 0
|
|
113
|
+
fi
|
|
114
|
+
git update-index --cacheinfo "160000,$NEW_SHA,engine"
|
|
115
|
+
git commit -m "Bump engine pin → ${NEW_SHA:0:12}"
|
|
116
|
+
done
|
|
117
|
+
[ -n "$pushed" ] || { echo "::error::could not push the pin bump after 3 attempts"; exit 1; }
|
|
118
|
+
echo "Bumped engine pin: ${CUR_SHA:0:12} → ${NEW_SHA:0:12} — starting deploy.yml"
|
|
119
|
+
gh workflow run deploy.yml --ref main
|
|
120
|
+
exit 0
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
# release mode — latest release tag → PR with the release notes.
|
|
124
|
+
TAG="$(gh api "repos/$ENGINE_REPO/releases/latest" --jq .tag_name 2>/dev/null || true)"
|
|
125
|
+
[ -n "$TAG" ] || { echo "No releases published on $ENGINE_REPO yet — nothing to do."; exit 0; }
|
|
126
|
+
NEW_SHA="$(git ls-remote "$ENGINE_URL" "refs/tags/$TAG^{}" | cut -f1)"
|
|
127
|
+
[ -n "$NEW_SHA" ] || NEW_SHA="$(git ls-remote "$ENGINE_URL" "refs/tags/$TAG" | cut -f1)"
|
|
128
|
+
[ -n "$NEW_SHA" ] || { echo "::error::could not resolve tag $TAG"; exit 1; }
|
|
129
|
+
[ "$NEW_SHA" != "$CUR_SHA" ] || { echo "Engine pin already at $TAG — nothing to do."; exit 0; }
|
|
130
|
+
BRANCH="engine-$TAG"
|
|
131
|
+
if gh pr list --head "$BRANCH" --state open --json number --jq length | grep -qv '^0$'; then
|
|
132
|
+
echo "Update PR for $TAG already open — nothing to do."; exit 0
|
|
133
|
+
fi
|
|
134
|
+
git checkout -b "$BRANCH"
|
|
135
|
+
git update-index --cacheinfo "160000,$NEW_SHA,engine"
|
|
136
|
+
git commit -m "Engine update: $TAG"
|
|
137
|
+
git push -f origin "$BRANCH"
|
|
138
|
+
NOTES="$(gh api "repos/$ENGINE_REPO/releases/latest" --jq .body || echo "")"
|
|
139
|
+
URL="$(gh api "repos/$ENGINE_REPO/releases/latest" --jq .html_url || echo "")"
|
|
140
|
+
printf 'Engine update to **%s** (`%s`).\n\n%s\n\n---\nRelease: %s\nMerging deploys it. Roll back later by reverting this commit.\n' \
|
|
141
|
+
"$TAG" "${NEW_SHA:0:12}" "$NOTES" "$URL" > /tmp/pr-body.md
|
|
142
|
+
gh pr create --title "Engine update: $TAG" --body-file /tmp/pr-body.md --base main --head "$BRANCH"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
name: Health canary
|
|
2
|
+
|
|
3
|
+
# Deploy-path monitoring. Space content ships via `augur publish` into the bundle
|
|
4
|
+
# store, not via pushes — so the failure worth catching is DRIFT: someone pushed
|
|
5
|
+
# work to a space repo and nothing published it, and the live site quietly lags
|
|
6
|
+
# what is in git. Nobody notices on their own, because everyone's own clone looks
|
|
7
|
+
# right.
|
|
8
|
+
#
|
|
9
|
+
# a. every space in the roster (deploy.config.json `spaces`): does the live
|
|
10
|
+
# /_build.json sha match the space repo's main HEAD? Drift older than a
|
|
11
|
+
# grace window = pushed-but-never-published (or a failed publish).
|
|
12
|
+
# b. a DIRTY publish that has outlived its window. Live content built from an
|
|
13
|
+
# uncommitted working tree exists in NO repository, so it cannot be rebuilt,
|
|
14
|
+
# reviewed or rolled forward. Fine for an hour of active work; not fine left
|
|
15
|
+
# standing.
|
|
16
|
+
# c. is AUGUR_PIN_TOKEN alive, if this shell uses one (roster-update.yml pushes
|
|
17
|
+
# with it)? Skipped when the secret is unset.
|
|
18
|
+
# d. is the site up at all? (implicit in (a)'s stamp fetch.)
|
|
19
|
+
# e. is this shell RUNNING what the engine's templates say it should be running?
|
|
20
|
+
# Same question as (a), one layer up — a fix that lands in templates/shell/ and
|
|
21
|
+
# never reaches the shells that already had a copy is invisible exactly the way
|
|
22
|
+
# publish drift is. Compared against the engine submodule, i.e. this shell's own
|
|
23
|
+
# pinned engine.
|
|
24
|
+
# g. is a gated path actually GATED? Nothing checked this until now, and (d) is why it
|
|
25
|
+
# needed its own check: /_build.json is a STATIC FILE, so a bare host with no worker
|
|
26
|
+
# running at all answers it correctly and this canary reports healthy. The probe asks
|
|
27
|
+
# for files that exist in the asset directory and must never be served — the roster
|
|
28
|
+
# at __config/instance.json, the worker, the modules it imports — and requires the
|
|
29
|
+
# answer not to BE those files. It matters most on a plain-Worker deploy, where the
|
|
30
|
+
# platform serves a matching asset before invoking the worker unless the config says
|
|
31
|
+
# otherwise, so this failure is one config line away and invisible to every other
|
|
32
|
+
# check here.
|
|
33
|
+
# f. is each space's BAKED chrome current with the serving engine? Page-level chrome
|
|
34
|
+
# (rail, overlays, layout) is baked at publish time (spaces.<id>.builtWithEngine),
|
|
35
|
+
# so an engine bump can leave a space serving older chrome than the deployed engine
|
|
36
|
+
# (engine.sha). deploy.yml nudges a re-bake and each space self-heals on a schedule;
|
|
37
|
+
# this catches the case where BOTH missed and the drift has outlived its window.
|
|
38
|
+
#
|
|
39
|
+
# On any failure the run exits non-zero (GitHub emails the owner) AND maintains a
|
|
40
|
+
# single open issue labelled `health`; a green run closes it.
|
|
41
|
+
#
|
|
42
|
+
# SET BEFORE USE: SITE below. Secrets: SUBMODULE_PAT (Contents:read on every
|
|
43
|
+
# space repo in the roster).
|
|
44
|
+
|
|
45
|
+
on:
|
|
46
|
+
schedule:
|
|
47
|
+
- cron: "23 */6 * * *" # every 6 hours (offset minute — GitHub drops top-of-hour crons)
|
|
48
|
+
workflow_dispatch:
|
|
49
|
+
|
|
50
|
+
permissions:
|
|
51
|
+
contents: read
|
|
52
|
+
issues: write # create/comment/close the health issue with the default token
|
|
53
|
+
|
|
54
|
+
concurrency:
|
|
55
|
+
group: health
|
|
56
|
+
cancel-in-progress: true
|
|
57
|
+
|
|
58
|
+
jobs:
|
|
59
|
+
health:
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
env:
|
|
62
|
+
SITE: https://your-site-origin.example # ← your deploy.config.json siteOrigin
|
|
63
|
+
# NB: the `runner` context is not available in job-level env, so a literal path.
|
|
64
|
+
FAILS: /tmp/health-failures.txt
|
|
65
|
+
GRACE_SECONDS: "3600" # 1h: a human or agent may publish minutes after pushing
|
|
66
|
+
DIRTY_GRACE_SECONDS: "21600" # 6h: an active session may legitimately sit dirty
|
|
67
|
+
REBAKE_GRACE_SECONDS: "28800" # 8h: > the space self-heal poll interval (6h), so (f)
|
|
68
|
+
# only fires once a re-bake has genuinely been missed
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/checkout@v4
|
|
71
|
+
with:
|
|
72
|
+
# The engine submodule is here for check (e) only — the roster is
|
|
73
|
+
# deploy.config.json and needs no checkout. It is the shell's OWN pinned
|
|
74
|
+
# engine, which is exactly the version its workflows should be compared to.
|
|
75
|
+
submodules: true
|
|
76
|
+
token: ${{ secrets.SUBMODULE_PAT }}
|
|
77
|
+
fetch-depth: 1
|
|
78
|
+
|
|
79
|
+
# (a)+(b) Live /_build.json sha vs each space repo's main HEAD. The stamp is
|
|
80
|
+
# written by `augur publish` (source sha + dirty flag + publishedAt), so
|
|
81
|
+
# equality means "what is in git is what is live". A recent push gets a grace
|
|
82
|
+
# window; so does a fresh working-tree publish, but only a window.
|
|
83
|
+
- name: Check live shas vs space repo HEADs (publish drift + dirty publishes)
|
|
84
|
+
env:
|
|
85
|
+
PAT: ${{ secrets.SUBMODULE_PAT }} # Contents:read on every space repo
|
|
86
|
+
run: |
|
|
87
|
+
set -euo pipefail
|
|
88
|
+
: > "$FAILS"
|
|
89
|
+
NOW=$(date -u +%s)
|
|
90
|
+
if ! STAMP="$(curl -fsS --max-time 30 "$SITE/_build.json")"; then
|
|
91
|
+
echo "could not fetch $SITE/_build.json — site down or stamp gone" >> "$FAILS"
|
|
92
|
+
exit 0
|
|
93
|
+
fi
|
|
94
|
+
jq -r '.spaces[]? | "\(.id) \(.repo)"' deploy.config.json | while read -r ID REPO; do
|
|
95
|
+
HEAD_SHA="$(GH_TOKEN="$PAT" gh api "repos/${REPO}/branches/main" --jq .commit.sha 2>/dev/null || true)"
|
|
96
|
+
if [ -z "$HEAD_SHA" ]; then
|
|
97
|
+
echo "cannot read ${REPO} main HEAD with SUBMODULE_PAT (token dead/expired, or repo access revoked?)" >> "$FAILS"
|
|
98
|
+
continue
|
|
99
|
+
fi
|
|
100
|
+
LIVE_SHA="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].sha // empty')"
|
|
101
|
+
DIRTY="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].dirty // false')"
|
|
102
|
+
if [ "$LIVE_SHA" = "$HEAD_SHA" ] && [ "$DIRTY" != "true" ]; then
|
|
103
|
+
echo "OK: $ID live sha matches main HEAD (${HEAD_SHA:0:12})"
|
|
104
|
+
continue
|
|
105
|
+
fi
|
|
106
|
+
PUSHED_AT="$(GH_TOKEN="$PAT" gh api "repos/${REPO}" --jq .pushed_at 2>/dev/null || true)"
|
|
107
|
+
AGE=$(( NOW - $(date -u -d "${PUSHED_AT:-1970-01-01T00:00:00Z}" +%s) ))
|
|
108
|
+
if [ "$DIRTY" = "true" ]; then
|
|
109
|
+
PUB_AT="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].publishedAt // empty')"
|
|
110
|
+
DIRTY_AGE=$(( NOW - $(date -u -d "${PUB_AT:-1970-01-01T00:00:00Z}" +%s) ))
|
|
111
|
+
if [ -n "$PUB_AT" ] && [ "$DIRTY_AGE" -le "$DIRTY_GRACE_SECONDS" ]; then
|
|
112
|
+
echo "OK: $ID live is a working-tree publish ${DIRTY_AGE}s ago — inside the ${DIRTY_GRACE_SECONDS}s window"
|
|
113
|
+
else
|
|
114
|
+
echo "dirty publish: $ID has been serving a working-tree publish (base ${LIVE_SHA:0:12}) for ${DIRTY_AGE}s — those exact bytes exist in NO repository. Commit the work and re-run \`augur publish\` from the $ID clone" >> "$FAILS"
|
|
115
|
+
fi
|
|
116
|
+
continue
|
|
117
|
+
fi
|
|
118
|
+
if [ "$AGE" -gt "$GRACE_SECONDS" ]; then
|
|
119
|
+
echo "publish drift: $ID main HEAD is ${HEAD_SHA:0:12} (pushed ${AGE}s ago) but live serves '${LIVE_SHA:-<absent>}' — pushed but never published; run \`augur publish\` from the $ID clone" >> "$FAILS"
|
|
120
|
+
else
|
|
121
|
+
echo "OK: $ID drifting but pushed ${AGE}s ago (inside the ${GRACE_SECONDS}s grace window)"
|
|
122
|
+
fi
|
|
123
|
+
done
|
|
124
|
+
|
|
125
|
+
# (f) Chrome-bake drift. engine.sha moves on every engine bump; a space's baked
|
|
126
|
+
# chrome (spaces.<id>.builtWithEngine) moves only when that space republishes. If a
|
|
127
|
+
# space is serving chrome older than the deployed engine and the drift has outlived
|
|
128
|
+
# REBAKE_GRACE_SECONDS (longer than the self-heal poll), the re-bake was missed.
|
|
129
|
+
- name: Check baked chrome vs the serving engine (re-bake drift)
|
|
130
|
+
run: |
|
|
131
|
+
set -uo pipefail
|
|
132
|
+
if ! STAMP="$(curl -fsS --max-time 30 "$SITE/_build.json")"; then
|
|
133
|
+
echo "no stamp — check (a) already recorded the site being down"; exit 0
|
|
134
|
+
fi
|
|
135
|
+
NOW=$(date -u +%s)
|
|
136
|
+
ENG="$(printf '%s' "$STAMP" | jq -r '.engine.sha // empty')"
|
|
137
|
+
if [ -z "$ENG" ]; then echo "no engine.sha in stamp — skipping (f)"; exit 0; fi
|
|
138
|
+
ENG_AT="$(printf '%s' "$STAMP" | jq -r '.engine.publishedAt // empty')"
|
|
139
|
+
ENG_AGE=$(( NOW - $(date -u -d "${ENG_AT:-1970-01-01T00:00:00Z}" +%s) ))
|
|
140
|
+
jq -r '.spaces[]? | "\(.id) \(.repo)"' deploy.config.json | while read -r ID REPO; do
|
|
141
|
+
BW="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].builtWithEngine // empty')"
|
|
142
|
+
DIRTY="$(printf '%s' "$STAMP" | jq -r --arg id "$ID" '.spaces[$id].dirty // false')"
|
|
143
|
+
if [ "$BW" = "$ENG" ]; then echo "OK: $ID baked chrome matches serving engine (${ENG:0:12})"; continue; fi
|
|
144
|
+
if [ "$DIRTY" = "true" ]; then echo "OK: $ID is a working-tree publish — chrome catches up when it republishes"; continue; fi
|
|
145
|
+
if [ "$ENG_AGE" -le "$REBAKE_GRACE_SECONDS" ]; then
|
|
146
|
+
echo "OK: $ID chrome ${BW:-<none>} vs engine ${ENG:0:12}, but engine deployed only ${ENG_AGE}s ago (grace)"; continue
|
|
147
|
+
fi
|
|
148
|
+
if [ -z "$BW" ]; then
|
|
149
|
+
echo "chrome drift: $ID has no builtWithEngine stamp ${ENG_AGE}s after the engine deployed — never re-baked; the shell's space-rebake should have converged this (check the 'Space re-bake' and 'Deploy site' runs), or run: gh workflow run space-rebake.yml" >> "$FAILS"
|
|
150
|
+
else
|
|
151
|
+
echo "chrome drift: $ID serves baked chrome ${BW:0:12} but the engine is ${ENG:0:12} (deployed ${ENG_AGE}s ago) — the re-bake was missed; the shell's space-rebake should have converged this (check the 'Space re-bake' and 'Deploy site' runs), or run: gh workflow run space-rebake.yml" >> "$FAILS"
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
154
|
+
|
|
155
|
+
# (g) Front-door integrity. The one check here that would notice the site serving
|
|
156
|
+
# its own roster to strangers. Skips gracefully on an engine pinned before the probe
|
|
157
|
+
# existed, same as (e), so bumping the engine is what turns it on.
|
|
158
|
+
- name: Check a gated path is gated (front-door parity)
|
|
159
|
+
run: |
|
|
160
|
+
set -uo pipefail
|
|
161
|
+
if [ ! -f engine/scripts/frontdoor-parity.mjs ]; then
|
|
162
|
+
echo "pinned engine predates frontdoor-parity — skipping (bump the engine to enable)"; exit 0
|
|
163
|
+
fi
|
|
164
|
+
if ! OUT="$(node engine/scripts/frontdoor-parity.mjs "$SITE" 2>&1)"; then
|
|
165
|
+
echo "$OUT"
|
|
166
|
+
echo "front door: a path that must be decided by the worker was answered by the platform instead — see the run log for which. If this instance is on a plain Worker, check run_worker_first = true in wrangler.toml" >> "$FAILS"
|
|
167
|
+
else
|
|
168
|
+
echo "$OUT"
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
# (c) AUGUR_PIN_TOKEN — only shells that wired roster write-back (or another
|
|
172
|
+
# push-must-retrigger-CI workflow) have one. Unset = nothing to monitor.
|
|
173
|
+
- name: Validate AUGUR_PIN_TOKEN
|
|
174
|
+
env:
|
|
175
|
+
GH_TOKEN: ${{ secrets.AUGUR_PIN_TOKEN }}
|
|
176
|
+
run: |
|
|
177
|
+
set -euo pipefail
|
|
178
|
+
if [ -z "${GH_TOKEN:-}" ]; then echo "AUGUR_PIN_TOKEN not configured — skipping"; exit 0; fi
|
|
179
|
+
if ! gh api "repos/${GITHUB_REPOSITORY}" --jq .full_name >/dev/null 2>&1; then
|
|
180
|
+
echo "AUGUR_PIN_TOKEN is dead/expired — workflows that push cannot retrigger CI (regenerate the PAT and update the secret)" >> "$FAILS"
|
|
181
|
+
else
|
|
182
|
+
echo "OK: AUGUR_PIN_TOKEN authenticates"
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
# (e) Template drift. Same shape as (a): compare what is RUNNING against what the
|
|
186
|
+
# source of truth says. Only behavioural divergence fails — a reworded comment or
|
|
187
|
+
# a filled-in placeholder is expected and stays quiet.
|
|
188
|
+
- name: Check workflows against the engine's templates
|
|
189
|
+
run: |
|
|
190
|
+
set -uo pipefail
|
|
191
|
+
if [ ! -f engine/scripts/shell-lint.mjs ]; then
|
|
192
|
+
echo "pinned engine predates shell-lint — skipping (bump the engine to enable)"; exit 0
|
|
193
|
+
fi
|
|
194
|
+
if ! OUT="$(node engine/scripts/shell-lint.mjs 2>&1)"; then
|
|
195
|
+
echo "$OUT"
|
|
196
|
+
echo "workflow drift: this shell is not running what its pinned engine's templates/shell/ says it should — see the run log for the diverging lines" >> "$FAILS"
|
|
197
|
+
else
|
|
198
|
+
echo "$OUT"
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
# Report — one issue, not a pile: create-or-comment on the single open
|
|
202
|
+
# `health` issue on failure (+ exit 1 so GitHub emails the owner); close it
|
|
203
|
+
# on recovery.
|
|
204
|
+
- name: Report — maintain the health issue
|
|
205
|
+
env:
|
|
206
|
+
GH_TOKEN: ${{ github.token }}
|
|
207
|
+
GH_REPO: ${{ github.repository }}
|
|
208
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
209
|
+
run: |
|
|
210
|
+
set -euo pipefail
|
|
211
|
+
gh label create health --color D93F0B --description "Deploy-path canary failures (health.yml)" 2>/dev/null || true
|
|
212
|
+
EXISTING="$(gh issue list --label health --state open --json number --jq '.[0].number // empty')"
|
|
213
|
+
if [ -s "$FAILS" ]; then
|
|
214
|
+
N="$(wc -l < "$FAILS" | tr -d ' ')"
|
|
215
|
+
FIRST="$(head -n1 "$FAILS" | cut -c1-100)"
|
|
216
|
+
if [ "$N" -gt 1 ]; then TITLE="🚨 Health: $N checks failing"; else TITLE="🚨 Health: $FIRST"; fi
|
|
217
|
+
BODY="$(printf 'Canary run: %s\n\nFailing checks:\n%s\n\n(Auto-maintained by health.yml — this issue closes itself when a run goes green.)' "$RUN_URL" "$(sed 's/^/- /' "$FAILS")")"
|
|
218
|
+
if [ -n "$EXISTING" ]; then
|
|
219
|
+
gh issue edit "$EXISTING" --title "$TITLE" >/dev/null
|
|
220
|
+
gh issue comment "$EXISTING" --body "$BODY" >/dev/null
|
|
221
|
+
echo "Updated open issue #$EXISTING"
|
|
222
|
+
else
|
|
223
|
+
gh issue create --title "$TITLE" --body "$BODY" --label health >/dev/null
|
|
224
|
+
echo "Opened a new health issue"
|
|
225
|
+
fi
|
|
226
|
+
echo "::error::health checks failing:"; sed 's/^/ - /' "$FAILS"
|
|
227
|
+
exit 1
|
|
228
|
+
fi
|
|
229
|
+
echo "All health checks green."
|
|
230
|
+
if [ -n "$EXISTING" ]; then
|
|
231
|
+
gh issue close "$EXISTING" --comment "All health checks green again — closing. ($RUN_URL)" >/dev/null
|
|
232
|
+
echo "Closed recovered issue #$EXISTING"
|
|
233
|
+
fi
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
name: KV backup (review archive)
|
|
2
|
+
|
|
3
|
+
# Nightly export of the production KV namespace — the comment/annotation threads,
|
|
4
|
+
# dev statuses, pins, renames and canvas state that make up the team's review
|
|
5
|
+
# decision record. KV is the ONLY copy (Cloudflare has no point-in-time restore
|
|
6
|
+
# for KV), so a bad write / bulk delete / account mishap would be unrecoverable
|
|
7
|
+
# without this.
|
|
8
|
+
#
|
|
9
|
+
# This is the OTHER half of the backup story, and it does not overlap with
|
|
10
|
+
# store-backup.yml: that one copies published CONTENT out of the bundle store
|
|
11
|
+
# (R2); this one copies the mutable state the worker accumulates around it.
|
|
12
|
+
# A shell needs both. Neither covers the other.
|
|
13
|
+
#
|
|
14
|
+
# The export lists every key (paginated), GETs each raw value, assembles one JSON
|
|
15
|
+
# object {key: value}, and commits it to the orphan `kv-backups` branch as
|
|
16
|
+
# backup-YYYY-MM-DD.json plus latest.json, pruning dated files older than 30 days.
|
|
17
|
+
# The branch is created by this workflow on first run. Restore = read the JSON,
|
|
18
|
+
# PUT each pair back via the same API.
|
|
19
|
+
#
|
|
20
|
+
# ⚠️ A KV VALUE IS BYTES, NOT TEXT, and that distinction cost this job every canvas
|
|
21
|
+
# board image it ever copied. `jq --rawfile` carries a value as a JSON string, and a
|
|
22
|
+
# JSON string carries TEXT: every byte sequence that is not valid UTF-8 became U+FFFD
|
|
23
|
+
# on the way in and no restore could bring it back. Board images are stored raw under
|
|
24
|
+
# `basset:<sha256-prefix>`, so the copy came back LONGER than the original, different,
|
|
25
|
+
# and no longer matching the checksum in its own key name — confidently wrong rather
|
|
26
|
+
# than visibly missing. So each value is round-tripped through jq and COMPARED with the
|
|
27
|
+
# bytes that came off the wire; anything that does not survive is carried as
|
|
28
|
+
# {"b64": "…"} instead. A value that is text is still a plain string, so a copy taken
|
|
29
|
+
# before this existed and one taken after are read by the same restore. Do not
|
|
30
|
+
# "simplify" the comparison away.
|
|
31
|
+
#
|
|
32
|
+
# ⚠️ THE SHELL REPO MUST BE PRIVATE. This export is the whole namespace, and the
|
|
33
|
+
# namespace holds `users:secrets` (PBKDF2 password hashes, and the tombstones that
|
|
34
|
+
# hold reset passwords out of service) and `publish:tokens` (live bearer tokens
|
|
35
|
+
# that can overwrite published content). Committing that to a public repo hands
|
|
36
|
+
# over the instance. Do not enable this workflow on a public shell.
|
|
37
|
+
#
|
|
38
|
+
# Secrets: CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID (the same ones deploy.yml
|
|
39
|
+
# uses) + AUGUR_KV_NS (the KV namespace id this instance's worker is bound to).
|
|
40
|
+
|
|
41
|
+
on:
|
|
42
|
+
schedule:
|
|
43
|
+
- cron: "0 3 * * *" # nightly, 03:00 UTC
|
|
44
|
+
workflow_dispatch: # allow manual runs from the Actions tab
|
|
45
|
+
|
|
46
|
+
permissions:
|
|
47
|
+
contents: write # push the backup commit to the kv-backups branch
|
|
48
|
+
|
|
49
|
+
concurrency:
|
|
50
|
+
group: kv-backup
|
|
51
|
+
cancel-in-progress: false
|
|
52
|
+
|
|
53
|
+
jobs:
|
|
54
|
+
backup:
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
# ⛔ REFUSE TO RUN ON A PUBLIC REPOSITORY. First step, on purpose.
|
|
58
|
+
#
|
|
59
|
+
# A COMMENT IS NOT A GUARD, AND THIS HAS ALREADY HAPPENED. A public fork of the
|
|
60
|
+
# engine ran a workflow of this shape and published a full production KV export of
|
|
61
|
+
# a live instance — 58 keys, 26 internal comment threads, five real names and
|
|
62
|
+
# personal addresses — reachable with no authentication. GitHub shares git objects
|
|
63
|
+
# across a fork network, so the commit resolved through the PARENT repository's own
|
|
64
|
+
# raw URL: deleting the branch, deleting the fork and rewriting the parent's history
|
|
65
|
+
# all leave it fetchable, and only a GitHub Support object purge closes it. The
|
|
66
|
+
# header above this job said "the shell repo must be private" the whole time.
|
|
67
|
+
#
|
|
68
|
+
# IT FAILS, IT DOES NOT SKIP. A skip is worse than having no backup workflow at all,
|
|
69
|
+
# because the operator goes on believing a backup exists.
|
|
70
|
+
#
|
|
71
|
+
# AND IT FAILS WHEN IT CANNOT TELL. `github.event.repository.private` is absent on
|
|
72
|
+
# some event types, and an absent value is not a private repository — it is an
|
|
73
|
+
# unanswered question, and this is the one question that must not have a default.
|
|
74
|
+
- name: Refuse to run on a public repository
|
|
75
|
+
env:
|
|
76
|
+
GH_TOKEN: ${{ github.token }}
|
|
77
|
+
CTX_PRIVATE: ${{ github.event.repository.private }}
|
|
78
|
+
run: |
|
|
79
|
+
set -uo pipefail
|
|
80
|
+
PRIVATE="$(gh api "repos/${GITHUB_REPOSITORY}" --jq .private 2>/dev/null || true)"
|
|
81
|
+
if [ -z "$PRIVATE" ]; then PRIVATE="${CTX_PRIVATE:-}"; fi
|
|
82
|
+
case "$PRIVATE" in
|
|
83
|
+
true)
|
|
84
|
+
echo "OK: ${GITHUB_REPOSITORY} is private"
|
|
85
|
+
;;
|
|
86
|
+
false)
|
|
87
|
+
echo "::error::REFUSING TO RUN: ${GITHUB_REPOSITORY} is a PUBLIC repository. This job exports the instance's whole state and writes it into this repo. On a public repo that publishes it to anyone, permanently — a fork network keeps the objects fetchable through the parent's URL after the branch, the fork and the history are all gone. Make the repository private, or delete this workflow."
|
|
88
|
+
exit 1
|
|
89
|
+
;;
|
|
90
|
+
*)
|
|
91
|
+
echo "::error::REFUSING TO RUN: could not determine whether ${GITHUB_REPOSITORY} is private (got '${PRIVATE}'). An unanswered question is not a private repository."
|
|
92
|
+
exit 1
|
|
93
|
+
;;
|
|
94
|
+
esac
|
|
95
|
+
|
|
96
|
+
- uses: actions/checkout@v4 # main — the kv-backups branch is fetched/created below
|
|
97
|
+
|
|
98
|
+
# Export the whole namespace to one JSON object. The namespace is small
|
|
99
|
+
# (hundreds of keys), so a plain sequential loop is fine — no rate-limit
|
|
100
|
+
# dance needed.
|
|
101
|
+
- name: Export KV namespace
|
|
102
|
+
env:
|
|
103
|
+
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
104
|
+
CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
105
|
+
CF_KV_NS: ${{ secrets.AUGUR_KV_NS }}
|
|
106
|
+
run: |
|
|
107
|
+
set -euo pipefail
|
|
108
|
+
if [ -z "${CF_KV_NS:-}" ]; then
|
|
109
|
+
echo "::error::AUGUR_KV_NS is not configured — set it to this instance's KV namespace id."; exit 1
|
|
110
|
+
fi
|
|
111
|
+
BASE="https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_KV_NS}"
|
|
112
|
+
AUTH=(-H "Authorization: Bearer ${CF_API_TOKEN}")
|
|
113
|
+
WORK="${RUNNER_TEMP}/kv"
|
|
114
|
+
mkdir -p "$WORK/objs"
|
|
115
|
+
|
|
116
|
+
# 1) List all keys (cursor-paginated, 1000/page).
|
|
117
|
+
: > "$WORK/keys.txt"
|
|
118
|
+
CURSOR=""
|
|
119
|
+
while :; do
|
|
120
|
+
URL="${BASE}/keys?limit=1000"
|
|
121
|
+
[ -n "$CURSOR" ] && URL="${URL}&cursor=${CURSOR}"
|
|
122
|
+
RESP="$(curl -fsS --max-time 60 "${AUTH[@]}" "$URL")"
|
|
123
|
+
printf '%s' "$RESP" | jq -e .success >/dev/null # hard-fail on an API error envelope
|
|
124
|
+
printf '%s' "$RESP" | jq -r '.result[].name' >> "$WORK/keys.txt"
|
|
125
|
+
CURSOR="$(printf '%s' "$RESP" | jq -r '.result_info.cursor // empty')"
|
|
126
|
+
[ -z "$CURSOR" ] && break
|
|
127
|
+
done
|
|
128
|
+
COUNT="$(wc -l < "$WORK/keys.txt" | tr -d ' ')"
|
|
129
|
+
echo "Listed $COUNT keys."
|
|
130
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
131
|
+
echo "::error::namespace listed ZERO keys — wrong namespace id, or the data is gone; refusing to write an empty backup."; exit 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
# 2) GET each raw value, wrap it as a single-pair object, then merge all pairs.
|
|
135
|
+
I=0
|
|
136
|
+
BIN=0
|
|
137
|
+
while IFS= read -r KEY; do
|
|
138
|
+
ENC="$(jq -rn --arg k "$KEY" '$k|@uri')"
|
|
139
|
+
if ! curl -fsS --max-time 60 "${AUTH[@]}" -o "$WORK/value" "${BASE}/values/${ENC}"; then
|
|
140
|
+
echo "::warning::key vanished/failed mid-export, skipped: $KEY"; continue
|
|
141
|
+
fi
|
|
142
|
+
OUT="$WORK/objs/$(printf '%08d' "$I").json"
|
|
143
|
+
# >>> value-encode (the engine's test/kv-backup-workflow.test.mjs extracts and
|
|
144
|
+
# runs exactly these lines against a real PNG — keep them self-contained, and
|
|
145
|
+
# keep them using only $KEY, $WORK and $OUT.)
|
|
146
|
+
#
|
|
147
|
+
# Ask the bytes, do not assume: write the string form, read it back out with
|
|
148
|
+
# `jq -j` (raw, no trailing newline) and compare with cmp. Equal means the
|
|
149
|
+
# value is text and rides as a string, exactly as it always has. Anything else
|
|
150
|
+
# — a JPEG, a PNG, a lone invalid byte — rides as {"b64": "…"} and comes back
|
|
151
|
+
# byte for byte. `tr -d '\n'` rather than `base64 -w0`: -w is GNU-only.
|
|
152
|
+
if jq -n --arg k "$KEY" --rawfile v "$WORK/value" '{($k): $v}' > "$WORK/one.json" 2>/dev/null \
|
|
153
|
+
&& jq -j --arg k "$KEY" '.[$k]' "$WORK/one.json" > "$WORK/back" 2>/dev/null \
|
|
154
|
+
&& cmp -s "$WORK/value" "$WORK/back"; then
|
|
155
|
+
mv "$WORK/one.json" "$OUT"
|
|
156
|
+
else
|
|
157
|
+
base64 < "$WORK/value" | tr -d '\n' > "$WORK/b64"
|
|
158
|
+
jq -n --arg k "$KEY" --rawfile b "$WORK/b64" '{($k): {b64: ($b|rtrimstr("\n"))}}' > "$OUT"
|
|
159
|
+
BIN=$((BIN+1))
|
|
160
|
+
fi
|
|
161
|
+
# <<< value-encode
|
|
162
|
+
I=$((I+1))
|
|
163
|
+
done < "$WORK/keys.txt"
|
|
164
|
+
jq -s 'add' "$WORK"/objs/*.json > "$WORK/backup.json"
|
|
165
|
+
echo "Exported $(jq 'length' "$WORK/backup.json") key/value pairs ($BIN carried as base64 — values that are not UTF-8 text)."
|
|
166
|
+
|
|
167
|
+
# Commit to the orphan kv-backups branch (created here on first run), pruning
|
|
168
|
+
# dated backups older than 30 days. latest.json always mirrors today's file.
|
|
169
|
+
- name: Commit to kv-backups branch
|
|
170
|
+
run: |
|
|
171
|
+
set -euo pipefail
|
|
172
|
+
WORK="${RUNNER_TEMP}/kv"
|
|
173
|
+
git config user.name "augur-bot"
|
|
174
|
+
git config user.email "augur-bot@users.noreply.github.com"
|
|
175
|
+
if git fetch origin kv-backups 2>/dev/null; then
|
|
176
|
+
git checkout -B kv-backups FETCH_HEAD
|
|
177
|
+
else
|
|
178
|
+
echo "No kv-backups branch yet — creating the orphan."
|
|
179
|
+
git checkout --orphan kv-backups
|
|
180
|
+
git rm -rfq .
|
|
181
|
+
git clean -fdq
|
|
182
|
+
fi
|
|
183
|
+
TODAY="$(date -u +%F)"
|
|
184
|
+
cp "$WORK/backup.json" "backup-${TODAY}.json"
|
|
185
|
+
cp "$WORK/backup.json" latest.json
|
|
186
|
+
git add "backup-${TODAY}.json" latest.json
|
|
187
|
+
# Prune dated files older than 30 days (lexicographic compare works on YYYY-MM-DD).
|
|
188
|
+
CUTOFF="$(date -u -d '30 days ago' +%F)"
|
|
189
|
+
for F in backup-*.json; do
|
|
190
|
+
D="${F#backup-}"; D="${D%.json}"
|
|
191
|
+
if [[ "$D" < "$CUTOFF" ]]; then
|
|
192
|
+
git rm -q "$F"
|
|
193
|
+
echo "Pruned $F (older than $CUTOFF)"
|
|
194
|
+
fi
|
|
195
|
+
done
|
|
196
|
+
if git diff --cached --quiet; then
|
|
197
|
+
echo "Nothing changed since the last backup — no commit."
|
|
198
|
+
exit 0
|
|
199
|
+
fi
|
|
200
|
+
git commit -m "KV backup ${TODAY} ($(jq 'length' latest.json) keys)"
|
|
201
|
+
git push origin kv-backups
|