diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e390599..f8c0ae7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ { "name": "lvbt-contributions", "description": "Create readable LVBT GitHub issues and pull requests.", - "version": "0.5.1", + "version": "0.5.2", "source": "./packages/cli/plugins/lvbt-contributions" } ] diff --git a/docs/how-to/adopt-in-an-existing-repository.md b/docs/how-to/adopt-in-an-existing-repository.md index c0e7246..a7f5836 100644 --- a/docs/how-to/adopt-in-an-existing-repository.md +++ b/docs/how-to/adopt-in-an-existing-repository.md @@ -32,8 +32,7 @@ Copy these from the example, overwriting your versions: - `.githooks/commit-msg`, `.githooks/prepare-commit-msg`, `.githooks/pre-push` - `.codex/hooks.json` and `.agents/plugins/marketplace.json` - `.github/actions/setup-node-pnpm/action.yml` and `.github/renovate.json` -- `.github/workflows/standard-update.yml`, and give your `ci.yml` a `workflow_dispatch` trigger so - the update pull requests it opens run `Validate` +- `.github/workflows/standard-update.yml` - `.editorconfig`, `.prettierignore`, `prettier.config.js` Merge these by hand, keeping what the repository already has: diff --git a/docs/how-to/publish-a-release.md b/docs/how-to/publish-a-release.md index 426aef2..d268d85 100644 --- a/docs/how-to/publish-a-release.md +++ b/docs/how-to/publish-a-release.md @@ -48,11 +48,13 @@ own token: - Every other repository runs the release's own updater, so the release's migrations apply in one pass however old the repository's current release is. -A patch release's pull request merges itself once `Validate` passes. A minor release's pull request -waits for a maintainer to merge it, because a minor release can change how a repository works. A -newer release closes the older pull requests it replaces, and an update branch that fell behind -`main` is rebuilt, unless someone pushed a fix to it. To roll a release out sooner, run -`Standard update` by hand in each repository's Actions tab. +A patch release's pull request merges itself once `Validate` passes. GitHub holds the workflow runs +of a pull request that a workflow's own token opened until someone approves them; `Standard update` +approves the runs its own update started, so `Validate` runs without anyone clicking. A minor +release's pull request waits for a maintainer to merge it, because a minor release can change how a +repository works. A newer release closes the older pull requests it replaces, and an update branch +that fell behind `main` is rebuilt, unless someone pushed a fix to it. To roll a release out sooner, +run `Standard update` by hand in each repository's Actions tab. A workflow's own token may not change workflow files. When a release changes one, such as an example's `ci.yml`, the update still opens its pull request without that file, and the run fails and @@ -74,8 +76,8 @@ The `Standard status` workflow runs daily. Its job summary lists each repository update pull request, and it fails when a repository has drifted: behind the latest release for more than three days, an unreleased vendored commit on `main`, a failing update pull request, a contribution plugin ref that differs from the vendored release, a missing `org-standard` ruleset, or -no way to update itself (no `standard-update.yml`, or a `ci.yml` without `workflow_dispatch`). Run -the same check locally with `pnpm standards:status`. +no way to update itself (no `standard-update.yml`). Run the same check locally with +`pnpm standards:status`. A failing update pull request means the repository needs a change the updater could not make. Fix it on the update branch; the pull request then merges itself. A repository that must diverge from one diff --git a/docs/reference/release-0-5-2.md b/docs/reference/release-0-5-2.md new file mode 100644 index 0000000..591341f --- /dev/null +++ b/docs/reference/release-0-5-2.md @@ -0,0 +1,16 @@ +# Repository tooling 0.5.2 + +Nothing changes in how your repository works. This patch makes the `Standard update` workflow finish +its job on its own. + +- The update commits and pushes with the repository's git hooks switched off. Installing + dependencies turns those hooks on, so before this the push ran the full pre-push check, end-to-end + tests included, and a template's regenerated tree left a hook pointing at deleted files. +- GitHub holds the workflow runs of a pull request that a workflow's own token opened until someone + approves them. The update now approves the runs it started, so `Validate` runs and a patch update + merges itself. If the token may not approve them, the run fails after opening the pull request and + says so; a maintainer approves the runs on the pull request. +- `ci.yml` no longer needs a `workflow_dispatch` trigger for updates. + +A repository on 0.5.0 or 0.5.1 whose own update failed for either reason needs this release applied +once by a maintainer with `standards/propose.ts`; after that it updates itself. diff --git a/examples/basic/.claude/settings.json b/examples/basic/.claude/settings.json index edfe297..39b3306 100644 --- a/examples/basic/.claude/settings.json +++ b/examples/basic/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.1" + "ref": "v0.5.2" } } }, diff --git a/examples/basic/package.json b/examples/basic/package.json index b3ff9b4..31805b1 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -21,8 +21,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.1", - "@lasvegasfortransit/prettier-config": "0.5.1", + "@lasvegasfortransit/cli": "0.5.2", + "@lasvegasfortransit/prettier-config": "0.5.2", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/examples/basic/packages/example/package.json b/examples/basic/packages/example/package.json index 1ec6203..1932630 100644 --- a/examples/basic/packages/example/package.json +++ b/examples/basic/packages/example/package.json @@ -16,9 +16,9 @@ "test": "vitest run" }, "devDependencies": { - "@lasvegasfortransit/eslint-config": "0.5.1", - "@lasvegasfortransit/typescript-config": "0.5.1", - "@lasvegasfortransit/vitest-config": "0.5.1", + "@lasvegasfortransit/eslint-config": "0.5.2", + "@lasvegasfortransit/typescript-config": "0.5.2", + "@lasvegasfortransit/vitest-config": "0.5.2", "@types/node": "catalog:", "eslint": "catalog:", "typescript": "catalog:", diff --git a/examples/with-astro/.claude/settings.json b/examples/with-astro/.claude/settings.json index edfe297..39b3306 100644 --- a/examples/with-astro/.claude/settings.json +++ b/examples/with-astro/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.1" + "ref": "v0.5.2" } } }, diff --git a/examples/with-astro/apps/site/package.json b/examples/with-astro/apps/site/package.json index cea3d2c..8f18b97 100644 --- a/examples/with-astro/apps/site/package.json +++ b/examples/with-astro/apps/site/package.json @@ -21,10 +21,10 @@ }, "devDependencies": { "@astrojs/check": "catalog:", - "@lasvegasfortransit/eslint-config": "0.5.1", - "@lasvegasfortransit/playwright-config": "0.5.1", - "@lasvegasfortransit/typescript-config": "0.5.1", - "@lasvegasfortransit/vitest-config": "0.5.1", + "@lasvegasfortransit/eslint-config": "0.5.2", + "@lasvegasfortransit/playwright-config": "0.5.2", + "@lasvegasfortransit/typescript-config": "0.5.2", + "@lasvegasfortransit/vitest-config": "0.5.2", "@playwright/test": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", diff --git a/examples/with-astro/package.json b/examples/with-astro/package.json index ee5c183..1c75559 100644 --- a/examples/with-astro/package.json +++ b/examples/with-astro/package.json @@ -23,8 +23,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.1", - "@lasvegasfortransit/prettier-config": "0.5.1", + "@lasvegasfortransit/cli": "0.5.2", + "@lasvegasfortransit/prettier-config": "0.5.2", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/examples/with-vite-react/.claude/settings.json b/examples/with-vite-react/.claude/settings.json index edfe297..39b3306 100644 --- a/examples/with-vite-react/.claude/settings.json +++ b/examples/with-vite-react/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.5.1" + "ref": "v0.5.2" } } }, diff --git a/examples/with-vite-react/apps/app/package.json b/examples/with-vite-react/apps/app/package.json index 6022f32..9e209af 100644 --- a/examples/with-vite-react/apps/app/package.json +++ b/examples/with-vite-react/apps/app/package.json @@ -17,10 +17,10 @@ "react-dom": "catalog:" }, "devDependencies": { - "@lasvegasfortransit/eslint-config": "0.5.1", - "@lasvegasfortransit/playwright-config": "0.5.1", - "@lasvegasfortransit/typescript-config": "0.5.1", - "@lasvegasfortransit/vitest-config": "0.5.1", + "@lasvegasfortransit/eslint-config": "0.5.2", + "@lasvegasfortransit/playwright-config": "0.5.2", + "@lasvegasfortransit/typescript-config": "0.5.2", + "@lasvegasfortransit/vitest-config": "0.5.2", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", diff --git a/examples/with-vite-react/package.json b/examples/with-vite-react/package.json index ecda05d..dc1bbf2 100644 --- a/examples/with-vite-react/package.json +++ b/examples/with-vite-react/package.json @@ -23,8 +23,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.5.1", - "@lasvegasfortransit/prettier-config": "0.5.1", + "@lasvegasfortransit/cli": "0.5.2", + "@lasvegasfortransit/prettier-config": "0.5.2", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/package.json b/package.json index 9acc987..20900d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lvbt-repository-tooling", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "description": "Source of truth for the LVBT repository standard: the shared @lasvegasfortransit packages and the example repositories create-turbo copies.", "packageManager": "pnpm@11.25.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 49acae7..54bc966 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/cli", - "version": "0.5.1", + "version": "0.5.2", "description": "The lvbt command every LVBT repository runs for bootstrap, preflight, and deploy, plus the production platform setup, the shared git hooks, and the lvbt-contributions agent plugin.", "license": "MIT", "type": "module", diff --git a/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json b/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json index ac5dc3a..ce879c6 100644 --- a/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json +++ b/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lvbt-contributions", - "version": "0.5.1", + "version": "0.5.2", "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", diff --git a/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json b/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json index 03f60e4..f18002b 100644 --- a/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json +++ b/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lvbt-contributions", - "version": "0.5.1", + "version": "0.5.2", "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 5e9c3d2..d1d9c1b 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/eslint-config", - "version": "0.5.1", + "version": "0.5.2", "description": "The ESLint configurations every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/packages/playwright-config/package.json b/packages/playwright-config/package.json index d6ae7c0..2d45257 100644 --- a/packages/playwright-config/package.json +++ b/packages/playwright-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/playwright-config", - "version": "0.5.1", + "version": "0.5.2", "description": "The Playwright configuration every LVBT repository spreads into its own: end-to-end tests under tests/e2e, desktop and mobile projects, traces on failure.", "license": "MIT", "type": "module", diff --git a/packages/prettier-config/package.json b/packages/prettier-config/package.json index e3cdf8d..8502cee 100644 --- a/packages/prettier-config/package.json +++ b/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/prettier-config", - "version": "0.5.1", + "version": "0.5.2", "description": "The Prettier configuration every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index 1330efa..a39afe9 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/typescript-config", - "version": "0.5.1", + "version": "0.5.2", "description": "TypeScript configurations every LVBT repository extends.", "license": "MIT", "repository": { diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json index e077e5d..f3edfbe 100644 --- a/packages/vitest-config/package.json +++ b/packages/vitest-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/vitest-config", - "version": "0.5.1", + "version": "0.5.2", "description": "The Vitest configuration every LVBT repository spreads into its own.", "license": "MIT", "type": "module", diff --git a/packages/web-platform/package.json b/packages/web-platform/package.json index 94ca12f..e7ed44a 100644 --- a/packages/web-platform/package.json +++ b/packages/web-platform/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/web-platform", - "version": "0.5.1", + "version": "0.5.2", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", diff --git a/standards/propagate.ts b/standards/propagate.ts index a892a27..5ed8e7d 100644 --- a/standards/propagate.ts +++ b/standards/propagate.ts @@ -276,6 +276,10 @@ export async function applyRelease(options: { 'user.name=lvbt-bot', '-c', 'user.email=noreply@lasvegasfortransit.org', + // The repository's own hooks are for people; installing dependencies can switch them on, + // and prepare-commit-msg runs even with --no-verify. + '-c', + 'core.hooksPath=/dev/null', 'commit', '--quiet', '--no-verify', diff --git a/standards/propose.ts b/standards/propose.ts index f86fdd8..d5acfd1 100644 --- a/standards/propose.ts +++ b/standards/propose.ts @@ -78,7 +78,19 @@ function openUpdates(target: string, runner: Runner): OpenUpdate[] { function pushUpdateBranch(target: string, name: string, branch: string, runner: Runner): boolean { const state = remoteBranchState(target, branch, runner); if (state === 'absent' || state === 'stale') { - runner('git', ['push', '--force-with-lease', '--set-upstream', 'origin', branch], target); + runner( + 'git', + [ + '-c', + 'core.hooksPath=/dev/null', + 'push', + '--force-with-lease', + '--set-upstream', + 'origin', + branch, + ], + target, + ); return true; } if (state === 'edited') { @@ -156,15 +168,45 @@ function defaultBranchRelease(target: string, runner: Runner): string | null { return (JSON.parse(manifest) as { release: string | null }).release; } -/** Runs Validate on the update branch; a workflow token's push alone starts no workflow. */ -function dispatchValidation(target: string, branch: string, runner: Runner): void { - try { - runner('gh', ['workflow', 'run', 'ci.yml', '--ref', branch], target); - } catch { - process.stderr.write( - `Could not run ci.yml on ${branch}. Give ci.yml a workflow_dispatch trigger, as the examples' has, so update pull requests get their Validate check.\n`, - ); - process.exitCode = 1; +/** + * GitHub holds the workflow runs of a pull request that a workflow's own token opened until someone + * with write access approves them. Approve the ones this update started so `Validate` runs on it. + */ +function approveHeldRuns(target: string, branch: string, runner: Runner): void { + const held = () => + ( + JSON.parse( + runner( + 'gh', + ['run', 'list', '--branch', branch, '--json', 'databaseId,conclusion', '--limit', '20'], + target, + ), + ) as { databaseId: number; conclusion: string }[] + ) + .filter(({ conclusion }) => conclusion === 'action_required') + .map(({ databaseId }) => databaseId); + const approved = new Set(); + // Runs appear a few seconds after the pull request opens; keep looking briefly after the first. + for (let attempt = 0, quiet = 0; attempt < 12 && quiet < 3; attempt += 1) { + const found = held().filter((id) => !approved.has(id)); + for (const id of found) { + try { + runner( + 'gh', + ['api', '-X', 'POST', `repos/{owner}/{repo}/actions/runs/${id}/approve`], + target, + ); + approved.add(id); + } catch { + process.stderr.write( + `Could not approve workflow run ${id} on ${branch}. A maintainer approves it on the pull request so Validate runs.\n`, + ); + process.exitCode = 1; + return; + } + } + quiet = approved.size > 0 && found.length === 0 ? quiet + 1 : 0; + runner('sleep', ['5'], target); } } @@ -196,9 +238,7 @@ export async function proposeRelease(options: { const pushed = pushUpdateBranch(target, entry.name, updateBranch(tag), runner); number = await openPullRequest({ ...options, automerge, runner }); if (automerge) runner('gh', ['pr', 'merge', String(number), '--auto', '--rebase'], target); - // A push made with a repository's own GITHUB_TOKEN starts no workflow, but a dispatch always - // does, and its Validate check lands on the branch's head commit. - if (pushed) dispatchValidation(target, updateBranch(tag), runner); + if (pushed) approveHeldRuns(target, updateBranch(tag), runner); } else if (defaultBranchRelease(target, runner) === tag) { // Only a default branch that already carries the release makes its update pull request moot; // a checkout that happens to be on the update branch does not. diff --git a/standards/status.ts b/standards/status.ts index 05ba85c..9e040e9 100644 --- a/standards/status.ts +++ b/standards/status.ts @@ -22,7 +22,7 @@ export interface RepositoryState { name: string; release: string | null; pluginRef: string | null; - /** The repository runs `Standard update`, and `ci.yml` accepts the dispatch it sends. */ + /** The repository runs `Standard update`. */ selfUpdating: boolean; rulesets: string[]; updates: { number: number; headRefName: string; failing: boolean }[]; @@ -76,7 +76,7 @@ export function findings(state: RepositoryState, releases: Release[], now: numbe if (!state.selfUpdating) add( 'self-update', - 'it cannot update itself: copy .github/workflows/standard-update.yml from the example and give ci.yml a workflow_dispatch trigger.', + 'it cannot update itself: copy .github/workflows/standard-update.yml from the example.', ); if (!state.rulesets.includes('org-standard')) add('ruleset', 'the org-standard ruleset is missing.'); @@ -186,9 +186,7 @@ function readState(entry: RegistryEntry): RepositoryState { name: entry.name, release, pluginRef: pluginRef(readRaw(entry.name, '.claude/settings.json')), - selfUpdating: - readRaw(entry.name, '.github/workflows/standard-update.yml') !== null && - /^\s{2}workflow_dispatch:/m.test(readRaw(entry.name, '.github/workflows/ci.yml') ?? ''), + selfUpdating: readRaw(entry.name, '.github/workflows/standard-update.yml') !== null, rulesets, updates, }; diff --git a/tests/propagate.test.mjs b/tests/propagate.test.mjs index 9bef008..a4e5663 100644 --- a/tests/propagate.test.mjs +++ b/tests/propagate.test.mjs @@ -249,14 +249,13 @@ test('every example updates itself with only its own workflow token', async () = assert.match(workflow, new RegExp(`^ {2}${permission}$`, 'm'), `${example}: ${permission}`); assert.match(workflow, /node \.lvbt\/web-platform\/standards\/self-update\.ts/, example); assert.doesNotMatch(workflow, /secrets\./, example); - const ci = await readFile(path.join(directory, 'ci.yml'), 'utf8'); - assert.match(ci, /^ {2}workflow_dispatch:$/m, `${example}: ci.yml must accept the dispatch`); } const propagate = await readFile(path.join(root, 'standards/propose.ts'), 'utf8'); assert.match(propagate, /github-create\.mjs/); assert.match(propagate, /'--auto', '--rebase'/); - assert.match(propagate, /'workflow', 'run', 'ci\.yml'/); + assert.match(propagate, /actions\/runs\/\$\{id\}\/approve/); + assert.match(propagate, /core\.hooksPath=\/dev\/null/); assert.doesNotMatch(propagate, /'pr', 'create'/); }); diff --git a/tests/propose.test.mjs b/tests/propose.test.mjs index 2db2bad..d9dd262 100644 --- a/tests/propose.test.mjs +++ b/tests/propose.test.mjs @@ -53,27 +53,45 @@ test('an unchanged checkout closes its update only when the default branch has t assert.ok(current.calls.some((call) => call.startsWith('gh pr close 7'))); }); -test('a branch GitHub deleted is pushed afresh and a failed dispatch is reported, not thrown', async (t) => { - const exitCode = process.exitCode; - t.after(() => { - process.exitCode = exitCode; - }); +test('a branch GitHub deleted is pushed afresh, without hooks, and its held runs are approved', async () => { const { calls, runner } = fakeRunner([ [/^gh pr list --state open/, '[]'], [/^git ls-remote --heads/, ''], [/^gh pr list --head/, JSON.stringify([{ number: 12 }])], - [/^gh workflow run ci\.yml/, new Error('no workflow_dispatch trigger')], + [/^gh run list/, JSON.stringify([{ databaseId: 99, conclusion: 'action_required' }])], ]); const outcome = await proposeRelease( options(runner, { changed: true, from: 'v0.5.0', tag: 'v0.5.1' }), ); assert.equal(outcome, 'example: #12'); const forget = calls.findIndex((call) => call.startsWith('git update-ref -d')); - const push = calls.findIndex((call) => call.startsWith('git push --force-with-lease')); + const push = calls.findIndex((call) => call.includes(' push --force-with-lease')); assert.ok(forget !== -1 && forget < push, 'the stale tracking ref is forgotten before the push'); - assert.ok( - calls.some((call) => call === 'gh pr merge 12 --auto --rebase'), - 'a patch merges itself', + assert.match(calls[push], /^git -c core\.hooksPath=\/dev\/null push/); + assert.ok(calls.includes('gh pr merge 12 --auto --rebase'), 'a patch merges itself'); + assert.equal( + calls.filter((call) => call === 'gh api -X POST repos/{owner}/{repo}/actions/runs/99/approve') + .length, + 1, + 'each held run is approved once', + ); +}); + +test('a held run the token may not approve is reported, not thrown', async (t) => { + const exitCode = process.exitCode; + t.after(() => { + process.exitCode = exitCode; + }); + const { runner } = fakeRunner([ + [/^gh pr list --state open/, '[]'], + [/^git ls-remote --heads/, ''], + [/^gh pr list --head/, JSON.stringify([{ number: 12 }])], + [/^gh run list/, JSON.stringify([{ databaseId: 99, conclusion: 'action_required' }])], + [/^gh api -X POST/, new Error('Resource not accessible by integration')], + ]); + assert.equal( + await proposeRelease(options(runner, { changed: true, from: 'v0.5.0', tag: 'v0.5.1' })), + 'example: #12', ); assert.equal(process.exitCode, 1); });