@rexezuge/tooling 0.0.0-stage → 1.0.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint-boundaries.d.ts +75 -0
  4. package/dist/eslint-boundaries.d.ts.map +1 -0
  5. package/dist/eslint-boundaries.js +138 -0
  6. package/dist/eslint-boundaries.js.map +1 -0
  7. package/dist/eslint-rules.d.ts +41 -0
  8. package/dist/eslint-rules.d.ts.map +1 -0
  9. package/dist/eslint-rules.js +273 -0
  10. package/dist/eslint-rules.js.map +1 -0
  11. package/dist/eslint.d.ts +96 -0
  12. package/dist/eslint.d.ts.map +1 -0
  13. package/dist/eslint.js +189 -0
  14. package/dist/eslint.js.map +1 -0
  15. package/dist/functions/pages-proxy.d.ts +113 -0
  16. package/dist/functions/pages-proxy.d.ts.map +1 -0
  17. package/dist/functions/pages-proxy.js +131 -0
  18. package/dist/functions/pages-proxy.js.map +1 -0
  19. package/dist/index.d.ts +31 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +28 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/scripts/backup/d1-target.d.ts +35 -0
  24. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  25. package/dist/scripts/backup/d1-target.js +31 -0
  26. package/dist/scripts/backup/d1-target.js.map +1 -0
  27. package/dist/scripts/backup/destination-config.d.ts +61 -0
  28. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  29. package/dist/scripts/backup/destination-config.js +57 -0
  30. package/dist/scripts/backup/destination-config.js.map +1 -0
  31. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  32. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  33. package/dist/scripts/backup/encrypt-backup.js +98 -0
  34. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  35. package/dist/scripts/backup/naming.d.ts +44 -0
  36. package/dist/scripts/backup/naming.d.ts.map +1 -0
  37. package/dist/scripts/backup/naming.js +58 -0
  38. package/dist/scripts/backup/naming.js.map +1 -0
  39. package/dist/scripts/check-god-files.d.ts +164 -0
  40. package/dist/scripts/check-god-files.d.ts.map +1 -0
  41. package/dist/scripts/check-god-files.js +271 -0
  42. package/dist/scripts/check-god-files.js.map +1 -0
  43. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  44. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  45. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  46. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  47. package/dist/scripts/init-secrets.d.ts +63 -0
  48. package/dist/scripts/init-secrets.d.ts.map +1 -0
  49. package/dist/scripts/init-secrets.js +240 -0
  50. package/dist/scripts/init-secrets.js.map +1 -0
  51. package/dist/scripts/lib/cli-args.d.ts +78 -0
  52. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  53. package/dist/scripts/lib/cli-args.js +116 -0
  54. package/dist/scripts/lib/cli-args.js.map +1 -0
  55. package/dist/scripts/lib/github-actions.d.ts +26 -0
  56. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  57. package/dist/scripts/lib/github-actions.js +38 -0
  58. package/dist/scripts/lib/github-actions.js.map +1 -0
  59. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  60. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  61. package/dist/scripts/lib/wrangler-table.js +99 -0
  62. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  63. package/dist/scripts/migrations-lock.d.ts +3 -0
  64. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  65. package/dist/scripts/migrations-lock.js +46 -0
  66. package/dist/scripts/migrations-lock.js.map +1 -0
  67. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  68. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  69. package/dist/scripts/prepare-wrangler-config.js +50 -0
  70. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  71. package/dist/scripts/spa-shell.d.ts +41 -0
  72. package/dist/scripts/spa-shell.d.ts.map +1 -0
  73. package/dist/scripts/spa-shell.js +155 -0
  74. package/dist/scripts/spa-shell.js.map +1 -0
  75. package/dist/scripts/validate-locales.d.ts +26 -0
  76. package/dist/scripts/validate-locales.d.ts.map +1 -0
  77. package/dist/scripts/validate-locales.js +350 -0
  78. package/dist/scripts/validate-locales.js.map +1 -0
  79. package/dist/scripts/verify-migrations.d.ts +62 -0
  80. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  81. package/dist/scripts/verify-migrations.js +302 -0
  82. package/dist/scripts/verify-migrations.js.map +1 -0
  83. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  84. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  85. package/dist/scripts/verify-spa-shell.js +53 -0
  86. package/dist/scripts/verify-spa-shell.js.map +1 -0
  87. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  88. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/cli.js +51 -0
  90. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  91. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  92. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/patches.js +140 -0
  94. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  95. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  96. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  97. package/dist/scripts/wrangler-config/resources.js +290 -0
  98. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  99. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  100. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  101. package/dist/scripts/wrangler-config/types.js +49 -0
  102. package/dist/scripts/wrangler-config/types.js.map +1 -0
  103. package/dist/test/integration-migrations.d.ts +167 -0
  104. package/dist/test/integration-migrations.d.ts.map +1 -0
  105. package/dist/test/integration-migrations.js +171 -0
  106. package/dist/test/integration-migrations.js.map +1 -0
  107. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  108. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  109. package/dist/test/mocks/cloudflare-workers.js +90 -0
  110. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  111. package/dist/vite.d.ts +117 -0
  112. package/dist/vite.d.ts.map +1 -0
  113. package/dist/vite.js +125 -0
  114. package/dist/vite.js.map +1 -0
  115. package/dist/vitest-web.d.ts +73 -0
  116. package/dist/vitest-web.d.ts.map +1 -0
  117. package/dist/vitest-web.js +72 -0
  118. package/dist/vitest-web.js.map +1 -0
  119. package/dist/vitest.d.ts +92 -0
  120. package/dist/vitest.d.ts.map +1 -0
  121. package/dist/vitest.js +128 -0
  122. package/dist/vitest.js.map +1 -0
  123. package/package.json +58 -3
  124. package/src/eslint-boundaries.ts +169 -0
  125. package/src/eslint-rules.ts +276 -0
  126. package/src/eslint.test.ts +175 -0
  127. package/src/eslint.ts +257 -0
  128. package/src/functions/pages-proxy.test.ts +72 -0
  129. package/src/functions/pages-proxy.ts +187 -0
  130. package/src/github/actions/retry-step/action.yml +39 -0
  131. package/src/github/actions/setup-env/action.yml +20 -0
  132. package/src/github/dependabot.yml +30 -0
  133. package/src/github/workflows/backup-main.yml +46 -0
  134. package/src/github/workflows/continuous-deployment.yml +188 -0
  135. package/src/github/workflows/continuous-integration.yml +259 -0
  136. package/src/github/workflows/scheduled-version-update.yml +38 -0
  137. package/src/github/workflows/upstream-sync.yml +56 -0
  138. package/src/index.ts +42 -0
  139. package/src/scripts/backup/backup-rules.test.ts +105 -0
  140. package/src/scripts/backup/d1-target.ts +54 -0
  141. package/src/scripts/backup/destination-config.ts +92 -0
  142. package/src/scripts/backup/encrypt-backup.ts +107 -0
  143. package/src/scripts/backup/naming.ts +62 -0
  144. package/src/scripts/check-god-files.test.ts +131 -0
  145. package/src/scripts/check-god-files.ts +327 -0
  146. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  147. package/src/scripts/init-secrets.ts +265 -0
  148. package/src/scripts/lib/cli-args.ts +154 -0
  149. package/src/scripts/lib/github-actions.ts +41 -0
  150. package/src/scripts/lib/wrangler-table.ts +105 -0
  151. package/src/scripts/migrations-lock.ts +52 -0
  152. package/src/scripts/prepare-wrangler-config.ts +51 -0
  153. package/src/scripts/spa-shell.test.ts +91 -0
  154. package/src/scripts/spa-shell.ts +179 -0
  155. package/src/scripts/validate-locales.test.ts +89 -0
  156. package/src/scripts/validate-locales.ts +380 -0
  157. package/src/scripts/verify-migrations.test.ts +71 -0
  158. package/src/scripts/verify-migrations.ts +364 -0
  159. package/src/scripts/verify-spa-shell.ts +56 -0
  160. package/src/scripts/wrangler-config/cli.ts +51 -0
  161. package/src/scripts/wrangler-config/patches.ts +157 -0
  162. package/src/scripts/wrangler-config/resources.ts +330 -0
  163. package/src/scripts/wrangler-config/types.ts +113 -0
  164. package/src/test/integration-migrations.test.ts +169 -0
  165. package/src/test/integration-migrations.ts +267 -0
  166. package/src/test/mocks/cloudflare-workers.ts +115 -0
  167. package/src/vite.test.ts +83 -0
  168. package/src/vite.ts +202 -0
  169. package/src/vitest-web.ts +109 -0
  170. package/src/vitest.ts +185 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The Cloudflare Pages catch-all proxy.
