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.
- package/README.md +91 -0
- package/bin/template-git-repo.js +257 -0
- package/docs/ACTIONS.md +195 -0
- package/docs/BADGES.md +221 -0
- package/package.json +56 -0
- package/src/apply.js +164 -0
- package/src/badges.js +353 -0
- package/src/context.js +228 -0
- package/src/index.js +29 -0
- package/src/readme.js +60 -0
- package/template/.github/workflows/auto-merge-and-create-prs.yml +102 -0
- package/template/.github/workflows/auto-merge-claude.yml +54 -0
- package/template/.github/workflows/deploy-test-reports.yml +98 -0
- package/template/.github/workflows/npm-publish.yml +320 -0
- package/template/.github/workflows/tests.yml +100 -0
- package/template/codecov.yml +46 -0
- package/template/scripts/list-test-packages.mjs +137 -0
- package/template/scripts/next-free-version.mjs +169 -0
- package/template/scripts/pin-workspace-deps.mjs +108 -0
- package/template/scripts/restore-pinned-deps.mjs +101 -0
- package/template/scripts/workspace-build-order.mjs +129 -0
- package/template/turbo.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# template-git-repo
|
|
2
|
+
|
|
3
|
+
One command to give a repository the CI, publishing, badges and Turborepo wiring
|
|
4
|
+
worked out in [qwksearch-research-agent](https://github.com/OpenSourceAGI/qwksearch-research-agent):
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
bunx template-git-repo
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
It detects the repo slug, default branch, package manager, workspace layout and
|
|
11
|
+
the package to advertise on npm, writes the workflows and their helper scripts,
|
|
12
|
+
wires up Turborepo, and injects a badge block into your README. Nothing existing
|
|
13
|
+
is overwritten without `--force`, and `--dry-run` prints the exact plan.
|
|
14
|
+
|
|
15
|
+
## What it writes
|
|
16
|
+
|
|
17
|
+
| Path | What it is |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `.github/workflows/tests.yml` | Per-package tests with a **discovered** matrix, coverage + test analytics to Codecov |
|
|
20
|
+
| `.github/workflows/npm-publish.yml` | Publishes every workspace package whose *content* changed — no version bookkeeping |
|
|
21
|
+
| `.github/workflows/auto-merge-claude.yml` | Auto-merges agent PRs once their checks pass |
|
|
22
|
+
| `.github/workflows/auto-merge-and-create-prs.yml` | Twice-daily sweep: merges green PRs, opens PRs for orphan branches |
|
|
23
|
+
| `.github/workflows/deploy-test-reports.yml` | Publishes the HTML test report to Cloudflare Workers |
|
|
24
|
+
| `scripts/*.mjs` | The five helpers those workflows call |
|
|
25
|
+
| `turbo.json` | Pipeline whose task names the workflows use |
|
|
26
|
+
| `codecov.yml` | Per-package flags with `carryforward` |
|
|
27
|
+
| `README.md` | The badge block, between markers |
|
|
28
|
+
|
|
29
|
+
Full setup notes: **[docs/ACTIONS.md](./docs/ACTIONS.md)** (workflows and secrets)
|
|
30
|
+
and **[docs/BADGES.md](./docs/BADGES.md)** (every badge, and what you have to do
|
|
31
|
+
outside the repo before it shows anything real).
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bunx template-git-repo --dry-run # print the plan, change nothing
|
|
37
|
+
bunx template-git-repo --force # replace files that already exist
|
|
38
|
+
bunx template-git-repo --actions-only # just the workflows and their scripts
|
|
39
|
+
bunx template-git-repo --badges-only # just the README badge block
|
|
40
|
+
bunx template-git-repo --no-turbo # leave turbo.json and package.json alone
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Badge inputs — each one enables the badge that needs it, and badges without their
|
|
44
|
+
input are left out with a note rather than rendered broken:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bunx template-git-repo \
|
|
48
|
+
--doi 10.5281/zenodo.20951725 \
|
|
49
|
+
--docs https://example.com/docs \
|
|
50
|
+
--discord-id 1110227955554209923 --discord-invite https://discord.gg/xxxx \
|
|
51
|
+
--stack Claude,Cloudflare,Next.js
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Run `bunx template-git-repo --help` for the rest.
|
|
55
|
+
|
|
56
|
+
## The parts worth knowing
|
|
57
|
+
|
|
58
|
+
**The test matrix is discovered, not written.** `scripts/list-test-packages.mjs`
|
|
59
|
+
reads the `workspaces` globs and emits one matrix entry per package with a test
|
|
60
|
+
script. A hand-maintained matrix fails silently — a package added to the repo but
|
|
61
|
+
not to the matrix is never tested, and nothing goes red to say so.
|
|
62
|
+
|
|
63
|
+
**Publishing compares content, not version numbers.** npm tarballs are
|
|
64
|
+
reproducible, so `npm pack` integrity against the registry answers "did anything
|
|
65
|
+
actually change". Version bumps happen as a consequence, not as a prerequisite,
|
|
66
|
+
and they are taken from what the registry says is free — including the versions a
|
|
67
|
+
half-finished publish reserved, which `latest` cannot show you.
|
|
68
|
+
|
|
69
|
+
**Badges carry their own setup instructions.** `docs/BADGES.md` is generated from
|
|
70
|
+
the catalog in `src/badges.js`, and a test fails if it drifts. A badge whose
|
|
71
|
+
prerequisites nobody wrote down is a badge that reads `unknown` forever.
|
|
72
|
+
|
|
73
|
+
**The badge block is re-runnable.** It lives between
|
|
74
|
+
`<!-- template-git-repo:badges:start -->` and `...:end -->`, so running the CLI
|
|
75
|
+
again after you publish a package or finish setting up Codecov replaces the block
|
|
76
|
+
and nothing else.
|
|
77
|
+
|
|
78
|
+
## Programmatic use
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { renderBadgeBlock, buildContext } from 'template-git-repo';
|
|
82
|
+
|
|
83
|
+
const context = buildContext({ overrides: { stack: 'Bun,Cloudflare' } });
|
|
84
|
+
const { markdown, skipped } = renderBadgeBlock(context);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Adding a badge
|
|
88
|
+
|
|
89
|
+
Add an entry to `BADGES` in `src/badges.js` — `needs`, `setup` and `group` are
|
|
90
|
+
what make it self-documenting — then `bun run docs:badges`. Nothing else needs to
|
|
91
|
+
know about it.
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One command that sets a repository up with the CI, publishing, badges and
|
|
4
|
+
* Turborepo wiring worked out in OpenSourceAGI/qwksearch-research-agent.
|
|
5
|
+
*
|
|
6
|
+
* bunx template-git-repo
|
|
7
|
+
*
|
|
8
|
+
* Everything it can detect, it detects — repo slug, default branch, package
|
|
9
|
+
* manager, workspace layout, the package to advertise on npm — so the common
|
|
10
|
+
* case takes no flags. What it cannot know (a DOI, a Discord server, a docs
|
|
11
|
+
* URL) is passed in, and the badges that need those are skipped with a note
|
|
12
|
+
* rather than rendered broken.
|
|
13
|
+
*
|
|
14
|
+
* Nothing existing is overwritten without `--force`, and `--dry-run` prints the
|
|
15
|
+
* exact plan a real run would carry out.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import process from 'node:process';
|
|
20
|
+
import { buildContext } from '../src/context.js';
|
|
21
|
+
import { renderBadgeBlock, BADGES } from '../src/badges.js';
|
|
22
|
+
import { injectBadges, hasUnmarkedBadges } from '../src/readme.js';
|
|
23
|
+
import { planFiles, applyPlan, wireTurbo } from '../src/apply.js';
|
|
24
|
+
|
|
25
|
+
const HELP = `
|
|
26
|
+
template-git-repo — set up a repo with the GitHub Actions, badges and Turborepo
|
|
27
|
+
config from the qwksearch reference setup.
|
|
28
|
+
|
|
29
|
+
Usage
|
|
30
|
+
bunx template-git-repo [options]
|
|
31
|
+
|
|
32
|
+
What it writes
|
|
33
|
+
.github/workflows/ tests, npm publish, auto-merge, hosted test reports
|
|
34
|
+
scripts/*.mjs the helpers those workflows call
|
|
35
|
+
turbo.json pipeline whose task names the workflows use
|
|
36
|
+
codecov.yml per-package flags with carryforward
|
|
37
|
+
README.md the badge block, between markers
|
|
38
|
+
|
|
39
|
+
Options
|
|
40
|
+
--dry-run Print the plan and change nothing
|
|
41
|
+
--force Overwrite files that already exist
|
|
42
|
+
--actions-only Only the workflows and their scripts
|
|
43
|
+
--badges-only Only the README badge block
|
|
44
|
+
--no-turbo Do not write turbo.json or touch the root package.json
|
|
45
|
+
--yes, -y Skip the confirmation prompt
|
|
46
|
+
|
|
47
|
+
Context (all optional — detected where possible)
|
|
48
|
+
--repo <owner/repo> Default: the origin remote
|
|
49
|
+
--branch <name> Default: the remote's HEAD branch
|
|
50
|
+
--pm <bun|pnpm|yarn|npm>
|
|
51
|
+
--npm-package <name> Package for the npm version/downloads badges
|
|
52
|
+
--workflow <file> Workflow file for the CI badge (default tests.yml)
|
|
53
|
+
|
|
54
|
+
Badge inputs (each enables the badge that needs it)
|
|
55
|
+
--doi <10.5281/...> --docs <url> --api <url>
|
|
56
|
+
--youtube <url> --uptime <url> --test-report <url>
|
|
57
|
+
--discord-id <id> --discord-invite <url>
|
|
58
|
+
--stack <A,B,C> --cloudflare-deploy
|
|
59
|
+
--exclude <id,id> --only <id,id>
|
|
60
|
+
|
|
61
|
+
Badge ids: ${BADGES.map((b) => b.id).join(', ')}
|
|
62
|
+
|
|
63
|
+
Docs
|
|
64
|
+
docs/ACTIONS.md what each workflow does and which secrets it needs
|
|
65
|
+
docs/BADGES.md how to set up each badge
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A deliberately small parser: this CLI takes `--flag` and `--flag value` and
|
|
70
|
+
* nothing else, so a dependency for it would cost more than it saves.
|
|
71
|
+
*
|
|
72
|
+
* @param {string[]} argv
|
|
73
|
+
* @returns {Record<string, string | boolean>}
|
|
74
|
+
*/
|
|
75
|
+
export function parseArgs(argv) {
|
|
76
|
+
const flags = {};
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const arg = argv[i];
|
|
80
|
+
if (!arg.startsWith('-')) continue;
|
|
81
|
+
|
|
82
|
+
const key = arg.replace(/^--?/, '');
|
|
83
|
+
const next = argv[i + 1];
|
|
84
|
+
|
|
85
|
+
if (next && !next.startsWith('-')) {
|
|
86
|
+
flags[key] = next;
|
|
87
|
+
i++;
|
|
88
|
+
} else {
|
|
89
|
+
flags[key] = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return flags;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {Record<string, string | boolean>} flags
|
|
98
|
+
* @returns {Record<string, string | undefined>}
|
|
99
|
+
*/
|
|
100
|
+
function overridesFrom(flags) {
|
|
101
|
+
const text = (key) => (typeof flags[key] === 'string' ? flags[key] : undefined);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
repoSlug: text('repo'),
|
|
105
|
+
defaultBranch: text('branch'),
|
|
106
|
+
packageManager: text('pm'),
|
|
107
|
+
npmPackage: text('npm-package'),
|
|
108
|
+
workflowFile: text('workflow'),
|
|
109
|
+
doi: text('doi'),
|
|
110
|
+
docsUrl: text('docs'),
|
|
111
|
+
apiUrl: text('api'),
|
|
112
|
+
youtubeUrl: text('youtube'),
|
|
113
|
+
uptimeUrl: text('uptime'),
|
|
114
|
+
testReportUrl: text('test-report'),
|
|
115
|
+
discordId: text('discord-id'),
|
|
116
|
+
discordInvite: text('discord-invite'),
|
|
117
|
+
stack: text('stack'),
|
|
118
|
+
cloudflareDeploy: flags['cloudflare-deploy'] ? 'true' : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string[]} [argv]
|
|
124
|
+
* @returns {Promise<number>} process exit code
|
|
125
|
+
*/
|
|
126
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
127
|
+
const flags = parseArgs(argv);
|
|
128
|
+
|
|
129
|
+
if (flags.help || flags.h) {
|
|
130
|
+
console.log(HELP);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const dryRun = Boolean(flags['dry-run']);
|
|
135
|
+
const force = Boolean(flags.force);
|
|
136
|
+
const badgesOnly = Boolean(flags['badges-only']);
|
|
137
|
+
const actionsOnly = Boolean(flags['actions-only']);
|
|
138
|
+
const skipTurbo = Boolean(flags['no-turbo']) || badgesOnly;
|
|
139
|
+
|
|
140
|
+
const context = buildContext({ overrides: overridesFrom(flags) });
|
|
141
|
+
|
|
142
|
+
if (!context.repoSlug) {
|
|
143
|
+
console.error(
|
|
144
|
+
'Could not work out which GitHub repo this is: no `origin` remote, or one that is not on github.com.\n' +
|
|
145
|
+
'Pass it explicitly: bunx template-git-repo --repo owner/repo',
|
|
146
|
+
);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(`\n📦 ${context.repoSlug} (${context.defaultBranch}, ${context.packageManager})`);
|
|
151
|
+
console.log(` ${context.root}\n`);
|
|
152
|
+
|
|
153
|
+
let changed = 0;
|
|
154
|
+
|
|
155
|
+
// ── Workflows, scripts, turbo.json, codecov.yml ───────────────────────────
|
|
156
|
+
if (!badgesOnly) {
|
|
157
|
+
const include = actionsOnly ? ['.github', 'scripts'] : undefined;
|
|
158
|
+
const filePlan = planFiles({ context, force, include }).filter(
|
|
159
|
+
(entry) => !(skipTurbo && entry.path === 'turbo.json'),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
for (const entry of filePlan) {
|
|
163
|
+
const mark = entry.action === 'skip' ? '·' : entry.action === 'overwrite' ? '~' : '+';
|
|
164
|
+
console.log(` ${mark} ${entry.path}${entry.action === 'skip' ? ' (exists — use --force to replace)' : ''}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { written } = applyPlan(filePlan, { dryRun });
|
|
168
|
+
changed += written.length;
|
|
169
|
+
console.log('');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Root package.json: turbo scripts and devDependency ────────────────────
|
|
173
|
+
if (!skipTurbo && !actionsOnly) {
|
|
174
|
+
const manifestPath = path.join(context.root, 'package.json');
|
|
175
|
+
|
|
176
|
+
if (!fs.existsSync(manifestPath)) {
|
|
177
|
+
console.log(' · package.json not found at the repo root — skipping the Turborepo wiring\n');
|
|
178
|
+
} else {
|
|
179
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
180
|
+
const { manifest: next, changes } = wireTurbo(manifest, { packagesDir: context.packagesDir });
|
|
181
|
+
|
|
182
|
+
if (changes.length === 0) {
|
|
183
|
+
console.log(' · package.json already has the turbo wiring\n');
|
|
184
|
+
} else {
|
|
185
|
+
console.log(` ~ package.json (${changes.join(', ')})\n`);
|
|
186
|
+
if (!dryRun) fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
187
|
+
changed++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── README badges ─────────────────────────────────────────────────────────
|
|
193
|
+
if (!actionsOnly) {
|
|
194
|
+
const readmePath = ['README.md', 'readme.md', 'Readme.md']
|
|
195
|
+
.map((name) => path.join(context.root, name))
|
|
196
|
+
.find((candidate) => fs.existsSync(candidate)) ?? path.join(context.root, 'README.md');
|
|
197
|
+
|
|
198
|
+
const existing = fs.existsSync(readmePath) ? fs.readFileSync(readmePath, 'utf8') : '';
|
|
199
|
+
|
|
200
|
+
const { markdown, skipped } = renderBadgeBlock(context, {
|
|
201
|
+
only: typeof flags.only === 'string' ? flags.only.split(',') : undefined,
|
|
202
|
+
exclude: typeof flags.exclude === 'string' ? flags.exclude.split(',') : [],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const { content, action } = injectBadges(existing, markdown);
|
|
206
|
+
|
|
207
|
+
if (content === existing) {
|
|
208
|
+
console.log(' · README.md badges already up to date\n');
|
|
209
|
+
} else {
|
|
210
|
+
console.log(` ~ ${path.basename(readmePath)} (badge block ${action})`);
|
|
211
|
+
if (hasUnmarkedBadges(existing)) {
|
|
212
|
+
console.log(' ⚠ the README already had badges of its own — they were left in place, remove the duplicates by hand');
|
|
213
|
+
}
|
|
214
|
+
if (!dryRun) fs.writeFileSync(readmePath, content);
|
|
215
|
+
changed++;
|
|
216
|
+
console.log('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (skipped.length > 0) {
|
|
220
|
+
console.log(' Badges not included (nothing to point them at):');
|
|
221
|
+
for (const { id, missing } of skipped) {
|
|
222
|
+
console.log(` ${id.padEnd(20)} needs ${missing.join(', ')}`);
|
|
223
|
+
}
|
|
224
|
+
console.log(' → see docs/BADGES.md, then re-run with the matching flag\n');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── What the human still has to do ────────────────────────────────────────
|
|
229
|
+
if (!badgesOnly) {
|
|
230
|
+
console.log(' Repository secrets the workflows expect:');
|
|
231
|
+
console.log(' CODECOV_TOKEN coverage + test analytics uploads');
|
|
232
|
+
console.log(' NPM_TOKEN only if you are not using npm trusted publishing');
|
|
233
|
+
console.log(' GIT_TOKEN a PAT for auto-merge (GITHUB_TOKEN merges do not trigger workflows)');
|
|
234
|
+
console.log(' CLOUDFLARE_API_TOKEN hosted test reports');
|
|
235
|
+
console.log(' CLOUDFLARE_ACCOUNT_ID hosted test reports');
|
|
236
|
+
console.log(' → docs/ACTIONS.md explains each one\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (dryRun) {
|
|
240
|
+
console.log(`Dry run — nothing was written (${changed} file(s) would change).\n`);
|
|
241
|
+
} else {
|
|
242
|
+
console.log(changed === 0 ? 'Already set up — nothing to do.\n' : `Done — ${changed} file(s) changed.\n`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Only run when invoked as a program, so the module can be imported by tests.
|
|
249
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
250
|
+
main().then(
|
|
251
|
+
(code) => process.exit(code),
|
|
252
|
+
(error) => {
|
|
253
|
+
console.error(error);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
}
|
package/docs/ACTIONS.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# GitHub Actions setup
|
|
2
|
+
|
|
3
|
+
What each workflow in `template/.github/workflows/` does, what it needs before it
|
|
4
|
+
can work, and the failure it exists to prevent. These are the workflows from
|
|
5
|
+
[OpenSourceAGI/qwksearch-research-agent](https://github.com/OpenSourceAGI/qwksearch-research-agent),
|
|
6
|
+
generalized — most of the comments in them describe an incident.
|
|
7
|
+
|
|
8
|
+
## The secrets, in one table
|
|
9
|
+
|
|
10
|
+
Add these under **Settings → Secrets and variables → Actions → New repository secret**.
|
|
11
|
+
|
|
12
|
+
| Secret | Needed by | How to get it | If it is missing |
|
|
13
|
+
| --- | --- | --- | --- |
|
|
14
|
+
| `CODECOV_TOKEN` | `tests.yml` | codecov.io → add the repo → Settings → Repository Upload Token | Uploads are rejected; the coverage badge stays `unknown`. Tests still run and still pass. |
|
|
15
|
+
| `NPM_TOKEN` | `npm-publish.yml` | npmjs.com → Access Tokens → Granular, read-write on the packages | Only needed if you are *not* using trusted publishing. See below. |
|
|
16
|
+
| `GIT_TOKEN` | `auto-merge-*.yml` | A fine-grained PAT with Contents: read-write and Pull requests: read-write | Falls back to `GITHUB_TOKEN`, which merges but does **not** trigger further workflows — so a merge would never fire the publish run. |
|
|
17
|
+
| `CLOUDFLARE_API_TOKEN` | `deploy-test-reports.yml` | dash.cloudflare.com → My Profile → API Tokens → "Edit Cloudflare Workers" | The deploy step fails; the rest of the run is unaffected. |
|
|
18
|
+
| `CLOUDFLARE_ACCOUNT_ID` | `deploy-test-reports.yml` | The hex id in any Cloudflare dashboard URL | Same. |
|
|
19
|
+
|
|
20
|
+
Repository settings that matter as much as the secrets:
|
|
21
|
+
|
|
22
|
+
- **Settings → General → Allow auto-merge** — without it, `--auto` is rejected and
|
|
23
|
+
the auto-merge workflows fall back to merging immediately, which skips waiting
|
|
24
|
+
for checks.
|
|
25
|
+
- **Settings → Actions → General → Workflow permissions → Read and write** —
|
|
26
|
+
without it, the version-bump commit in `npm-publish.yml` cannot be pushed.
|
|
27
|
+
- **Branch protection with at least one required check** — this is what `--auto`
|
|
28
|
+
waits on. With no required check there is nothing to wait for.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## `tests.yml` — test, coverage, and test analytics
|
|
33
|
+
|
|
34
|
+
Runs every workspace package that has a test suite, uploads `coverage/lcov.info`
|
|
35
|
+
to Codecov under a per-package flag, and uploads `junit.xml` to Codecov Test
|
|
36
|
+
Analytics, which tracks per-test run time and flakiness and comments failures on
|
|
37
|
+
the PR.
|
|
38
|
+
|
|
39
|
+
**The matrix is discovered, not written.** A `discover` job runs
|
|
40
|
+
`scripts/list-test-packages.mjs`, which reads the `workspaces` globs from the root
|
|
41
|
+
`package.json` and emits one entry per package with a `test:coverage`, `test:ci`
|
|
42
|
+
or `test` script. A hand-maintained matrix fails silently — a package added to
|
|
43
|
+
the repo but not to the matrix is simply never tested, and nothing goes red to
|
|
44
|
+
say so.
|
|
45
|
+
|
|
46
|
+
What each package has to provide:
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{
|
|
50
|
+
"scripts": {
|
|
51
|
+
// Whatever writes coverage/lcov.info and junit.xml. For Vitest:
|
|
52
|
+
"test:coverage": "vitest run --coverage"
|
|
53
|
+
},
|
|
54
|
+
// Optional: keep a currently-red suite from failing the workflow while it
|
|
55
|
+
// still uploads its results. Remove once the suite is green.
|
|
56
|
+
"ci": { "allowFailure": true }
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For Vitest, `junit.xml` comes from the reporter config, not a flag:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
reporters: process.env.CI ? ['default', 'junit'] : ['default'],
|
|
64
|
+
outputFile: { junit: './junit.xml' },
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Why `if: ${{ !cancelled() }}` and not `always()`** on the upload steps: a
|
|
68
|
+
cancelled run has nothing worth uploading, but a *failed* one is exactly when the
|
|
69
|
+
report matters most. `always()` also runs on cancellation and clutters the
|
|
70
|
+
dashboard with partial data.
|
|
71
|
+
|
|
72
|
+
**Why `fail_ci_if_error: false`**: Codecov having an outage must not turn a green
|
|
73
|
+
suite red.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## `npm-publish.yml` — publish what changed, without version bookkeeping
|
|
78
|
+
|
|
79
|
+
Publishes every non-private workspace package whose *content* changed, on every
|
|
80
|
+
push to the default branch. Nobody has to remember to bump a version.
|
|
81
|
+
|
|
82
|
+
Five rules are encoded in it, each learned from a specific failure:
|
|
83
|
+
|
|
84
|
+
1. **Compare pack integrity, not version numbers.** npm tarballs are reproducible
|
|
85
|
+
(normalized mtimes), so `npm pack --dry-run --json` integrity against
|
|
86
|
+
`npm view <pkg>@<version> dist.integrity` is a reliable "did anything change".
|
|
87
|
+
A version bump is not evidence of a change, and an unchanged version is not
|
|
88
|
+
evidence of no change.
|
|
89
|
+
2. **Check the credential before building anything.** npm answers an unauthorized
|
|
90
|
+
PUT with `E404 ... could not be found or you do not have permission to access
|
|
91
|
+
it`, which reads like a missing package — and it arrives *after* every package
|
|
92
|
+
has been built, with a provenance statement already signed into the public
|
|
93
|
+
sigstore transparency log for each failed attempt.
|
|
94
|
+
3. **Walk the workspace in dependency order** (`scripts/workspace-build-order.mjs`).
|
|
95
|
+
Package managers link workspace siblings as symlinks; a sibling that has not
|
|
96
|
+
been built has no `dist/`, and its `exports` → `types` entries point at files
|
|
97
|
+
that do not exist. Alphabetical order produces
|
|
98
|
+
`Cannot find module '<sibling>' or its corresponding type declarations`.
|
|
99
|
+
4. **Never publish the literal `workspace:*` protocol** — no consumer outside the
|
|
100
|
+
monorepo can resolve it. `scripts/pin-workspace-deps.mjs` substitutes real
|
|
101
|
+
ranges at pack time; `scripts/restore-pinned-deps.mjs` takes them back out
|
|
102
|
+
afterwards, so only the version bump gets committed.
|
|
103
|
+
5. **The registry decides which versions are spent** (`scripts/next-free-version.mjs`).
|
|
104
|
+
npm refuses a PUT for any version it has *ever* seen, including ones staged by
|
|
105
|
+
an interrupted publish that the `latest` dist-tag cannot show you:
|
|
106
|
+
`E409 ... Cannot publish over previously staged version`. The union of
|
|
107
|
+
`versions` and the `time` timeline is the real taken set.
|
|
108
|
+
|
|
109
|
+
### Trusted publishing (recommended) vs `NPM_TOKEN`
|
|
110
|
+
|
|
111
|
+
**Trusted publishing (OIDC)** — no secret at all. Leave `NPM_TOKEN` unset. The
|
|
112
|
+
job's `id-token: write` permission is traded by npm ≥ 11.5.1 for a short-lived
|
|
113
|
+
publish token. For each package, go to
|
|
114
|
+
`npmjs.com/package/<name>/access` → **Trusted publisher** and name this
|
|
115
|
+
repository plus `npm-publish.yml`. Nothing to rotate, nothing to expire.
|
|
116
|
+
|
|
117
|
+
**`NPM_TOKEN`** — a granular access token with read-write on the packages. It
|
|
118
|
+
expires after 90 days at most, and classic automation tokens no longer work for
|
|
119
|
+
direct publishing. Setting the secret selects this path; leaving it unset selects
|
|
120
|
+
OIDC.
|
|
121
|
+
|
|
122
|
+
### Failure decoder
|
|
123
|
+
|
|
124
|
+
| Symptom | Cause → fix |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `E404` on publish for a package that exists | The credential cannot write. Rotate `NPM_TOKEN`, or finish the trusted-publisher setup for that package. |
|
|
127
|
+
| `E409 Cannot publish over previously staged version` | A number the registry reserved. The workflow already retries at the next free version five times; more than that means the registry is refusing everything. |
|
|
128
|
+
| `Cannot implicitly apply the latest tag` | The local version fell behind the registry, usually a bump commit that never landed back. The workflow syncs to `latest` and re-evaluates. |
|
|
129
|
+
| `EUNSUPPORTEDPROTOCOL workspace:*` | `npm` was run where the workspace's own package manager should have been, or without `--no-workspaces` over its symlinks. |
|
|
130
|
+
| `husky: not found` (exit 127) | A dependency ships a broken `prepare: "husky install"`. The no-op husky shim step handles it; do not remove that step. |
|
|
131
|
+
| `vite: not found` during a package build | Dependencies were installed with plain `npm` inside a bun/pnpm workspace, which leaves devDependencies uninstalled. |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## `auto-merge-claude.yml` — merge agent PRs when they go green
|
|
136
|
+
|
|
137
|
+
Fires on every PR event and, **only for the actors in its `if:` allowlist**,
|
|
138
|
+
enables auto-merge. That allowlist is the entire safety model — edit it to the
|
|
139
|
+
accounts you actually want merged unattended, and never widen it to all
|
|
140
|
+
contributors.
|
|
141
|
+
|
|
142
|
+
It tries `gh pr merge --auto` first (GitHub merges once required checks pass) and
|
|
143
|
+
falls back to an immediate merge, because `--auto` is rejected outright on a repo
|
|
144
|
+
with no branch protection — there is nothing for it to wait on.
|
|
145
|
+
|
|
146
|
+
Long-lived branches (`production`, `prod`, `staging`, `develop`) are merged
|
|
147
|
+
without `--delete-branch`.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## `auto-merge-and-create-prs.yml` — the twice-daily sweep
|
|
152
|
+
|
|
153
|
+
The backstop for two things that fall through the cracks: an agent session that
|
|
154
|
+
pushes a branch and never opens a PR, and a PR whose last check finished after
|
|
155
|
+
the per-PR workflow had already run.
|
|
156
|
+
|
|
157
|
+
It merges every open PR that is non-draft, `CLEAN`, not blocked by a requested
|
|
158
|
+
change, and whose checks all concluded `SUCCESS`/`SKIPPED`/`NEUTRAL` — a *pending*
|
|
159
|
+
check means "not yet", not "merge it". Then it opens a PR for every remote branch
|
|
160
|
+
that has neither an open nor a previously merged PR and is not already an ancestor
|
|
161
|
+
of the default branch.
|
|
162
|
+
|
|
163
|
+
It deliberately does not use `actions/checkout`: it needs every remote ref, and
|
|
164
|
+
`git init` + a full `git fetch` is cheaper than `fetch-depth: 0`.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## `deploy-test-reports.yml` — a test report with a URL
|
|
169
|
+
|
|
170
|
+
Publishes the Vitest HTML reporter output to Cloudflare Workers on every push to
|
|
171
|
+
the default branch, so the Test Report badge links to something current instead of
|
|
172
|
+
to a workflow log.
|
|
173
|
+
|
|
174
|
+
Needs an `apps/test-reports/` directory with a `wrangler.toml` whose
|
|
175
|
+
`assets.directory` is `dist`, and a root `test:report` script that writes there.
|
|
176
|
+
|
|
177
|
+
The test step is `continue-on-error: true` — a red suite is exactly when the
|
|
178
|
+
report is worth reading. What it must not do is leave `dist/` missing: wrangler
|
|
179
|
+
treats an absent `assets.directory` as a hard error, so the run would end on a
|
|
180
|
+
config error instead of on the test signal. The placeholder-page step exists for
|
|
181
|
+
that.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Adding your own
|
|
186
|
+
|
|
187
|
+
Keep the shape the rest of these use:
|
|
188
|
+
|
|
189
|
+
- `concurrency.group` on anything that pushes or deploys, so two runs cannot race.
|
|
190
|
+
- `fail-fast: false` on any matrix where one entry's failure should not hide the
|
|
191
|
+
others' results.
|
|
192
|
+
- `if: ${{ !cancelled() }}` rather than `always()` on upload steps.
|
|
193
|
+
- A comment at the top saying what the workflow is *for*, and a comment at any
|
|
194
|
+
step whose reason is not obvious from the code. The comments in these files are
|
|
195
|
+
the part that survives; the YAML is the easy half.
|