diff --git a/.github/workflows/publish-standard.yml b/.github/workflows/publish-standard.yml new file mode 100644 index 0000000..1a5a203 --- /dev/null +++ b/.github/workflows/publish-standard.yml @@ -0,0 +1,142 @@ +# 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/publish-template.yml b/.github/workflows/publish-template.yml deleted file mode 100644 index 8fdad6e..0000000 --- a/.github/workflows/publish-template.yml +++ /dev/null @@ -1,134 +0,0 @@ -# Publishes each example to its template repository -# (examples/basic to LasVegasForTransit/template-basic, and so on), so a new -# repository starts from GitHub's "Use this template" button (or -# `gh repo create --template LasVegasForTransit/template-basic`) with no -# command line to remember. Runs on each release and by hand; the examples in -# this repository stay the single source. -# -# Needs a secret TEMPLATE_PUBLISH_TOKEN with contents and pull-request write -# access on the template repositories. Each repository remains protected by -# the organization ruleset and reviews the generated update before merging. -name: Publish template - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: The release tag whose example to publish (for example v0.2.0) - required: true - -permissions: - contents: read - -jobs: - publish: - name: Publish examples/${{ matrix.example }} to template-${{ matrix.example }} - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - fail-fast: false - matrix: - example: [basic, with-astro, with-vite-react] - env: - TAG: ${{ inputs.tag || github.event.release.tag_name }} - EXAMPLE: ${{ matrix.example }} - steps: - - name: Checkout the release - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ env.TAG }} - persist-credentials: false - path: source - - - name: Checkout the template repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: LasVegasForTransit/template-${{ matrix.example }} - token: ${{ secrets.TEMPLATE_PUBLISH_TOKEN }} - path: template - - - 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: source/package.json - - - name: Materialize the released template - id: update - shell: bash - run: | - set -euo pipefail - if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Template publication requires a stable release tag." >&2 - exit 1 - fi - node source/standards/template-publication.ts \ - --source source --target template --example "$EXAMPLE" --release "$TAG" - cd template - git switch -C "automation/repository-standard-${TAG}" - pnpm install --lockfile-only --no-frozen-lockfile - if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then - echo "changed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - printf '%s\n' \ - "chore: publish LVBT repository standard ${TAG}" \ - '' \ - "Refresh this template from the reviewed ${TAG} source." \ - > .git/lvbt-template-commit-message - git restore --staged . && git add -A && \ - git -c user.name='lvbt-bot' -c user.email='noreply@lasvegasfortransit.org' \ - commit -F .git/lvbt-template-commit-message - echo "changed=true" >> "$GITHUB_OUTPUT" - - - name: Push publication branch - if: steps.update.outputs.changed == 'true' - shell: bash - working-directory: template - run: | - set -euo pipefail - BRANCH="automation/repository-standard-${TAG}" - git push --force-with-lease --set-upstream origin "${BRANCH}" - - - name: Open template update for review - if: steps.update.outputs.changed == 'true' - shell: bash - working-directory: template - env: - GH_TOKEN: ${{ secrets.TEMPLATE_PUBLISH_TOKEN }} - run: | - set -euo pipefail - printf '%s\n' \ - '# TL;DR' \ - '' \ - "Updates this repository template to LVBT standard ${TAG}." \ - '' \ - '# Overview of Changes' \ - '' \ - "The files are generated from the reviewed ${TAG} example in repository-tooling." \ - '' \ - '# Follow-ups' \ - '' \ - 'None.' \ - > .git/lvbt-template-pr.md - HELPER=../source/packages/cli/plugins/lvbt-contributions/scripts/github-create.mjs - TITLE="chore: publish LVBT repository standard ${TAG}" - BRANCH="automation/repository-standard-${TAG}" - node "$HELPER" pr \ - --title "$TITLE" \ - --body-file .git/lvbt-template-pr.md --base main --dry-run --json - PR=$(gh pr list --head "$BRANCH" --base main --state open \ - --json number --jq '.[0].number // empty') - if [ -n "$PR" ]; then - gh pr edit "$PR" --title "$TITLE" --body-file .git/lvbt-template-pr.md - else - node "$HELPER" pr \ - --title "$TITLE" \ - --body-file .git/lvbt-template-pr.md --base main --json - fi diff --git a/.github/workflows/standard-status.yml b/.github/workflows/standard-status.yml new file mode 100644 index 0000000..25a31ef --- /dev/null +++ b/.github/workflows/standard-status.yml @@ -0,0 +1,48 @@ +# 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. +name: Standard status + +on: + schedule: + - cron: '47 14 * * *' + workflow_run: + workflows: [Publish standard] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +jobs: + status: + name: Check repositories + 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: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + + - name: Report drift + env: + GH_TOKEN: ${{ steps.bot.outputs.token }} + run: node standards/status.ts diff --git a/AGENTS.md b/AGENTS.md index 6cce08d..aabb949 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,5 +26,6 @@ Commit scopes are optional. The repository's [`.lvbt/commit-scopes.txt`](.lvbt/c file is the complete list of durable boundaries for this repository. Do not invent a scope for a 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. The -publish workflow runs only by hand. +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`. diff --git a/docs/README.md b/docs/README.md index d71b646..3928209 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ 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 2c0832c..0443ab3 100644 --- a/docs/explanation/packages-and-examples.md +++ b/docs/explanation/packages-and-examples.md @@ -49,5 +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, followed by an explicit, -reviewed preset update in each repository. +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. diff --git a/docs/how-to/create-a-repository.md b/docs/how-to/create-a-repository.md index 576fe49..188a88a 100644 --- a/docs/how-to/create-a-repository.md +++ b/docs/how-to/create-a-repository.md @@ -79,9 +79,10 @@ git commit -m "chore(dx): start from the LVBT repository standard" git push ``` -Then add the repository's name to `standards/repositories.json` here so the organization ruleset -applies. A deployable repository also needs the `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` -secrets in a `production` environment before `.github/workflows/deploy.yml` can run. +Then add the repository to `standards/repositories.json` here with `"kind": "consumer"`, so the +organization ruleset applies and every release opens its update pull request. A deployable +repository also needs the `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` secrets in a +`production` environment before `.github/workflows/deploy.yml` can run. ## Common problems diff --git a/docs/how-to/publish-a-release.md b/docs/how-to/publish-a-release.md index 94bf727..fda5618 100644 --- a/docs/how-to/publish-a-release.md +++ b/docs/how-to/publish-a-release.md @@ -33,19 +33,28 @@ git push origin main v0.3.0 ``` Create the GitHub release from the tag with `gh release create v0.3.0 --generate-notes`, then edit -the notes so the first line says what changes for a repository that updates. +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. -Publishing the release runs the `Publish template` workflow, which prepares each example at that tag -in a protected template repository (`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`). Each generated update uses a release-specific branch and pull request; -the organization ruleset still guards `main`. Merge those reviews after their `Validate` checks -pass. These repositories power GitHub's "Use this template" button. +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-`: -The workflow requires the repositories to be marked as templates and a `TEMPLATE_PUBLISH_TOKEN` -secret with contents and pull-request write access to all three. Manual dispatch accepts stable -release tags only; development commits and prerelease tags do not publish templates. +- Each 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 + 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. + +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. ## 3. Publish to GitHub Packages @@ -53,7 +62,20 @@ Trigger the `Publish packages` workflow with the release tag. It publishes every `npm.pkg.github.com` under the `@lasvegasfortransit` scope. Contributors authenticate their local pnpm configuration to GitHub Packages; CI uses its repository token. -## 4. Update the repositories +## 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`. + +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 +rule for a while records an exception in `standards/repositories.json` with the rule, a reason, and +an expiry date. -Renovate opens one grouped pull request titled "LVBT repository standard" in each repository. Review -it, run `pnpm check`, and merge. +Do not cut a release to unblock one repository. Try an unreleased change there with +`pnpm standards:update --commit ` on a branch that is never merged; `Standard status` flags +`release: null` on `main`. diff --git a/docs/how-to/set-up-the-standard-bot.md b/docs/how-to/set-up-the-standard-bot.md new file mode 100644 index 0000000..0ea0b0d --- /dev/null +++ b/docs/how-to/set-up-the-standard-bot.md @@ -0,0 +1,87 @@ +# 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 c6cd27a..69eded6 100644 --- a/docs/reference/examples.md +++ b/docs/reference/examples.md @@ -67,6 +67,6 @@ adds the task to any repository that gains an Astro package. ## Adding an example Copy `examples/basic`, change what the profile needs, and add it to the table above, to the -`examples` map in `tests/example.test.mjs` so it is proven on every check, and to the matrix in -`.github/workflows/publish-template.yml`. Keep the standard scripts and the catalog identical across -examples. +`examples` map in `tests/example.test.mjs` so it is proven on every check, and to +`standards/repositories.json` as a `template` with its `example`, so each release publishes it. Keep +the standard scripts and the catalog identical across examples. diff --git a/docs/reference/release-0-2-9.md b/docs/reference/release-0-2-9.md new file mode 100644 index 0000000..64c1346 --- /dev/null +++ b/docs/reference/release-0-2-9.md @@ -0,0 +1,11 @@ +# Repository tooling 0.2.9 + +The template repositories now vendor the standard in `.lvbt/web-platform/`, the same way every other +repository does, instead of installing the shared packages from a git tag. A repository created from +a template therefore validates without network access or a registry token. + +`pnpm provision` also recognizes Cloudflare Web Analytics sites that report their hostname at the +top level of the API response, so a site created by the current dashboard is no longer provisioned +twice. Nothing changes for a repository that updates. + +These notes were written after the release, which shipped without them. diff --git a/docs/reference/release-0-4-5.md b/docs/reference/release-0-4-5.md new file mode 100644 index 0000000..31fb9cc --- /dev/null +++ b/docs/reference/release-0-4-5.md @@ -0,0 +1,16 @@ +# Repository tooling 0.4.5 + +A repository's CI can now install private `@lasvegasfortransit/*` packages from GitHub Packages. The +examples' `.github/actions/setup-node-pnpm/action.yml` points `actions/setup-node` at +`https://npm.pkg.github.com` for the `@lasvegasfortransit` scope and passes the workflow token as +`NODE_AUTH_TOKEN` during `pnpm install`. + +That action is a file the repository owns, so `pnpm standards:update` does not change it. A +repository that installs `@lasvegasfortransit/analytics` or another private package in CI copies the +`registry-url`, `scope`, and `NODE_AUTH_TOKEN` lines from the example's action. + +The production setup guides also gained the last owner-verified steps for Cloudflare Access and +Google sign-in: check the Cloudflare account switcher for the correctly spelled account name, and +turn PKCE back off only if the Access login test fails with a code-verifier error. + +These notes were written after the release, which shipped without them. diff --git a/docs/superpowers/plans/2026-09-01-repository-standardization.md b/docs/superpowers/plans/2026-09-01-repository-standardization.md index 7a86b41..c050a54 100644 --- a/docs/superpowers/plans/2026-09-01-repository-standardization.md +++ b/docs/superpowers/plans/2026-09-01-repository-standardization.md @@ -1,5 +1,10 @@ # LVBT repository standardization plan (mass-volunteer readiness) +> **Superseded (2026-09-27).** The status section below stopped tracking releases after v0.2.2. +> Releases now reach every repository through the `Publish standard` workflow, and the +> `Standard status` workflow reports drift; see +> [Publish a tooling release](../../how-to/publish-a-release.md). + ## Status (2026-09-02) Phase 0 is released: PR #11 merged (squash), then #12 and #13 (rebase) as patch releases; the diff --git a/package.json b/package.json index 05bd35f..ee7c390 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node --import tsx --test --test-concurrency=1 'tests/**/*.test.mjs' && pnpm --filter @lasvegasfortransit/web-platform test", - "prepare": "git config --local core.hooksPath .githooks 2>/dev/null || true" + "prepare": "git config --local core.hooksPath .githooks 2>/dev/null || true", + "standards:status": "node standards/status.ts" }, "devDependencies": { "@astrojs/check": "catalog:", diff --git a/standards/propagate.ts b/standards/propagate.ts new file mode 100644 index 0000000..37f4eca --- /dev/null +++ b/standards/propagate.ts @@ -0,0 +1,436 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, realpathSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +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-'; +const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +export interface RegistryEntry { + name: string; + requiredStatus: string; + kind: 'source' | 'template' | 'consumer'; + example?: string; +} + +export interface Registry { + version: number; + repositories: RegistryEntry[]; + exceptions: RegistryException[]; +} + +export interface RegistryException { + repository: string; + rule: string; + reason: string; + expires: string; +} + +export interface OpenUpdate { + number: number; + headRefName: string; +} + +export async function readRegistry( + file = path.join(import.meta.dirname, 'repositories.json'), +): Promise { + return JSON.parse(await readFile(file, 'utf8')) as Registry; +} + +/** Repositories that receive each release: every template and consumer, never the source. */ +export function propagationTargets(registry: Registry): RegistryEntry[] { + return registry.repositories.filter(({ kind }) => kind !== 'source'); +} + +export function parseRelease(tag: string): [number, number, number] { + const match = STABLE_TAG.exec(tag); + if (!match) throw new Error(`Propagation requires a stable release tag, not ${tag}.`); + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +export function compareReleases(left: string, right: string): number { + const a = parseRelease(left); + const b = parseRelease(right); + for (let index = 0; index < 3; index += 1) { + const difference = (a[index] ?? 0) - (b[index] ?? 0); + if (difference !== 0) return difference; + } + return 0; +} + +export function latestRelease(tags: string[]): string | undefined { + return tags + .filter((tag) => STABLE_TAG.test(tag)) + .sort(compareReleases) + .at(-1); +} + +export function updateBranch(tag: string): string { + parseRelease(tag); + return `${BRANCH_PREFIX}${tag}`; +} + +/** + * Decides what to do with the update pull requests already open in a repository. Older releases + * are closed as superseded; a newer one means this release must not be proposed at all. + */ +export function planOpenUpdates(open: OpenUpdate[], tag: string) { + const current = updateBranch(tag); + const superseded: OpenUpdate[] = []; + const newer: OpenUpdate[] = []; + for (const update of open) { + if (!update.headRefName.startsWith(BRANCH_PREFIX) || update.headRefName === current) continue; + const release = update.headRefName.slice(BRANCH_PREFIX.length); + if (!STABLE_TAG.test(release)) continue; + if (compareReleases(release, tag) < 0) superseded.push(update); + else newer.push(update); + } + return { superseded, newer }; +} + +export function releaseNotesPath(tag: string): string { + return `docs/reference/release-${tag.slice(1).replaceAll('.', '-')}.md`; +} + +export function pullRequestTitle(tag: string): string { + return `chore: update LVBT repository standard to ${tag}`; +} + +export function pullRequestBody(options: { + tag: string; + kind: RegistryEntry['kind']; + hasNotes: boolean; +}): string { + const { tag, kind, hasNotes } = options; + const source = `https://github.com/${OWNER}/repository-tooling`; + const notes = hasNotes + ? `The [release notes](${source}/blob/${tag}/${releaseNotesPath(tag)}) say what changes for a repository that updates.` + : `The [${tag} release](${source}/releases/tag/${tag}) describes what changed.`; + const change = + kind === 'template' + ? `The files are generated from the reviewed ${tag} example in repository-tooling.` + : `The vendored standard in \`.lvbt/web-platform/\` is replaced by ${tag}, and the release's own migrations update the files it manages.`; + return [ + '# TL;DR', + '', + `Moves this repository to LVBT repository standard ${tag}.`, + '', + '# Overview of Changes', + '', + `${change} ${notes}`, + '', + '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.', + '', + '# Follow-ups', + '', + 'None.', + '', + ].join('\n'); +} + +export function commitMessage(tag: string): string { + return `${pullRequestTitle(tag)}\n\nApply the reviewed ${tag} release with its own updater.\n`; +} + +type Runner = (command: string, args: string[], cwd: string) => string; + +const run: Runner = (command, args, cwd) => + execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }).trim(); + +async function currentRelease(target: string): Promise { + const file = path.join(target, '.lvbt/web-platform.json'); + if (!existsSync(file)) return null; + const { release } = JSON.parse(await readFile(file, 'utf8')) as { release: string | null }; + return release; +} + +/** + * Applies a release to one checked-out repository with the release's own scripts and commits the + * result on the release's update branch. Returns false when the repository already matches. + */ +export async function applyRelease(options: { + source: string; + target: string; + entry: RegistryEntry; + tag: string; + install?: boolean; + runner?: Runner; +}): Promise<{ changed: boolean; reason?: string }> { + const { entry, tag, install = true, runner = run } = options; + // A release's scripts only run as entry points when invoked by their real path. + const source = realpathSync(options.source); + const target = realpathSync(options.target); + parseRelease(tag); + const release = await currentRelease(target); + if (release && STABLE_TAG.test(release) && compareReleases(release, tag) > 0) { + return { changed: false, reason: `${entry.name} is already on ${release}, newer than ${tag}.` }; + } + + if (entry.kind === 'template') { + 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') { + runner( + 'node', + [ + path.join(source, 'standards/web-platform-cli.ts'), + 'update', + '--root', + target, + '--source', + source, + '--release', + tag, + '--apply', + '--json', + ], + target, + ); + } else throw new Error(`${entry.name} does not receive releases.`); + + if (install) runner('pnpm', ['install', '--lockfile-only', '--no-frozen-lockfile'], target); + if (!runner('git', ['status', '--porcelain'], target)) return { changed: false }; + + 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( + 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. */ +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; +} + +/** + * 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; + 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; + } +} diff --git a/standards/repositories.json b/standards/repositories.json index 772cc3c..39f6380 100644 --- a/standards/repositories.json +++ b/standards/repositories.json @@ -3,43 +3,56 @@ "repositories": [ { "name": ".github", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "analytics", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "labs", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "repository-tooling", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "source" }, { "name": "template-basic", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "template", + "example": "basic" }, { "name": "template-with-astro", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "template", + "example": "with-astro" }, { "name": "template-with-vite-react", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "template", + "example": "with-vite-react" }, { "name": "transit-mapper", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "website", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" }, { "name": "week-without-driving", - "requiredStatus": "Validate" + "requiredStatus": "Validate", + "kind": "consumer" } ], "exceptions": [] diff --git a/standards/status.ts b/standards/status.ts new file mode 100644 index 0000000..60050bf --- /dev/null +++ b/standards/status.ts @@ -0,0 +1,270 @@ +import { execFileSync } from 'node:child_process'; +import { appendFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { + BRANCH_PREFIX, + OWNER, + type Registry, + type RegistryEntry, + compareReleases, + latestRelease, + propagationTargets, + readRegistry, +} from './propagate.ts'; + +/** Days a repository may trail the latest release while its update pull request runs. */ +export const GRACE_DAYS = 3; + +export 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>; + rulesets: string[]; + updates: { number: number; headRefName: string; failing: boolean }[]; +} + +export interface Finding { + repository: string; + rule: string; + message: string; +} + +export interface Release { + tag: string; + date: string; +} + +/** Days since the first release the repository has not adopted, or 0 when it is current. */ +export function daysBehind(release: string, releases: Release[], now: number): number { + const missed = releases + .filter(({ tag }) => compareReleases(tag, release) > 0) + .sort((left, right) => compareReleases(left.tag, right.tag))[0]; + return missed ? (now - Date.parse(missed.date)) / 86_400_000 : 0; +} + +/** + * Compares one repository with the releases and the organization settings. Pure, so the rules are + * tested without GitHub. + */ +export function findings(state: RepositoryState, releases: Release[], now: number): Finding[] { + const found: Finding[] = []; + const add = (rule: string, message: string) => + found.push({ repository: state.name, rule, message }); + const latest = latestRelease(releases.map(({ tag }) => tag)); + + if (state.release === null) { + add('release', 'main vendors an unreleased commit (`release: null`); merge a tagged release.'); + } else { + const days = daysBehind(state.release, releases, now); + if (days > GRACE_DAYS) + add( + 'release', + `on ${state.release} and behind ${latest ?? 'the latest release'} for ${Math.floor(days)} days.`, + ); + } + if (state.release && state.pluginRef !== state.release) { + add( + 'plugin-ref', + `.claude/settings.json pins the contribution plugin to ${state.pluginRef ?? 'nothing'}, not ${state.release}.`, + ); + } + 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.rulesets.includes('org-standard')) + add('ruleset', 'the org-standard ruleset is missing.'); + for (const update of state.updates) { + if (update.failing) add('update', `update pull request #${update.number} is failing Validate.`); + } + return found; +} + +/** Removes findings covered by an unexpired, documented exception in repositories.json. */ +export function applyExceptions(found: Finding[], registry: Registry, today: string): Finding[] { + return found.filter( + (finding) => + !registry.exceptions.some( + (exception) => + exception.repository === finding.repository && + exception.rule === finding.rule && + exception.expires >= today, + ), + ); +} + +export function report(states: RepositoryState[], latest: string, open: Finding[]): string { + const lines = [ + `# Repository standard status`, + '', + `Latest release: **${latest}**.`, + '', + '| Repository | Release | Open update | Findings |', + '| --- | --- | --- | --- |', + ]; + for (const state of states) { + const mine = open.filter(({ repository }) => repository === state.name); + const updates = state.updates.map(({ number }) => `#${number}`).join(', ') || '—'; + const summary = mine.map(({ message }) => message).join('
') || 'none'; + lines.push(`| ${state.name} | ${state.release ?? 'unreleased'} | ${updates} | ${summary} |`); + } + lines.push(''); + return lines.join('\n'); +} + +const gh = (args: string[]) => + execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + +function readRaw(repository: string, file: string): string | null { + try { + return gh([ + 'api', + `repos/${OWNER}/${repository}/contents/${file}`, + '-H', + 'Accept: application/vnd.github.raw', + ]); + } catch { + return null; + } +} + +export function pluginRef(settings: string | null): string | null { + if (!settings) return null; + const parsed = JSON.parse(settings) as { + extraKnownMarketplaces?: Record; + }; + const marketplace = Object.values(parsed.extraKnownMarketplaces ?? {}).find( + ({ source }) => source?.repo === `${OWNER}/repository-tooling`, + ); + return marketplace?.source?.ref ?? null; +} + +function readState(entry: RegistryEntry): RepositoryState { + const manifest = readRaw(entry.name, '.lvbt/web-platform.json'); + const release = manifest ? (JSON.parse(manifest) as { release: string | null }).release : null; + const 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); + const pulls = JSON.parse( + gh([ + 'pr', + 'list', + '--repo', + `${OWNER}/${entry.name}`, + '--state', + 'open', + '--json', + 'number,headRefName,statusCheckRollup', + '--limit', + '100', + ]), + ) as { + number: number; + headRefName: string; + statusCheckRollup: { name?: string; context?: string; conclusion?: string; state?: string }[]; + }[]; + const updates = pulls + .filter(({ headRefName }) => headRefName.startsWith(BRANCH_PREFIX)) + .map(({ number, headRefName, statusCheckRollup }) => ({ + number, + headRefName, + failing: statusCheckRollup.some( + (check) => + (check.name ?? check.context) === entry.requiredStatus && + ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT'].includes( + check.conclusion ?? check.state ?? '', + ), + ), + })); + return { + name: entry.name, + release, + pluginRef: pluginRef(readRaw(entry.name, '.claude/settings.json')), + settings: Object.fromEntries( + Object.keys(SETTINGS).map((key) => [key, settings[key as keyof typeof SETTINGS]]), + ), + rulesets, + updates, + }; +} + +function readReleases(): Release[] { + const query = `query { + repository(owner: "${OWNER}", name: "repository-tooling") { + refs(refPrefix: "refs/tags/", first: 100) { + nodes { + name + target { + ... on Commit { committedDate } + ... on Tag { target { ... on Commit { committedDate } } } + } + } + } + } + }`; + const nodes = ( + JSON.parse(gh(['api', 'graphql', '-f', `query=${query}`])) as { + data: { + repository: { + refs: { + nodes: { + name: string; + target: { committedDate?: string; target?: { committedDate?: string } }; + }[]; + }; + }; + }; + } + ).data.repository.refs.nodes; + return nodes + .filter(({ name }) => /^v\d+\.\d+\.\d+$/.test(name)) + .map(({ name, target }) => ({ + tag: name, + date: target.committedDate ?? target.target?.committedDate ?? '', + })); +} + +export async function main(args: string[]): Promise { + const { values } = parseArgs({ args, options: { json: { type: 'boolean' } } }); + const registry = await readRegistry(); + const releases = readReleases(); + const latest = latestRelease(releases.map(({ tag }) => tag)); + if (!latest) throw new Error('repository-tooling has no stable release tag.'); + const states = propagationTargets(registry).map(readState); + const now = Date.now(); + const open = applyExceptions( + states.flatMap((state) => findings(state, releases, now)), + registry, + new Date(now).toISOString().slice(0, 10), + ); + const output = values.json + ? `${JSON.stringify({ latest, states, findings: open }, null, 2)}\n` + : report(states, latest, open); + process.stdout.write(output); + if (process.env.GITHUB_STEP_SUMMARY) + await appendFile(process.env.GITHUB_STEP_SUMMARY, report(states, latest, open)); + if (open.length > 0) process.exitCode = 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(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/tests/propagate.test.mjs b/tests/propagate.test.mjs new file mode 100644 index 0000000..3a37f04 --- /dev/null +++ b/tests/propagate.test.mjs @@ -0,0 +1,193 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + applyRelease, + compareReleases, + latestRelease, + planOpenUpdates, + propagationTargets, + pullRequestBody, + pullRequestTitle, + readRegistry, + updateBranch, +} from '../standards/propagate.ts'; +import { materializeTemplate } from '../standards/template-publication.ts'; + +const root = path.resolve(import.meta.dirname, '..'); +const git = (directory, ...args) => + execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8' }).trim(); + +test('every template and consumer receives each release, and the source does not', async () => { + const registry = await readRegistry(); + const targets = propagationTargets(registry); + assert.ok(!targets.some(({ name }) => name === 'repository-tooling')); + assert.equal(targets.length, registry.repositories.length - 1); + for (const entry of targets) { + if (entry.kind === 'template') assert.equal(entry.name, `template-${entry.example}`); + else assert.equal(entry.kind, 'consumer'); + } +}); + +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); + assert.equal(latestRelease(['v0.4.5', 'v0.3.0-rc.15', 'v0.10.1', 'v0.4.10']), 'v0.10.1'); + assert.throws(() => updateBranch('v0.3.0-rc.1'), /stable release tag/); +}); + +test('an update supersedes older update pull requests and yields to newer ones', () => { + const open = [ + { number: 1, headRefName: 'automation/repository-standard-v0.4.3' }, + { number: 2, headRefName: 'automation/repository-standard-v0.4.5' }, + { number: 3, headRefName: 'automation/repository-standard-v0.5.0' }, + { number: 4, headRefName: 'feat/something-else' }, + ]; + const plan = planOpenUpdates(open, 'v0.4.5'); + assert.deepEqual( + plan.superseded.map(({ number }) => number), + [1], + ); + assert.deepEqual( + plan.newer.map(({ number }) => number), + [3], + ); +}); + +test('the update pull request follows the organization template', () => { + const body = pullRequestBody({ tag: 'v0.4.5', kind: 'consumer', hasNotes: true }); + assert.deepEqual( + [...body.matchAll(/^# (.+)$/gm)].map(([, heading]) => heading), + ['TL;DR', 'Overview of Changes', 'Follow-ups'], + ); + assert.match(body, /release-0-4-5\.md/); + assert.match(body, /merges itself once `Validate` passes/); + assert.doesNotMatch(body, /