3
+ *
4
+ * Provenance: the `functions/[[path]].ts` shim duplicated in Durable-DAV,
5
+ * Mail-Meow and Edge-Git (and present in nine repos in total). Each copy is an
6
+ * `onRequest` handler that forwards the request to the API worker through a
7
+ * service binding, rewriting the three headers the worker routes on.
8
+ *
9
+ * What each source contributes:
10
+ *
11
+ * - **Durable-DAV / Edge-Git** — the core shape: set `X-Forwarded-Host`,
12
+ * `X-Forwarded-Proto` and `X-Forwarded-Uri` from the original URL, forward the
13
+ * body for non-GET/HEAD methods, and hand the request to `env.API_WORKER`.
14
+ * - **Mail-Meow** — the hop-by-hop header strip, and the decision to *always*
15
+ * delete `X-Forwarded-For` before conditionally setting it from
16
+ * `CF-Connecting-IP`. Setting it only when the header was present left a
17
+ * caller-supplied value intact on any request that reached Pages without
18
+ * `CF-Connecting-IP`, so the origin would trust an attacker-chosen client IP.
19
+ * - **Durable-DAV-Router** — `duplex: 'half'`, required whenever the forwarded
20
+ * body is a stream. Without it this threw a `TypeError` on every non-GET
21
+ * request rather than proxying it.
22
+ * - **Edge-Sonic** — the client-forwarding-header strip list, which additionally
23
+ * removes `Forwarded`, `True-Client-IP` and `X-Real-Ip`. Edge-Sonic goes
24
+ * further and *never* sets `X-Forwarded-For` at all, because its worker keys
25
+ * the rate limiter on `CF-Connecting-IP` and refuses `X-Forwarded-For` on
26
+ * purpose.
27
+ *
28
+ * ### The convergence decision on `X-Forwarded-For`
29
+ *
30
+ * The two families disagree, and the disagreement is about which worker is
31
+ * behind the binding. Mail-Meow's behaviour is kept as the default because it is
32
+ * the only one that preserves the client IP for the audit trail, which five repos
33
+ * read. A repo whose worker refuses the header — Edge-Sonic's — passes
34
+ * `forwardClientIp: false`, which deletes it and sets nothing. What is *not*
35
+ * available is the source repos' conditional `if (clientIp) set(...)`: that is
36
+ * the defect Mail-Meow recorded fixing, and a factory that could reintroduce it
37
+ * would be a factory that carries the bug forward.
38
+ *
39
+ * ### `duplex: 'half'` rides with the body
40
+ *
41
+ * Required by the Fetch spec whenever a `Request` is constructed with a **stream**
42
+ * body. workerd does not throw without it, which is why the line was absent from
43
+ * every Pages copy — and Node does, which is why `test/pages-proxy.test.ts` in
44
+ * Edge-Sonic could not drive the handler at all. It is spread only where a body
45
+ * is forwarded, because a `RequestInit` carrying `duplex` and no body is a shape
46
+ * no runtime documents; the cast is `as RequestInit` either way, since `duplex`
47
+ * is not in the DOM lib's type.
48
+ *
49
+ * The result is a handler factory, so a consumer's `functions/[[path]].ts` is a
50
+ * one-line re-export. Structural `Fetcher` and `PagesFunction` types are declared
51
+ * here rather than imported from `@cloudflare/workers-types`, so the kit stays
52
+ * zero-dependency and this module typechecks without the generated
53
+ * `worker-configuration.d.ts`.
54
+ */
55
+
56
+ /**
57
+ * The subset of Cloudflare's `Fetcher` this proxy uses.
58
+ *
59
+ * Structural, not imported: the shim only ever calls `fetch`, and requiring
60
+ * `@cloudflare/workers-types` for one method would couple the kit to a generated
61
+ * types file that only exists inside a Wrangler project.
62
+ */
63
+ export interface Fetcher {
64
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
65
+ }
66
+
67
+ /**
68
+ * The environment a Pages Function is invoked with.
69
+ */
70
+ export interface PagesProxyEnv {
71
+ readonly API_WORKER: Fetcher;
72
+ }
73
+
74
+ /**
75
+ * The context Cloudflare passes to `onRequest`.
76
+ *
77
+ * Only the fields this proxy reads are declared. `params` and `waitUntil` are
78
+ * omitted because the catch-all does not use them — a shim that named them would
79
+ * be claiming a contract it does not exercise.
80
+ */
81
+ export interface PagesFunctionContext {
82
+ readonly request: Request;
83
+ readonly env: PagesProxyEnv;
84
+ }
85
+
86
+ /**
87
+ * A Pages Function handler.
88
+ */
89
+ export type PagesFunction = (context: PagesFunctionContext) => Promise<Response>;
90
+
91
+ /**
92
+ * Headers a *client* may have set, stripped before the request crosses the
93
+ * binding.
94
+ *
95
+ * These describe the connection between the client and the proxy. Forwarding
96
+ * them would let the origin believe the proxy's view of the client is the
97
+ * client's own claim about itself. The three the proxy sets itself are set from
98
+ * values it reads off the request, not from the client's headers.
99
+ */
100
+ const CLIENT_FORWARDING_HEADERS: readonly string[] = [
101
+ 'connection',
102
+ 'keep-alive',
103
+ 'proxy-authenticate',
104
+ 'proxy-authorization',
105
+ 'te',
106
+ 'trailer',
107
+ 'transfer-encoding',
108
+ 'upgrade',
109
+ 'X-Forwarded-For',
110
+ 'X-Forwarded-Host',
111
+ 'X-Forwarded-Proto',
112
+ 'X-Forwarded-Uri',
113
+ 'Forwarded',
114
+ 'True-Client-IP',
115
+ 'X-Real-Ip',
116
+ ];
117
+
118
+ /** The header Cloudflare sets with the socket's peer address. */
119
+ const CF_CONNECTING_IP_HEADER = 'CF-Connecting-IP';
120
+
121
+ /**
122
+ * What `createPagesProxy` accepts.
123
+ */
124
+ export interface PagesProxyOptions {
125
+ /**
126
+ * Set `X-Forwarded-For` from `CF-Connecting-IP`.
127
+ *
128
+ * Default `true` — Mail-Meow's behaviour, which five repos rely on for their
129
+ * audit trail. Pass `false` for a worker that refuses the header outright
130
+ * (Edge-Sonic's does), and the header is deleted and nothing set.
131
+ */
132
+ readonly forwardClientIp?: boolean;
133
+ }
134
+
135
+ /**
136
+ * The `onRequest` handler for `functions/[[path]].ts`.
137
+ *
138
+ * Usage — the consumer's `functions/[[path]].ts` becomes two lines:
139
+ *
140
+ * ```ts
141
+ * import { createPagesProxy } from '@rexezuge/tooling/functions/pages-proxy';
142
+ * export const onRequest = createPagesProxy();
143
+ * ```
144
+ *
145
+ * @param options see {@link PagesProxyOptions}. Omitted gives the family's
146
+ * majority behaviour.
147
+ */
148
+ export function createPagesProxy(options: PagesProxyOptions = {}): PagesFunction {
149
+ const forwardClientIp = options.forwardClientIp ?? true;
150
+
151
+ return async ({ request, env }: PagesFunctionContext): Promise<Response> => {
152
+ const originalUrl: URL = new URL(request.url);
153
+
154
+ const headers: Headers = new Headers(request.headers);
155
+ for (const header of CLIENT_FORWARDING_HEADERS) headers.delete(header);
156
+
157
+ // Set from values read off the request rather than from the client: only the
158
+ // proxy knows which host it served, and a service binding hands over a
159
+ // rewritten URL, so the original path is what has to be carried across.
160
+ headers.set('X-Forwarded-Host', originalUrl.host);
161
+ headers.set('X-Forwarded-Proto', originalUrl.protocol.replace(':', ''));
162
+ headers.set('X-Forwarded-Uri', `${originalUrl.pathname}${originalUrl.search}`);
163
+
164
+ if (forwardClientIp) {
165
+ const clientIp: string | null = request.headers.get(CF_CONNECTING_IP_HEADER);
166
+ if (clientIp) headers.set('X-Forwarded-For', clientIp);
167
+ }
168
+
169
+ // `GET`/`HEAD` carry no body, and a `Request` with one is rejected outright
170
+ // for those methods. The body is forwarded as a *stream*, so a large upload
171
+ // does not have to fit in memory on the Pages side.
172
+ const hasBody: boolean = request.method !== 'GET' && request.method !== 'HEAD';
173
+
174
+ const proxyRequest: Request = new Request(originalUrl.href, {
175
+ method: request.method,
176
+ headers,
177
+ body: hasBody ? request.body : undefined,
178
+ // See the header: `duplex` is not in the DOM lib's `RequestInit` type, and
179
+ // removing the cast would mean not forwarding bodies at all, which is a
180
+ // worse answer than one assertion.
181
+ ...(hasBody && { duplex: 'half' }),
182
+ redirect: request.redirect,
183
+ } as RequestInit);
184
+
185
+ return env.API_WORKER.fetch(proxyRequest);
186
+ };
187
+ }
@@ -0,0 +1,39 @@
1
+ name: Retry Step
2
+ description: Retry a bash command on failure
3
+
4
+ inputs:
5
+ command:
6
+ description: The bash command to run
7
+ required: true
8
+ max_attempts:
9
+ description: Maximum number of attempts
10
+ required: false
11
+ default: '3'
12
+ delay:
13
+ description: Seconds to wait between attempts
14
+ required: false
15
+ default: '10'
16
+
17
+ runs:
18
+ using: composite
19
+ steps:
20
+ - shell: bash
21
+ run: |
22
+ attempt=0
23
+ max=${{ inputs.max_attempts }}
24
+ delay=${{ inputs.delay }}
25
+ while true; do
26
+ attempt=$(( attempt + 1 ))
27
+ printf '::notice title=Retry Step::Attempt %d of %d\n' "$attempt" "$max"
28
+ if eval "$RETRY_COMMAND"; then
29
+ exit 0
30
+ fi
31
+ if [ "$attempt" -ge "$max" ]; then
32
+ printf '::error title=Retry Step::Command failed after %d attempts\n' "$max"
33
+ exit 1
34
+ fi
35
+ printf '::warning title=Retry Step::Command failed, retrying in %ds...\n' "$delay"
36
+ sleep "$delay"
37
+ done
38
+ env:
39
+ RETRY_COMMAND: ${{ inputs.command }}
@@ -0,0 +1,20 @@
1
+ name: Setup Environment
2
+ description: Install pnpm, set up Node.js with pnpm store cache, and install workspace dependencies
3
+
4
+ runs:
5
+ using: composite
6
+ steps:
7
+ - name: Install pnpm
8
+ uses: pnpm/action-setup@v4
9
+
10
+ - name: Setup Node.js
11
+ uses: actions/setup-node@v4
12
+ with:
13
+ node-version: '24'
14
+ cache: pnpm
15
+
16
+ - name: Install Dependencies
17
+ uses: ./.github/actions/retry-step
18
+ with:
19
+ command: pnpm install
20
+ max_attempts: '3'
@@ -0,0 +1,30 @@
1
+ # Dependabot — weekly dependency updates, grouped so one PR covers the
2
+ # toolchain rather than twelve separate ones. @rexezuge/* updates land in
3
+ # their own group, which is the kit's upgrade path: a bump PR per repo, and
4
+ # the repo's own CI is the gate.
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: 'npm'
9
+ directory: '/'
10
+ schedule:
11
+ interval: 'weekly'
12
+ open-pull-requests-limit: 5
13
+ labels:
14
+ - 'dependencies'
15
+ groups:
16
+ kit:
17
+ patterns:
18
+ - '@rexezuge/*'
19
+ toolchain:
20
+ patterns:
21
+ - 'typescript'
22
+ - 'vitest'
23
+ - 'vite'
24
+ - 'eslint'
25
+ - 'typescript-eslint'
26
+ - 'prettier'
27
+ - 'wrangler'
28
+ cloudflare:
29
+ patterns:
30
+ - '@cloudflare/*'
@@ -0,0 +1,46 @@
1
+ # Backup Main Branch — mirror the repository to secondary git forges.
2
+ #
3
+ # Converged from the family's backup-main.yml. Destinations are opt-in by
4
+ # secret: with neither BACKUP_REPO_AZURE_DEVOPS_SSH_KEY nor
5
+ # BACKUP_REPO_GITLAB_* configured, the check job reports both false and every
6
+ # downstream job is skipped. Push to main seeds the mirror; the monthly
7
+ # schedule re-verifies it.
8
+
9
+ name: Backup Main Branch
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ on:
15
+ push:
16
+ branches:
17
+ - main
18
+ schedule:
19
+ - cron: '0 0 5 * *'
20
+
21
+ jobs:
22
+ check-secrets:
23
+ name: Check Secrets
24
+ runs-on: ubuntu-latest
25
+ outputs:
26
+ azure-configured: ${{ steps.check.outputs.azure }}
27
+ gitlab-configured: ${{ steps.check.outputs.gitlab }}
28
+ steps:
29
+ - name: Detect Configured Destinations
30
+ id: check
31
+ env:
32
+ AZURE_KEY: ${{ secrets.BACKUP_REPO_AZURE_DEVOPS_SSH_KEY }}
33
+ AZURE_URL: ${{ secrets.BACKUP_REPO_AZURE_DEVOPS_REPO_URL }}
34
+ GITLAB_KEY: ${{ secrets.BACKUP_REPO_GITLAB_SSH_KEY }}
35
+ GITLAB_URL: ${{ secrets.BACKUP_REPO_GITLAB_REPO_URL }}
36
+ run: |
37
+ if [ -n "$AZURE_KEY" ] && [ -n "$AZURE_URL" ]; then
38
+ echo "azure=true" >> "$GITHUB_OUTPUT"
39
+ else
40
+ echo "azure=false" >> "$GITHUB_OUTPUT"
41
+ fi
42
+ if [ -n "$GITLAB_KEY" ] && [ -n "$GITLAB_URL" ]; then
43
+ echo "gitlab=true" >> "$GITHUB_OUTPUT"
44
+ else
45
+ echo "gitlab=false" >> "$GITHUB_OUTPUT"
46
+ fi
@@ -0,0 +1,188 @@
1
+ # Continuous Deployment — deploy the worker after CI passes on main.
2
+ #
3
+ # Converged from the eight repos that deploy (Records-Provider and CalDAV-Bridge
4
+ # deploy differently and are left out of this merge). The shape: a `workflow_run`
5
+ # trigger so the deploy runs against the SHA CI actually approved, then the
6
+ # wrangler config materialized from the template — no committed secrets — then the
7
+ # secrets provisioned, then the database migrated, then a dry run as a gate, then
8
+ # the deploy.
9
+ #
10
+ # PLACEHOLDERS:
11
+ # * REPLACE_ME_SCOPE the npm scope, in `pnpm --filter @REPLACE_ME_SCOPE/web build`.
12
+ # * REPLACE_ME_D1_BINDING the D1 binding `wrangler d1 migrations apply` targets.
13
+ # * REPLACE_ME_PAGES the Cloudflare Pages project name, for repos with a SPA.
14
+ #
15
+ # The two workspace variables the config step reads (`WRANGLER_JSONC`,
16
+ # `WRANGLER_PATCH_JSON`, `WRANGLER_VARS_PATCH_JSON`) are how a repo with several
17
+ # environments supplies per-environment settings without a second template file.
18
+
19
+ name: Continuous Deployment
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ on:
25
+ workflow_run:
26
+ workflows:
27
+ - Continuous Integration
28
+ branches:
29
+ - main
30
+ types:
31
+ - completed
32
+ repository_dispatch:
33
+ types:
34
+ - Upstream Sync Deployment
35
+ workflow_dispatch:
36
+
37
+ concurrency:
38
+ group: continuous-deployment-${{ github.event.workflow_run.head_branch || github.event.client_payload.branch || github.ref }}
39
+ cancel-in-progress: false
40
+
41
+ jobs:
42
+ deploy-worker:
43
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' || github.event.workflow_run.conclusion == 'success' }}
44
+ name: Deploy Cloudflare Worker
45
+ runs-on: ubuntu-latest
46
+
47
+ steps:
48
+ - name: Checkout Repository
49
+ uses: actions/checkout@v6
50
+ with:
51
+ # The SHA CI approved, not the tip of main: a push that lands while the
52
+ # workflow_run is in flight must not be what gets deployed.
53
+ ref: ${{ github.event.workflow_run.head_sha || github.event.client_payload.sha || github.sha }}
54
+
55
+ - name: Setup Environment
56
+ uses: ./.github/actions/setup-env
57
+
58
+ - name: Prepare Wrangler Configuration
59
+ uses: ./.github/actions/retry-step
60
+ with:
61
+ command: pnpm exec tsx scripts/prepare-wrangler-config.ts
62
+ max_attempts: '3'
63
+ env:
64
+ WRANGLER_JSONC: ${{ vars.WRANGLER_JSONC }}
65
+ WRANGLER_PATCH_JSON: ${{ vars.WRANGLER_PATCH_JSON }}
66
+ WRANGLER_VARS_PATCH_JSON: ${{ vars.WRANGLER_VARS_PATCH_JSON }}
67
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
68
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
69
+
70
+ # A `secrets_store_secrets` binding fails the deploy unless the named secret
71
+ # already exists. Nothing creates it — Cloudflare will not generate a value —
72
+ # so this is the only thing standing between a fresh account and a deploy that
73
+ # cannot succeed. Create-if-absent: an existing value still seals every stored
74
+ # credential and is never replaced by a redeploy.
75
+ - name: Initialize Cloudflare Secrets
76
+ uses: ./.github/actions/retry-step
77
+ with:
78
+ command: pnpm exec tsx scripts/init-secrets.ts
79
+ max_attempts: '3'
80
+ env:
81
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
82
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
83
+
84
+ # PLACEHOLDER: the D1 binding to migrate.
85
+ - name: Apply Database Migration
86
+ uses: ./.github/actions/retry-step
87
+ with:
88
+ command: pnpm exec wrangler d1 migrations apply REPLACE_ME_D1_BINDING --remote
89
+ max_attempts: '3'
90
+ env:
91
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
92
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
93
+
94
+ # PLACEHOLDER: the workspace filter for the SPA. The API worker serves the
95
+ # shell this build embeds, so the build is a precondition of the deploy, not
96
+ # a convenience.
97
+ - name: Build Web App
98
+ run: pnpm --filter @REPLACE_ME_SCOPE/web build
99
+
100
+ # A gate, not a rehearsal. Separate from the deploy below and deliberately
101
+ # **not** retried: a dry run that fails is a real defect — a bundle over the
102
+ # size limit, a binding the template no longer declares — and retrying it
103
+ # three times only delays the report while making the log harder to read.
104
+ - name: Dry Run Deploy with Wrangler
105
+ run: pnpm exec wrangler deploy --dry-run
106
+ env:
107
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
108
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
109
+
110
+ - name: Deploy with Wrangler
111
+ uses: ./.github/actions/retry-step
112
+ with:
113
+ command: pnpm exec wrangler deploy
114
+ max_attempts: '3'
115
+ env:
116
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
117
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
118
+
119
+ # Repos with a Pages front end keep this job; it is the reason
120
+ # `functions/[[path]].ts` exists — Pages serves `dist/` as static files, and
121
+ # `/user/...` would resolve to a 404 without the catch-all proxy to the worker.
122
+ deploy-pages:
123
+ if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' || github.event.workflow_run.conclusion == 'success' }}
124
+ name: Deploy Cloudflare Pages
125
+ runs-on: ubuntu-latest
126
+
127
+ steps:
128
+ - name: Checkout Repository
129
+ uses: actions/checkout@v6
130
+ with:
131
+ ref: ${{ github.event.workflow_run.head_sha || github.event.client_payload.sha || github.sha }}
132
+
133
+ - name: Setup Environment
134
+ uses: ./.github/actions/setup-env
135
+
136
+ # PLACEHOLDER: the workspace filter for the SPA.
137
+ - name: Build Frontend
138
+ run: pnpm --filter @REPLACE_ME_SCOPE/web build
139
+
140
+ - name: Configure Pages Service Bindings
141
+ run: cp apps/web/wrangler.template.jsonc wrangler.jsonc
142
+
143
+ # PLACEHOLDER: the Pages project name. Created if absent, and then polled
144
+ # until it is visible, because a project that exists but is not yet listed
145
+ # makes the deploy below fail with an error that names the deploy rather
146
+ # than the propagation delay.
147
+ - name: Ensure Pages Project Exists
148
+ env:
149
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
150
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
151
+ PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME || 'REPLACE_ME_PAGES' }}
152
+ DEPLOY_BRANCH: ${{ github.event.workflow_run.head_branch || github.event.client_payload.branch || github.ref_name }}
153
+ run: |
154
+ if CREATE_OUTPUT="$(pnpm exec wrangler pages project create "${PROJECT}" --production-branch "${DEPLOY_BRANCH}" 2>&1)"; then
155
+ echo "Created Pages project \"${PROJECT}\"."
156
+ elif echo "${CREATE_OUTPUT}" | grep -qi "already exists"; then
157
+ echo "Pages project \"${PROJECT}\" already exists."
158
+ else
159
+ echo "${CREATE_OUTPUT}"
160
+ echo "Failed to create Pages project \"${PROJECT}\"." >&2
161
+ exit 1
162
+ fi
163
+ for _ in $(seq 1 18); do
164
+ if pnpm exec wrangler pages project list 2>/dev/null | grep -qE "(^|[[:space:]])${PROJECT}([[:space:]]|$)"; then
165
+ echo "Pages project \"${PROJECT}\" is ready."
166
+ exit 0
167
+ fi
168
+ sleep 5
169
+ done
170
+ echo "Pages project \"${PROJECT}\" is still not visible after polling." >&2
171
+ exit 1
172
+
173
+ - name: Deploy with Wrangler Pages
174
+ uses: ./.github/actions/retry-step
175
+ with:
176
+ command: >
177
+ pnpm exec wrangler pages deploy apps/web/dist
178
+ --project-name "${PROJECT}"
179
+ --branch "${DEPLOY_BRANCH}"
180
+ --commit-hash "${DEPLOY_SHA}"
181
+ --commit-message "$(git log -1 --pretty=%s)"
182
+ max_attempts: '3'
183
+ env:
184
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
185
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
186
+ PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME || 'REPLACE_ME_PAGES' }}
187
+ DEPLOY_BRANCH: ${{ github.event.workflow_run.head_branch || github.event.client_payload.branch || github.ref_name }}
188
+ DEPLOY_SHA: ${{ github.event.workflow_run.head_sha || github.event.client_payload.sha || github.sha }}