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.
- package/README.md +105 -0
- package/bin/setup-git-repo.js +289 -0
- package/package.json +48 -0
- package/src/git.mjs +74 -0
- package/src/resolve-template.mjs +44 -0
- package/src/template.mjs +142 -0
- package/template/.github/scripts/next-free-version.mjs +73 -0
- package/template/.github/workflows/auto-merge-and-create-prs.yml +96 -0
- package/template/.github/workflows/auto-merge-claude.yml +50 -0
- package/template/.github/workflows/deploy-test-reports.yml +85 -0
- package/template/.github/workflows/npm-publish.yml +394 -0
- package/template/.github/workflows/tests.yml +116 -0
- package/template/README.md +89 -0
- package/template/apps/test-reports/package.json +14 -0
- package/template/apps/test-reports/wrangler.jsonc +10 -0
- package/template/codecov.yml +50 -0
- package/template/docs/BADGES.md +271 -0
- package/template/docs/SECRETS.md +77 -0
- package/template/docs/WORKFLOWS.md +205 -0
- package/template/gitignore +11 -0
- package/template/package.json +35 -0
- package/template/packages/README.md +1 -0
- package/template/turbo.json +42 -0
- package/template/vitest.config.ts +54 -0
|
@@ -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
|
+
}
|