@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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. 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