template-git-repo 0.1.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,102 @@
1
+ # A twice-daily sweep that keeps branches from being forgotten: it merges every
2
+ # open PR that is already green and unblocked, then opens a PR for any branch
3
+ # that does not have one.
4
+ #
5
+ # This is the backstop for agent sessions that push a branch and never open a
6
+ # PR, and for PRs whose merge was skipped because a check finished after the
7
+ # per-PR auto-merge workflow had already run.
8
+
9
+ name: Auto-merge PRs and create missing PRs
10
+
11
+ on:
12
+ schedule:
13
+ # 00:00 and 12:00 UTC. Scheduled workflows can be delayed under high
14
+ # GitHub Actions load — this is a sweep, not a deadline.
15
+ - cron: "0 */12 * * *"
16
+ workflow_dispatch:
17
+
18
+ permissions:
19
+ contents: write
20
+ pull-requests: write
21
+
22
+ concurrency:
23
+ group: auto-merge-and-create-prs
24
+ cancel-in-progress: false
25
+
26
+ jobs:
27
+ maintain-pull-requests:
28
+ runs-on: ubuntu-latest
29
+ env:
30
+ GH_TOKEN: ${{ secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
31
+ GIT_TOKEN: ${{ secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
32
+ BASE_BRANCH: ${{ github.event.repository.default_branch }}
33
+ steps:
34
+ - name: Merge eligible existing pull requests
35
+ env:
36
+ REPOSITORY: ${{ github.repository }}
37
+ run: |
38
+ set -euo pipefail
39
+
40
+ # Only PRs that are non-draft, cleanly mergeable, not blocked by a
41
+ # requested change, and whose every check concluded SUCCESS, SKIPPED
42
+ # or NEUTRAL. A pending check means "not yet", not "merge it".
43
+ gh pr list \
44
+ --repo "$REPOSITORY" \
45
+ --state open \
46
+ --json number,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup \
47
+ --jq '.[] | select(.isDraft == false) | select(.mergeStateStatus == "CLEAN") | select((.reviewDecision == "APPROVED") or (.reviewDecision == "")) | select(all(.statusCheckRollup[]?; (.conclusion == "SUCCESS" or .conclusion == "SKIPPED" or .conclusion == "NEUTRAL"))) | .number' \
48
+ | while read -r pr_number; do
49
+ echo "Attempting to merge PR #${pr_number}"
50
+ gh pr merge "$pr_number" \
51
+ --repo "$REPOSITORY" \
52
+ --squash \
53
+ --delete-branch \
54
+ --auto || echo "Could not merge PR #${pr_number}; it may require review, checks, or conflict resolution."
55
+ done
56
+
57
+ - name: Create pull requests for branches without one
58
+ env:
59
+ REPOSITORY: ${{ github.repository }}
60
+ run: |
61
+ set -euo pipefail
62
+
63
+ # No actions/checkout above: this needs every remote branch, and a
64
+ # normal checkout fetches one. Fetching all refs into a bare-ish
65
+ # workspace is cheaper than checkout with fetch-depth: 0.
66
+ git config --global --add safe.directory "$GITHUB_WORKSPACE"
67
+ git init -q "$GITHUB_WORKSPACE"
68
+ cd "$GITHUB_WORKSPACE"
69
+ git remote add origin "https://x-access-token:${GIT_TOKEN}@github.com/${REPOSITORY}.git"
70
+ git fetch --quiet origin '+refs/heads/*:refs/remotes/origin/*'
71
+
72
+ base_sha="$(git rev-parse "origin/${BASE_BRANCH}")"
73
+
74
+ git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin \
75
+ | grep -Ev "^(HEAD|${BASE_BRANCH})$" \
76
+ | while read -r branch; do
77
+ open_pr="$(gh pr list --repo "$REPOSITORY" --state open --head "$branch" --json number --jq 'length')"
78
+ # `merged` too: a branch whose PR was merged but which was never
79
+ # deleted would otherwise get a fresh PR opened on every sweep.
80
+ merged_pr="$(gh pr list --repo "$REPOSITORY" --state merged --head "$branch" --json number --jq 'length')"
81
+ branch_sha="$(git rev-parse "origin/${branch}")"
82
+
83
+ if [ "$open_pr" -gt 0 ] || [ "$merged_pr" -gt 0 ]; then
84
+ echo "Skipping ${branch}: a pull request already exists or was previously merged."
85
+ continue
86
+ fi
87
+
88
+ # An ancestor of the base branch has nothing to contribute; a PR
89
+ # for it would be empty.
90
+ if git merge-base --is-ancestor "$branch_sha" "$base_sha"; then
91
+ echo "Skipping ${branch}: it is already contained in ${BASE_BRANCH}."
92
+ continue
93
+ fi
94
+
95
+ echo "Creating PR for ${branch} -> ${BASE_BRANCH}"
96
+ gh pr create \
97
+ --repo "$REPOSITORY" \
98
+ --head "$branch" \
99
+ --base "$BASE_BRANCH" \
100
+ --title "Merge ${branch} into ${BASE_BRANCH}" \
101
+ --body "Automated pull request for branch \`${branch}\`."
102
+ done
@@ -0,0 +1,54 @@
1
+ # Merges pull requests opened by an agent (or by the maintainers) as soon as
2
+ # their checks pass, so agent-authored work does not sit waiting for a human to
3
+ # click a button.
4
+ #
5
+ # The actor allowlist is the whole safety model: this must never fire for a PR
6
+ # from an arbitrary contributor. Edit the `if:` below to match the accounts you
7
+ # actually want merged automatically.
8
+
9
+ name: Auto-merge agent PRs
10
+
11
+ on:
12
+ pull_request:
13
+ types: [opened, synchronize, reopened]
14
+
15
+ permissions:
16
+ contents: write
17
+ pull-requests: write
18
+
19
+ jobs:
20
+ auto-merge:
21
+ runs-on: ubuntu-latest
22
+ if: >
23
+ github.actor == 'claude[bot]' ||
24
+ github.actor == 'anthropic-claude[bot]' ||
25
+ github.actor == '{{OWNER}}'
26
+ steps:
27
+ - name: Enable auto-merge (or merge directly)
28
+ env:
29
+ # A PAT rather than GITHUB_TOKEN: merges pushed with GITHUB_TOKEN do
30
+ # not trigger further workflow runs, so a publish workflow listening
31
+ # on the default branch would never fire for the merge commit.
32
+ GH_TOKEN: ${{ secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
33
+ PR: ${{ github.event.pull_request.number }}
34
+ REPO: ${{ github.repository }}
35
+ HEAD_REF: ${{ github.head_ref }}
36
+ run: |
37
+ # Never delete long-lived branches after merging them.
38
+ case "$HEAD_REF" in
39
+ production|prod|staging|develop) MERGE_OPTS="--squash" ;;
40
+ *) MERGE_OPTS="--squash --delete-branch" ;;
41
+ esac
42
+
43
+ # Preferred path: let GitHub merge once required checks pass. Needs
44
+ # "Allow auto-merge" in repo settings plus branch protection with at
45
+ # least one required status check.
46
+ if gh pr merge "$PR" --repo "$REPO" $MERGE_OPTS --auto; then
47
+ echo "Auto-merge enabled for PR #$PR"
48
+ else
49
+ # Fallback for unprotected branches (no required checks): merge now
50
+ # if the PR is mergeable. Without branch protection there is nothing
51
+ # for --auto to wait on, so GitHub rejects it outright.
52
+ echo "Auto-merge unavailable, attempting direct merge"
53
+ gh pr merge "$PR" --repo "$REPO" $MERGE_OPTS
54
+ fi
@@ -0,0 +1,98 @@
1
+ # Publishes the HTML test report to Cloudflare Workers on every push to the
2
+ # default branch, so the "Test Report" badge in the README links to something
3
+ # current rather than to a workflow log.
4
+ #
5
+ # Requires an `apps/test-reports/` (or equivalent) directory with a
6
+ # wrangler.toml whose `assets.directory` is `dist`, and a root `test:report`
7
+ # script that writes the Vitest HTML reporter output there.
8
+
9
+ name: Test Reports
10
+
11
+ on:
12
+ push:
13
+ branches: [{{DEFAULT_BRANCH}}]
14
+ workflow_dispatch:
15
+
16
+ concurrency:
17
+ group: test-reports
18
+ cancel-in-progress: true
19
+
20
+ env:
21
+ REPORT_DIR: apps/test-reports
22
+
23
+ jobs:
24
+ deploy:
25
+ name: Generate & deploy test reports
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+
30
+ - uses: oven-sh/setup-bun@v2
31
+
32
+ - name: Install dependencies
33
+ run: {{PACKAGE_MANAGER}} install --ignore-scripts
34
+
35
+ - name: Build workspace dependencies
36
+ run: bunx turbo build || echo "No build tasks"
37
+
38
+ # A red suite is exactly when the report is worth reading, so the run is
39
+ # allowed to fail here and still deploy.
40
+ - name: Run tests with the HTML reporter
41
+ run: {{PACKAGE_MANAGER}} run test:report
42
+ continue-on-error: true
43
+
44
+ # What the step above must not do is leave dist/ missing: wrangler treats
45
+ # an absent `assets.directory` as a hard error, so the run would end on a
46
+ # config error instead of on the test signal. Publish a placeholder rather
47
+ # than lose the deploy.
48
+ - name: Ensure a report exists to deploy
49
+ env:
50
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
51
+ run: |
52
+ if [ -f "$REPORT_DIR/dist/index.html" ]; then
53
+ exit 0
54
+ fi
55
+ echo "::warning::The test runner wrote no HTML report; deploying a placeholder page."
56
+ mkdir -p "$REPORT_DIR/dist"
57
+ cat > "$REPORT_DIR/dist/index.html" <<HTML
58
+ <!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <title>Test report unavailable</title>
64
+ <style>
65
+ :root { color-scheme: light dark; }
66
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
67
+ font: 16px/1.6 system-ui, sans-serif; padding: 24px; }
68
+ main { max-width: 34rem; }
69
+ h1 { font-size: 1.25rem; margin: 0 0 .75rem; }
70
+ p { margin: 0 0 .75rem; opacity: .85; }
71
+ </style>
72
+ </head>
73
+ <body>
74
+ <main>
75
+ <h1>Test report unavailable</h1>
76
+ <p>The last run of the test suite ended before the HTML reporter could
77
+ write a report, so there is nothing to show here yet.</p>
78
+ <p><a href="$RUN_URL">Open the workflow run</a> for the full output.</p>
79
+ <p>Commit $GITHUB_SHA</p>
80
+ </main>
81
+ </body>
82
+ </html>
83
+ HTML
84
+
85
+ - name: Deploy to Cloudflare Workers
86
+ working-directory: ${{ env.REPORT_DIR }}
87
+ run: npx wrangler deploy
88
+ env:
89
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
90
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
91
+
92
+ - name: Upload report artifact
93
+ if: ${{ !cancelled() }}
94
+ uses: actions/upload-artifact@v4
95
+ with:
96
+ name: test-reports
97
+ path: ${{ env.REPORT_DIR }}/dist/
98
+ if-no-files-found: ignore
@@ -0,0 +1,320 @@
1
+ # Publishes every non-private workspace package whose *content* changed, without
2
+ # anyone having to remember to bump a version.
3
+ #
4
+ # The rules this encodes were all learned the hard way:
5
+ #
6
+ # - Compare npm pack integrity against the registry, not version numbers. A
7
+ # version bump is not evidence of a change and an unchanged version is not
8
+ # evidence of no change.
9
+ # - Check the publish credential before building anything. npm answers an
10
+ # unauthorized PUT with E404 ("could not be found"), which reads like a
11
+ # missing package — after every package has already been built, and after
12
+ # each failed attempt has signed a provenance statement into the public
13
+ # sigstore transparency log.
14
+ # - Walk the workspace in dependency order, not alphabetical order. A sibling
15
+ # that has not been built yet has no dist/, and its package.json `types`
16
+ # entries point at files that do not exist.
17
+ # - Never publish the literal "workspace:*" protocol — consumers cannot
18
+ # resolve it.
19
+ # - Treat the registry as the source of truth for which version numbers are
20
+ # spent. It refuses a PUT for any version it has ever seen, including
21
+ # staged and unpublished ones that the `latest` dist-tag cannot show you.
22
+ #
23
+ # Credentials, in order of preference:
24
+ #
25
+ # 1. Trusted publishing (OIDC) — no secret at all. Leave NPM_TOKEN unset and
26
+ # name this repo + workflow as the trusted publisher for each package on
27
+ # npmjs.com. Nothing to rotate, nothing to expire.
28
+ # 2. An NPM_TOKEN secret. Granular access tokens expire after 90 days at
29
+ # most; classic automation tokens no longer work for direct publishing.
30
+
31
+ name: Publish to npm
32
+
33
+ on:
34
+ push:
35
+ branches: [{{DEFAULT_BRANCH}}]
36
+ workflow_dispatch:
37
+
38
+ permissions:
39
+ contents: write
40
+ # Required for trusted publishing (OIDC) and for --provenance.
41
+ id-token: write
42
+
43
+ concurrency:
44
+ group: npm-publish
45
+ cancel-in-progress: false
46
+
47
+ jobs:
48
+ publish:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ with:
53
+ token: ${{ secrets.GITHUB_TOKEN }}
54
+ fetch-depth: 2
55
+
56
+ - uses: actions/setup-node@v4
57
+ with:
58
+ node-version: 22
59
+ registry-url: "https://registry.npmjs.org"
60
+
61
+ - uses: oven-sh/setup-bun@v2
62
+
63
+ # setup-node writes `always-auth` into the npmrc it generates. npm 10+
64
+ # does not know the key and warns about it on every single invocation,
65
+ # which is hundreds of lines of noise across a run. Authentication comes
66
+ # from the _authToken line, which stays.
67
+ - name: Drop the deprecated always-auth key from the generated npmrc
68
+ run: |
69
+ if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
70
+ sed -i '/^always-auth[[:space:]]*[= ]/d' "$NPM_CONFIG_USERCONFIG"
71
+ fi
72
+
73
+ # Some published packages ship a broken `prepare: "husky install"` in
74
+ # their package.json. When npm reconciles bun's linked node_modules store
75
+ # it runs that hook, which dies with "husky: not found" (exit 127) — and
76
+ # npm's --ignore-scripts does NOT suppress it for bun-linked packages.
77
+ # Put a no-op `husky` on PATH so a stray hook becomes harmless.
78
+ - name: Neutralize unused husky hooks
79
+ run: |
80
+ mkdir -p "$RUNNER_TEMP/husky-shim"
81
+ printf '#!/bin/sh\nexit 0\n' > "$RUNNER_TEMP/husky-shim/husky"
82
+ chmod +x "$RUNNER_TEMP/husky-shim/husky"
83
+ echo "$RUNNER_TEMP/husky-shim" >> "$GITHUB_PATH"
84
+
85
+ - name: Verify npm publish credentials
86
+ env:
87
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
88
+ run: |
89
+ set -uo pipefail
90
+
91
+ if [ -n "${NODE_AUTH_TOKEN:-}" ]; then
92
+ if whoami=$(npm whoami 2>&1); then
93
+ echo "🔑 Publishing as npm user '$whoami' (NPM_TOKEN)"
94
+ exit 0
95
+ fi
96
+ echo "$whoami"
97
+ echo "::error title=npm token rejected::The NPM_TOKEN secret no longer authenticates with registry.npmjs.org — it has expired, been revoked, or was replaced. Granular access tokens last 90 days at most. 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."
98
+ exit 1
99
+ fi
100
+
101
+ # No secret: trusted publishing. npm >= 11.5.1 trades this job's
102
+ # id-token for a short-lived publish token on its own.
103
+ if ! node -e "
104
+ const need = [11, 5, 1];
105
+ const have = process.argv[1].split('.').map(Number);
106
+ for (let i = 0; i < 3; i++) {
107
+ if ((have[i] || 0) !== need[i]) process.exit((have[i] || 0) > need[i] ? 0 : 1);
108
+ }
109
+ " "$(npm --version)"; then
110
+ echo "⏫ npm $(npm --version) is older than 11.5.1 — upgrading for trusted publishing (OIDC)"
111
+ npm install -g npm@latest
112
+ fi
113
+
114
+ # A half-configured empty token in the generated npmrc would make npm
115
+ # try (and fail) to authenticate with it instead of using OIDC.
116
+ if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
117
+ sed -i '/_authToken/d' "$NPM_CONFIG_USERCONFIG"
118
+ fi
119
+ echo "🔑 No NPM_TOKEN secret — publishing via trusted publishing (OIDC) with npm $(npm --version)"
120
+
121
+ # Install the whole workspace once with the repo's package manager, which
122
+ # understands the "workspace:*" protocol and links local packages. Plain
123
+ # `npm install` cannot do this inside a bun/pnpm workspace — it dies on
124
+ # sibling "workspace:*" deps (EUNSUPPORTEDPROTOCOL), which leaves
125
+ # devDependencies uninstalled and builds failing with "vite: not found".
126
+ - name: Install workspace dependencies
127
+ run: {{PACKAGE_MANAGER}} install --ignore-scripts
128
+
129
+ - name: Configure git
130
+ run: |
131
+ git config user.name "github-actions[bot]"
132
+ git config user.email "github-actions[bot]@users.noreply.github.com"
133
+
134
+ - name: Publish packages whose content changed
135
+ env:
136
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
137
+ run: |
138
+ # GitHub runs `run:` steps with `bash -e -o pipefail`, so not writing
139
+ # `set -e` does not disable errexit. With it on, the first package
140
+ # whose publish fails kills the step and every later package goes
141
+ # unreleased. Turn it off explicitly; per-package outcomes are handled
142
+ # through $rc, and the run still exits non-zero at the end.
143
+ set -uo pipefail
144
+ set +e
145
+
146
+ repo_root="$PWD"
147
+ failed_packages=""
148
+ skipped_builds=""
149
+ unauthorized_packages=""
150
+ unattempted_packages=""
151
+ auth_broken=""
152
+
153
+ build_order=$(node scripts/workspace-build-order.mjs)
154
+ echo "📋 Build order:"
155
+ echo "$build_order" | sed 's/^/ /'
156
+
157
+ for dir in $build_order; do
158
+ pkg="${dir}package.json"
159
+ [ -f "$pkg" ] || { echo "⏭ Skipping $dir (no package.json)"; continue; }
160
+
161
+ is_private=$(node -p "!!require('./$pkg').private")
162
+ name=$(node -p "require('./$pkg').name")
163
+
164
+ if [ "$is_private" = "true" ]; then
165
+ echo "⏭ Skipping $name (private)"
166
+ continue
167
+ fi
168
+
169
+ # One rejected PUT means the credential cannot write, and every
170
+ # remaining package would fail the same way — after another build
171
+ # each, and after signing provenance for a version that will never
172
+ # exist. Stop attempting them; the run still fails at the end.
173
+ if [ -n "$auth_broken" ]; then
174
+ echo "⏭ Skipping $name (npm rejected the publish credential — see above)"
175
+ unattempted_packages="$unattempted_packages $name"
176
+ continue
177
+ fi
178
+
179
+ echo "📦 Evaluating $name from $dir"
180
+
181
+ # Each package runs in a subshell with its own `set -e` so a failing
182
+ # step propagates to $rc without killing the outer loop.
183
+ (
184
+ set -e
185
+ cd "$dir"
186
+
187
+ # npm keeps the literal "workspace:*" in the tarball, which
188
+ # consumers cannot resolve. Replace it with a real semver range
189
+ # from the referenced local package; drop deps on local packages
190
+ # that are not published. Only committed back if the version is
191
+ # bumped.
192
+ node "$repo_root/scripts/pin-workspace-deps.mjs"
193
+
194
+ # Build before deciding anything: the tarball comparison below
195
+ # needs real dist output, and a package whose build fails is never
196
+ # published.
197
+ if [ "$(node -p "!!(require('./package.json').scripts||{}).build")" = "true" ]; then
198
+ if ! npm run build; then
199
+ echo "⚠ Build failed for $name — not publishing (won't ship a broken tarball)"
200
+ exit 42
201
+ fi
202
+ fi
203
+
204
+ version=$(node -p "require('./package.json').version")
205
+ latest=$(npm view "$name" version 2>/dev/null || echo "")
206
+ already=$(npm view "$name@$version" version 2>/dev/null || echo "")
207
+
208
+ # If the local version has fallen behind the registry (a bump
209
+ # commit that never landed back in the repo), publishing fails
210
+ # with "Cannot implicitly apply the latest tag". Sync first, then
211
+ # let the content comparison decide whether to release at all.
212
+ if [ -z "$already" ] && [ -n "$latest" ]; then
213
+ behind=$(node -e "
214
+ 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; };
215
+ console.log(cmp(process.argv[1], process.argv[2]) < 0);
216
+ " "$version" "$latest")
217
+ if [ "$behind" = "true" ]; then
218
+ echo "⏫ $name local version $version is behind npm latest $latest — syncing"
219
+ npm version "$latest" --no-git-tag-version --no-workspaces
220
+ version="$latest"
221
+ already="$latest"
222
+ fi
223
+ fi
224
+
225
+ if [ -n "$already" ]; then
226
+ # This exact version is on npm. Publish anyway only if the
227
+ # content changed: npm tarballs are reproducible (normalized
228
+ # mtimes), so pack integrity is a reliable comparison.
229
+ 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))")
230
+ remote_integrity=$(npm view "$name@$version" dist.integrity 2>/dev/null || echo "")
231
+
232
+ if [ "$local_integrity" = "$remote_integrity" ]; then
233
+ echo "⏭ $name@$version already on npm with identical content — nothing new to release"
234
+ exit 0
235
+ fi
236
+
237
+ # Bumping one patch above `latest` is not enough: `latest` lags
238
+ # any version staged by an interrupted publish, and npm answers
239
+ # a PUT for one of those with E409 "Cannot publish over
240
+ # previously staged version".
241
+ next=$(node "$repo_root/scripts/next-free-version.mjs" "$name" "$version")
242
+ echo "🔼 $name content changed since $version — bumping to $next"
243
+ # --no-workspaces stops npm from doing workspace-aware
244
+ # processing over bun's symlinks, which trips
245
+ # EUNSUPPORTEDPROTOCOL on workspace:* siblings.
246
+ npm version "$next" --no-git-tag-version --no-workspaces
247
+ version="$next"
248
+ fi
249
+
250
+ # Exit 43 for an authorization failure so the loop stops early.
251
+ # A version the registry reserved between the query above and the
252
+ # PUT is still possible, so E409 retries at the next free version
253
+ # rather than failing the package. Five refusals in a row is a
254
+ # problem no amount of bumping fixes.
255
+ log="$RUNNER_TEMP/npm-publish.log"
256
+ attempt=1
257
+
258
+ while :; do
259
+ echo "Publishing $name@$version"
260
+ if npm publish --provenance --access public --ignore-scripts 2>&1 | tee "$log"; then
261
+ exit 0
262
+ fi
263
+
264
+ if grep -qE "npm error code (E401|E404|ENEEDAUTH|EAUTHUNKNOWN|EOTP)" "$log"; then
265
+ exit 43
266
+ fi
267
+ if grep -q "npm error code E403" "$log" && ! grep -qi "cannot publish over" "$log"; then
268
+ exit 43
269
+ fi
270
+ if ! grep -qi "cannot publish over" "$log"; then
271
+ exit 1
272
+ fi
273
+ if [ "$attempt" -ge 5 ]; then
274
+ echo "⚠ npm refused $attempt versions in a row for $name — giving up"
275
+ exit 1
276
+ fi
277
+
278
+ next=$(node "$repo_root/scripts/next-free-version.mjs" "$name" "$version")
279
+ echo "↩ npm has $name@$version reserved — retrying as $next"
280
+ npm version "$next" --no-git-tag-version --no-workspaces
281
+ version="$next"
282
+ attempt=$((attempt + 1))
283
+ done
284
+ )
285
+ rc=$?
286
+
287
+ case "$rc" in
288
+ 0) ;;
289
+ 42) skipped_builds="$skipped_builds $name" ;;
290
+ 43) unauthorized_packages="$unauthorized_packages $name"; auth_broken="1" ;;
291
+ *) failed_packages="$failed_packages $name" ;;
292
+ esac
293
+ done
294
+
295
+ [ -n "$skipped_builds" ] && echo "::warning::Skipped (build failed, not published):$skipped_builds"
296
+ [ -n "$unattempted_packages" ] && echo "⏭ Not attempted (the credential was already rejected):$unattempted_packages"
297
+
298
+ if [ -n "$unauthorized_packages" ]; then
299
+ echo "❌ Not authorized to publish:$unauthorized_packages"
300
+ 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 on npmjs.com."
301
+ fi
302
+
303
+ [ -n "$failed_packages" ] && echo "❌ Failed to publish:$failed_packages"
304
+
305
+ if [ -n "$failed_packages$unauthorized_packages$unattempted_packages" ]; then
306
+ exit 1
307
+ fi
308
+ exit 0
309
+
310
+ - name: Commit version bumps
311
+ if: success()
312
+ run: |
313
+ # Bumps made during publish must land back in the repo so the next run
314
+ # sees them. The workspace:* pinning done for the tarballs must not —
315
+ # restore everything except the "version" field.
316
+ node scripts/restore-pinned-deps.mjs
317
+ git add '{{PACKAGES_GLOB}}/package.json'
318
+ git diff --staged --quiet && echo "No version changes to commit" && exit 0
319
+ git commit -m "chore: bump published package versions [skip ci]"
320
+ git push
@@ -0,0 +1,100 @@
1
+ # Runs every workspace package that has a test suite, and ships both coverage
2
+ # and test results to Codecov.
3
+ #
4
+ # The matrix is discovered rather than hand-maintained: a first job reads the
5
+ # workspace globs out of the root package.json and emits one matrix entry per
6
+ # package that declares a `test:coverage` (or `test`) script. Adding a package
7
+ # to the repo therefore adds it to CI with no edit here — the failure mode of a
8
+ # hand-written matrix is a package that silently stops being tested.
9
+ #
10
+ # Each entry is expected to produce, relative to its own directory:
11
+ #
12
+ # coverage/lcov.info coverage, uploaded under a Codecov flag named for the package
13
+ # junit.xml test results, ingested by Codecov Test Analytics
14
+ #
15
+ # Neither is required — a package that writes only one still uploads that one.
16
+
17
+ name: tests
18
+
19
+ on:
20
+ push:
21
+ branches: [{{DEFAULT_BRANCH}}]
22
+ pull_request:
23
+ workflow_dispatch:
24
+
25
+ concurrency:
26
+ group: tests-${{ github.ref }}
27
+ cancel-in-progress: true
28
+
29
+ jobs:
30
+ discover:
31
+ name: Discover packages
32
+ runs-on: ubuntu-latest
33
+ outputs:
34
+ matrix: ${{ steps.list.outputs.matrix }}
35
+ empty: ${{ steps.list.outputs.empty }}
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+
39
+ - id: list
40
+ name: List packages with a test script
41
+ run: node scripts/list-test-packages.mjs >> "$GITHUB_OUTPUT"
42
+
43
+ test:
44
+ name: ${{ matrix.flag }}
45
+ needs: discover
46
+ if: needs.discover.outputs.empty != 'true'
47
+ runs-on: ubuntu-latest
48
+
49
+ # A package whose suite is currently red still uploads its results — that
50
+ # report is the point of Test Analytics — but it does not fail the run.
51
+ # Set `"ci": { "allowFailure": true }` in the package's package.json to
52
+ # opt into that, and drop it once the suite is green.
53
+ continue-on-error: ${{ matrix.allowFailure }}
54
+
55
+ strategy:
56
+ # One package's failure must not cancel the others' uploads.
57
+ fail-fast: false
58
+ matrix: ${{ fromJson(needs.discover.outputs.matrix) }}
59
+
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+
63
+ - uses: oven-sh/setup-bun@v2
64
+
65
+ # --ignore-scripts: a dependency's postinstall has no business running in
66
+ # CI, and some published packages ship a `prepare: "husky install"` that
67
+ # dies with exit 127 when husky is not a dependency of this repo.
68
+ - name: Install dependencies
69
+ run: {{PACKAGE_MANAGER}} install --ignore-scripts
70
+
71
+ # Workspace libraries that siblings consume through their built `dist`
72
+ # must exist before anything imports them. Turbo resolves the dependency
73
+ # order; packages with no build script are a no-op.
74
+ - name: Build workspace dependencies
75
+ run: bunx turbo build --filter=${{ matrix.name }}^... || echo "No buildable dependencies for ${{ matrix.name }}"
76
+
77
+ - name: Run tests
78
+ working-directory: ${{ matrix.dir }}
79
+ run: {{PACKAGE_MANAGER}} run ${{ matrix.script }}
80
+
81
+ # `!cancelled()` rather than `always()`: a cancelled run has nothing worth
82
+ # uploading, but a *failed* one is exactly when the report matters most.
83
+ - name: Upload coverage to Codecov
84
+ if: ${{ !cancelled() }}
85
+ uses: codecov/codecov-action@v5
86
+ with:
87
+ files: ${{ matrix.dir }}/coverage/lcov.info
88
+ flags: ${{ matrix.flag }}
89
+ token: ${{ secrets.CODECOV_TOKEN }}
90
+ # Codecov being down must not turn a green suite red.
91
+ fail_ci_if_error: false
92
+
93
+ - name: Upload test results to Codecov
94
+ if: ${{ !cancelled() }}
95
+ uses: codecov/test-results-action@v1
96
+ with:
97
+ files: ${{ matrix.dir }}/junit.xml
98
+ flags: ${{ matrix.flag }}
99
+ token: ${{ secrets.CODECOV_TOKEN }}
100
+ fail_ci_if_error: false