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/.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/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/.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/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/.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/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/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-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/.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/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/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/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/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/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", 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)`, + ]); + }));