setup-git-repo 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.
@@ -0,0 +1,394 @@
1
+ # Publishes every non-private workspace package whose *content* changed, and
2
+ # commits the resulting version bumps back to the default branch.
3
+ #
4
+ # The design rule here is: never ask a human to remember a version number.
5
+ # Each package's tarball is compared against what is already on the registry;
6
+ # identical content is skipped, changed content gets the next free patch.
7
+ name: Publish to npm
8
+
9
+ on:
10
+ push:
11
+ branches: [{{DEFAULT_BRANCH}}]
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: write
16
+ # Required for trusted publishing (OIDC). Harmless when NPM_TOKEN is used.
17
+ id-token: write
18
+
19
+ concurrency:
20
+ group: npm-publish
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ publish-packages:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ with:
29
+ token: ${{ secrets.GITHUB_TOKEN }}
30
+ fetch-depth: 2
31
+
32
+ - uses: actions/setup-node@v4
33
+ with:
34
+ node-version: 24
35
+ registry-url: "https://registry.npmjs.org"
36
+
37
+ - uses: oven-sh/setup-bun@v2
38
+
39
+ # setup-node writes "always-auth=false" into the .npmrc it generates, but
40
+ # npm 10+ no longer knows that option and prints
41
+ # `npm warn Unknown user config "always-auth"` on every npm command.
42
+ - name: Remove deprecated always-auth npm config
43
+ run: |
44
+ if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
45
+ sed -i '/^always-auth[ =]/d' "$NPM_CONFIG_USERCONFIG"
46
+ fi
47
+
48
+ # Some published dependencies ship a `prepare: "husky install"` in their
49
+ # package.json (fuse.js, among others). When npm reconciles bun's linked
50
+ # node_modules store it runs that hook, which dies with "husky: not found"
51
+ # (exit 127) — and npm's --ignore-scripts does NOT suppress it for
52
+ # bun-linked packages. A no-op `husky` on PATH turns it harmless.
53
+ - name: Neutralize unused husky hooks
54
+ run: |
55
+ mkdir -p "$RUNNER_TEMP/husky-shim"
56
+ printf '#!/bin/sh\nexit 0\n' > "$RUNNER_TEMP/husky-shim/husky"
57
+ chmod +x "$RUNNER_TEMP/husky-shim/husky"
58
+ echo "$RUNNER_TEMP/husky-shim" >> "$GITHUB_PATH"
59
+
60
+ # A credential that no longer authorizes writes is the likeliest reason
61
+ # for this workflow to fail, and it fails *late*: npm answers an
62
+ # unauthorized PUT with `E404 ... could not be found or you do not have
63
+ # permission to access it`, which reads like a missing package. Every
64
+ # package gets built (minutes) before the first one hits it, and each
65
+ # failed attempt still signs a provenance statement into the public
66
+ # sigstore transparency log. Check the credential up front instead.
67
+ #
68
+ # Two supported credentials, in order of preference:
69
+ # 1. Trusted publishing (OIDC). No secret: npm >= 11.5.1 trades this
70
+ # job's id-token for a short-lived publish token. Nothing to rotate,
71
+ # but each package must name this repo + workflow as its trusted
72
+ # publisher (npmjs.com/package/<name>/access).
73
+ # 2. An NPM_TOKEN secret. Granular tokens expire (90 days max); classic
74
+ # automation tokens no longer work for direct publishing.
75
+ # Setting the secret picks (2); leaving it unset picks (1).
76
+ - name: Verify npm publish credentials
77
+ env:
78
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
79
+ run: |
80
+ set -uo pipefail
81
+
82
+ if [ -n "${NODE_AUTH_TOKEN:-}" ]; then
83
+ if whoami=$(npm whoami 2>&1); then
84
+ echo "🔑 Publishing as npm user '$whoami' (NPM_TOKEN)"
85
+ # Authenticating says nothing about *write* access: a read-only or
86
+ # differently scoped token gets the same E404 on PUT. Report what
87
+ # it can write to, without failing — `npm access` is not available
88
+ # to every token type, and the publish loop reports the truth.
89
+ if writable=$(npm access list packages --json 2>/dev/null); then
90
+ echo "$writable" | node -e "
91
+ let s = '';
92
+ process.stdin.on('data', d => s += d).on('end', () => {
93
+ let perms = {};
94
+ try { perms = JSON.parse(s) || {}; } catch { return; }
95
+ const rw = Object.keys(perms).filter(k => perms[k] === 'read-write');
96
+ console.log(rw.length
97
+ ? '🔓 Token has write access to ' + rw.length + ' package(s)'
98
+ : '::warning title=npm token may be read-only::The NPM_TOKEN secret authenticates but reports no packages with read-write access. Publishes will fail with E404.');
99
+ });
100
+ "
101
+ fi
102
+ exit 0
103
+ fi
104
+ echo "$whoami"
105
+ echo "::error title=npm token rejected::The NPM_TOKEN secret no longer authenticates with registry.npmjs.org — it expired, was revoked, or was replaced. Mint a new token with write access at npmjs.com/settings/<user>/tokens and update the NPM_TOKEN repository secret, or remove the secret entirely and configure trusted publishing."
106
+ exit 1
107
+ fi
108
+
109
+ # No secret: trusted publishing. Make sure npm is new enough and that
110
+ # nothing left a half-configured empty token in the generated .npmrc,
111
+ # which would make npm try (and fail) to authenticate with it.
112
+ npm_version=$(npm --version)
113
+ if ! node -e "
114
+ const need = [11, 5, 1];
115
+ const have = '$npm_version'.split('.').map(Number);
116
+ for (let i = 0; i < 3; i++) {
117
+ if ((have[i] || 0) !== need[i]) process.exit((have[i] || 0) > need[i] ? 0 : 1);
118
+ }
119
+ "; then
120
+ echo "⏫ npm $npm_version is older than 11.5.1 — upgrading for trusted publishing (OIDC)"
121
+ npm install -g npm@latest
122
+ fi
123
+
124
+ if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
125
+ sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG"
126
+ fi
127
+ echo "🔑 No NPM_TOKEN secret — publishing via trusted publishing (OIDC) with npm $(npm --version)"
128
+
129
+ # Install the whole workspace once with bun. bun understands the
130
+ # "workspace:*" protocol and links local packages, so every package gets
131
+ # its devDependencies and can be built. Plain `npm install` cannot do this
132
+ # inside a bun workspace — it dies on sibling "workspace:*" deps
133
+ # (EUNSUPPORTEDPROTOCOL), leaving devDependencies uninstalled and builds
134
+ # failing with "vite: not found".
135
+ - name: Install workspace dependencies
136
+ run: bun install --ignore-scripts
137
+
138
+ # Build everything up front, in dependency order, so a package that
139
+ # imports a sibling through its built `dist` finds real files instead of
140
+ # `exports` entries pointing at nothing. Turbo derives that order from the
141
+ # workspace graph, so nothing here needs maintaining by hand.
142
+ - name: Build all packages
143
+ run: bunx turbo run build
144
+
145
+ - name: Configure git
146
+ run: |
147
+ git config user.name "github-actions[bot]"
148
+ git config user.email "github-actions[bot]@users.noreply.github.com"
149
+
150
+ - name: Publish non-private packages with new content
151
+ env:
152
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
153
+ run: |
154
+ # GitHub runs `run:` steps with `bash -e -o pipefail`, so omitting
155
+ # `set -e` does not disable errexit. With it on, the first package
156
+ # whose publish fails kills the step and every later package goes
157
+ # unreleased without even being evaluated. Turn it off explicitly;
158
+ # per-package outcomes travel through $rc, and the run still exits
159
+ # non-zero at the end if anything failed.
160
+ set -uo pipefail
161
+ set +e
162
+
163
+ failed_packages=""
164
+ skipped_builds=""
165
+ unauthorized_packages=""
166
+ unattempted_packages=""
167
+ auth_broken=""
168
+
169
+ for dir in packages/*/ apps/*/; do
170
+ pkg="${dir}package.json"
171
+ [ -f "$pkg" ] || continue
172
+
173
+ is_private=$(node -e "const p=require('./$pkg'); console.log(!!p.private)")
174
+ name=$(node -e "const p=require('./$pkg'); console.log(p.name || '')")
175
+
176
+ if [ "$is_private" = "true" ] || [ -z "$name" ]; then
177
+ echo "⏭ Skipping $dir (private or unnamed)"
178
+ continue
179
+ fi
180
+
181
+ # One rejected PUT means the credential cannot write, and every
182
+ # remaining package would fail the same way — after another build
183
+ # each, and after signing a provenance statement for a version that
184
+ # will never exist. Stop attempting them; the run still fails.
185
+ if [ -n "$auth_broken" ]; then
186
+ echo "⏭ Skipping $name (npm rejected the publish credential — see above)"
187
+ unattempted_packages="$unattempted_packages $name"
188
+ continue
189
+ fi
190
+
191
+ echo "📦 Evaluating $name from $dir"
192
+
193
+ # Each package runs in a subshell with its own `set -e`, so a failing
194
+ # step lands in $rc instead of killing the loop.
195
+ (
196
+ set -e
197
+ cd "$dir"
198
+
199
+ # npm keeps the literal "workspace:*" protocol in the tarball,
200
+ # which consumers cannot resolve. Replace each one with a real
201
+ # semver range taken from the referenced local package; drop
202
+ # local packages that are not published. Only the version field
203
+ # of this edit is committed back, in the final step.
204
+ node -e "
205
+ const fs = require('fs');
206
+ const path = require('path');
207
+ const localVersions = {};
208
+ for (const root of ['../../packages', '../../apps']) {
209
+ if (!fs.existsSync(root)) continue;
210
+ for (const d of fs.readdirSync(root)) {
211
+ const sib = path.join(root, d, 'package.json');
212
+ if (!fs.existsSync(sib)) continue;
213
+ try {
214
+ const sp = JSON.parse(fs.readFileSync(sib, 'utf8'));
215
+ if (sp.name && sp.version && !sp.private) localVersions[sp.name] = sp.version;
216
+ } catch {}
217
+ }
218
+ }
219
+ const p = JSON.parse(fs.readFileSync('package.json', 'utf8'));
220
+ for (const s of ['dependencies', 'devDependencies', 'peerDependencies']) {
221
+ if (!p[s]) continue;
222
+ for (const [k, v] of Object.entries(p[s])) {
223
+ if (typeof v !== 'string' || !v.startsWith('workspace:')) continue;
224
+ if (localVersions[k]) {
225
+ p[s][k] = '^' + localVersions[k];
226
+ console.log('Pinned workspace dependency', k, '->', p[s][k]);
227
+ } else {
228
+ delete p[s][k];
229
+ console.log('Removed unresolved workspace dependency:', k);
230
+ }
231
+ }
232
+ }
233
+ fs.writeFileSync('package.json', JSON.stringify(p, null, 2));
234
+ "
235
+
236
+ version=$(node -e "const p=require('./package.json'); console.log(p.version)")
237
+ latest=$(npm view "$name" version 2>/dev/null || echo "")
238
+ already=$(npm view "$name@$version" version 2>/dev/null || echo "")
239
+
240
+ # If the local version has fallen behind the registry (a bump
241
+ # commit that never landed back), publishing fails with "Cannot
242
+ # implicitly apply the latest tag". Sync forward first, then let
243
+ # the content comparison decide whether to release at all.
244
+ if [ -z "$already" ] && [ -n "$latest" ]; then
245
+ behind=$(node -e "
246
+ const cmp = (x, y) => { const a = x.split('.').map(Number), b = y.split('.').map(Number); for (let i = 0; i < 3; i++) { if ((a[i]||0) !== (b[i]||0)) return (a[i]||0) - (b[i]||0); } return 0; };
247
+ console.log(cmp('$version', '$latest') < 0);
248
+ ")
249
+ if [ "$behind" = "true" ]; then
250
+ echo "⏫ $name local version $version is behind npm latest $latest — syncing"
251
+ npm version "$latest" --no-git-tag-version --no-workspaces
252
+ version="$latest"
253
+ already="$latest"
254
+ fi
255
+ fi
256
+
257
+ if [ -n "$already" ]; then
258
+ # This exact version is on npm. Publish anyway only if the
259
+ # content changed: npm tarballs are reproducible (normalized
260
+ # mtimes), so pack integrity is comparable against the registry.
261
+ local_integrity=$(npm pack --dry-run --ignore-scripts --json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s)[0].integrity))")
262
+ remote_integrity=$(npm view "$name@$version" dist.integrity 2>/dev/null || echo "")
263
+
264
+ if [ "$local_integrity" = "$remote_integrity" ]; then
265
+ echo "⏭ $name@$version already on npm with identical content — nothing new to release"
266
+ exit 0
267
+ fi
268
+
269
+ next=$(node "$GITHUB_WORKSPACE/.github/scripts/next-free-version.mjs" "$name" "$version")
270
+ echo "🔼 $name content changed since $version — bumping to $next"
271
+ # --no-workspaces stops npm doing workspace-aware processing
272
+ # against bun's symlinks, which would raise EUNSUPPORTEDPROTOCOL.
273
+ npm version "$next" --no-git-tag-version --no-workspaces
274
+ version="$next"
275
+ fi
276
+
277
+ # --ignore-scripts: the build already ran, in dependency order.
278
+ # Exit 43 for an authorization failure so the loop can stop early.
279
+ #
280
+ # A version the registry has reserved but does not list is still
281
+ # possible (a reservation can land between the query above and the
282
+ # PUT), so E409 "cannot publish over" retries at the next free
283
+ # version. The attempt cap keeps a registry that rejects
284
+ # everything from looping forever.
285
+ log="$RUNNER_TEMP/npm-publish.log"
286
+ attempt=1
287
+
288
+ while :; do
289
+ echo "Publishing $name@$version"
290
+ if npm publish --provenance --access public --ignore-scripts 2>&1 | tee "$log"; then
291
+ exit 0
292
+ fi
293
+
294
+ if grep -qE "npm error code (E401|E404|ENEEDAUTH|EAUTHUNKNOWN|EOTP)" "$log"; then
295
+ exit 43
296
+ fi
297
+ if grep -q "npm error code E403" "$log" && ! grep -qi "cannot publish over" "$log"; then
298
+ exit 43
299
+ fi
300
+ if ! grep -qi "cannot publish over" "$log"; then
301
+ exit 1
302
+ fi
303
+ if [ "$attempt" -ge 5 ]; then
304
+ echo "⚠ npm refused $attempt versions in a row for $name — giving up"
305
+ exit 1
306
+ fi
307
+
308
+ next=$(node "$GITHUB_WORKSPACE/.github/scripts/next-free-version.mjs" "$name" "$version")
309
+ echo "↩ npm has $name@$version reserved — retrying as $next"
310
+ npm version "$next" --no-git-tag-version --no-workspaces
311
+ version="$next"
312
+ attempt=$((attempt + 1))
313
+ done
314
+ )
315
+ rc=$?
316
+
317
+ if [ "$rc" = "42" ]; then
318
+ skipped_builds="$skipped_builds $name"
319
+ elif [ "$rc" = "43" ]; then
320
+ unauthorized_packages="$unauthorized_packages $name"
321
+ auth_broken="1"
322
+ elif [ "$rc" != "0" ]; then
323
+ failed_packages="$failed_packages $name"
324
+ fi
325
+ done
326
+
327
+ if [ -n "$skipped_builds" ]; then
328
+ echo "::warning::Skipped (build failed, not published):$skipped_builds"
329
+ fi
330
+
331
+ if [ -n "$unattempted_packages" ]; then
332
+ echo "⏭ Not attempted (the credential was already rejected):$unattempted_packages"
333
+ fi
334
+
335
+ if [ -n "$unauthorized_packages" ]; then
336
+ echo "❌ Not authorized to publish:$unauthorized_packages"
337
+ echo "::error title=npm rejected the publish credential::npm answers an unauthorized PUT with E404 ('could not be found or you do not have permission to access it'), so this reads as a missing package — the packages exist, the write was refused. The NPM_TOKEN secret is read-only, scoped to a different set of packages, or expired; or, with trusted publishing, this repo + workflow is not yet named as the trusted publisher for these packages."
338
+ fi
339
+
340
+ if [ -n "$failed_packages" ]; then
341
+ echo "❌ Failed to publish:$failed_packages"
342
+ fi
343
+
344
+ if [ -n "$failed_packages" ] || [ -n "$unauthorized_packages" ] || [ -n "$unattempted_packages" ]; then
345
+ exit 1
346
+ fi
347
+
348
+ - name: Commit version bumps
349
+ if: success()
350
+ run: |
351
+ # Version bumps made during publish must land back in the repo so the
352
+ # next run sees them. Restore the workspace:* pinning done for the
353
+ # tarballs first — only the "version" field should be committed.
354
+ node -e "
355
+ const fs = require('fs');
356
+ const cp = require('child_process');
357
+ for (const root of ['packages', 'apps']) {
358
+ if (!fs.existsSync(root)) continue;
359
+ for (const d of fs.readdirSync(root)) {
360
+ const f = root + '/' + d + '/package.json';
361
+ if (!fs.existsSync(f)) continue;
362
+ const now = JSON.parse(fs.readFileSync(f, 'utf8'));
363
+ let raw;
364
+ try {
365
+ raw = cp.execSync('git show HEAD:' + f, { encoding: 'utf8' });
366
+ } catch { continue; }
367
+ const orig = JSON.parse(raw);
368
+ if (orig.version !== now.version) {
369
+ // Patch only the version string so the file keeps whatever
370
+ // formatting it had. Matching on a literal '\"version\": \"x\"'
371
+ // would miss a package.json written without the space after
372
+ // the colon, and silently drop the bump while claiming to
373
+ // keep it — so match the field, then assert it changed.
374
+ const before = raw;
375
+ raw = raw.replace(
376
+ /(\"version\"\s*:\s*\")[^\"]*(\")/,
377
+ (m, open_, close) => open_ + now.version + close
378
+ );
379
+ if (raw === before) {
380
+ console.log('::error title=Version bump lost::Could not rewrite the version field in ' + f + '. ' + now.name + ' was published as ' + now.version + ' but the repo still says ' + orig.version + '.');
381
+ process.exitCode = 1;
382
+ continue;
383
+ }
384
+ console.log('Keeping version bump for', now.name, '->', now.version);
385
+ }
386
+ fs.writeFileSync(f, raw);
387
+ }
388
+ }
389
+ "
390
+ git add packages/*/package.json apps/*/package.json 2>/dev/null || true
391
+ git diff --staged --quiet && echo "No version changes to commit" && exit 0
392
+ # [skip ci] so the bump commit does not retrigger this workflow.
393
+ git commit -m "chore: bump published package versions [skip ci]"
394
+ git push
@@ -0,0 +1,116 @@
1
+ # Runs every workspace package that has a `test:ci` script and ships the results
2
+ # to Codecov Test Analytics, which tracks run time, failure rate and flakiness
3
+ # per test and comments the failing ones on the pull request.
4
+ #
5
+ # The matrix is discovered at run time rather than hand-maintained: adding a
6
+ # package with a `test:ci` script is all it takes to get it tested here. That
7
+ # script must write, relative to the package directory:
8
+ #
9
+ # junit.xml - test results, the input Test Analytics ingests
10
+ # coverage/lcov.info - coverage, uploaded when the runner can produce it
11
+ #
12
+ # For Vitest that is:
13
+ # "test:ci": "vitest run --reporter=junit --outputFile=junit.xml --coverage"
14
+ name: tests
15
+
16
+ on:
17
+ pull_request:
18
+ push:
19
+ branches: [{{DEFAULT_BRANCH}}]
20
+ workflow_dispatch:
21
+
22
+ concurrency:
23
+ group: tests-${{ github.ref }}
24
+ cancel-in-progress: true
25
+
26
+ jobs:
27
+ # Emits the list of packages to test. Kept separate so the test job below can
28
+ # stay a plain matrix — GitHub cannot compute a matrix inside the job that
29
+ # uses it.
30
+ discover:
31
+ runs-on: ubuntu-latest
32
+ outputs:
33
+ packages: ${{ steps.list.outputs.packages }}
34
+ any: ${{ steps.list.outputs.any }}
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+
38
+ - id: list
39
+ name: Find packages with a test:ci script
40
+ run: |
41
+ node --input-type=module -e '
42
+ import { readdirSync, readFileSync, existsSync, appendFileSync } from "node:fs";
43
+ const out = [];
44
+ for (const root of ["packages", "apps"]) {
45
+ if (!existsSync(root)) continue;
46
+ for (const dir of readdirSync(root)) {
47
+ const file = `${root}/${dir}/package.json`;
48
+ if (!existsSync(file)) continue;
49
+ let pkg;
50
+ try { pkg = JSON.parse(readFileSync(file, "utf8")); } catch { continue; }
51
+ if (!pkg.scripts?.["test:ci"]) continue;
52
+ out.push({ name: pkg.name ?? dir, dir: `${root}/${dir}` });
53
+ }
54
+ }
55
+ out.sort((a, b) => a.dir.localeCompare(b.dir));
56
+ appendFileSync(process.env.GITHUB_OUTPUT, `packages=${JSON.stringify(out)}\n`);
57
+ appendFileSync(process.env.GITHUB_OUTPUT, `any=${out.length > 0}\n`);
58
+ console.log(out.length ? out.map((p) => p.dir).join("\n") : "No package defines a test:ci script.");
59
+ '
60
+
61
+ test:
62
+ needs: discover
63
+ if: needs.discover.outputs.any == 'true'
64
+ name: ${{ matrix.package.name }}
65
+ runs-on: ubuntu-latest
66
+ strategy:
67
+ # One package's failure must not cancel the others' uploads.
68
+ fail-fast: false
69
+ matrix:
70
+ package: ${{ fromJSON(needs.discover.outputs.packages) }}
71
+ steps:
72
+ # Codecov needs the parent commit to attribute results to the right base.
73
+ - uses: actions/checkout@v4
74
+ with:
75
+ fetch-depth: 2
76
+
77
+ - uses: oven-sh/setup-bun@v2
78
+
79
+ # Postinstall hooks in a workspace tend to need env vars CI does not have
80
+ # (database URLs, Tauri toolchains, doc generators) and none of them
81
+ # affect the tests, so they are skipped.
82
+ - name: Install workspace dependencies
83
+ run: bun install --ignore-scripts
84
+
85
+ # `turbo run` builds this package's workspace dependencies first, so a
86
+ # package that imports a sibling through its built `dist` output finds it.
87
+ - name: Run tests
88
+ run: bunx turbo run test:ci --filter=./${{ matrix.package.dir }}
89
+
90
+ # `!cancelled()` so a red suite still reports: without it the upload is
91
+ # skipped on exactly the runs whose results matter most.
92
+ - name: Upload test results to Codecov
93
+ if: ${{ !cancelled() }}
94
+ uses: codecov/test-results-action@v1
95
+ with:
96
+ token: ${{ secrets.CODECOV_TOKEN }}
97
+ files: ${{ matrix.package.dir }}/junit.xml
98
+ flags: ${{ matrix.package.name }}
99
+
100
+ - name: Upload coverage to Codecov
101
+ if: ${{ !cancelled() }}
102
+ uses: codecov/codecov-action@v5
103
+ with:
104
+ token: ${{ secrets.CODECOV_TOKEN }}
105
+ files: ${{ matrix.package.dir }}/coverage/lcov.info
106
+ flags: ${{ matrix.package.name }}
107
+ fail_ci_if_error: false
108
+
109
+ - name: Upload coverage artifact
110
+ if: ${{ !cancelled() }}
111
+ uses: actions/upload-artifact@v4
112
+ with:
113
+ name: coverage-${{ matrix.package.name }}
114
+ path: ${{ matrix.package.dir }}/coverage/
115
+ retention-days: 7
116
+ if-no-files-found: ignore
@@ -0,0 +1,89 @@
1
+ <p align="center">
2
+ <!-- badge:doi --><a href="https://doi.org/{{DOI}}"><img src="https://zenodo.org/badge/DOI/{{DOI}}.svg" alt="DOI" /></a>
3
+ <a href="https://deepwiki.com/{{OWNER}}/{{REPO}}"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" /></a>
4
+ <!-- badge:docs --><a href="{{DOCS_URL}}"><img src="https://img.shields.io/badge/Docs-blue?logo=ReadTheDocs&logoColor=white" alt="Documentation" /></a>
5
+ <!-- badge:api --><a href="{{API_URL}}"><img src="https://img.shields.io/badge/API-blue?logo=fastapi&logoColor=white" alt="API" /></a>
6
+ <!-- badge:youtube --><a href="{{YOUTUBE_URL}}"><img height="20px" src="https://img.shields.io/badge/YouTube-red?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube" /></a>
7
+ <a href="https://deploy.workers.cloudflare.com/?url=https://github.com/{{OWNER}}/{{REPO}}"><img height="24px" src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" /></a>
8
+ <a href="https://github.com/{{OWNER}}/{{REPO}}/stargazers"><img src="https://img.shields.io/github/stars/{{OWNER}}/{{REPO}}" alt="GitHub Stars" /></a>
9
+ <br />
10
+ <!-- badge:npm --><a href="https://www.npmjs.com/package/{{PACKAGE}}"><img src="https://img.shields.io/npm/dm/{{PACKAGE}}.svg" alt="NPM Monthly Downloads" /></a>
11
+ <a href="https://codecov.io/gh/{{OWNER}}/{{REPO}}"><img src="https://codecov.io/gh/{{OWNER}}/{{REPO}}/graph/badge.svg" alt="Coverage" /></a>
12
+ <a href="https://github.com/{{OWNER}}/{{REPO}}/graphs/contributors"><img src="https://img.shields.io/github/commit-activity/m/{{OWNER}}/{{REPO}}" alt="Commit Activity" /></a>
13
+ <a href="https://github.com/{{OWNER}}/{{REPO}}/commits/{{DEFAULT_BRANCH}}/"><img src="https://img.shields.io/github/last-commit/{{OWNER}}/{{REPO}}.svg" alt="Last Commit" /></a>
14
+ <a href="https://github.com/{{OWNER}}/{{REPO}}/actions/workflows/tests.yml"><img src="https://github.com/{{OWNER}}/{{REPO}}/actions/workflows/tests.yml/badge.svg?branch={{DEFAULT_BRANCH}}" alt="Tests" /></a>
15
+ <br />
16
+ <!-- badge:uptime --><a href="https://stats.uptimerobot.com/{{UPTIME_ID}}"><img src="https://img.shields.io/badge/Uptime-Status-brightgreen?logo=uptimerobot&logoColor=white" alt="Uptime Status" /></a>
17
+ <!-- badge:npm --><a href="https://www.npmjs.com/package/{{PACKAGE}}"><img src="https://img.shields.io/npm/v/{{PACKAGE}}.svg" alt="npm version" /></a>
18
+ <!-- badge:discord --><a href="{{DISCORD_INVITE}}"><img src="https://img.shields.io/discord/{{DISCORD_ID}}.svg?label=Chat&logo=Discord&colorB=7289da&style=flat" alt="Join Discord" /></a>
19
+ <a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
20
+ <a href="./LICENSE.md"><img src="https://img.shields.io/github/license/{{OWNER}}/{{REPO}}" alt="License" /></a>
21
+ <br />
22
+ <img src="https://img.shields.io/badge/Claude-D97757?logo=claude&logoColor=fff" alt="Claude AI" />
23
+ <img src="https://img.shields.io/badge/Cloudflare-F38020?logo=Cloudflare&logoColor=white" alt="Cloudflare" />
24
+ <img src="https://img.shields.io/badge/Turborepo-EF4444?logo=turborepo&logoColor=white" alt="Turborepo" />
25
+ <img src="https://img.shields.io/badge/Bun-000000?logo=bun&logoColor=white" alt="Bun" />
26
+ </p>
27
+
28
+ # {{REPO}}
29
+
30
+ {{DESCRIPTION}}
31
+
32
+ ```bash
33
+ bun install
34
+ bun run dev
35
+ ```
36
+
37
+ ## Layout
38
+
39
+ ```
40
+ packages/ publishable libraries, one directory per package
41
+ apps/ deployable applications, one directory per app
42
+ test-reports/ the HTML test report deployed to Cloudflare Workers
43
+ .github/workflows/ CI: tests, publishing, auto-merge, report deploys
44
+ turbo.json the task graph every script above runs through
45
+ codecov.yml coverage flags and pull-request comment layout
46
+ docs/ how to set up the badges, workflows and secrets
47
+ ```
48
+
49
+ Everything is driven by [Turborepo](https://turborepo.com): `bun run build`,
50
+ `bun run test`, `bun run lint` and `bun run typecheck` at the root fan out to
51
+ every workspace package in dependency order, and cache what has not changed.
52
+
53
+ | Script | What it does |
54
+ | --- | --- |
55
+ | `bun run build` | `turbo run build` across the workspace, in dependency order |
56
+ | `bun run dev` | every package's watch/dev task, in parallel |
57
+ | `bun run test` | every package's `test` task |
58
+ | `bun run test:ci` | the CI variant: writes `junit.xml` and `coverage/lcov.info` |
59
+ | `bun run test:report` | the whole suite with the HTML reporter, into `apps/test-reports/dist` |
60
+ | `bun run coverage` | every package's coverage task |
61
+ | `bun run lint` / `typecheck` | the corresponding task per package |
62
+ | `bun run clean` | drops build output, `.turbo` and `node_modules` |
63
+
64
+ Filter to one package with `--filter`:
65
+
66
+ ```bash
67
+ bunx turbo run test --filter=./packages/my-package
68
+ ```
69
+
70
+ ## Docs
71
+
72
+ | Doc | Covers |
73
+ | --- | --- |
74
+ | [docs/BADGES.md](./docs/BADGES.md) | Every badge above: what it needs, where the id comes from, how to verify it |
75
+ | [docs/WORKFLOWS.md](./docs/WORKFLOWS.md) | Every workflow in `.github/workflows`: what it does, what it needs, how it fails |
76
+ | [docs/SECRETS.md](./docs/SECRETS.md) | The repository secrets and settings CI depends on |
77
+
78
+ ## Adding a package
79
+
80
+ 1. `mkdir packages/my-package` with a `package.json` (`"name"`, `"version"`, and
81
+ a `build`/`test` script as needed).
82
+ 2. Give it a `test:ci` script that writes `junit.xml` and `coverage/lcov.info`:
83
+
84
+ ```json
85
+ "test:ci": "vitest run --reporter=junit --outputFile=junit.xml --coverage"
86
+ ```
87
+
88
+ 3. That is all: `.github/workflows/tests.yml` discovers packages by that script,
89
+ and `npm-publish.yml` publishes any non-private package whose content changed.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "test-reports",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "scripts": {
6
+ "generate": "cd ../.. && VITEST_HTML_REPORT_DIR=apps/test-reports/dist vitest run",
7
+ "generate:coverage": "cd ../.. && VITEST_HTML_REPORT_DIR=apps/test-reports/dist vitest run --coverage",
8
+ "preview": "wrangler dev",
9
+ "deploy": "wrangler deploy"
10
+ },
11
+ "devDependencies": {
12
+ "wrangler": "^4.130.0"
13
+ }
14
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "{{REPO}}-test-reports",
4
+ "compatibility_date": "2026-07-29",
5
+ "assets": {
6
+ "directory": "./dist",
7
+ // Vitest's HTML reporter is a client-routed single page app.
8
+ "not_found_handling": "single-page-application"
9
+ }
10
+ }