diff --git a/.claude/settings.json b/.claude/settings.json index 8f22028..22dbad4 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.4.5" + "ref": "v0.5.0" } } }, diff --git a/.github/workflows/standard-update.yml b/.github/workflows/standard-update.yml new file mode 100644 index 0000000..2a087ca --- /dev/null +++ b/.github/workflows/standard-update.yml @@ -0,0 +1,45 @@ +# Keeps this repository on the latest release of the LVBT repository standard. +# Each day it compares .lvbt/web-platform.json with the newest release of +# repository-tooling. When a release is newer, it applies it with the +# release's own updater and opens one pull request that merges itself once +# Validate passes. It uses only this workflow's own token. +# +# The token may not change workflow files, so this file stays small and the +# work lives in the vendored standard. If a release changes a workflow file, +# the run fails and names the file to copy by hand. +name: Standard update + +on: + schedule: + - cron: '23 14 * * *' + workflow_dispatch: + +permissions: + actions: write + contents: write + packages: read + pull-requests: write + +concurrency: + group: standard-update + cancel-in-progress: false + +jobs: + update: + name: Update the standard + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Setup Node + pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Open the update pull request + env: + GH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: node .lvbt/web-platform/standards/self-update.ts diff --git a/.lvbt/commit-scopes.txt b/.lvbt/commit-scopes.txt index b03823f..93a5c16 100644 --- a/.lvbt/commit-scopes.txt +++ b/.lvbt/commit-scopes.txt @@ -2,5 +2,4 @@ # boundaries of your repository; omit a scope when a change crosses them. example docs -ci dx diff --git a/.lvbt/web-platform.json b/.lvbt/web-platform.json index 977faba..af928ed 100644 --- a/.lvbt/web-platform.json +++ b/.lvbt/web-platform.json @@ -1,9 +1,9 @@ { "formatVersion": 1, "preset": "lvbt-web", - "release": "v0.4.5", - "commit": "402288c0a379a3eaec3922ed5cdd42395a044b3b", - "contentHash": "77cc0e091ed3560e8608d95ff7efc4542cdb05047a9b9ed381f02ce47646f3b7", + "release": "v0.5.0", + "commit": "cc7458f97c1699ea48633810298e4982d308f0ad", + "contentHash": "13d5ae98e03b50cb5419af95a197efc540d4af3daa0eefed0856806068273895", "executables": [ "examples/with-astro/.githooks/commit-msg", "examples/with-astro/.githooks/pre-commit", diff --git a/.lvbt/web-platform/examples/with-astro/.claude/settings.json b/.lvbt/web-platform/examples/with-astro/.claude/settings.json index 8f22028..22dbad4 100644 --- a/.lvbt/web-platform/examples/with-astro/.claude/settings.json +++ b/.lvbt/web-platform/examples/with-astro/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.4.5" + "ref": "v0.5.0" } } }, diff --git a/.lvbt/web-platform/examples/with-astro/.github/workflows/standard-update.yml b/.lvbt/web-platform/examples/with-astro/.github/workflows/standard-update.yml new file mode 100644 index 0000000..2a087ca --- /dev/null +++ b/.lvbt/web-platform/examples/with-astro/.github/workflows/standard-update.yml @@ -0,0 +1,45 @@ +# Keeps this repository on the latest release of the LVBT repository standard. +# Each day it compares .lvbt/web-platform.json with the newest release of +# repository-tooling. When a release is newer, it applies it with the +# release's own updater and opens one pull request that merges itself once +# Validate passes. It uses only this workflow's own token. +# +# The token may not change workflow files, so this file stays small and the +# work lives in the vendored standard. If a release changes a workflow file, +# the run fails and names the file to copy by hand. +name: Standard update + +on: + schedule: + - cron: '23 14 * * *' + workflow_dispatch: + +permissions: + actions: write + contents: write + packages: read + pull-requests: write + +concurrency: + group: standard-update + cancel-in-progress: false + +jobs: + update: + name: Update the standard + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Setup Node + pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Open the update pull request + env: + GH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: node .lvbt/web-platform/standards/self-update.ts diff --git a/.lvbt/web-platform/examples/with-astro/.lvbt/commit-scopes.txt b/.lvbt/web-platform/examples/with-astro/.lvbt/commit-scopes.txt index 7be9d71..ab0a7e1 100644 --- a/.lvbt/web-platform/examples/with-astro/.lvbt/commit-scopes.txt +++ b/.lvbt/web-platform/examples/with-astro/.lvbt/commit-scopes.txt @@ -2,5 +2,4 @@ # boundaries of your repository; omit a scope when a change crosses them. site docs -ci dx diff --git a/.lvbt/web-platform/examples/with-astro/apps/site/package.json b/.lvbt/web-platform/examples/with-astro/apps/site/package.json index b306b65..19ac71d 100644 --- a/.lvbt/web-platform/examples/with-astro/apps/site/package.json +++ b/.lvbt/web-platform/examples/with-astro/apps/site/package.json @@ -21,10 +21,10 @@ }, "devDependencies": { "@astrojs/check": "catalog:", - "@lasvegasfortransit/eslint-config": "0.4.5", - "@lasvegasfortransit/playwright-config": "0.4.5", - "@lasvegasfortransit/typescript-config": "0.4.5", - "@lasvegasfortransit/vitest-config": "0.4.5", + "@lasvegasfortransit/eslint-config": "0.5.0", + "@lasvegasfortransit/playwright-config": "0.5.0", + "@lasvegasfortransit/typescript-config": "0.5.0", + "@lasvegasfortransit/vitest-config": "0.5.0", "@playwright/test": "catalog:", "@types/node": "catalog:", "eslint": "catalog:", diff --git a/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md b/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md index 809a0b9..3646d2c 100644 --- a/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md +++ b/.lvbt/web-platform/examples/with-astro/docs/development/tutorials/start-here.md @@ -61,10 +61,10 @@ git commit ``` The commit hook checks the message. Subjects look like `type(scope): description` where `type` is -one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`, `build`, `style`, `revert`, -and the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or -`fix` commit also needs a body that says what changed for a person using the product and why. The -hook tells you exactly what to change when it rejects a message. +one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `style`, `revert`, and +the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or `fix` +commit also needs a body that says what changed for a person using the product and why. The hook +tells you exactly what to change when it rejects a message. ## 4. Open a pull request diff --git a/.lvbt/web-platform/examples/with-astro/package.json b/.lvbt/web-platform/examples/with-astro/package.json index 1e3f895..50671a2 100644 --- a/.lvbt/web-platform/examples/with-astro/package.json +++ b/.lvbt/web-platform/examples/with-astro/package.json @@ -23,8 +23,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.4.5", - "@lasvegasfortransit/prettier-config": "0.4.5", + "@lasvegasfortransit/cli": "0.5.0", + "@lasvegasfortransit/prettier-config": "0.5.0", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json index 8f22028..22dbad4 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json +++ b/.lvbt/web-platform/examples/with-vite-react/.claude/settings.json @@ -4,7 +4,7 @@ "source": { "source": "github", "repo": "LasVegasForTransit/repository-tooling", - "ref": "v0.4.5" + "ref": "v0.5.0" } } }, diff --git a/.lvbt/web-platform/examples/with-vite-react/.github/workflows/standard-update.yml b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/standard-update.yml new file mode 100644 index 0000000..2a087ca --- /dev/null +++ b/.lvbt/web-platform/examples/with-vite-react/.github/workflows/standard-update.yml @@ -0,0 +1,45 @@ +# Keeps this repository on the latest release of the LVBT repository standard. +# Each day it compares .lvbt/web-platform.json with the newest release of +# repository-tooling. When a release is newer, it applies it with the +# release's own updater and opens one pull request that merges itself once +# Validate passes. It uses only this workflow's own token. +# +# The token may not change workflow files, so this file stays small and the +# work lives in the vendored standard. If a release changes a workflow file, +# the run fails and names the file to copy by hand. +name: Standard update + +on: + schedule: + - cron: '23 14 * * *' + workflow_dispatch: + +permissions: + actions: write + contents: write + packages: read + pull-requests: write + +concurrency: + group: standard-update + cancel-in-progress: false + +jobs: + update: + name: Update the standard + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Setup Node + pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Open the update pull request + env: + GH_TOKEN: ${{ github.token }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: node .lvbt/web-platform/standards/self-update.ts diff --git a/.lvbt/web-platform/examples/with-vite-react/.lvbt/commit-scopes.txt b/.lvbt/web-platform/examples/with-vite-react/.lvbt/commit-scopes.txt index 36c4e52..f5b08a1 100644 --- a/.lvbt/web-platform/examples/with-vite-react/.lvbt/commit-scopes.txt +++ b/.lvbt/web-platform/examples/with-vite-react/.lvbt/commit-scopes.txt @@ -2,5 +2,4 @@ # boundaries of your repository; omit a scope when a change crosses them. app docs -ci dx diff --git a/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json index 19b1af2..dc885d2 100644 --- a/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json +++ b/.lvbt/web-platform/examples/with-vite-react/apps/app/package.json @@ -17,10 +17,10 @@ "react-dom": "catalog:" }, "devDependencies": { - "@lasvegasfortransit/eslint-config": "0.4.5", - "@lasvegasfortransit/playwright-config": "0.4.5", - "@lasvegasfortransit/typescript-config": "0.4.5", - "@lasvegasfortransit/vitest-config": "0.4.5", + "@lasvegasfortransit/eslint-config": "0.5.0", + "@lasvegasfortransit/playwright-config": "0.5.0", + "@lasvegasfortransit/typescript-config": "0.5.0", + "@lasvegasfortransit/vitest-config": "0.5.0", "@playwright/test": "catalog:", "@tailwindcss/vite": "catalog:", "@types/node": "catalog:", diff --git a/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md b/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md index c129eb2..dc39c88 100644 --- a/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md +++ b/.lvbt/web-platform/examples/with-vite-react/docs/development/tutorials/start-here.md @@ -61,10 +61,10 @@ git commit ``` The commit hook checks the message. Subjects look like `type(scope): description` where `type` is -one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`, `build`, `style`, `revert`, -and the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or -`fix` commit also needs a body that says what changed for a person using the product and why. The -hook tells you exactly what to change when it rejects a message. +one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `style`, `revert`, and +the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or `fix` +commit also needs a body that says what changed for a person using the product and why. The hook +tells you exactly what to change when it rejects a message. ## 4. Open a pull request diff --git a/.lvbt/web-platform/examples/with-vite-react/package.json b/.lvbt/web-platform/examples/with-vite-react/package.json index 503ca61..2199229 100644 --- a/.lvbt/web-platform/examples/with-vite-react/package.json +++ b/.lvbt/web-platform/examples/with-vite-react/package.json @@ -23,8 +23,8 @@ "*": "prettier --write --ignore-unknown" }, "devDependencies": { - "@lasvegasfortransit/cli": "0.4.5", - "@lasvegasfortransit/prettier-config": "0.4.5", + "@lasvegasfortransit/cli": "0.5.0", + "@lasvegasfortransit/prettier-config": "0.5.0", "lint-staged": "catalog:", "markdownlint-cli2": "catalog:", "markdownlint-rule-relative-links": "catalog:", diff --git a/.lvbt/web-platform/packages/cli/package.json b/.lvbt/web-platform/packages/cli/package.json index 1fa546a..500ac05 100644 --- a/.lvbt/web-platform/packages/cli/package.json +++ b/.lvbt/web-platform/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/cli", - "version": "0.4.5", + "version": "0.5.0", "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/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json index 3585f0b..18c099d 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lvbt-contributions", - "version": "0.4.5", + "version": "0.5.0", "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json index 445c46a..5c68dc0 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lvbt-contributions", - "version": "0.4.5", + "version": "0.5.0", "description": "Create readable LVBT GitHub issues and pull requests through the organization workflow.", "author": { "name": "Las Vegans for Better Transit", diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs index aef133f..df4e255 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs @@ -1,7 +1,7 @@ import { readFileSync, realpathSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { commitSubjectError } from './validate-commit-subject.mjs'; +import { commitSubjectError, commitSubjectWarning } from './validate-commit-subject.mjs'; /** * The whole-message rules, applied by the commit-msg hook after the subject @@ -132,7 +132,10 @@ if (isDirectInvocation()) { process.stderr.write('Usage: validate-commit-message.mjs \n'); process.exitCode = 2; } else { - const errors = commitMessageErrors(readFileSync(file, 'utf8')); + const message = readFileSync(file, 'utf8'); + const warning = commitSubjectWarning(message.split(/\r?\n/)[0] ?? ''); + if (warning) process.stderr.write(`warning: ${warning}\n`); + const errors = commitMessageErrors(message); if (errors.length > 0) { process.stderr.write(`Commit blocked:\n${errors.map((error) => ` ${error}`).join('\n')}\n`); process.exitCode = 1; diff --git a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs index 675aaf5..b97896c 100644 --- a/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs +++ b/.lvbt/web-platform/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs @@ -20,6 +20,28 @@ export const commitTypes = Object.freeze( const subjectPattern = /^(?[a-z]+)(?:\((?[a-z0-9-]+)\))?: \S.*$/; +/** + * Names on their way out: neither a kind of change nor a boundary of a repository. `ci` behaved + * exactly like `chore` in every changelog and release tool, so it only added a choice to argue about. + * They still pass, with a warning, until the release that retires them. + */ +export const deprecatedNames = Object.freeze({ + ci: 'Use `chore` for workflow and CI configuration changes; from standard v0.6.0 the commit hook rejects `ci`.', +}); + +/** A warning for a subject that uses a deprecated type or scope, or undefined. */ +export function commitSubjectWarning(subject) { + const { type, scope } = subjectPattern.exec(subject)?.groups ?? {}; + for (const [kind, name] of [ + ['Type', type], + ['Scope', scope], + ]) { + if (name && Object.hasOwn(deprecatedNames, name)) + return `${kind} \`${name}\` is deprecated. ${deprecatedNames[name]}`; + } + return undefined; +} + function choices(values) { return values.map((value) => `\`${value}\``).join(', '); } @@ -98,6 +120,8 @@ function isDirectInvocation() { if (isDirectInvocation()) { const error = commitSubjectError(process.argv[2] ?? ''); + const warning = commitSubjectWarning(process.argv[2] ?? ''); + if (warning) process.stderr.write(`warning: ${warning}\n`); if (error) { process.stderr.write(`Commit blocked: ${error}\n`); process.exitCode = 1; diff --git a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs index a6fda7b..c0b8cf4 100644 --- a/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs +++ b/.lvbt/web-platform/packages/cli/src/lib/check/contract.mjs @@ -24,6 +24,8 @@ const IGNORED = new Set([ '.astro', ]); +const STANDARD_CATALOG = new URL('../../../catalog.json', import.meta.url); + /** Version specifiers the standard permits besides the catalog. */ function allowedRange(name, range) { return ( @@ -68,6 +70,38 @@ function workspaceGlobs(root) { return globs; } +/** The default `catalog:` of pnpm-workspace.yaml as name → version, without a YAML dependency. */ +export function catalogEntries(text) { + const entries = {}; + let inCatalog = false; + for (const line of text.split(/\r?\n/)) { + if (/^catalog:\s*$/.test(line)) { + inCatalog = true; + continue; + } + if (!inCatalog || /^\s*(?:#.*)?$/.test(line)) continue; + if (/^\S/.test(line)) break; + const match = /^\s+(['"]?)([^'"\s:]+)\1:\s*(['"]?)([^'"\s#]+)\3/.exec(line); + if (match) entries[match[2]] = match[4]; + } + return entries; +} + +/** + * Shared catalog versions belong to the standard; a repository adds entries but never re-pins one. + * These are warnings until standard v0.6.0, which moves the entries and fails on any that differ. + */ +function catalogWarnings(root) { + const standard = JSON.parse(readFileSync(STANDARD_CATALOG, 'utf8')).catalog; + const entries = catalogEntries(readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8')); + return Object.entries(entries) + .filter(([name, version]) => Object.hasOwn(standard, name) && standard[name] !== version) + .map( + ([name, version]) => + `warning: pnpm-workspace.yaml pins "${name}" to "${version}"; the standard's catalog has "${standard[name]}" (from v0.6.0 this fails)`, + ); +} + function packageDirectories(root) { const directories = []; for (const glob of workspaceGlobs(root)) { @@ -200,7 +234,7 @@ export function checkContract({ cwd }) { return { name: 'contract', ok: lines.length === 0, - lines, + lines: [...lines, ...catalogWarnings(cwd)], fix: 'add the missing script, move test material under tests/, set the range to "catalog:" and add the version to pnpm-workspace.yaml, or run `pnpm standards:update` to wire an Astro package\'s "sync" task before lint', }; } diff --git a/.lvbt/web-platform/packages/eslint-config/package.json b/.lvbt/web-platform/packages/eslint-config/package.json index 3eee154..50b0c72 100644 --- a/.lvbt/web-platform/packages/eslint-config/package.json +++ b/.lvbt/web-platform/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/eslint-config", - "version": "0.4.5", + "version": "0.5.0", "description": "The ESLint configurations every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/playwright-config/package.json b/.lvbt/web-platform/packages/playwright-config/package.json index adc4ac3..f2108b1 100644 --- a/.lvbt/web-platform/packages/playwright-config/package.json +++ b/.lvbt/web-platform/packages/playwright-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/playwright-config", - "version": "0.4.5", + "version": "0.5.0", "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/.lvbt/web-platform/packages/prettier-config/package.json b/.lvbt/web-platform/packages/prettier-config/package.json index 4bd3a61..6428c0f 100644 --- a/.lvbt/web-platform/packages/prettier-config/package.json +++ b/.lvbt/web-platform/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/prettier-config", - "version": "0.4.5", + "version": "0.5.0", "description": "The Prettier configuration every LVBT repository uses.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/typescript-config/package.json b/.lvbt/web-platform/packages/typescript-config/package.json index 3f3af9e..da0e97c 100644 --- a/.lvbt/web-platform/packages/typescript-config/package.json +++ b/.lvbt/web-platform/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/typescript-config", - "version": "0.4.5", + "version": "0.5.0", "description": "TypeScript configurations every LVBT repository extends.", "license": "MIT", "repository": { diff --git a/.lvbt/web-platform/packages/vitest-config/package.json b/.lvbt/web-platform/packages/vitest-config/package.json index 5b6d50f..35ab091 100644 --- a/.lvbt/web-platform/packages/vitest-config/package.json +++ b/.lvbt/web-platform/packages/vitest-config/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/vitest-config", - "version": "0.4.5", + "version": "0.5.0", "description": "The Vitest configuration every LVBT repository spreads into its own.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/packages/web-platform/package.json b/.lvbt/web-platform/packages/web-platform/package.json index ab76a9d..bff470a 100644 --- a/.lvbt/web-platform/packages/web-platform/package.json +++ b/.lvbt/web-platform/packages/web-platform/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/web-platform", - "version": "0.4.5", + "version": "0.5.0", "description": "Provider-neutral deployment, provisioning, and validation primitives for LVBT web repositories.", "license": "MIT", "type": "module", diff --git a/.lvbt/web-platform/standards/owned-files.ts b/.lvbt/web-platform/standards/owned-files.ts new file mode 100644 index 0000000..c7cf673 --- /dev/null +++ b/.lvbt/web-platform/standards/owned-files.ts @@ -0,0 +1,133 @@ +import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import type { WebPreset } from './web-platform.ts'; + +/** + * Files every repository copies from the example and never edits: the adoption guide's "copy these, + * overwriting your versions" list. `standards:check` warns when one differs from the vendored + * release; from v0.6.0 it fails, and the update restores the standard's copy. + */ +export const OWNED_FILES = [ + '.githooks/commit-msg', + '.githooks/pre-commit', + '.githooks/pre-push', + '.githooks/prepare-commit-msg', + '.codex/hooks.json', + '.agents/plugins/marketplace.json', + '.github/actions/setup-node-pnpm/action.yml', + '.editorconfig', +]; + +/** + * Files the updater adds when a repository lacks them but never rewrites. A repository's own + * workflow token may not change workflow files, so rewriting one would block every self-update. + */ +export const SEEDED_FILES = ['.github/workflows/standard-update.yml']; + +/** Every example carries the same owned files; the Astro example is the vendored reference. */ +const REFERENCE = 'examples/with-astro'; + +// Prettier reads these before prettier.config.js, so any of them silently replaces the org rules. +const SHADOWING_PRETTIER_CONFIGS = [ + '.prettierrc', + '.prettierrc.json', + '.prettierrc.json5', + '.prettierrc.yaml', + '.prettierrc.yml', + '.prettierrc.js', + '.prettierrc.cjs', + '.prettierrc.mjs', + '.prettierrc.ts', + '.prettierrc.toml', +]; + +const SETTINGS = '.claude/settings.json'; +const MARKETPLACE_REF = + /("repo"\s*:\s*"LasVegasForTransit\/repository-tooling"\s*,\s*"ref"\s*:\s*")([^"]*)(")/; + +async function readOptional(file: string): Promise { + return readFile(file, 'utf8').catch(() => null); +} + +async function isExecutable(file: string): Promise { + return ((await stat(file)).mode & 0o111) !== 0; +} + +/** Adds each seeded file the repository lacks, from the incoming release. */ +export async function seedFiles( + root: string, + bundle: WebPreset, + dryRun: boolean, +): Promise { + const added: string[] = []; + for (const name of SEEDED_FILES) { + const reference = `${REFERENCE}/${name}`; + const content = bundle.files[reference]; + const file = path.join(root, name); + if (content === undefined || (await readOptional(file)) !== null) continue; + added.push(name); + if (dryRun) continue; + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + if (bundle.executables?.includes(reference)) await chmod(file, 0o755); + } + return added; +} + +/** + * Points the Claude Code contribution plugin at the release being installed. A repository without + * a settings file gets the example's; one that pins another marketplace keeps its own. + */ +export async function syncPluginRef( + root: string, + bundle: WebPreset, + dryRun: boolean, +): Promise { + if (!bundle.release) return []; + const file = path.join(root, SETTINGS); + const current = await readOptional(file); + let next: string | undefined; + if (current === null) next = bundle.files[`${REFERENCE}/${SETTINGS}`]; + else if (MARKETPLACE_REF.test(current)) + next = current.replace(MARKETPLACE_REF, `$1${bundle.release}$3`); + if (next === undefined || next === current) return []; + if (!dryRun) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, next); + } + return [SETTINGS]; +} + +async function ownedFileProblem(root: string, name: string): Promise { + const vendored = path.join(root, '.lvbt/web-platform', REFERENCE, name); + const expected = await readOptional(vendored); + if (expected === null) return undefined; + const actual = await readOptional(path.join(root, name)); + if (actual === null) return `${name} is missing.`; + if (actual !== expected) return `${name} differs from the standard's copy.`; + if ((await isExecutable(vendored)) && !(await isExecutable(path.join(root, name)))) + return `${name} is not executable.`; + return undefined; +} + +/** What `standards:check` reports: files the standard owns that the repository changed. */ +export async function ownedFileDrift(root: string, release: string | null): Promise { + const problems: string[] = []; + for (const name of OWNED_FILES) { + const problem = await ownedFileProblem(root, name); + if (problem) problems.push(problem); + } + if ((await readOptional(path.join(root, 'prettier.config.js'))) !== null) { + for (const name of SHADOWING_PRETTIER_CONFIGS) + if ((await readOptional(path.join(root, name))) !== null) + problems.push(`${name} replaces the organization's prettier.config.js.`); + } + if (release) { + const settings = await readOptional(path.join(root, SETTINGS)); + const ref = settings ? MARKETPLACE_REF.exec(settings)?.[2] : undefined; + if (ref !== undefined && ref !== release) + problems.push(`${SETTINGS} loads the contribution plugin from ${ref}, not ${release}.`); + } + return problems; +} diff --git a/.lvbt/web-platform/standards/propagate.ts b/.lvbt/web-platform/standards/propagate.ts new file mode 100644 index 0000000..a892a27 --- /dev/null +++ b/.lvbt/web-platform/standards/propagate.ts @@ -0,0 +1,304 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export const OWNER = 'LasVegasForTransit'; +export const BRANCH_PREFIX = 'automation/repository-standard-'; +const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +export interface RegistryEntry { + name: string; + requiredStatus: string; + kind: 'source' | 'template' | 'consumer'; + example?: string; +} + +export interface Registry { + version: number; + repositories: RegistryEntry[]; + exceptions: RegistryException[]; +} + +export interface RegistryException { + repository: string; + rule: string; + reason: string; + expires: string; +} + +export interface OpenUpdate { + number: number; + headRefName: string; +} + +export async function readRegistry( + file = path.join(import.meta.dirname, 'repositories.json'), +): Promise { + return JSON.parse(await readFile(file, 'utf8')) as Registry; +} + +/** Repositories that receive each release: every template and consumer, never the source. */ +export function propagationTargets(registry: Registry): RegistryEntry[] { + return registry.repositories.filter(({ kind }) => kind !== 'source'); +} + +export function parseRelease(tag: string): [number, number, number] { + const match = STABLE_TAG.exec(tag); + if (!match) throw new Error(`Propagation requires a stable release tag, not ${tag}.`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function compareReleases(left: string, right: string): number { + const a = parseRelease(left); + const b = parseRelease(right); + for (let index = 0; index < 3; index += 1) { + const difference = (a[index] ?? 0) - (b[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +export function latestRelease(tags: string[]): string | undefined { + return tags + .filter((tag) => STABLE_TAG.test(tag)) + .sort(compareReleases) + .at(-1); +} + +/** + * Whether moving from one release to another is a patch: fixes only, so its update merges itself. + * A minor or major step can change behavior, so a maintainer merges it after reading the notes. + */ +export function isPatchUpdate(from: string | null | undefined, to: string): boolean { + if (!from || !STABLE_TAG.test(from)) return false; + const [fromMajor, fromMinor] = parseRelease(from); + const [toMajor, toMinor] = parseRelease(to); + return fromMajor === toMajor && fromMinor === toMinor && compareReleases(to, from) > 0; +} + +export function updateBranch(tag: string): string { + parseRelease(tag); + return `${BRANCH_PREFIX}${tag}`; +} + +/** + * Decides what to do with the update pull requests already open in a repository. Older releases + * are closed as superseded; a newer one means this release must not be proposed at all. + */ +export function planOpenUpdates(open: OpenUpdate[], tag: string) { + const current = updateBranch(tag); + const superseded: OpenUpdate[] = []; + const newer: OpenUpdate[] = []; + for (const update of open) { + if (!update.headRefName.startsWith(BRANCH_PREFIX) || update.headRefName === current) continue; + const release = update.headRefName.slice(BRANCH_PREFIX.length); + if (!STABLE_TAG.test(release)) continue; + if (compareReleases(release, tag) < 0) superseded.push(update); + else newer.push(update); + } + return { superseded, newer }; +} + +export function releaseNotesPath(tag: string): string { + return `docs/reference/release-${tag.slice(1).replaceAll('.', '-')}.md`; +} + +export function pullRequestTitle(tag: string): string { + return `chore: update LVBT repository standard to ${tag}`; +} + +export function pullRequestBody(options: { + tag: string; + kind: RegistryEntry['kind']; + hasNotes: boolean; + automerge: boolean; +}): string { + const { tag, kind, hasNotes, automerge } = options; + const source = `https://github.com/${OWNER}/repository-tooling`; + const notes = hasNotes + ? `The [release notes](${source}/blob/${tag}/${releaseNotesPath(tag)}) say what changes for a repository that updates.` + : `The [${tag} release](${source}/releases/tag/${tag}) describes what changed.`; + const change = + kind === 'template' + ? `The files are generated from the reviewed ${tag} example in repository-tooling.` + : `The vendored standard in \`.lvbt/web-platform/\` is replaced by ${tag}, and the release's own migrations update the files it manages.`; + return [ + '# TL;DR', + '', + `Moves this repository to LVBT repository standard ${tag}.`, + '', + '# Overview of Changes', + '', + `${change} ${notes}`, + '', + automerge + ? "This pull request was opened by this repository's `Standard update` workflow. It is a patch release, so it merges itself once `Validate` passes. If `Validate` fails, fix the repository on this branch; a newer release closes this pull request and opens its own." + : "This pull request was opened by this repository's `Standard update` workflow. It is a minor release, which can change how the repository works, so a maintainer merges it after reading the release notes. If `Validate` fails, fix the repository on this branch; a newer release closes this pull request and opens its own.", + '', + '# Follow-ups', + '', + 'None.', + '', + ].join('\n'); +} + +export function commitMessage(tag: string): string { + return `${pullRequestTitle(tag)}\n\nApply the reviewed ${tag} release with its own updater.\n`; +} + +export type Runner = (command: string, args: string[], cwd: string) => string; + +/** Writes a file for git or gh to read, outside the checkout so no repository check sees it. */ +export async function withTemporaryFile(content: string, use: (file: string) => T | Promise) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'lvbt-propagate-')); + try { + const file = path.join(directory, 'content'); + await writeFile(file, content); + return await use(file); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +const WORKFLOWS = '.github/workflows'; + +export const run: Runner = (command, args, cwd) => + execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }).trim(); + +async function currentRelease(target: string): Promise { + const file = path.join(target, '.lvbt/web-platform.json'); + if (!existsSync(file)) return null; + const { release } = JSON.parse(await readFile(file, 'utf8')) as { release: string | null }; + return release; +} + +/** + * Regenerates a template repository from its example with the release's own publication script. + * Publication rebuilds the lockfile too; keeping the published one makes pnpm resolve only what the + * release changed, so a re-run with no new release proposes nothing. + */ +async function publishTemplate(options: { + source: string; + target: string; + entry: RegistryEntry; + tag: string; + runner: Runner; +}): Promise { + const { source, target, entry, tag, runner } = options; + if (!entry.example) throw new Error(`${entry.name} is a template without an example.`); + const lockfile = path.join(target, 'pnpm-lock.yaml'); + const published = existsSync(lockfile) ? await readFile(lockfile, 'utf8') : undefined; + runner( + 'node', + [ + path.join(source, 'standards/template-publication.ts'), + '--source', + source, + '--target', + target, + '--example', + entry.example, + '--release', + tag, + ], + target, + ); + if (published !== undefined) await writeFile(lockfile, published); +} + +/** + * Applies a release to one checked-out repository with the release's own scripts and commits the + * result on the release's update branch. Returns false when the repository already matches. + */ +export async function applyRelease(options: { + source: string; + target: string; + entry: RegistryEntry; + tag: string; + install?: boolean; + skipWorkflows?: boolean; + runner?: Runner; +}): Promise<{ + changed: boolean; + from?: string | null | undefined; + reason?: string; + skippedWorkflows?: string[]; +}> { + const { entry, tag, install = true, skipWorkflows = false, runner = run } = options; + // A release's scripts only run as entry points when invoked by their real path. + const source = realpathSync(options.source); + const target = realpathSync(options.target); + parseRelease(tag); + const release = await currentRelease(target); + if (release && STABLE_TAG.test(release) && compareReleases(release, tag) > 0) { + return { changed: false, reason: `${entry.name} is already on ${release}, newer than ${tag}.` }; + } + + if (entry.kind === 'template') await publishTemplate({ source, target, entry, tag, runner }); + else if (entry.kind === 'consumer') + runner( + 'node', + [ + path.join(source, 'standards/web-platform-cli.ts'), + 'update', + '--root', + target, + '--source', + source, + '--release', + tag, + '--apply', + '--json', + ], + target, + ); + else throw new Error(`${entry.name} does not receive releases.`); + + if (install) runner('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile'], target); + const skippedWorkflows = skipWorkflows ? restoreWorkflows(target, runner) : []; + if (!runner('git', ['status', '--porcelain'], target)) + return { changed: false, from: release, skippedWorkflows }; + + runner('git', ['switch', '-C', updateBranch(tag)], target); + runner('git', ['restore', '--staged', '.'], target); + runner('git', ['add', '-A'], target); + await withTemporaryFile(commitMessage(tag), (message) => + runner( + 'git', + [ + '-c', + 'user.name=lvbt-bot', + '-c', + 'user.email=noreply@lasvegasfortransit.org', + 'commit', + '--quiet', + '--no-verify', + '-F', + message, + ], + target, + ), + ); + return { changed: true, from: release, skippedWorkflows }; +} + +/** + * A repository's own GITHUB_TOKEN may not push workflow files, so a self-update leaves them as they + * are and reports which ones the release changed; a maintainer copies those by hand. + */ +function restoreWorkflows(target: string, runner: Runner): string[] { + const changed = runner('git', ['status', '--porcelain', '--', WORKFLOWS], target) + .split('\n') + .filter(Boolean) + .map((line) => line.slice(3)); + if (changed.length === 0) return []; + runner('git', ['checkout', '--quiet', 'HEAD', '--', WORKFLOWS], target); + runner('git', ['clean', '--quiet', '--force', '--', WORKFLOWS], target); + return changed; +} diff --git a/.lvbt/web-platform/standards/propose.ts b/.lvbt/web-platform/standards/propose.ts new file mode 100644 index 0000000..f2b5720 --- /dev/null +++ b/.lvbt/web-platform/standards/propose.ts @@ -0,0 +1,247 @@ +import { existsSync, realpathSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { + type OpenUpdate, + type RegistryEntry, + type Runner, + applyRelease, + isPatchUpdate, + planOpenUpdates, + propagationTargets, + readRegistry, + pullRequestBody, + pullRequestTitle, + releaseNotesPath, + run, + updateBranch, + withTemporaryFile, +} from './propagate.ts'; + +/** + * Compares the freshly generated update commit with the branch already on GitHub. A branch that + * carries someone's fix is kept; a bot-only branch is replaced when the base or the result moved. + */ +function remoteBranchState( + target: string, + branch: string, + runner: Runner, +): 'absent' | 'current' | 'stale' | 'edited' { + if (!runner('git', ['ls-remote', '--heads', 'origin', branch], target)) return 'absent'; + runner( + 'git', + ['fetch', '--quiet', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], + target, + ); + const authors = runner('git', ['log', '--format=%ae', `HEAD^..origin/${branch}`], target).split( + '\n', + ); + if (authors.some((author) => author !== 'noreply@lasvegasfortransit.org')) return 'edited'; + const same = (ref: string) => + runner('git', ['rev-parse', `origin/${branch}${ref}`], target) === + runner('git', ['rev-parse', `HEAD${ref}`], target); + return same('^') && same('^{tree}') ? 'current' : 'stale'; +} + +function openUpdates(target: string, runner: Runner): OpenUpdate[] { + return JSON.parse( + runner( + 'gh', + [ + 'pr', + 'list', + '--state', + 'open', + '--base', + 'main', + '--json', + 'number,headRefName', + '--limit', + '100', + ], + target, + ), + ) as OpenUpdate[]; +} + +/** + * Pushes a new or rebuilt update branch, or adopts the one on GitHub when it is current or fixed. + * Returns true when it pushed. + */ +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); + return true; + } + if (state === 'edited') { + process.stdout.write(`${name}: keeping the fixes already pushed to ${branch}.\n`); + runner('git', ['reset', '--quiet', '--hard', `origin/${branch}`], target); + } + runner('git', ['branch', '--set-upstream-to', `origin/${branch}`], target); + return false; +} + +/** Opens the update pull request with the shared helper, or refreshes the one already open. */ +async function openPullRequest(options: { + source: string; + tooling: string; + target: string; + entry: RegistryEntry; + tag: string; + automerge: boolean; + runner: Runner; +}): Promise { + const { source, tooling, target, entry, tag, automerge, runner } = options; + const title = pullRequestTitle(tag); + const hasNotes = existsSync(path.join(source, releaseNotesPath(tag))); + const content = pullRequestBody({ tag, kind: entry.kind, hasNotes, automerge }); + return withTemporaryFile(content, (body) => + createOrEdit({ tooling, target, tag, title, body, runner }), + ); +} + +function createOrEdit(options: { + tooling: string; + target: string; + tag: string; + title: string; + body: string; + runner: Runner; +}): number { + const { tooling, target, tag, title, body, runner } = options; + const existing = JSON.parse( + runner( + 'gh', + ['pr', 'list', '--head', updateBranch(tag), '--state', 'open', '--json', 'number'], + target, + ), + ) as { number: number }[]; + if (existing[0]) { + runner( + 'gh', + ['pr', 'edit', String(existing[0].number), '--title', title, '--body-file', body], + target, + ); + return existing[0].number; + } + const helper = path.join( + tooling, + 'packages/cli/plugins/lvbt-contributions/scripts/github-create.mjs', + ); + const args = [helper, 'pr', '--title', title, '--body-file', body, '--base', 'main', '--json']; + runner('node', [...args, '--dry-run'], target); + return (JSON.parse(runner('node', args, target)) as { number: number }).number; +} + +/** + * Pushes the update branch, opens or refreshes its pull request, enables auto-merge, and closes + * update pull requests for older releases. + */ +export async function proposeRelease(options: { + source: string; + tooling: string; + target: string; + entry: RegistryEntry; + tag: string; + changed: boolean; + from?: string | null | undefined; + runner?: Runner; +}): Promise { + const { target, entry, tag, changed, from, runner = run } = options; + const automerge = isPatchUpdate(from, tag); + const open = openUpdates(target, runner); + const { superseded, newer } = planOpenUpdates(open, tag); + if (newer.length > 0) { + process.stdout.write(`${entry.name}: a newer update is already open (#${newer[0]?.number}).\n`); + return undefined; + } + + let number: number | undefined; + if (changed) { + 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) runner('gh', ['workflow', 'run', 'ci.yml', '--ref', updateBranch(tag)], target); + } else { + superseded.push(...open.filter(({ headRefName }) => headRefName === updateBranch(tag))); + } + + for (const update of superseded) { + const comment = number + ? `Superseded by #${number}, which updates to ${tag}.` + : `Superseded: this repository already matches ${tag}.`; + runner( + 'gh', + ['pr', 'close', String(update.number), '--comment', comment, '--delete-branch'], + target, + ); + } + return number ? `${entry.name}: #${number}` : `${entry.name}: already on ${tag}`; +} + +/** + * Applies a release to a local checkout and proposes it, as a repository's own `Standard update` + * workflow would. A maintainer uses it to give a repository its first `standard-update.yml`, which + * that workflow's token cannot add. + */ +export async function main(args: string[]): Promise { + const { values } = parseArgs({ + args, + options: { + source: { type: 'string' }, + target: { type: 'string' }, + repository: { type: 'string' }, + release: { type: 'string' }, + 'dry-run': { type: 'boolean' }, + }, + }); + if (!values.source || !values.target || !values.repository || !values.release) { + throw new Error( + 'Usage: --source --target --repository --release [--dry-run]', + ); + } + const registry = await readRegistry(); + const entry = propagationTargets(registry).find(({ name }) => name === values.repository); + if (!entry) + throw new Error(`${values.repository} is not a propagation target in repositories.json.`); + + const source = path.resolve(values.source); + const target = path.resolve(values.target); + const result = await applyRelease({ source, target, entry, tag: values.release }); + if (result.reason) { + process.stdout.write(`${result.reason}\n`); + return; + } + if (values['dry-run']) { + process.stdout.write( + result.changed + ? `${entry.name}: committed ${values.release} on ${updateBranch(values.release)}; not pushed (dry run).\n` + : `${entry.name}: already matches ${values.release}.\n`, + ); + return; + } + const outcome = await proposeRelease({ + source, + tooling: path.resolve(import.meta.dirname, '..'), + target, + entry, + tag: values.release, + changed: result.changed, + from: result.from, + }); + if (outcome) process.stdout.write(`${outcome}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + try { + await main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/.lvbt/web-platform/standards/repositories.json b/.lvbt/web-platform/standards/repositories.json index 772cc3c..39f6380 100644 --- a/.lvbt/web-platform/standards/repositories.json +++ b/.lvbt/web-platform/standards/repositories.json @@ -3,43 +3,56 @@ "repositories": [ { "name": ".github", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "analytics", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "labs", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "repository-tooling", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "source" }, { "name": "template-basic", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "template", + "example": "basic" }, { "name": "template-with-astro", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "template", + "example": "with-astro" }, { "name": "template-with-vite-react", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "template", + "example": "with-vite-react" }, { "name": "transit-mapper", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "website", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "week-without-driving", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" } ], "exceptions": [] diff --git a/.lvbt/web-platform/standards/self-update.ts b/.lvbt/web-platform/standards/self-update.ts new file mode 100644 index 0000000..65a0524 --- /dev/null +++ b/.lvbt/web-platform/standards/self-update.ts @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { OWNER, type RegistryEntry, applyRelease, latestRelease } from './propagate.ts'; +import { proposeRelease } from './propose.ts'; + +const UPSTREAM = `https://github.com/${OWNER}/repository-tooling.git`; + +const git = (args: string[], cwd?: string) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }).trim(); + +/** The newest stable release tag of the standard, read without any credentials. */ +export function readLatestRelease(): string { + const tags = git(['ls-remote', '--tags', '--refs', UPSTREAM]) + .split('\n') + .map((line) => line.split('refs/tags/')[1] ?? ''); + const latest = latestRelease(tags); + if (!latest) throw new Error('repository-tooling has no stable release tag.'); + return latest; +} + +/** A template repository is regenerated from its example; every other repository is updated. */ +export function repositoryEntry(name: string, isTemplate: boolean): RegistryEntry { + return isTemplate + ? { + name, + requiredStatus: 'Validate', + kind: 'template', + example: name.replace(/^template-/, ''), + } + : { name, requiredStatus: 'Validate', kind: 'consumer' }; +} + +async function vendoredRelease(root: string): Promise { + const file = path.join(root, '.lvbt/web-platform.json'); + if (!existsSync(file)) return null; + return (JSON.parse(await readFile(file, 'utf8')) as { release: string | null }).release; +} + +/** + * Runs in a repository's own `Standard update` workflow with that repository's GITHUB_TOKEN: moves + * the repository to the latest release through a self-merging pull request, and closes update pull + * requests the repository no longer needs. No credential beyond the workflow's token is involved. + */ +export async function main(): Promise { + const root = process.cwd(); + const repository = process.env.GITHUB_REPOSITORY ?? `${OWNER}/${path.basename(root)}`; + const name = repository.split('/')[1] ?? path.basename(root); + const isTemplate = + execFileSync('gh', ['api', `repos/${repository}`, '--jq', '.is_template'], { + encoding: 'utf8', + }).trim() === 'true'; + const entry = repositoryEntry(name, isTemplate); + const tag = readLatestRelease(); + const tooling = path.resolve(import.meta.dirname, '..'); + + const source = await mkdtemp(path.join(os.tmpdir(), 'lvbt-release-')); + try { + git(['clone', '--quiet', '--depth', '1', '--branch', tag, '--single-branch', UPSTREAM, source]); + const current = await vendoredRelease(root); + const result = + current === tag + ? { changed: false, from: current, skippedWorkflows: [] } + : await applyRelease({ source, target: root, entry, tag, skipWorkflows: true }); + if (result.reason) { + process.stdout.write(`${result.reason}\n`); + return; + } + const outcome = await proposeRelease({ + source, + tooling, + target: root, + entry, + tag, + changed: result.changed, + from: result.from, + }); + if (outcome) process.stdout.write(`${outcome}\n`); + if (result.skippedWorkflows?.length) { + process.stderr.write( + `${tag} changes workflow files this workflow's token may not push. Copy them from the ${tag} example by hand:\n${result.skippedWorkflows.join('\n')}\n`, + ); + process.exitCode = 1; + } + } finally { + await rm(source, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/.lvbt/web-platform/standards/status.ts b/.lvbt/web-platform/standards/status.ts new file mode 100644 index 0000000..05ba85c --- /dev/null +++ b/.lvbt/web-platform/standards/status.ts @@ -0,0 +1,262 @@ +import { execFileSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { appendFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { + BRANCH_PREFIX, + OWNER, + type Registry, + type RegistryEntry, + compareReleases, + latestRelease, + propagationTargets, + readRegistry, +} from './propagate.ts'; + +/** Days a repository may trail the latest release while its update pull request runs. */ +export const GRACE_DAYS = 3; + +export interface RepositoryState { + name: string; + release: string | null; + pluginRef: string | null; + /** The repository runs `Standard update`, and `ci.yml` accepts the dispatch it sends. */ + selfUpdating: boolean; + rulesets: string[]; + updates: { number: number; headRefName: string; failing: boolean }[]; +} + +export interface Finding { + repository: string; + rule: string; + message: string; +} + +export interface Release { + tag: string; + date: string; +} + +/** Days since the first release the repository has not adopted, or 0 when it is current. */ +export function daysBehind(release: string, releases: Release[], now: number): number { + const missed = releases + .filter(({ tag }) => compareReleases(tag, release) > 0) + .sort((left, right) => compareReleases(left.tag, right.tag))[0]; + return missed ? (now - Date.parse(missed.date)) / 86_400_000 : 0; +} + +/** + * Compares one repository with the releases and the organization settings. Pure, so the rules are + * tested without GitHub. + */ +export function findings(state: RepositoryState, releases: Release[], now: number): Finding[] { + const found: Finding[] = []; + const add = (rule: string, message: string) => + found.push({ repository: state.name, rule, message }); + const latest = latestRelease(releases.map(({ tag }) => tag)); + + if (state.release === null) { + add('release', 'main vendors an unreleased commit (`release: null`); merge a tagged release.'); + } else { + const days = daysBehind(state.release, releases, now); + if (days > GRACE_DAYS) + add( + 'release', + `on ${state.release} and behind ${latest ?? 'the latest release'} for ${Math.floor(days)} days.`, + ); + } + if (state.release && state.pluginRef !== state.release) { + add( + 'plugin-ref', + `.claude/settings.json pins the contribution plugin to ${state.pluginRef ?? 'nothing'}, not ${state.release}.`, + ); + } + 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.', + ); + if (!state.rulesets.includes('org-standard')) + add('ruleset', 'the org-standard ruleset is missing.'); + for (const update of state.updates) { + if (update.failing) add('update', `update pull request #${update.number} is failing Validate.`); + } + return found; +} + +/** Removes findings covered by an unexpired, documented exception in repositories.json. */ +export function applyExceptions(found: Finding[], registry: Registry, today: string): Finding[] { + return found.filter( + (finding) => + !registry.exceptions.some( + (exception) => + exception.repository === finding.repository && + exception.rule === finding.rule && + exception.expires >= today, + ), + ); +} + +export function report(states: RepositoryState[], latest: string, open: Finding[]): string { + const lines = [ + `# Repository standard status`, + '', + `Latest release: **${latest}**.`, + '', + '| Repository | Release | Open update | Findings |', + '| --- | --- | --- | --- |', + ]; + for (const state of states) { + const mine = open.filter(({ repository }) => repository === state.name); + const updates = state.updates.map(({ number }) => `#${number}`).join(', ') || '—'; + const summary = mine.map(({ message }) => message).join('
') || 'none'; + lines.push(`| ${state.name} | ${state.release ?? 'unreleased'} | ${updates} | ${summary} |`); + } + lines.push(''); + return lines.join('\n'); +} + +const gh = (args: string[]) => + execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +function readRaw(repository: string, file: string): string | null { + try { + return gh([ + 'api', + `repos/${OWNER}/${repository}/contents/${file}`, + '-H', + 'Accept: application/vnd.github.raw', + ]); + } catch { + return null; + } +} + +export function pluginRef(settings: string | null): string | null { + if (!settings) return null; + const parsed = JSON.parse(settings) as { + extraKnownMarketplaces?: Record; + }; + const marketplace = Object.values(parsed.extraKnownMarketplaces ?? {}).find( + ({ source }) => source?.repo === `${OWNER}/repository-tooling`, + ); + return marketplace?.source?.ref ?? null; +} + +function readState(entry: RegistryEntry): RepositoryState { + const manifest = readRaw(entry.name, '.lvbt/web-platform.json'); + const release = manifest ? (JSON.parse(manifest) as { release: string | null }).release : null; + const rulesets = ( + JSON.parse(gh(['api', `repos/${OWNER}/${entry.name}/rulesets`])) as { name: string }[] + ).map(({ name }) => name); + const pulls = JSON.parse( + gh([ + 'pr', + 'list', + '--repo', + `${OWNER}/${entry.name}`, + '--state', + 'open', + '--json', + 'number,headRefName,statusCheckRollup', + '--limit', + '100', + ]), + ) as { + number: number; + headRefName: string; + statusCheckRollup: { name?: string; context?: string; conclusion?: string; state?: string }[]; + }[]; + const updates = pulls + .filter(({ headRefName }) => headRefName.startsWith(BRANCH_PREFIX)) + .map(({ number, headRefName, statusCheckRollup }) => ({ + number, + headRefName, + failing: statusCheckRollup.some( + (check) => + (check.name ?? check.context) === entry.requiredStatus && + ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes( + check.conclusion ?? check.state ?? '', + ), + ), + })); + return { + 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') ?? ''), + rulesets, + updates, + }; +} + +function readReleases(): Release[] { + const query = `query { + repository(owner: "${OWNER}", name: "repository-tooling") { + refs(refPrefix: "refs/tags/", first: 100) { + nodes { + name + target { + ... on Commit { committedDate } + ... on Tag { target { ... on Commit { committedDate } } } + } + } + } + } + }`; + const nodes = ( + JSON.parse(gh(['api', 'graphql', '-f', `query=${query}`])) as { + data: { + repository: { + refs: { + nodes: { + name: string; + target: { committedDate?: string; target?: { committedDate?: string } }; + }[]; + }; + }; + }; + } + ).data.repository.refs.nodes; + return nodes + .filter(({ name }) => /^v\d+\.\d+\.\d+$/.test(name)) + .map(({ name, target }) => ({ + tag: name, + date: target.committedDate ?? target.target?.committedDate ?? '', + })); +} + +export async function main(args: string[]): Promise { + const { values } = parseArgs({ args, options: { json: { type: 'boolean' } } }); + const registry = await readRegistry(); + const releases = readReleases(); + const latest = latestRelease(releases.map(({ tag }) => tag)); + if (!latest) throw new Error('repository-tooling has no stable release tag.'); + const states = propagationTargets(registry).map(readState); + const now = Date.now(); + const open = applyExceptions( + states.flatMap((state) => findings(state, releases, now)), + registry, + new Date(now).toISOString().slice(0, 10), + ); + const output = values.json + ? `${JSON.stringify({ latest, states, findings: open }, null, 2)}\n` + : report(states, latest, open); + process.stdout.write(output); + if (process.env.GITHUB_STEP_SUMMARY) + await appendFile(process.env.GITHUB_STEP_SUMMARY, report(states, latest, open)); + if (open.length > 0) process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + try { + await main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/.lvbt/web-platform/standards/template-publication.ts b/.lvbt/web-platform/standards/template-publication.ts index b0f9fe5..d2e1463 100644 --- a/.lvbt/web-platform/standards/template-publication.ts +++ b/.lvbt/web-platform/standards/template-publication.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; import { cp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -116,7 +117,7 @@ export async function main(args: string[]): Promise { }); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { try { await main(process.argv.slice(2)); } catch (error) { diff --git a/.lvbt/web-platform/standards/web-platform-cli.ts b/.lvbt/web-platform/standards/web-platform-cli.ts index bbac24e..beee0ad 100644 --- a/.lvbt/web-platform/standards/web-platform-cli.ts +++ b/.lvbt/web-platform/standards/web-platform-cli.ts @@ -1,10 +1,12 @@ import { execFileSync } from 'node:child_process'; +import { realpathSync } from 'node:fs'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; +import { ownedFileDrift } from './owned-files.ts'; import { applyPreset, verifyPreset, type WebPreset } from './web-platform.ts'; import { readCommit, readRelease } from './web-platform-source.ts'; @@ -91,6 +93,17 @@ async function update( } } +/** Verifies the vendored preset and the files the standard owns in the repository. */ +async function check(root: string, json: boolean | undefined) { + const metadata = await verifyPreset(root); + // Warnings until v0.6.0, when these fail and the update restores the standard's copies. + for (const problem of await ownedFileDrift(root, metadata.release)) + process.stderr.write( + `warning: ${problem} This file belongs to the standard; from v0.6.0 \`pnpm standards:check\` fails on it. Make the change in repository-tooling instead.\n`, + ); + process.stdout.write(`${JSON.stringify({ ok: true, metadata }, null, json ? 0 : 2)}\n`); +} + export async function main(args: string[]): Promise { const { values, positionals } = parseArgs({ args, @@ -111,10 +124,7 @@ export async function main(args: string[]): Promise { if (positionals.length !== 1 || (values.apply && values['dry-run'])) throw new Error('Choose one command and either --apply or --dry-run.'); if (command === 'check') { - const metadata = await verifyPreset(root); - process.stdout.write( - `${JSON.stringify({ ok: true, metadata }, null, values.json ? 0 : 2)}\n`, - ); + await check(root, values.json); return; } if (command !== 'update') throw new Error('Choose check or update.'); @@ -132,6 +142,6 @@ export async function main(args: string[]): Promise { } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { await main(process.argv.slice(2)); } diff --git a/.lvbt/web-platform/standards/web-platform.ts b/.lvbt/web-platform/standards/web-platform.ts index a1dc4b7..98ff387 100644 --- a/.lvbt/web-platform/standards/web-platform.ts +++ b/.lvbt/web-platform/standards/web-platform.ts @@ -9,6 +9,7 @@ import { consumerIgnoreWarnings, syncConsumerIgnores, } from './consumer-ignores.ts'; +import { seedFiles, syncPluginRef } from './owned-files.ts'; export interface WebPreset { formatVersion: number; @@ -158,6 +159,8 @@ export async function applyPreset(root: string, bundle: WebPreset, dryRun = fals ...(await migrateLegacyPackageScope(root, dry)), ...(await syncConsumerIgnores(root, dry)), ...(await syncAstroTypesBeforeLint(root, dry)), + ...(await seedFiles(root, bundle, dry)), + ...(await syncPluginRef(root, bundle, dry)), ]), ].sort(); // Planning first means a consumer file a migration can't read stops the update before any write. diff --git a/docs/development/tutorials/start-here.md b/docs/development/tutorials/start-here.md index 52e68bd..777252f 100644 --- a/docs/development/tutorials/start-here.md +++ b/docs/development/tutorials/start-here.md @@ -61,10 +61,10 @@ git commit ``` The commit hook checks the message. Subjects look like `type(scope): description` where `type` is -one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`, `build`, `style`, `revert`, -and the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or -`fix` commit also needs a body that says what changed for a person using the product and why. The -hook tells you exactly what to change when it rejects a message. +one of `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `build`, `style`, `revert`, and +the optional `scope` is one of the boundaries listed in `.lvbt/commit-scopes.txt`. A `feat` or `fix` +commit also needs a body that says what changed for a person using the product and why. The hook +tells you exactly what to change when it rejects a message. ## 4. Open a pull request