From bc53ff494a1dc44fad955d0361457a6552651c8a Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Sun, 27 Sep 2026 21:28:42 -0700 Subject: [PATCH 1/2] feat(tooling): let every repository keep itself on the standard Repositories drifted because nothing moved them to new releases. This makes each repository update itself, with no credential beyond its own workflow token, and warns about the drift the audit found without yet failing anyone's checks. Every example now carries a daily Standard update workflow. It runs the vendored self-update script, which applies the latest release with the release's own updater, opens one pull request, dispatches ci.yml so Validate runs on a pull request the workflow token opened, and closes update pull requests a newer release or an earlier merge made unnecessary. A patch release's pull request merges itself once Validate passes; a minor release's waits for a maintainer. The central Publish standard workflow and its GitHub App are gone, and Standard status reads every public repository with its own token. The updater now adds the Standard update workflow when a repository lacks it and points .claude/settings.json at the installed release. These only warn until v0.6.0, which will enforce them: - standards:check reports hooks, Codex and agent plugin files, the setup action, and .editorconfig that differ from the release, and a .prettierrc that shadows prettier.config.js; - lvbt check contract reports shared catalog entries pinned to another version; - the commit hook reports ci as a type or scope, which behaved exactly like chore in every changelog and release tool. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish-standard.yml | 142 -------- .github/workflows/standard-status.yml | 23 +- AGENTS.md | 6 +- docs/README.md | 1 - docs/explanation/packages-and-examples.md | 6 +- .../how-to/adopt-in-an-existing-repository.md | 2 + docs/how-to/create-a-repository.md | 2 +- docs/how-to/publish-a-release.md | 42 ++- docs/how-to/set-up-the-standard-bot.md | 87 ----- docs/reference/examples.md | 1 + .../.github/workflows/standard-update.yml | 45 +++ examples/basic/.lvbt/commit-scopes.txt | 1 - .../docs/development/tutorials/start-here.md | 8 +- .../.github/workflows/standard-update.yml | 45 +++ examples/with-astro/.lvbt/commit-scopes.txt | 1 - .../docs/development/tutorials/start-here.md | 8 +- .../.github/workflows/standard-update.yml | 45 +++ .../with-vite-react/.lvbt/commit-scopes.txt | 1 - .../docs/development/tutorials/start-here.md | 8 +- .../scripts/validate-commit-message.mjs | 7 +- .../scripts/validate-commit-subject.mjs | 24 ++ packages/cli/src/lib/check/contract.mjs | 36 +- standards/owned-files.ts | 133 +++++++ standards/propagate.ts | 342 ++++++------------ standards/propose.ts | 247 +++++++++++++ standards/self-update.ts | 101 ++++++ standards/status.ts | 32 +- standards/template-publication.ts | 3 +- standards/web-platform-cli.ts | 20 +- standards/web-platform.ts | 3 + tests/hooks.test.mjs | 12 + tests/owned-files.test.mjs | 111 ++++++ tests/propagate.test.mjs | 136 +++++-- tests/status.test.mjs | 22 +- tests/structure.test.mjs | 3 - tests/template-publication.test.mjs | 9 +- tests/vendor-contract.test.mjs | 19 +- 37 files changed, 1132 insertions(+), 602 deletions(-) delete mode 100644 .github/workflows/publish-standard.yml delete mode 100644 docs/how-to/set-up-the-standard-bot.md create mode 100644 examples/basic/.github/workflows/standard-update.yml create mode 100644 examples/with-astro/.github/workflows/standard-update.yml create mode 100644 examples/with-vite-react/.github/workflows/standard-update.yml create mode 100644 standards/owned-files.ts create mode 100644 standards/propose.ts create mode 100644 standards/self-update.ts create mode 100644 tests/owned-files.test.mjs diff --git a/.github/workflows/publish-standard.yml b/.github/workflows/publish-standard.yml deleted file mode 100644 index 1a5a203..0000000 --- a/.github/workflows/publish-standard.yml +++ /dev/null @@ -1,142 +0,0 @@ -# Moves every repository in standards/repositories.json to a release of the -# standard. Each template is regenerated from its example; each consumer runs -# the release's own updater. Every repository gets one pull request on -# automation/repository-standard-, which merges itself once Validate -# passes. A newer release closes the older pull requests. -# -# Runs when a release tag is pushed, by hand for any stable tag, and daily for -# the latest release so an update branch that fell behind main is rebuilt. -# A branch someone pushed a fix to is left as it is. -# -# Needs the LVBT standard bot GitHub App: the LVBT_BOT_CLIENT_ID repository -# variable and the LVBT_BOT_PRIVATE_KEY secret. See -# docs/how-to/set-up-the-standard-bot.md. -name: Publish standard - -on: - push: - tags: ['v[0-9]+.[0-9]+.[0-9]+'] - schedule: - - cron: '17 14 * * *' - workflow_dispatch: - inputs: - tag: - description: The release tag to publish (for example v0.4.5); empty means the latest - required: false - repository: - description: One repository to update; empty means every repository - required: false - -permissions: - contents: read - packages: read - -concurrency: - group: publish-standard - cancel-in-progress: false - -jobs: - targets: - name: Resolve release and repositories - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - tag: ${{ steps.resolve.outputs.tag }} - repositories: ${{ steps.resolve.outputs.repositories }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - fetch-tags: true - persist-credentials: false - - - name: Resolve - id: resolve - shell: bash - env: - INPUT_TAG: ${{ inputs.tag }} - INPUT_REPOSITORY: ${{ inputs.repository }} - PUSHED_TAG: ${{ github.ref_type == 'tag' && github.ref_name || '' }} - run: | - set -euo pipefail - TAG="${INPUT_TAG:-$PUSHED_TAG}" - if [ -z "$TAG" ]; then - TAG=$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) - fi - if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Publishing the standard requires a stable release tag, not '${TAG}'." >&2 - exit 1 - fi - REPOSITORIES=$(jq -c --arg only "$INPUT_REPOSITORY" \ - '[.repositories[] | select(.kind != "source") | .name | select($only == "" or . == $only)]' \ - standards/repositories.json) - if [ "$REPOSITORIES" = "[]" ]; then - echo "No propagation target is named '${INPUT_REPOSITORY}'." >&2 - exit 1 - fi - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "repositories=${REPOSITORIES}" >> "$GITHUB_OUTPUT" - - publish: - name: Update ${{ matrix.repository }} - needs: targets - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - repository: ${{ fromJSON(needs.targets.outputs.repositories) }} - env: - TAG: ${{ needs.targets.outputs.tag }} - REPOSITORY: ${{ matrix.repository }} - steps: - - name: Create the standard bot token - id: bot - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.LVBT_BOT_CLIENT_ID }} - private-key: ${{ secrets.LVBT_BOT_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - repositories: ${{ matrix.repository }} - - - name: Checkout the propagation tooling - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - path: tooling - - - name: Checkout the release - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ env.TAG }} - persist-credentials: false - path: source - - - name: Checkout ${{ matrix.repository }} - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ github.repository_owner }}/${{ matrix.repository }} - token: ${{ steps.bot.outputs.token }} - fetch-depth: 0 - path: target - - - name: Setup pnpm - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - with: - version: 11.25.0 - - - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: tooling/package.json - - - name: Open the update pull request - shell: bash - env: - GH_TOKEN: ${{ steps.bot.outputs.token }} - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - node tooling/standards/propagate.ts \ - --source source --target target --repository "$REPOSITORY" --release "$TAG" diff --git a/.github/workflows/standard-status.yml b/.github/workflows/standard-status.yml index 25a31ef..cca403f 100644 --- a/.github/workflows/standard-status.yml +++ b/.github/workflows/standard-status.yml @@ -1,22 +1,21 @@ # Checks every repository in standards/repositories.json against the latest # release and the organization settings, and fails when one has drifted: # behind the latest release for more than three days, an unreleased vendored -# commit on main, a failing update pull request, a stale plugin ref, or merge -# settings that differ from the standard. The job summary lists every -# repository. A documented, unexpired exception in repositories.json silences -# one rule for one repository. +# commit on main, a failing update pull request, a stale plugin ref, a missing +# org-standard ruleset, or a repository that cannot update itself. The job +# summary lists every repository. A documented, unexpired exception in +# repositories.json silences one rule for one repository. Every repository is +# public, so the workflow's own token reads everything it needs. name: Standard status on: schedule: - cron: '47 14 * * *' - workflow_run: - workflows: [Publish standard] - types: [completed] workflow_dispatch: permissions: contents: read + pull-requests: read jobs: status: @@ -24,14 +23,6 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - name: Create the standard bot token - id: bot - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.LVBT_BOT_CLIENT_ID }} - private-key: ${{ secrets.LVBT_BOT_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,5 +35,5 @@ jobs: - name: Report drift env: - GH_TOKEN: ${{ steps.bot.outputs.token }} + GH_TOKEN: ${{ github.token }} run: node standards/status.ts diff --git a/AGENTS.md b/AGENTS.md index aabb949..db1c785 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,5 +27,7 @@ file is the complete list of durable boundaries for this repository. Do not inve feature, file, or task; omit it when the change crosses boundaries. Nothing is published or tagged from this repository without the maintainer's explicit approval. -`Publish packages` runs only by hand. Pushing a release tag runs `Publish standard`, which opens a -self-merging update pull request in every repository in `standards/repositories.json`. +`Publish packages` runs only by hand. Every repository's daily `Standard update` workflow picks up a +new release tag and opens an update pull request in that repository, using only that workflow's own +token. A patch release's pull request merges itself; a minor release's waits for a maintainer. A new +rule warns for at least one minor release before it fails. diff --git a/docs/README.md b/docs/README.md index 3928209..d71b646 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,6 @@ reference pages record facts and contracts, and explanation gives the reasoning. - [Adopt the standard in an existing repository](how-to/adopt-in-an-existing-repository.md) - [Set up a repository's production platform](how-to/set-up-production.md) - [Publish a tooling release](how-to/publish-a-release.md) -- [Set up the standard bot](how-to/set-up-the-standard-bot.md) ## Reference diff --git a/docs/explanation/packages-and-examples.md b/docs/explanation/packages-and-examples.md index 0443ab3..2c7ae14 100644 --- a/docs/explanation/packages-and-examples.md +++ b/docs/explanation/packages-and-examples.md @@ -49,6 +49,6 @@ snapshot. It does not generate or overwrite application-owned configuration. The organization must keep the packages small and stable, because every repository feels a change to them. A repository that needs to diverge does so in its own file, on top of the shared rule, and -says why in the commit. A release still needs a tag and release notes. The `Publish standard` -workflow then opens the preset update in every repository, and each one merges once that -repository's own checks pass. +says why in the commit. A release still needs a tag and release notes. Each repository's daily +`Standard update` workflow then opens the preset update in that repository. A patch update merges +once the repository's own checks pass; a minor update waits for a maintainer. diff --git a/docs/how-to/adopt-in-an-existing-repository.md b/docs/how-to/adopt-in-an-existing-repository.md index 33dcd89..c0e7246 100644 --- a/docs/how-to/adopt-in-an-existing-repository.md +++ b/docs/how-to/adopt-in-an-existing-repository.md @@ -32,6 +32,8 @@ 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` - `.editorconfig`, `.prettierignore`, `prettier.config.js` Merge these by hand, keeping what the repository already has: diff --git a/docs/how-to/create-a-repository.md b/docs/how-to/create-a-repository.md index 188a88a..b56bbf4 100644 --- a/docs/how-to/create-a-repository.md +++ b/docs/how-to/create-a-repository.md @@ -10,7 +10,7 @@ shared rules arrive as ordinary dependencies. - Node.js 24 or newer and git are installed on your machine. pnpm is activated by Corepack if it is missing (`corepack enable`). - You know the repository's durable commit scopes: the two to six boundaries a change can belong to, - such as `web`, `worker`, `docs`, `ci`, `dx`. A scope is never a feature, file, task, or role. + such as `web`, `worker`, `docs`, `dx`. A scope is never a feature, file, task, or role. ## 1. Create the repository from a template diff --git a/docs/how-to/publish-a-release.md b/docs/how-to/publish-a-release.md index fda5618..426aef2 100644 --- a/docs/how-to/publish-a-release.md +++ b/docs/how-to/publish-a-release.md @@ -36,25 +36,31 @@ Create the GitHub release from the tag with `gh release create v0.3.0 --generate the notes so the first line says what changes for a repository that updates. The release notes page `docs/reference/release-.md` belongs in the release commit; `pnpm check` fails without it. -Pushing the tag runs the `Publish standard` workflow. It opens one pull request in every repository -listed in [`standards/repositories.json`](../../standards/repositories.json), on the branch -`automation/repository-standard-`: +Nothing else is needed to roll the release out. Every repository runs its own `Standard update` +workflow each day. When it finds a release newer than the one in `.lvbt/web-platform.json`, it opens +one pull request on the branch `automation/repository-standard-`, using only that workflow's +own token: -- Each template repository is regenerated from its example (`examples/basic` for +- A template repository is regenerated from its example (`examples/basic` for [LasVegasForTransit/template-basic](https://github.com/LasVegasForTransit/template-basic), `examples/with-astro` for `template-with-astro`, and `examples/with-vite-react` for `template-with-vite-react`). These power GitHub's "Use this template" button. -- Each other repository runs the release's own updater, so the release's migrations apply in one +- 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. -Every pull request merges itself once its `Validate` check passes; approving the release is the -review. A newer release closes the older pull requests it replaces. The workflow also runs daily for -the latest release, which rebuilds an update branch that fell behind `main` and leaves alone a -branch that someone pushed a fix to. +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. -The workflow authenticates as the LVBT standard bot; [set it up](set-up-the-standard-bot.md) once -before the first release. Manual dispatch accepts stable release tags only; development commits and -prerelease tags are never published. +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 +names the file to copy by hand. + +Other contributors depend on the standard staying predictable. A new rule ships as a warning in one +minor release and becomes a failure only in a later one; the first release's notes say which release +enforces it, so every repository sees the warning in its own checks first. ## 3. Publish to GitHub Packages @@ -64,12 +70,12 @@ pnpm configuration to GitHub Packages; CI uses its repository token. ## 4. Watch the repositories update -The `Standard status` workflow runs daily and after every publication. Its job summary lists each -repository's release and open 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, or -merge settings that differ from the standard. Run the same check locally with -`pnpm standards:status`. +The `Standard status` workflow runs daily. Its job summary lists each repository's release and open +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`. 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/how-to/set-up-the-standard-bot.md b/docs/how-to/set-up-the-standard-bot.md deleted file mode 100644 index 0ea0b0d..0000000 --- a/docs/how-to/set-up-the-standard-bot.md +++ /dev/null @@ -1,87 +0,0 @@ -# Set up the standard bot - -The `Publish standard` and `Standard status` workflows act on every repository in the organization. -They authenticate as a GitHub App called the LVBT standard bot, not as a person. The App's key never -expires and is not tied to anyone's account. Pull requests it opens also run the `Validate` check; -pull requests opened with a workflow's own `GITHUB_TOKEN` would not. - -You need to be an owner of the `LasVegasForTransit` organization. The setup takes about ten minutes, -and you do it once. - -## 1. Create the App - -1. Open . -2. Fill in the form with exactly these values: - - | Field | Value | - | ---------------- | ---------------------------------------------------------- | - | GitHub App name | `LVBT standard bot` | - | Homepage URL | `https://github.com/LasVegasForTransit/repository-tooling` | - | Webhook → Active | Unchecked | - -3. Under **Permissions → Repository permissions**, set these and leave everything else at **No - access**: - - | Permission | Access | - | --------------- | ----------------------------- | - | Administration | Read-only | - | Checks | Read-only | - | Commit statuses | Read-only | - | Contents | Read and write | - | Metadata | Read-only (set automatically) | - | Pull requests | Read and write | - | Workflows | Read and write | - -4. Under **Where can this GitHub App be installed?**, choose **Only on this account**. -5. Select **Create GitHub App**. - -## 2. Save its credentials - -1. On the App's page, copy the **Client ID**. It starts with `Iv`. -2. Scroll to **Private keys** and select **Generate a private key**. Your browser downloads a `.pem` - file. - -## 3. Install it on every repository - -1. In the App's left sidebar, select **Install App**. -2. Next to `LasVegasForTransit`, select **Install**. -3. Choose **All repositories** and select **Install**. - -## 4. Give the credentials to repository-tooling - -Run these two commands from any directory, replacing the Client ID and the path to the downloaded -file: - -```bash -gh variable set LVBT_BOT_CLIENT_ID --repo LasVegasForTransit/repository-tooling --body Iv23liExample -``` - -```bash -gh secret set LVBT_BOT_PRIVATE_KEY --repo LasVegasForTransit/repository-tooling < ~/Downloads/lvbt-standard-bot.private-key.pem -``` - -Then delete the downloaded `.pem` file. GitHub keeps no other copy; if it is lost, generate a new -key on the App's page and run the second command again. - -## 5. Check that it works - -1. Open **Actions → Standard status** in repository-tooling and select **Run workflow**. The job - summary lists every repository with its release. A red run with findings is expected until every - repository is current; a run that fails before the table appears means the credentials are wrong. -2. Open **Actions → Publish standard** and select **Run workflow** with both boxes empty. Every - repository that is behind the latest release gets one pull request titled - `chore: update LVBT repository standard to `, with auto-merge turned on. - -Running either workflow a second time changes nothing that is already current. - -## 6. Retire the old token - -The template repositories used to be updated with a personal access token stored as -`TEMPLATE_PUBLISH_TOKEN`. Once step 5 works, delete that secret and revoke the token: - -```bash -gh secret delete TEMPLATE_PUBLISH_TOKEN --repo LasVegasForTransit/repository-tooling -``` - -Then open , find the token that had access to -the three template repositories, and select **Delete**. diff --git a/docs/reference/examples.md b/docs/reference/examples.md index 69eded6..aceade1 100644 --- a/docs/reference/examples.md +++ b/docs/reference/examples.md @@ -25,6 +25,7 @@ its own `check` with the shared packages. | `.codex/hooks.json`, `.agents/plugins/` | Codex loads the plugin from `node_modules` and runs its guard | | `.claude/settings.json` | Claude Code loads the plugin from the release tag, formats on edit, and cannot read secret files | | `.github/workflows/ci.yml` | The `Validate` job: `pnpm check`, dependency audit, secret scan | +| `.github/workflows/standard-update.yml` | Daily: opens a self-merging pull request when a newer standard release exists | | `.github/actions/setup-node-pnpm/action.yml` | Node from `package.json`, pinned pnpm, frozen install | | `.github/renovate.json` | Weekly grouped updates; `@lasvegasfortransit/*` bumps grouped as one | | `.github/CODEOWNERS` | The maintainers team reviews everything | diff --git a/examples/basic/.github/workflows/standard-update.yml b/examples/basic/.github/workflows/standard-update.yml new file mode 100644 index 0000000..2a087ca --- /dev/null +++ b/examples/basic/.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/examples/basic/.lvbt/commit-scopes.txt b/examples/basic/.lvbt/commit-scopes.txt index b03823f..93a5c16 100644 --- a/examples/basic/.lvbt/commit-scopes.txt +++ b/examples/basic/.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/examples/basic/docs/development/tutorials/start-here.md b/examples/basic/docs/development/tutorials/start-here.md index 52e68bd..777252f 100644 --- a/examples/basic/docs/development/tutorials/start-here.md +++ b/examples/basic/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/examples/with-astro/.github/workflows/standard-update.yml b/examples/with-astro/.github/workflows/standard-update.yml new file mode 100644 index 0000000..2a087ca --- /dev/null +++ b/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/examples/with-astro/.lvbt/commit-scopes.txt b/examples/with-astro/.lvbt/commit-scopes.txt index 7be9d71..ab0a7e1 100644 --- a/examples/with-astro/.lvbt/commit-scopes.txt +++ b/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/examples/with-astro/docs/development/tutorials/start-here.md b/examples/with-astro/docs/development/tutorials/start-here.md index 809a0b9..3646d2c 100644 --- a/examples/with-astro/docs/development/tutorials/start-here.md +++ b/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/examples/with-vite-react/.github/workflows/standard-update.yml b/examples/with-vite-react/.github/workflows/standard-update.yml new file mode 100644 index 0000000..2a087ca --- /dev/null +++ b/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/examples/with-vite-react/.lvbt/commit-scopes.txt b/examples/with-vite-react/.lvbt/commit-scopes.txt index 36c4e52..f5b08a1 100644 --- a/examples/with-vite-react/.lvbt/commit-scopes.txt +++ b/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/examples/with-vite-react/docs/development/tutorials/start-here.md b/examples/with-vite-react/docs/development/tutorials/start-here.md index c129eb2..dc39c88 100644 --- a/examples/with-vite-react/docs/development/tutorials/start-here.md +++ b/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/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs b/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs index aef133f..df4e255 100644 --- a/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-message.mjs +++ b/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/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs b/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs index 675aaf5..b97896c 100644 --- a/packages/cli/plugins/lvbt-contributions/scripts/validate-commit-subject.mjs +++ b/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/packages/cli/src/lib/check/contract.mjs b/packages/cli/src/lib/check/contract.mjs index a6fda7b..c0b8cf4 100644 --- a/packages/cli/src/lib/check/contract.mjs +++ b/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/standards/owned-files.ts b/standards/owned-files.ts new file mode 100644 index 0000000..c7cf673 --- /dev/null +++ b/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/standards/propagate.ts b/standards/propagate.ts index 37f4eca..a892a27 100644 --- a/standards/propagate.ts +++ b/standards/propagate.ts @@ -1,9 +1,8 @@ import { execFileSync } from 'node:child_process'; import { existsSync, realpathSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, 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'; export const OWNER = 'LasVegasForTransit'; export const BRANCH_PREFIX = 'automation/repository-standard-'; @@ -68,6 +67,17 @@ export function latestRelease(tags: string[]): string | undefined { .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}`; @@ -103,8 +113,9 @@ export function pullRequestBody(options: { tag: string; kind: RegistryEntry['kind']; hasNotes: boolean; + automerge: boolean; }): string { - const { tag, kind, hasNotes } = options; + 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.` @@ -122,7 +133,9 @@ export function pullRequestBody(options: { '', `${change} ${notes}`, '', - 'This pull request was opened by the `Publish standard` workflow when the release was tagged, and 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.', + 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', '', @@ -135,9 +148,23 @@ export function commitMessage(tag: string): string { return `${pullRequestTitle(tag)}\n\nApply the reviewed ${tag} release with its own updater.\n`; } -type Runner = (command: string, args: string[], cwd: string) => string; +export type Runner = (command: string, args: string[], cwd: string) => string; -const run: Runner = (command, args, cwd) => +/** 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', @@ -151,6 +178,40 @@ async function currentRelease(target: string): Promise { 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. @@ -161,9 +222,15 @@ export async function applyRelease(options: { entry: RegistryEntry; tag: string; install?: boolean; + skipWorkflows?: boolean; runner?: Runner; -}): Promise<{ changed: boolean; reason?: string }> { - const { entry, tag, install = true, runner = run } = options; +}): 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); @@ -173,30 +240,8 @@ export async function applyRelease(options: { return { changed: false, reason: `${entry.name} is already on ${release}, newer than ${tag}.` }; } - if (entry.kind === 'template') { - if (!entry.example) throw new Error(`${entry.name} is a template without an example.`); - // Publication rebuilds the template from the example, lockfile included. Keeping the published - // lockfile makes pnpm resolve only what the release changed, so a re-run with no new release - // proposes nothing instead of whichever transitive packages were published since. - 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); - } else if (entry.kind === 'consumer') { + if (entry.kind === 'template') await publishTemplate({ source, target, entry, tag, runner }); + else if (entry.kind === 'consumer') runner( 'node', [ @@ -213,224 +258,47 @@ export async function applyRelease(options: { ], target, ); - } else throw new Error(`${entry.name} does not receive releases.`); + else throw new Error(`${entry.name} does not receive releases.`); if (install) runner('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile'], target); - if (!runner('git', ['status', '--porcelain'], target)) return { changed: false }; + const skippedWorkflows = skipWorkflows ? restoreWorkflows(target, runner) : []; + if (!runner('git', ['status', '--porcelain'], target)) + return { changed: false, from: release, skippedWorkflows }; - const message = path.join(target, '.git', 'lvbt-standard-commit-message'); - await writeFile(message, commitMessage(tag)); runner('git', ['switch', '-C', updateBranch(tag)], target); runner('git', ['restore', '--staged', '.'], target); runner('git', ['add', '-A'], target); - runner( - 'git', - [ - '-c', - 'user.name=lvbt-bot', - '-c', - 'user.email=noreply@lasvegasfortransit.org', - 'commit', - '--quiet', - '--no-verify', - '-F', - message, - ], - target, - ); - return { changed: true }; -} - -/** - * 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( + await withTemporaryFile(commitMessage(tag), (message) => runner( - 'gh', + 'git', [ - 'pr', - 'list', - '--state', - 'open', - '--base', - 'main', - '--json', - 'number,headRefName', - '--limit', - '100', + '-c', + 'user.name=lvbt-bot', + '-c', + 'user.email=noreply@lasvegasfortransit.org', + 'commit', + '--quiet', + '--no-verify', + '-F', + message, ], target, ), - ) as OpenUpdate[]; -} - -/** Pushes a new or rebuilt update branch, or adopts the one on GitHub when it is current or fixed. */ -function pushUpdateBranch(target: string, name: string, branch: string, runner: Runner): void { - const state = remoteBranchState(target, branch, runner); - if (state === 'absent' || state === 'stale') { - runner('git', ['push', '--force-with-lease', '--set-upstream', 'origin', branch], target); - return; - } - 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); -} - -/** 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; - runner: Runner; -}): Promise { - const { source, tooling, target, entry, tag, runner } = options; - const title = pullRequestTitle(tag); - const body = path.join(target, '.git', 'lvbt-standard-pr.md'); - const hasNotes = existsSync(path.join(source, releaseNotesPath(tag))); - await writeFile(body, pullRequestBody({ tag, kind: entry.kind, hasNotes })); - 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; + return { changed: true, from: release, skippedWorkflows }; } /** - * Pushes the update branch, opens or refreshes its pull request, enables auto-merge, and closes - * update pull requests for older releases. + * 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. */ -export async function proposeRelease(options: { - source: string; - tooling: string; - target: string; - entry: RegistryEntry; - tag: string; - changed: boolean; - runner?: Runner; -}): Promise { - const { target, entry, tag, changed, runner = run } = options; - const { superseded, newer } = planOpenUpdates(openUpdates(target, runner), 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) { - pushUpdateBranch(target, entry.name, updateBranch(tag), runner); - number = await openPullRequest({ ...options, runner }); - runner('gh', ['pr', 'merge', String(number), '--auto', '--rebase'], target); - } - - 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}`; -} - -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, - }); - if (outcome) process.stdout.write(`${outcome}\n`); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(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; - } +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/standards/propose.ts b/standards/propose.ts new file mode 100644 index 0000000..f2b5720 --- /dev/null +++ b/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/standards/self-update.ts b/standards/self-update.ts new file mode 100644 index 0000000..65a0524 --- /dev/null +++ b/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/standards/status.ts b/standards/status.ts index 60050bf..05ba85c 100644 --- a/standards/status.ts +++ b/standards/status.ts @@ -1,4 +1,5 @@ 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'; @@ -17,19 +18,12 @@ import { /** Days a repository may trail the latest release while its update pull request runs. */ export const GRACE_DAYS = 3; -export const SETTINGS = { - allow_merge_commit: false, - allow_squash_merge: false, - allow_rebase_merge: true, - allow_auto_merge: true, - delete_branch_on_merge: true, -} as const; - export interface RepositoryState { name: string; release: string | null; pluginRef: string | null; - settings: Partial>; + /** The repository runs `Standard update`, and `ci.yml` accepts the dispatch it sends. */ + selfUpdating: boolean; rulesets: string[]; updates: { number: number; headRefName: string; failing: boolean }[]; } @@ -79,10 +73,11 @@ export function findings(state: RepositoryState, releases: Release[], now: numbe `.claude/settings.json pins the contribution plugin to ${state.pluginRef ?? 'nothing'}, not ${state.release}.`, ); } - for (const [key, expected] of Object.entries(SETTINGS)) { - const actual = state.settings[key as keyof typeof SETTINGS]; - if (actual !== expected) add('settings', `${key} is ${String(actual)}; expected ${expected}.`); - } + 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) { @@ -153,9 +148,6 @@ export function pluginRef(settings: string | null): string | 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 settings = JSON.parse( - gh(['api', `repos/${OWNER}/${entry.name}`]), - ) as RepositoryState['settings']; const rulesets = ( JSON.parse(gh(['api', `repos/${OWNER}/${entry.name}/rulesets`])) as { name: string }[] ).map(({ name }) => name); @@ -194,9 +186,9 @@ function readState(entry: RegistryEntry): RepositoryState { name: entry.name, release, pluginRef: pluginRef(readRaw(entry.name, '.claude/settings.json')), - settings: Object.fromEntries( - Object.keys(SETTINGS).map((key) => [key, settings[key as keyof typeof SETTINGS]]), - ), + 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, }; @@ -260,7 +252,7 @@ export async function main(args: string[]): Promise { if (open.length > 0) process.exitCode = 1; } -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/standards/template-publication.ts b/standards/template-publication.ts index b0f9fe5..d2e1463 100644 --- a/standards/template-publication.ts +++ b/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/standards/web-platform-cli.ts b/standards/web-platform-cli.ts index bbac24e..beee0ad 100644 --- a/standards/web-platform-cli.ts +++ b/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/standards/web-platform.ts b/standards/web-platform.ts index a1dc4b7..98ff387 100644 --- a/standards/web-platform.ts +++ b/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/tests/hooks.test.mjs b/tests/hooks.test.mjs index c83076c..1647289 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -131,6 +131,18 @@ test('the shared validator rejects catch-all scopes', () => { assert.match(result.stderr, /scope.*repo.*allowed/i); }); +test('the shared validator accepts ci as a type or scope with a deprecation warning', async () => { + const repository = await repositoryWithScopes(['ci', 'site']); + const asType = validateSubject('ci: pin the checkout action', repository); + const asScope = validateSubject('chore(ci): pin the checkout action', repository); + + assert.equal(asType.status, 0, asType.stderr); + assert.match(asType.stderr, /warning: Type `ci` is deprecated.*`chore`/); + assert.equal(asScope.status, 0, asScope.stderr); + assert.match(asScope.stderr, /warning: Scope `ci` is deprecated/); + assert.equal(validateSubject('chore: pin the checkout action', repository).stderr, ''); +}); + test('the shared validator rejects a subject longer than 72 characters', () => { const subject = `chore(dx): ${'a'.repeat(63)}`; const result = validateSubject(subject); diff --git a/tests/owned-files.test.mjs b/tests/owned-files.test.mjs new file mode 100644 index 0000000..181ea26 --- /dev/null +++ b/tests/owned-files.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + OWNED_FILES, + SEEDED_FILES, + ownedFileDrift, + seedFiles, + syncPluginRef, +} from '../standards/owned-files.ts'; + +const root = path.resolve(import.meta.dirname, '..'); +const reference = 'examples/with-astro'; + +async function exampleFiles() { + const files = {}; + for (const name of [...OWNED_FILES, ...SEEDED_FILES, '.claude/settings.json']) + files[`${reference}/${name}`] = await readFile(path.join(root, reference, name), 'utf8'); + files['packages/cli/catalog.json'] = await readFile( + path.join(root, 'packages/cli/catalog.json'), + 'utf8', + ); + return files; +} + +async function bundle(release = 'v9.9.9') { + return { + formatVersion: 1, + preset: 'lvbt-web', + release, + commit: 'a'.repeat(40), + files: await exampleFiles(), + executables: OWNED_FILES.filter((name) => name.startsWith('.githooks/')).map( + (name) => `${reference}/${name}`, + ), + }; +} + +async function repository(t) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'lvbt-owned-files-')); + t.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +test('every example carries identical owned and seeded files', async () => { + for (const name of [...OWNED_FILES, ...SEEDED_FILES]) { + const expected = await readFile(path.join(root, reference, name), 'utf8'); + for (const example of ['basic', 'with-vite-react']) + assert.equal( + await readFile(path.join(root, 'examples', example, name), 'utf8'), + expected, + `${example}/${name} must match ${reference}`, + ); + } +}); + +test('an update adds the Standard update workflow once and never rewrites it', async (t) => { + const directory = await repository(t); + const incoming = await bundle(); + assert.deepEqual(await seedFiles(directory, incoming, true), SEEDED_FILES); + await assert.rejects(readFile(path.join(directory, SEEDED_FILES[0])), 'a dry run writes nothing'); + + assert.deepEqual(await seedFiles(directory, incoming, false), SEEDED_FILES); + assert.equal( + await readFile(path.join(directory, SEEDED_FILES[0]), 'utf8'), + incoming.files[`${reference}/${SEEDED_FILES[0]}`], + ); + await writeFile(path.join(directory, SEEDED_FILES[0]), 'name: Standard update\n'); + assert.deepEqual(await seedFiles(directory, incoming, false), []); + assert.equal( + await readFile(path.join(directory, SEEDED_FILES[0]), 'utf8'), + 'name: Standard update\n', + ); +}); + +test('the plugin ref follows the installed release, and check reports drift', async (t) => { + const directory = await repository(t); + await mkdir(path.join(directory, '.claude')); + const settings = ( + await readFile(path.join(root, reference, '.claude/settings.json'), 'utf8') + ).replace(/"ref": "[^"]*"/, '"ref": "v0.1.0"'); + await writeFile(path.join(directory, '.claude/settings.json'), settings); + assert.deepEqual(await syncPluginRef(directory, await bundle('v9.9.9'), false), [ + '.claude/settings.json', + ]); + assert.match( + await readFile(path.join(directory, '.claude/settings.json'), 'utf8'), + /"ref": "v9\.9\.9"/, + ); + assert.deepEqual(await syncPluginRef(directory, await bundle(null), false), []); + + const vendored = path.join(directory, '.lvbt/web-platform', reference); + for (const name of OWNED_FILES) { + await mkdir(path.dirname(path.join(vendored, name)), { recursive: true }); + await writeFile(path.join(vendored, name), `${name}\n`); + await mkdir(path.dirname(path.join(directory, name)), { recursive: true }); + await writeFile(path.join(directory, name), `${name}\n`); + } + assert.deepEqual(await ownedFileDrift(directory, 'v9.9.9'), []); + await writeFile(path.join(directory, '.editorconfig'), 'root = false\n'); + await writeFile(path.join(directory, 'prettier.config.js'), 'export default {};\n'); + await writeFile(path.join(directory, '.prettierrc.json'), '{}\n'); + assert.deepEqual(await ownedFileDrift(directory, 'v1.0.0'), [ + ".editorconfig differs from the standard's copy.", + ".prettierrc.json replaces the organization's prettier.config.js.", + '.claude/settings.json loads the contribution plugin from v9.9.9, not v1.0.0.', + ]); +}); diff --git a/tests/propagate.test.mjs b/tests/propagate.test.mjs index 3a37f04..9bef008 100644 --- a/tests/propagate.test.mjs +++ b/tests/propagate.test.mjs @@ -8,6 +8,7 @@ import test from 'node:test'; import { applyRelease, compareReleases, + isPatchUpdate, latestRelease, planOpenUpdates, propagationTargets, @@ -16,6 +17,7 @@ import { readRegistry, updateBranch, } from '../standards/propagate.ts'; +import { repositoryEntry } from '../standards/self-update.ts'; import { materializeTemplate } from '../standards/template-publication.ts'; const root = path.resolve(import.meta.dirname, '..'); @@ -33,6 +35,18 @@ test('every template and consumer receives each release, and the source does not } }); +test('only a patch step merges itself', () => { + assert.equal(isPatchUpdate('v0.4.5', 'v0.4.6'), true); + assert.equal(isPatchUpdate('v0.4.5', 'v0.5.0'), false); + assert.equal(isPatchUpdate('v0.4.5', 'v1.4.6'), false); + assert.equal(isPatchUpdate(null, 'v0.4.6'), false); + assert.equal(isPatchUpdate('v0.4.6', 'v0.4.6'), false); + assert.match( + pullRequestBody({ tag: 'v0.5.0', kind: 'consumer', hasNotes: true, automerge: false }), + /a maintainer merges it/, + ); +}); + test('releases compare by version, not by text', () => { assert.ok(compareReleases('v0.10.0', 'v0.9.9') > 0); assert.equal(compareReleases('v1.2.3', 'v1.2.3'), 0); @@ -59,7 +73,12 @@ test('an update supersedes older update pull requests and yields to newer ones', }); test('the update pull request follows the organization template', () => { - const body = pullRequestBody({ tag: 'v0.4.5', kind: 'consumer', hasNotes: true }); + const body = pullRequestBody({ + tag: 'v0.4.5', + kind: 'consumer', + hasNotes: true, + automerge: true, + }); assert.deepEqual( [...body.matchAll(/^# (.+)$/gm)].map(([, heading]) => heading), ['TL;DR', 'Overview of Changes', 'Follow-ups'], @@ -113,15 +132,16 @@ test('a release moves a repository forward once, with its own updater, and never kind: 'template', example: 'basic', }; - assert.deepEqual( - await applyRelease({ - source, - target: template, - entry: templateEntry, - tag: 'v99.0.0', - install: false, - }), - { changed: false }, + const republished = await applyRelease({ + source, + target: template, + entry: templateEntry, + tag: 'v99.0.0', + install: false, + }); + assert.equal( + republished.changed, + false, 'republishing the same release keeps the published lockfile and proposes nothing', ); @@ -149,7 +169,7 @@ test('a release moves a repository forward once, with its own updater, and never tag: 'v99.0.1', install: false, }); - assert.deepEqual(first, { changed: true }); + assert.equal(first.changed, true); assert.equal(git(consumer, 'branch', '--show-current'), 'automation/repository-standard-v99.0.1'); assert.equal(git(consumer, 'log', '-1', '--format=%ae'), 'noreply@lasvegasfortransit.org'); const manifest = JSON.parse( @@ -157,9 +177,10 @@ test('a release moves a repository forward once, with its own updater, and never ); assert.equal(manifest.release, 'v99.0.1'); - assert.deepEqual( - await applyRelease({ source, target: consumer, entry, tag: 'v99.0.1', install: false }), - { changed: false }, + assert.equal( + (await applyRelease({ source, target: consumer, entry, tag: 'v99.0.1', install: false })) + .changed, + false, ); const older = await applyRelease({ source, @@ -170,24 +191,81 @@ test('a release moves a repository forward once, with its own updater, and never }); assert.equal(older.changed, false); assert.match(older.reason ?? '', /newer than v99\.0\.0/); -}); -test('the publish workflow proposes every release through the shared helper and the bot', async () => { - const workflow = await readFile( - path.join(root, '.github/workflows/publish-standard.yml'), - 'utf8', + // A repository's own token may not push workflow files, so a self-update leaves them alone. + const selfUpdating = path.join(fixture, 'self-updating'); + await materializeTemplate({ source, target: selfUpdating, example: 'basic', release: 'v99.0.1' }); + await rm(path.join(selfUpdating, '.github/workflows/standard-update.yml')); + git(selfUpdating, 'init', '--quiet', '--initial-branch', 'main'); + git(selfUpdating, 'add', '-A'); + git( + selfUpdating, + '-c', + 'user.name=test', + '-c', + 'user.email=test@example.org', + 'commit', + '-qm', + 'init', + ); + await writeFile( + path.join(source, 'LICENSE'), + `${await readFile(path.join(source, 'LICENSE'), 'utf8')}\n`, + ); + git( + source, + '-c', + 'user.name=test', + '-c', + 'user.email=test@example.org', + 'commit', + '-qam', + 'patch', + ); + git(source, 'tag', 'v99.0.2', 'HEAD'); + const skipped = await applyRelease({ + source, + target: selfUpdating, + entry, + tag: 'v99.0.2', + install: false, + skipWorkflows: true, + }); + assert.equal(skipped.changed, true); + assert.deepEqual(skipped.skippedWorkflows, ['.github/workflows/standard-update.yml']); + assert.equal( + git(selfUpdating, 'show', '--name-only', '--format=', 'HEAD').includes('.github/workflows/'), + false, ); - assert.match(workflow, /^ {2}push:\n {4}tags:/m); - assert.match(workflow, /^ {2}schedule:/m); - assert.match(workflow, /^ {2}workflow_dispatch:/m); - assert.match(workflow, /actions\/create-github-app-token@[0-9a-f]{40}/); - assert.match(workflow, /LVBT_BOT_PRIVATE_KEY/); - assert.match(workflow, /standards\/repositories\.json/); - assert.match(workflow, /node tooling\/standards\/propagate\.ts/); - assert.doesNotMatch(workflow, /TEMPLATE_PUBLISH_TOKEN/); - - const propagate = await readFile(path.join(root, 'standards/propagate.ts'), 'utf8'); +}); + +test('every example updates itself with only its own workflow token', async () => { + for (const example of ['basic', 'with-astro', 'with-vite-react']) { + const directory = path.join(root, 'examples', example, '.github/workflows'); + const workflow = await readFile(path.join(directory, 'standard-update.yml'), 'utf8'); + assert.match(workflow, /^ {2}schedule:/m, example); + assert.match(workflow, /^ {2}workflow_dispatch:/m, example); + for (const permission of ['actions: write', 'contents: write', 'pull-requests: write']) + 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.doesNotMatch(propagate, /'pr', 'create'/); }); + +test('a template repository is regenerated from the example its name gives', () => { + assert.deepEqual(repositoryEntry('template-with-astro', true), { + name: 'template-with-astro', + requiredStatus: 'Validate', + kind: 'template', + example: 'with-astro', + }); + assert.equal(repositoryEntry('labs', false).kind, 'consumer'); +}); diff --git a/tests/status.test.mjs b/tests/status.test.mjs index 8c9ee86..598bac1 100644 --- a/tests/status.test.mjs +++ b/tests/status.test.mjs @@ -3,14 +3,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; -import { - SETTINGS, - applyExceptions, - daysBehind, - findings, - pluginRef, - report, -} from '../standards/status.ts'; +import { applyExceptions, daysBehind, findings, pluginRef, report } from '../standards/status.ts'; const root = path.resolve(import.meta.dirname, '..'); const day = 86_400_000; @@ -24,7 +17,7 @@ const current = (overrides = {}) => ({ name: 'example', release: 'v0.4.5', pluginRef: 'v0.4.5', - settings: { ...SETTINGS }, + selfUpdating: true, rulesets: ['org-standard'], updates: [], ...overrides, @@ -43,12 +36,10 @@ test('lag counts from the first release a repository missed, not the latest', () assert.ok(daysBehind('v0.4.4', releases, now + 4 * day) > 3); }); -test('unreleased vendoring, plugin refs, settings, rulesets, and red updates are drift', () => { +test('unreleased vendoring, plugin refs, self-update, rulesets, and red updates are drift', () => { assert.deepEqual(rules(current({ release: null, pluginRef: null })), ['release']); assert.deepEqual(rules(current({ pluginRef: 'v0.4.0' })), ['plugin-ref']); - assert.deepEqual(rules(current({ settings: { ...SETTINGS, allow_squash_merge: true } })), [ - 'settings', - ]); + assert.deepEqual(rules(current({ selfUpdating: false })), ['self-update']); assert.deepEqual(rules(current({ rulesets: [] })), ['ruleset']); assert.deepEqual( rules( @@ -89,10 +80,11 @@ test('the report lists every repository', () => { assert.match(output, /\| other \| unreleased \|/); }); -test('the status workflow runs daily, after publishing, and by hand', async () => { +test('the status workflow runs daily and by hand with only its own token', async () => { const workflow = await readFile(path.join(root, '.github/workflows/standard-status.yml'), 'utf8'); assert.match(workflow, /^ {2}schedule:/m); - assert.match(workflow, /workflows: \[Publish standard\]/); assert.match(workflow, /^ {2}workflow_dispatch:/m); assert.match(workflow, /node standards\/status\.ts/); + assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/); + assert.doesNotMatch(workflow, /secrets\./); }); diff --git a/tests/structure.test.mjs b/tests/structure.test.mjs index 341fcec..af14fa3 100644 --- a/tests/structure.test.mjs +++ b/tests/structure.test.mjs @@ -152,11 +152,8 @@ test('generated repositories authenticate GitHub Packages during installation', }); test('publication commits an installable frozen lockfile', async () => { - const workflow = await read('.github/workflows/publish-standard.yml'); const propagate = await read('standards/propagate.ts'); - assert.match(workflow, /uses: pnpm\/action-setup@/); - assert.match(workflow, /node-version-file: tooling\/package\.json/); assert.match(propagate, /'install', '--lockfile-only', '--no-frozen-lockfile'/); assert.ok( propagate.indexOf("'--lockfile-only'") < propagate.indexOf("'add', '-A'"), diff --git a/tests/template-publication.test.mjs b/tests/template-publication.test.mjs index b1b3c9c..3096005 100644 --- a/tests/template-publication.test.mjs +++ b/tests/template-publication.test.mjs @@ -78,10 +78,13 @@ test('browser templates type their Node.js test configuration', async () => { test('template publication respects protected branches through a reviewed pull request', async () => { const workflow = await readFile( - path.join(root, '.github/workflows/publish-standard.yml'), + path.join(root, 'examples/basic/.github/workflows/standard-update.yml'), 'utf8', ); - const propagate = await readFile(path.join(root, 'standards/propagate.ts'), 'utf8'); + const propagate = [ + await readFile(path.join(root, 'standards/propagate.ts'), 'utf8'), + await readFile(path.join(root, 'standards/propose.ts'), 'utf8'), + ].join('\n'); assert.match(propagate, /automation\/repository-standard-/); assert.match(propagate, /github-create\.mjs/); @@ -90,7 +93,7 @@ test('template publication respects protected branches through a reviewed pull r assert.match(propagate, /'pr', 'list'/); assert.match(propagate, /'pr', 'edit'/); assert.match(propagate, /standards\/template-publication\.ts/); - assert.match(workflow, /LVBT_BOT_PRIVATE_KEY/); + assert.match(workflow, /self-update\.ts/); assert.doesNotMatch(`${workflow}\n${propagate}`, /HEAD:main/); }); diff --git a/tests/vendor-contract.test.mjs b/tests/vendor-contract.test.mjs index e94fe86..deb87bc 100644 --- a/tests/vendor-contract.test.mjs +++ b/tests/vendor-contract.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -52,3 +52,20 @@ test('rejects arbitrary file dependencies and mismatched vendor package names', ); assert.equal(checkContract({ cwd: root }).lines.length, 2); })); + +test('a repository may add catalog entries and is warned when it re-pins a shared one', () => + fixture(async (root) => { + const standard = JSON.parse( + await readFile(new URL('../packages/cli/catalog.json', import.meta.url), 'utf8'), + ).catalog; + const workspace = (eslint) => + `packages:\n - apps/*\ncatalog:\n eslint: ${eslint}\n left-pad: 1.3.0\n`; + await writeFile(path.join(root, 'pnpm-workspace.yaml'), workspace(standard.eslint)); + assert.deepEqual(checkContract({ cwd: root }).lines, []); + await writeFile(path.join(root, 'pnpm-workspace.yaml'), workspace('^9.0.0')); + const result = checkContract({ cwd: root }); + assert.equal(result.ok, true, 'a re-pinned shared entry only warns until v0.6.0'); + assert.deepEqual(result.lines, [ + `warning: pnpm-workspace.yaml pins "eslint" to "^9.0.0"; the standard's catalog has "${standard.eslint}" (from v0.6.0 this fails)`, + ]); + })); From ed6e9713f4664812783adfa9c3bc6e6e782e52ba Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Sun, 27 Sep 2026 22:41:57 -0700 Subject: [PATCH 2/2] chore(tooling): release v0.5.0 Publish self-updating repositories and the warnings that precede v0.6.0 enforcement as a minor release of the shared web standard. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- docs/reference/release-0-5-0.md | 27 +++++++++++++++++++ examples/basic/.claude/settings.json | 2 +- examples/basic/package.json | 4 +-- examples/basic/packages/example/package.json | 6 ++--- examples/with-astro/.claude/settings.json | 2 +- examples/with-astro/apps/site/package.json | 8 +++--- examples/with-astro/package.json | 4 +-- .../with-vite-react/.claude/settings.json | 2 +- .../with-vite-react/apps/app/package.json | 8 +++--- examples/with-vite-react/package.json | 4 +-- package.json | 2 +- packages/cli/package.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- packages/eslint-config/package.json | 2 +- packages/playwright-config/package.json | 2 +- packages/prettier-config/package.json | 2 +- packages/typescript-config/package.json | 2 +- packages/vitest-config/package.json | 2 +- packages/web-platform/package.json | 2 +- 21 files changed, 58 insertions(+), 31 deletions(-) create mode 100644 docs/reference/release-0-5-0.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2290ed8..80c2336 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.4.5", + "version": "0.5.0", "source": "./packages/cli/plugins/lvbt-contributions" } ] diff --git a/docs/reference/release-0-5-0.md b/docs/reference/release-0-5-0.md new file mode 100644 index 0000000..fd04d95 --- /dev/null +++ b/docs/reference/release-0-5-0.md @@ -0,0 +1,27 @@ +# Repository tooling 0.5.0 + +A repository now keeps itself on the standard. Updating to 0.5.0 adds a daily `Standard update` +workflow. When a newer release exists, it applies that release with the release's own updater and +opens a pull request. A patch release's pull request merges itself once `Validate` passes; a minor +release's waits for a maintainer, because a minor release can change how the repository works. The +workflow uses only its own token, so there is nothing to set up. It dispatches `ci.yml` to run +`Validate` on the pull request it opened, so `ci.yml` needs a `workflow_dispatch` trigger, as the +examples' has. + +The update also points `.claude/settings.json` at the installed release, so the contribution plugin +matches the vendored standard. + +Nothing else changes how your repository works yet. Three new rules only warn, and v0.6.0 will +enforce them. Fix what they report before then: + +- `pnpm standards:check` warns about files the standard owns that your repository changed: + `.githooks/*`, `.codex/hooks.json`, `.agents/plugins/marketplace.json`, + `.github/actions/setup-node-pnpm/action.yml`, and `.editorconfig`, and about a `.prettierrc` file + that replaces `prettier.config.js`. From v0.6.0 the check fails and the update restores the + standard's copies. Make changes to those files in repository-tooling instead. +- `lvbt check contract` warns about a shared catalog entry in `pnpm-workspace.yaml` pinned to a + different version than the standard's catalog. From v0.6.0 the update moves those entries and the + check fails on any that differ. Entries only your repository uses stay yours. +- The commit hook warns about `ci` as a commit type or scope. It behaved exactly like `chore` in + every changelog and release tool; use `chore`. From v0.6.0 the hook rejects `ci`, and the update + removes it from `.lvbt/commit-scopes.txt`. diff --git a/examples/basic/.claude/settings.json b/examples/basic/.claude/settings.json index 8f22028..22dbad4 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.4.5" + "ref": "v0.5.0" } } }, diff --git a/examples/basic/package.json b/examples/basic/package.json index 7140c0b..ed616f4 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -21,8 +21,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/examples/basic/packages/example/package.json b/examples/basic/packages/example/package.json index 9a75f6f..10b4c09 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.4.5", - "@lasvegasfortransit/typescript-config": "0.4.5", - "@lasvegasfortransit/vitest-config": "0.4.5", + "@lasvegasfortransit/eslint-config": "0.5.0", + "@lasvegasfortransit/typescript-config": "0.5.0", + "@lasvegasfortransit/vitest-config": "0.5.0", "@types/node": "catalog:", "eslint": "catalog:", "typescript": "catalog:", diff --git a/examples/with-astro/.claude/settings.json b/examples/with-astro/.claude/settings.json index 8f22028..22dbad4 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.4.5" + "ref": "v0.5.0" } } }, diff --git a/examples/with-astro/apps/site/package.json b/examples/with-astro/apps/site/package.json index b306b65..19ac71d 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.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/examples/with-astro/package.json b/examples/with-astro/package.json index 1e3f895..50671a2 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.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/examples/with-vite-react/.claude/settings.json b/examples/with-vite-react/.claude/settings.json index 8f22028..22dbad4 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.4.5" + "ref": "v0.5.0" } } }, diff --git a/examples/with-vite-react/apps/app/package.json b/examples/with-vite-react/apps/app/package.json index 19b1af2..dc885d2 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.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/examples/with-vite-react/package.json b/examples/with-vite-react/package.json index 503ca61..2199229 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.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/package.json b/package.json index ee7c390..87ef2b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lvbt-repository-tooling", "private": true, - "version": "0.4.5", + "version": "0.5.0", "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 1fa546a..500ac05 100644 --- a/packages/cli/package.json +++ b/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/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json b/packages/cli/plugins/lvbt-contributions/.claude-plugin/plugin.json index 3585f0b..18c099d 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.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/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json b/packages/cli/plugins/lvbt-contributions/.codex-plugin/plugin.json index 445c46a..5c68dc0 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.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/packages/eslint-config/package.json b/packages/eslint-config/package.json index 3eee154..50b0c72 100644 --- a/packages/eslint-config/package.json +++ b/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/packages/playwright-config/package.json b/packages/playwright-config/package.json index adc4ac3..f2108b1 100644 --- a/packages/playwright-config/package.json +++ b/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/packages/prettier-config/package.json b/packages/prettier-config/package.json index 4bd3a61..6428c0f 100644 --- a/packages/prettier-config/package.json +++ b/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/packages/typescript-config/package.json b/packages/typescript-config/package.json index 3f3af9e..da0e97c 100644 --- a/packages/typescript-config/package.json +++ b/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/packages/vitest-config/package.json b/packages/vitest-config/package.json index 5b6d50f..35ab091 100644 --- a/packages/vitest-config/package.json +++ b/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/packages/web-platform/package.json b/packages/web-platform/package.json index ab76a9d..bff470a 100644 --- a/packages/web-platform/package.json +++ b/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",