@rexezuge/tooling 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +297 -2
- package/dist/eslint.d.ts +120 -0
- package/dist/eslint.d.ts.map +1 -0
- package/dist/eslint.js +551 -0
- package/dist/eslint.js.map +1 -0
- package/dist/functions/pages-proxy.d.ts +113 -0
- package/dist/functions/pages-proxy.d.ts.map +1 -0
- package/dist/functions/pages-proxy.js +131 -0
- package/dist/functions/pages-proxy.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/scripts/backup/d1-target.d.ts +35 -0
- package/dist/scripts/backup/d1-target.d.ts.map +1 -0
- package/dist/scripts/backup/d1-target.js +31 -0
- package/dist/scripts/backup/d1-target.js.map +1 -0
- package/dist/scripts/backup/destination-config.d.ts +61 -0
- package/dist/scripts/backup/destination-config.d.ts.map +1 -0
- package/dist/scripts/backup/destination-config.js +57 -0
- package/dist/scripts/backup/destination-config.js.map +1 -0
- package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
- package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
- package/dist/scripts/backup/encrypt-backup.js +98 -0
- package/dist/scripts/backup/encrypt-backup.js.map +1 -0
- package/dist/scripts/backup/naming.d.ts +44 -0
- package/dist/scripts/backup/naming.d.ts.map +1 -0
- package/dist/scripts/backup/naming.js +58 -0
- package/dist/scripts/backup/naming.js.map +1 -0
- package/dist/scripts/check-god-files.d.ts +164 -0
- package/dist/scripts/check-god-files.d.ts.map +1 -0
- package/dist/scripts/check-god-files.js +271 -0
- package/dist/scripts/check-god-files.js.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
- package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
- package/dist/scripts/ensure-spa-shell-stub.js +30 -0
- package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
- package/dist/scripts/init-secrets.d.ts +63 -0
- package/dist/scripts/init-secrets.d.ts.map +1 -0
- package/dist/scripts/init-secrets.js +240 -0
- package/dist/scripts/init-secrets.js.map +1 -0
- package/dist/scripts/lib/cli-args.d.ts +78 -0
- package/dist/scripts/lib/cli-args.d.ts.map +1 -0
- package/dist/scripts/lib/cli-args.js +116 -0
- package/dist/scripts/lib/cli-args.js.map +1 -0
- package/dist/scripts/lib/github-actions.d.ts +26 -0
- package/dist/scripts/lib/github-actions.d.ts.map +1 -0
- package/dist/scripts/lib/github-actions.js +38 -0
- package/dist/scripts/lib/github-actions.js.map +1 -0
- package/dist/scripts/lib/wrangler-table.d.ts +46 -0
- package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
- package/dist/scripts/lib/wrangler-table.js +99 -0
- package/dist/scripts/lib/wrangler-table.js.map +1 -0
- package/dist/scripts/migrations-lock.d.ts +3 -0
- package/dist/scripts/migrations-lock.d.ts.map +1 -0
- package/dist/scripts/migrations-lock.js +46 -0
- package/dist/scripts/migrations-lock.js.map +1 -0
- package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
- package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
- package/dist/scripts/prepare-wrangler-config.js +50 -0
- package/dist/scripts/prepare-wrangler-config.js.map +1 -0
- package/dist/scripts/spa-shell.d.ts +41 -0
- package/dist/scripts/spa-shell.d.ts.map +1 -0
- package/dist/scripts/spa-shell.js +155 -0
- package/dist/scripts/spa-shell.js.map +1 -0
- package/dist/scripts/validate-locales.d.ts +26 -0
- package/dist/scripts/validate-locales.d.ts.map +1 -0
- package/dist/scripts/validate-locales.js +350 -0
- package/dist/scripts/validate-locales.js.map +1 -0
- package/dist/scripts/verify-migrations.d.ts +62 -0
- package/dist/scripts/verify-migrations.d.ts.map +1 -0
- package/dist/scripts/verify-migrations.js +302 -0
- package/dist/scripts/verify-migrations.js.map +1 -0
- package/dist/scripts/verify-spa-shell.d.ts +3 -0
- package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
- package/dist/scripts/verify-spa-shell.js +53 -0
- package/dist/scripts/verify-spa-shell.js.map +1 -0
- package/dist/scripts/wrangler-config/cli.d.ts +22 -0
- package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/cli.js +51 -0
- package/dist/scripts/wrangler-config/cli.js.map +1 -0
- package/dist/scripts/wrangler-config/patches.d.ts +51 -0
- package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/patches.js +140 -0
- package/dist/scripts/wrangler-config/patches.js.map +1 -0
- package/dist/scripts/wrangler-config/resources.d.ts +70 -0
- package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/resources.js +290 -0
- package/dist/scripts/wrangler-config/resources.js.map +1 -0
- package/dist/scripts/wrangler-config/types.d.ts +103 -0
- package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
- package/dist/scripts/wrangler-config/types.js +49 -0
- package/dist/scripts/wrangler-config/types.js.map +1 -0
- package/dist/test/integration-migrations.d.ts +167 -0
- package/dist/test/integration-migrations.d.ts.map +1 -0
- package/dist/test/integration-migrations.js +171 -0
- package/dist/test/integration-migrations.js.map +1 -0
- package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
- package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
- package/dist/test/mocks/cloudflare-workers.js +90 -0
- package/dist/test/mocks/cloudflare-workers.js.map +1 -0
- package/dist/vite.d.ts +117 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +125 -0
- package/dist/vite.js.map +1 -0
- package/dist/vitest-web.d.ts +73 -0
- package/dist/vitest-web.d.ts.map +1 -0
- package/dist/vitest-web.js +72 -0
- package/dist/vitest-web.js.map +1 -0
- package/dist/vitest.d.ts +92 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +128 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +58 -3
- package/src/eslint.test.ts +175 -0
- package/src/eslint.ts +640 -0
- package/src/functions/pages-proxy.test.ts +72 -0
- package/src/functions/pages-proxy.ts +187 -0
- package/src/github/actions/retry-step/action.yml +39 -0
- package/src/github/actions/setup-env/action.yml +20 -0
- package/src/github/dependabot.yml +30 -0
- package/src/github/workflows/backup-main.yml +46 -0
- package/src/github/workflows/continuous-deployment.yml +188 -0
- package/src/github/workflows/continuous-integration.yml +259 -0
- package/src/github/workflows/scheduled-version-update.yml +38 -0
- package/src/github/workflows/upstream-sync.yml +56 -0
- package/src/index.ts +42 -0
- package/src/scripts/backup/backup-rules.test.ts +105 -0
- package/src/scripts/backup/d1-target.ts +54 -0
- package/src/scripts/backup/destination-config.ts +92 -0
- package/src/scripts/backup/encrypt-backup.ts +107 -0
- package/src/scripts/backup/naming.ts +62 -0
- package/src/scripts/check-god-files.test.ts +131 -0
- package/src/scripts/check-god-files.ts +327 -0
- package/src/scripts/ensure-spa-shell-stub.ts +34 -0
- package/src/scripts/init-secrets.ts +265 -0
- package/src/scripts/lib/cli-args.ts +154 -0
- package/src/scripts/lib/github-actions.ts +41 -0
- package/src/scripts/lib/wrangler-table.ts +105 -0
- package/src/scripts/migrations-lock.ts +52 -0
- package/src/scripts/prepare-wrangler-config.ts +51 -0
- package/src/scripts/spa-shell.test.ts +91 -0
- package/src/scripts/spa-shell.ts +179 -0
- package/src/scripts/validate-locales.test.ts +89 -0
- package/src/scripts/validate-locales.ts +380 -0
- package/src/scripts/verify-migrations.test.ts +71 -0
- package/src/scripts/verify-migrations.ts +364 -0
- package/src/scripts/verify-spa-shell.ts +56 -0
- package/src/scripts/wrangler-config/cli.ts +51 -0
- package/src/scripts/wrangler-config/patches.ts +157 -0
- package/src/scripts/wrangler-config/resources.ts +330 -0
- package/src/scripts/wrangler-config/types.ts +113 -0
- package/src/test/integration-migrations.test.ts +169 -0
- package/src/test/integration-migrations.ts +267 -0
- package/src/test/mocks/cloudflare-workers.ts +115 -0
- package/src/vite.test.ts +83 -0
- package/src/vite.ts +202 -0
- package/src/vitest-web.ts +109 -0
- package/src/vitest.ts +185 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Continuous Integration — the converged gate for the family's worker repos.
|
|
2
|
+
#
|
|
3
|
+
# The most-common superset of the nine repos' CI workflows. Each job exists because
|
|
4
|
+
# its failure is invisible to every other job: a locale defect still typechecks, a
|
|
5
|
+
# god file still lints, an edited migration still deploys. A job per gate means the
|
|
6
|
+
# run log names the tool that broke rather than burying it in a `checks` job.
|
|
7
|
+
#
|
|
8
|
+
# PLACEHOLDERS — the things a consumer must change for this to run at all:
|
|
9
|
+
# * REPLACE_ME_SCOPE the npm scope, in the `pnpm --filter @REPLACE_ME_SCOPE/web build`
|
|
10
|
+
# invocations below.
|
|
11
|
+
# * REPLACE_ME_WEB_FILTER the workspace filter for the SPA (usually @REPLACE_ME_SCOPE/web).
|
|
12
|
+
# * REPLACE_ME_MIGRATIONS the D1 binding `wrangler d1 migrations apply` targets.
|
|
13
|
+
#
|
|
14
|
+
# The script names in the `run:` steps are the ones this package's scripts suggest;
|
|
15
|
+
# see README.md for the package.json wiring.
|
|
16
|
+
|
|
17
|
+
name: Continuous Integration
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read
|
|
21
|
+
|
|
22
|
+
on:
|
|
23
|
+
repository_dispatch:
|
|
24
|
+
types:
|
|
25
|
+
- Upstream Sync Integration
|
|
26
|
+
pull_request:
|
|
27
|
+
push:
|
|
28
|
+
branches:
|
|
29
|
+
- main
|
|
30
|
+
workflow_dispatch:
|
|
31
|
+
|
|
32
|
+
concurrency:
|
|
33
|
+
group: continuous-integration-${{ github.ref }}
|
|
34
|
+
cancel-in-progress: true
|
|
35
|
+
|
|
36
|
+
jobs:
|
|
37
|
+
# Typecheck and lint are separate from the gates that follow, because they need
|
|
38
|
+
# nothing but a checkout while the gates are read-only consumers of the tree.
|
|
39
|
+
checks:
|
|
40
|
+
name: Checks
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
|
|
43
|
+
steps:
|
|
44
|
+
- name: Checkout Repository
|
|
45
|
+
uses: actions/checkout@v6
|
|
46
|
+
|
|
47
|
+
- name: Setup Environment
|
|
48
|
+
uses: ./.github/actions/setup-env
|
|
49
|
+
|
|
50
|
+
# `pnpm -r typecheck` reaches every workspace package, `test/` among them.
|
|
51
|
+
- name: Run Type Check and Lint
|
|
52
|
+
run: pnpm -r typecheck && pnpm run lint
|
|
53
|
+
|
|
54
|
+
# Its own step, and read-only, because the `lint` job cannot catch this:
|
|
55
|
+
# `pnpm run lint` is `eslint --fix --quiet`, and `--fix` repairs what it can
|
|
56
|
+
# while `--quiet` suppresses the rest — so CI exits 0 on a tree Prettier
|
|
57
|
+
# rejects, the repairs land in the runner's checkout, and they are thrown
|
|
58
|
+
# away. `prettier --check` is the one command that fails instead of
|
|
59
|
+
# repairing. Edge-Sonic had 242 files in exactly that state.
|
|
60
|
+
- name: Check Formatting
|
|
61
|
+
run: pnpm run format:check
|
|
62
|
+
|
|
63
|
+
- name: Check God-Files
|
|
64
|
+
uses: ./.github/actions/retry-step
|
|
65
|
+
with:
|
|
66
|
+
command: pnpm run check:god-files
|
|
67
|
+
max_attempts: '2'
|
|
68
|
+
|
|
69
|
+
# A locale defect is invisible to the unit tests: the catalogs are data, and
|
|
70
|
+
# a key missing from every bundle still typechecks and still builds.
|
|
71
|
+
- name: Validate Locale Bundles
|
|
72
|
+
uses: ./.github/actions/retry-step
|
|
73
|
+
with:
|
|
74
|
+
command: pnpm run validate:locales
|
|
75
|
+
max_attempts: '2'
|
|
76
|
+
|
|
77
|
+
# Editing an already-applied migration is invisible to every other check:
|
|
78
|
+
# the deploy succeeds, fresh databases pick up the edited statements, and
|
|
79
|
+
# production keeps the schema it had. The lock is what turns that into a
|
|
80
|
+
# failure, and this job is read-only — `migrations-lock` is the operator's
|
|
81
|
+
# command, deliberately not CI's.
|
|
82
|
+
- name: Verify Migration Lock
|
|
83
|
+
uses: ./.github/actions/retry-step
|
|
84
|
+
with:
|
|
85
|
+
command: pnpm run validate:migrations
|
|
86
|
+
max_attempts: '2'
|
|
87
|
+
|
|
88
|
+
unit-tests:
|
|
89
|
+
name: Unit Tests
|
|
90
|
+
runs-on: ubuntu-latest
|
|
91
|
+
|
|
92
|
+
steps:
|
|
93
|
+
- name: Checkout Repository
|
|
94
|
+
uses: actions/checkout@v6
|
|
95
|
+
|
|
96
|
+
- name: Setup Environment
|
|
97
|
+
uses: ./.github/actions/setup-env
|
|
98
|
+
|
|
99
|
+
- name: Run Tests with Coverage
|
|
100
|
+
uses: ./.github/actions/retry-step
|
|
101
|
+
with:
|
|
102
|
+
command: pnpm run test:coverage
|
|
103
|
+
max_attempts: '2'
|
|
104
|
+
|
|
105
|
+
- name: Upload Coverage Report
|
|
106
|
+
if: always()
|
|
107
|
+
uses: actions/upload-artifact@v4
|
|
108
|
+
with:
|
|
109
|
+
name: coverage-report
|
|
110
|
+
path: coverage/
|
|
111
|
+
retention-days: 14
|
|
112
|
+
|
|
113
|
+
# The SPA's own suite. Only repositories with a Vite SPA keep this job; the two
|
|
114
|
+
# configs partition the suite rather than each running all of it, so a jsdom
|
|
115
|
+
# suite under `node` cannot pass while failing.
|
|
116
|
+
web-tests:
|
|
117
|
+
name: Web Tests
|
|
118
|
+
runs-on: ubuntu-latest
|
|
119
|
+
|
|
120
|
+
steps:
|
|
121
|
+
- name: Checkout Repository
|
|
122
|
+
uses: actions/checkout@v6
|
|
123
|
+
|
|
124
|
+
- name: Setup Environment
|
|
125
|
+
uses: ./.github/actions/setup-env
|
|
126
|
+
|
|
127
|
+
- name: Run SPA Unit Tests
|
|
128
|
+
run: pnpm run test:web
|
|
129
|
+
|
|
130
|
+
integration-tests:
|
|
131
|
+
name: Integration Tests
|
|
132
|
+
runs-on: ubuntu-latest
|
|
133
|
+
|
|
134
|
+
steps:
|
|
135
|
+
- name: Checkout Repository
|
|
136
|
+
uses: actions/checkout@v6
|
|
137
|
+
|
|
138
|
+
- name: Setup Environment
|
|
139
|
+
uses: ./.github/actions/setup-env
|
|
140
|
+
|
|
141
|
+
- name: Run Integration Tests
|
|
142
|
+
run: pnpm run test:integration
|
|
143
|
+
|
|
144
|
+
# Only the SPA build. The API worker is typechecked by `pnpm -r typecheck` above,
|
|
145
|
+
# so repeating it here would run the same `tsc --noEmit` against the same
|
|
146
|
+
# tsconfig a second time.
|
|
147
|
+
build:
|
|
148
|
+
name: Build Web App
|
|
149
|
+
runs-on: ubuntu-latest
|
|
150
|
+
|
|
151
|
+
steps:
|
|
152
|
+
- name: Checkout Repository
|
|
153
|
+
uses: actions/checkout@v6
|
|
154
|
+
|
|
155
|
+
- name: Setup Environment
|
|
156
|
+
uses: ./.github/actions/setup-env
|
|
157
|
+
|
|
158
|
+
# PLACEHOLDER: the workspace filter for the SPA.
|
|
159
|
+
- name: Build Web App
|
|
160
|
+
run: pnpm --filter @REPLACE_ME_SCOPE/web build
|
|
161
|
+
|
|
162
|
+
# Verifies the shell the API worker *serves*, which is a different artifact from
|
|
163
|
+
# `apps/web/dist/` — it is embedded into `apps/api/src/generated/spa-shell.ts` by
|
|
164
|
+
# the Vite plugin, and both are gitignored. A stale-but-present shell is a deploy
|
|
165
|
+
# that serves yesterday's operator UI.
|
|
166
|
+
#
|
|
167
|
+
# It builds first rather than depending on `build`, so the failure says "the
|
|
168
|
+
# served shell is wrong" instead of arriving inside a build log. That costs a
|
|
169
|
+
# second Vite build per run; the alternative — verifying inside `build` — is free
|
|
170
|
+
# and is the trade being made here.
|
|
171
|
+
spa-shell:
|
|
172
|
+
name: Verify SPA Shell
|
|
173
|
+
runs-on: ubuntu-latest
|
|
174
|
+
|
|
175
|
+
steps:
|
|
176
|
+
- name: Checkout Repository
|
|
177
|
+
uses: actions/checkout@v6
|
|
178
|
+
|
|
179
|
+
- name: Setup Environment
|
|
180
|
+
uses: ./.github/actions/setup-env
|
|
181
|
+
|
|
182
|
+
# PLACEHOLDER: the workspace filter for the SPA.
|
|
183
|
+
- name: Build Web App
|
|
184
|
+
run: pnpm --filter @REPLACE_ME_SCOPE/web build
|
|
185
|
+
|
|
186
|
+
# The build is a **precondition**, not a convenience: a fresh checkout has
|
|
187
|
+
# neither `apps/web/dist/` nor `apps/api/src/generated/`, and postinstall
|
|
188
|
+
# writes a deliberately empty stub so `tsc` can resolve `SPA_HTML` before
|
|
189
|
+
# anyone has built. Verifying without building cannot pass, and it fails
|
|
190
|
+
# with precisely the message the check exists to give.
|
|
191
|
+
- name: Verify The SPA Shell
|
|
192
|
+
uses: ./.github/actions/retry-step
|
|
193
|
+
with:
|
|
194
|
+
command: pnpm run verify:spa-shell
|
|
195
|
+
max_attempts: '2'
|
|
196
|
+
|
|
197
|
+
dependabot-auto-merge:
|
|
198
|
+
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.state == 'open' && !github.event.repository.fork }}
|
|
199
|
+
name: Auto-Merge Dependabot PR
|
|
200
|
+
needs:
|
|
201
|
+
- checks
|
|
202
|
+
- unit-tests
|
|
203
|
+
- web-tests
|
|
204
|
+
- integration-tests
|
|
205
|
+
- build
|
|
206
|
+
- spa-shell
|
|
207
|
+
permissions:
|
|
208
|
+
contents: write
|
|
209
|
+
pull-requests: write
|
|
210
|
+
runs-on: ubuntu-latest
|
|
211
|
+
|
|
212
|
+
steps:
|
|
213
|
+
- name: Checkout Repository
|
|
214
|
+
uses: actions/checkout@v6
|
|
215
|
+
|
|
216
|
+
# Auto-merge only after every gate above is green, which is what makes it
|
|
217
|
+
# safe: a dependency bump that breaks the suite waits for a human instead of
|
|
218
|
+
# merging itself.
|
|
219
|
+
- name: Enable Auto-Merge
|
|
220
|
+
uses: ./.github/actions/retry-step
|
|
221
|
+
with:
|
|
222
|
+
command: gh pr merge --auto --merge "$PR_URL"
|
|
223
|
+
max_attempts: '5'
|
|
224
|
+
env:
|
|
225
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
226
|
+
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
227
|
+
|
|
228
|
+
dispatch-upstream-sync-deployment:
|
|
229
|
+
if: ${{ github.event_name == 'repository_dispatch' && github.event.action == 'Upstream Sync Integration' }}
|
|
230
|
+
name: Dispatch Upstream Sync Deployment
|
|
231
|
+
needs:
|
|
232
|
+
- checks
|
|
233
|
+
- unit-tests
|
|
234
|
+
- web-tests
|
|
235
|
+
- integration-tests
|
|
236
|
+
- build
|
|
237
|
+
- spa-shell
|
|
238
|
+
permissions:
|
|
239
|
+
contents: write
|
|
240
|
+
runs-on: ubuntu-latest
|
|
241
|
+
|
|
242
|
+
steps:
|
|
243
|
+
- name: Checkout Repository
|
|
244
|
+
uses: actions/checkout@v6
|
|
245
|
+
|
|
246
|
+
# The second half of the upstream-sync contract: a fork's sync runs CI, and
|
|
247
|
+
# CI — not the sync workflow — is what authorises the deploy.
|
|
248
|
+
- name: Dispatch Deployment
|
|
249
|
+
uses: ./.github/actions/retry-step
|
|
250
|
+
with:
|
|
251
|
+
command: >
|
|
252
|
+
printf '{"event_type":"Upstream Sync Deployment","client_payload":{"branch":"%s","sha":"%s"}}\n'
|
|
253
|
+
"$PAYLOAD_BRANCH" "$PAYLOAD_SHA"
|
|
254
|
+
| gh api repos/${{ github.repository }}/dispatches --method POST --input -
|
|
255
|
+
max_attempts: '2'
|
|
256
|
+
env:
|
|
257
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
258
|
+
PAYLOAD_BRANCH: ${{ github.event.client_payload.branch || 'main' }}
|
|
259
|
+
PAYLOAD_SHA: ${{ github.event.client_payload.sha || github.sha }}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Scheduled Version Update — stamp .github/.version twice a month.
|
|
2
|
+
#
|
|
3
|
+
# The stamp is the family's cache-buster: the CD workflow reads it, and the
|
|
4
|
+
# backup workflows use it in branch names. Forked repos skip the schedule
|
|
5
|
+
# (`if: !github.event.repository.fork`) because a fork must not race its
|
|
6
|
+
# upstream's push.
|
|
7
|
+
|
|
8
|
+
name: Scheduled Version Update
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
schedule:
|
|
12
|
+
- cron: '0 0 1,15 * *'
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
update-version:
|
|
19
|
+
if: ${{ !github.event.repository.fork }}
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- name: Checkout Repository
|
|
24
|
+
uses: actions/checkout@v6
|
|
25
|
+
with:
|
|
26
|
+
persist-credentials: true
|
|
27
|
+
|
|
28
|
+
- name: Write Current Unix Timestamp
|
|
29
|
+
run: date +%s > .github/.version
|
|
30
|
+
|
|
31
|
+
- name: Commit and Push
|
|
32
|
+
run: |
|
|
33
|
+
git config user.name "github-actions[bot]"
|
|
34
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
35
|
+
|
|
36
|
+
git add .github/.version
|
|
37
|
+
git diff --staged --quiet || git commit -m "chore: update .version stamp"
|
|
38
|
+
git push
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Upstream Sync — a fork's scheduled pull from the repository it forked.
|
|
2
|
+
#
|
|
3
|
+
# Converged from eight repos (all but Records-Provider and CalDAV-Bridge ship this
|
|
4
|
+
# file, and the two that differ do so only in the upstream repository name).
|
|
5
|
+
#
|
|
6
|
+
# The contract has two sides and they change in the same commit: this workflow
|
|
7
|
+
# publishes `Upstream Sync Integration`, and continuous-integration.yml listens for
|
|
8
|
+
# it. The event is named `Upstream Sync Integration` rather than `Upstream Sync`
|
|
9
|
+
# precisely so it cannot be confused with the `Upstream Sync Deployment` event the
|
|
10
|
+
# deploy workflow listens for — one sync, two dispatches, and the name is what keeps
|
|
11
|
+
# a reader from wiring the wrong one.
|
|
12
|
+
|
|
13
|
+
name: Upstream Sync
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
schedule:
|
|
17
|
+
# Weekly, Monday 02:00 UTC. Off the hour on purpose: GitHub drops scheduled
|
|
18
|
+
# workflows that share a minute with high-traffic crons.
|
|
19
|
+
- cron: '0 2 * * 1'
|
|
20
|
+
workflow_dispatch:
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: write
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
sync:
|
|
27
|
+
if: ${{ !github.event.repository.fork }}
|
|
28
|
+
name: Sync From Upstream
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
|
|
31
|
+
steps:
|
|
32
|
+
- name: Checkout Repository
|
|
33
|
+
uses: actions/checkout@v6
|
|
34
|
+
|
|
35
|
+
# Pinned by commit rather than a tag: this action writes a config file and
|
|
36
|
+
# fetches a binary onto the runner, so a moved tag is a supply-chain change
|
|
37
|
+
# arriving through a version bump nobody reviewed.
|
|
38
|
+
- name: Sync Changes
|
|
39
|
+
id: sync
|
|
40
|
+
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
|
41
|
+
with:
|
|
42
|
+
# PLACEHOLDER: the upstream repository this one forks, as owner/name.
|
|
43
|
+
upstream_sync_repo: REPLACE_ME_ORG/REPLACE_ME_REPO
|
|
44
|
+
upstream_sync_branch: main
|
|
45
|
+
target_sync_branch: main
|
|
46
|
+
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
47
|
+
test_mode: false
|
|
48
|
+
|
|
49
|
+
- name: Trigger Continuous Integration
|
|
50
|
+
if: steps.sync.outputs.has_new_commits == 'true'
|
|
51
|
+
uses: ./.github/actions/retry-step
|
|
52
|
+
with:
|
|
53
|
+
command: gh api repos/${{ github.repository }}/dispatches --method POST -f event_type='Upstream Sync Integration'
|
|
54
|
+
max_attempts: '2'
|
|
55
|
+
env:
|
|
56
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@rexezuge/tooling` — the tooling layer of the kit.
|
|
3
|
+
*
|
|
4
|
+
* The config factories and the test helpers are re-exported here, so a consumer has
|
|
5
|
+
* one import for everything its `*.config.mts` files and its mocks need. The
|
|
6
|
+
* scripts under `scripts/` are **not** re-exported: they resolve paths relative to
|
|
7
|
+
* the repository they run in, so they are run from disk with `tsx`, not imported.
|
|
8
|
+
* See `README.md` for the copy-in recipe and the per-script usage.
|
|
9
|
+
*
|
|
10
|
+
* The zero-dependency rule: nothing here imports another `@rexezuge/*` package. The
|
|
11
|
+
* one exception in the tree is `scripts/backup/encrypt-backup.ts`, which uses
|
|
12
|
+
* `@rexezuge/d1`'s AES-GCM so the backup and the workers share one definition of
|
|
13
|
+
* "what an encryption key looks like"; that dependency is declared on the package
|
|
14
|
+
* and is only ever resolved in the consumer's repo, where the kit is installed
|
|
15
|
+
* alongside `@rexezuge/d1`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { defineRepoLintConfig } from './eslint';
|
|
19
|
+
export type { RepoLintConfigOptions } from './eslint';
|
|
20
|
+
|
|
21
|
+
export { defineUnitVitestConfig } from './vitest';
|
|
22
|
+
export type { CoverageThresholds, UnitVitestConfigOptions, VitestAlias } from './vitest';
|
|
23
|
+
|
|
24
|
+
export { defineWebVitestConfig } from './vitest-web';
|
|
25
|
+
export type { WebVitestConfigOptions } from './vitest-web';
|
|
26
|
+
|
|
27
|
+
export { defineWebViteConfig, spaShellEmbedPlugin } from './vite';
|
|
28
|
+
export type { ProxyEntry, SpaShellTarget, WebViteConfigOptions } from './vite';
|
|
29
|
+
|
|
30
|
+
export { createPagesProxy } from './functions/pages-proxy';
|
|
31
|
+
export type { Fetcher, PagesFunction, PagesFunctionContext, PagesProxyEnv, PagesProxyOptions } from './functions/pages-proxy';
|
|
32
|
+
|
|
33
|
+
// --- Test helpers ---------------------------------------------------------
|
|
34
|
+
//
|
|
35
|
+
// Aliased in a Vitest config, not imported by application code, so the mocks stay
|
|
36
|
+
// where the suites can reach them.
|
|
37
|
+
|
|
38
|
+
export { DurableObject, RpcTarget, WorkflowEntrypoint } from './test/mocks/cloudflare-workers';
|
|
39
|
+
export type { DurableObjectState, ExecutionContext } from './test/mocks/cloudflare-workers';
|
|
40
|
+
|
|
41
|
+
export { createMigrationHelper, readMigrationFiles, splitSql } from './test/integration-migrations';
|
|
42
|
+
export type { D1DatabaseLike, D1PreparedStatementLike, MigrationFile, MigrationHelper, MigrationHelperOptions, MigrationRange } from './test/integration-migrations';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveD1Target } from './d1-target';
|
|
3
|
+
import { evaluateConfig } from './destination-config';
|
|
4
|
+
import { backupFileName, backupStamp, requireRetentionDays, retentionCutoff } from './naming';
|
|
5
|
+
|
|
6
|
+
describe('resolveD1Target', () => {
|
|
7
|
+
it('prefers database_name, falling back to the binding', () => {
|
|
8
|
+
// `wrangler d1 export` takes a database *name*, so the name wins when present.
|
|
9
|
+
expect(resolveD1Target({ d1_databases: [{ binding: 'DB', database_name: 'my-db', database_id: 'abc' }] })).toStrictEqual({
|
|
10
|
+
binding: 'DB',
|
|
11
|
+
databaseId: 'abc',
|
|
12
|
+
target: 'my-db',
|
|
13
|
+
});
|
|
14
|
+
expect(resolveD1Target({ d1_databases: [{ binding: 'DB', database_id: 'abc' }] }).target).toBe('DB');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('refuses a placeholder id, because the database was never created', () => {
|
|
18
|
+
expect(() => resolveD1Target({ d1_databases: [{ binding: 'DB', database_id: '00000000-0000-0000-0000-000000000000' }] })).toThrow(
|
|
19
|
+
/template placeholder/,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('refuses a config with no binding to export under', () => {
|
|
24
|
+
expect(() => resolveD1Target({ d1_databases: [{ database_id: 'abc' }] })).toThrow(/No d1_databases entry with a binding/);
|
|
25
|
+
expect(() => resolveD1Target({})).toThrow(/No d1_databases entry with a binding/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('evaluateConfig', () => {
|
|
30
|
+
it('stays silent when nothing is configured', () => {
|
|
31
|
+
// An unconfigured repository is a legitimate "I do not want off-site backups"
|
|
32
|
+
// state and must not turn the schedule red.
|
|
33
|
+
expect(evaluateConfig({})).toMatchObject({ cloudflare: false, encryption: false, anyDestination: false, errors: [] });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('fails closed on a destination without an encryption key', () => {
|
|
37
|
+
const config = evaluateConfig({
|
|
38
|
+
CLOUDFLARE_API_TOKEN: 'token',
|
|
39
|
+
CLOUDFLARE_ACCOUNT_ID: 'account',
|
|
40
|
+
S3_ACCESS_KEY_ID: 'key',
|
|
41
|
+
S3_SECRET_ACCESS_KEY: 'secret',
|
|
42
|
+
S3_BUCKET: 'bucket',
|
|
43
|
+
});
|
|
44
|
+
expect(config.s3).toBe(true);
|
|
45
|
+
expect(config.encryption).toBe(false);
|
|
46
|
+
expect(config.errors.join('\n')).toMatch(/BACKUP_ENCRYPTION_KEY is required/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails a destination with no Cloudflare credentials', () => {
|
|
50
|
+
expect(evaluateConfig({ BACKUP_ENCRYPTION_KEY: 'k', WEBDAV_URL: 'https://dav.example.com', WEBDAV_USER: 'u', WEBDAV_PASSWORD: 'p' }).errors).toStrictEqual(
|
|
51
|
+
['A backup destination is configured but CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are missing.'],
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('passes when both a destination and the key are present', () => {
|
|
56
|
+
const config = evaluateConfig({
|
|
57
|
+
CLOUDFLARE_API_TOKEN: 'token',
|
|
58
|
+
CLOUDFLARE_ACCOUNT_ID: 'account',
|
|
59
|
+
BACKUP_ENCRYPTION_KEY: 'key',
|
|
60
|
+
WEBDAV_URL: 'https://dav.example.com',
|
|
61
|
+
WEBDAV_USER: 'u',
|
|
62
|
+
WEBDAV_PASSWORD: 'p',
|
|
63
|
+
});
|
|
64
|
+
expect(config.errors).toStrictEqual([]);
|
|
65
|
+
expect(config.webdav).toBe(true);
|
|
66
|
+
expect(config.s3).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('treats whitespace-only secrets as absent', () => {
|
|
70
|
+
expect(evaluateConfig({ S3_ACCESS_KEY_ID: ' ', S3_SECRET_ACCESS_KEY: ' ', S3_BUCKET: '\t' }).s3).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('naming', () => {
|
|
75
|
+
it('names the artifact sortably, in UTC', () => {
|
|
76
|
+
// UTC rather than local: a retention window computed across a DST boundary is
|
|
77
|
+
// one day wide in one direction, and a backup pruned a day early is a backup
|
|
78
|
+
// that was never needed.
|
|
79
|
+
const stamp = backupStamp(new Date('2026-10-01T04:15:00.000Z'));
|
|
80
|
+
expect(stamp).toBe('2026-10-01_04-15-00');
|
|
81
|
+
expect(backupFileName(new Date('2026-10-01T04:15:00.000Z'))).toBe('backup_prod_2026-10-01_04-15-00.sql.xz.enc');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('computes a retention cutoff as a date, days back', () => {
|
|
85
|
+
expect(retentionCutoff(new Date('2026-10-31T12:00:00.000Z'), 30)).toBe('2026-10-01');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects a bad retention window rather than defaulting one', () => {
|
|
89
|
+
// Silently falling back would either prune everything or keep backups forever.
|
|
90
|
+
const before = process.env['BACKUP_RETENTION_DAYS'];
|
|
91
|
+
try {
|
|
92
|
+
process.env['BACKUP_RETENTION_DAYS'] = '30d';
|
|
93
|
+
expect(() => requireRetentionDays()).toThrow(/must be a positive number/);
|
|
94
|
+
process.env['BACKUP_RETENTION_DAYS'] = '-1';
|
|
95
|
+
expect(() => requireRetentionDays()).toThrow(/must be a positive number/);
|
|
96
|
+
delete process.env['BACKUP_RETENTION_DAYS'];
|
|
97
|
+
expect(() => requireRetentionDays()).toThrow(/must be a positive number/);
|
|
98
|
+
process.env['BACKUP_RETENTION_DAYS'] = '30';
|
|
99
|
+
expect(requireRetentionDays()).toBe(30);
|
|
100
|
+
} finally {
|
|
101
|
+
if (before === undefined) delete process.env['BACKUP_RETENTION_DAYS'];
|
|
102
|
+
else process.env['BACKUP_RETENTION_DAYS'] = before;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing which D1 database the backup workflow exports.
|
|
3
|
+
*
|
|
4
|
+
* Provenance: ported from Edge-Sonic's `scripts/backup/d1-target.ts`. Pure, so the
|
|
5
|
+
* empty-database guard is asserted without running Actions or touching a real
|
|
6
|
+
* account.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DEFAULT_UUID } from '../wrangler-config/types';
|
|
10
|
+
|
|
11
|
+
/** A D1 binding as a parsed wrangler config carries one. */
|
|
12
|
+
export interface D1DatabaseBinding {
|
|
13
|
+
binding?: string;
|
|
14
|
+
database_id?: string;
|
|
15
|
+
database_name?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The resolved export target. */
|
|
19
|
+
export interface D1Target {
|
|
20
|
+
/** The binding name, used for log output and as the export fallback. */
|
|
21
|
+
binding: string;
|
|
22
|
+
/** The database id, verified against Cloudflare by the caller. */
|
|
23
|
+
databaseId: string;
|
|
24
|
+
/** What `wrangler d1 export` should be pointed at. */
|
|
25
|
+
target: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pick the export target from a parsed wrangler config.
|
|
30
|
+
*
|
|
31
|
+
* `wrangler d1 export` takes a database *name*, so `database_name` wins when
|
|
32
|
+
* present. Hand-written configs that omit it fall back to the binding name.
|
|
33
|
+
*
|
|
34
|
+
* @throws when there is no binding, or when `database_id` is still the template
|
|
35
|
+
* placeholder — meaning the database was auto-provisioned and holds no data.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveD1Target(config: { d1_databases?: D1DatabaseBinding[] }): D1Target {
|
|
38
|
+
const database = config.d1_databases?.[0];
|
|
39
|
+
|
|
40
|
+
const binding = database?.binding?.trim();
|
|
41
|
+
if (binding === undefined || binding === '') {
|
|
42
|
+
throw new Error('No d1_databases entry with a binding found in wrangler.jsonc.');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const databaseId = database?.database_id?.trim() ?? '';
|
|
46
|
+
if (databaseId === DEFAULT_UUID) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`D1 database_id for ${binding} is still the template placeholder. The database does not exist yet, so there is nothing to back up.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const target = database?.database_name?.trim() || binding;
|
|
53
|
+
return { binding, databaseId, target };
|
|
54
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destination and encryption policy for the D1 backup workflow.
|
|
3
|
+
*
|
|
4
|
+
* Provenance: ported from Edge-Sonic's `scripts/backup/destination-config.ts`.
|
|
5
|
+
* Pure and side-effect free, so the fail-closed rules are asserted without running
|
|
6
|
+
* Actions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The environment the `check-secrets` job is given, as repository secrets. */
|
|
10
|
+
export interface BackupSecretEnvironment {
|
|
11
|
+
CLOUDFLARE_API_TOKEN?: string;
|
|
12
|
+
CLOUDFLARE_ACCOUNT_ID?: string;
|
|
13
|
+
BACKUP_ENCRYPTION_KEY?: string;
|
|
14
|
+
S3_ACCESS_KEY_ID?: string;
|
|
15
|
+
S3_SECRET_ACCESS_KEY?: string;
|
|
16
|
+
S3_BUCKET?: string;
|
|
17
|
+
WEBDAV_URL?: string;
|
|
18
|
+
WEBDAV_USER?: string;
|
|
19
|
+
WEBDAV_PASSWORD?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What is configured, and why the run cannot proceed when it is not. */
|
|
23
|
+
export interface DestinationConfig {
|
|
24
|
+
/** Cloudflare credentials present, so the export job can run. */
|
|
25
|
+
cloudflare: boolean;
|
|
26
|
+
/** An encryption passphrase is available. */
|
|
27
|
+
encryption: boolean;
|
|
28
|
+
/** At least one upload destination is configured. */
|
|
29
|
+
anyDestination: boolean;
|
|
30
|
+
/** The S3 destination is fully configured. */
|
|
31
|
+
s3: boolean;
|
|
32
|
+
/** The WebDAV destination is fully configured. */
|
|
33
|
+
webdav: boolean;
|
|
34
|
+
/** Human-readable reasons the run cannot proceed, empty when it can. */
|
|
35
|
+
errors: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A secret counts as set only when it has non-whitespace content. */
|
|
39
|
+
function isSet(value: string | undefined): boolean {
|
|
40
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decides which destinations are configured and enforces fail-closed encryption.
|
|
45
|
+
*
|
|
46
|
+
* ### Why the encryption requirement is not optional here
|
|
47
|
+
*
|
|
48
|
+
* Two independent reasons, and they have different consequences so both are stated.
|
|
49
|
+
*
|
|
50
|
+
* **The at-rest protection assumes the dump is not in hand.** Every repo in this
|
|
51
|
+
* family seals credentials with AES-256-GCM, and each one's migration notes say the
|
|
52
|
+
* same thing: *obfuscation against a D1 dump, not protection against a full worker
|
|
53
|
+
* compromise.* An unencrypted backup inverts that assumption — dump plus the
|
|
54
|
+
* Secrets Store key is a complete credential set, and the WebDAV key is read on
|
|
55
|
+
* every scan and every stream.
|
|
56
|
+
*
|
|
57
|
+
* **The dump is a listening history.** Stars, ratings, play counts, play queues and
|
|
58
|
+
* auth failures record what each user does and when, and the schema records the
|
|
59
|
+
* full topology and every file path. None of that is credential material, and all
|
|
60
|
+
* of it is nobody else's to read.
|
|
61
|
+
*
|
|
62
|
+
* `S3_REGION` is deliberately not part of the S3 gate. R2 and MinIO have no
|
|
63
|
+
* meaningful region — Cloudflare documents an R2 bucket's region as `auto`, with
|
|
64
|
+
* empty and `us-east-1` both aliasing to it — so requiring the secret would
|
|
65
|
+
* silently disable backups for anyone using such a provider. The upload script
|
|
66
|
+
* defaults `AWS_DEFAULT_REGION` to `auto` instead.
|
|
67
|
+
*/
|
|
68
|
+
export function evaluateConfig(env: BackupSecretEnvironment): DestinationConfig {
|
|
69
|
+
const cloudflare = isSet(env.CLOUDFLARE_API_TOKEN) && isSet(env.CLOUDFLARE_ACCOUNT_ID);
|
|
70
|
+
const encryption = isSet(env.BACKUP_ENCRYPTION_KEY);
|
|
71
|
+
const s3 = isSet(env.S3_ACCESS_KEY_ID) && isSet(env.S3_SECRET_ACCESS_KEY) && isSet(env.S3_BUCKET);
|
|
72
|
+
const webdav = isSet(env.WEBDAV_URL) && isSet(env.WEBDAV_USER) && isSet(env.WEBDAV_PASSWORD);
|
|
73
|
+
const anyDestination = s3 || webdav;
|
|
74
|
+
|
|
75
|
+
const errors: string[] = [];
|
|
76
|
+
|
|
77
|
+
// Fail closed, but only once someone has opted into a destination. An
|
|
78
|
+
// unconfigured repository is a legitimate "I do not want off-site backups" state
|
|
79
|
+
// and must not turn the schedule red.
|
|
80
|
+
if (anyDestination) {
|
|
81
|
+
if (!cloudflare) {
|
|
82
|
+
errors.push('A backup destination is configured but CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID are missing.');
|
|
83
|
+
}
|
|
84
|
+
if (!encryption) {
|
|
85
|
+
errors.push(
|
|
86
|
+
'BACKUP_ENCRYPTION_KEY is required. The D1 dump holds AES-GCM-obfuscated credentials — obfuscation that, by its own design, assumes the dump is not in hand — plus every user history and the full schema topology.',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { cloudflare, encryption, anyDestination, s3, webdav, errors };
|
|
92
|
+
}
|