diff --git a/.devtools/check-branch-name.sh b/.devtools/check-branch-name.sh index 883e628..09e4198 100755 --- a/.devtools/check-branch-name.sh +++ b/.devtools/check-branch-name.sh @@ -4,8 +4,8 @@ # Usage: check-branch-name.sh [branch] # # The branch is the argument, then $BRANCH_NAME, then the current branch. The -# variable exists for the `conventional-branch` pre-commit hook: pre-commit cannot -# pass a per-run argument, and a guard checking `git switch -c ` has to +# variable exists for the `branch-name` lefthook job: a hook cannot be given a +# per-run argument, and a guard checking `git switch -c ` has to # validate a branch that does not exist yet. set -euo pipefail diff --git a/.devtools/check-conventional-commit.sh b/.devtools/check-conventional-commit.sh deleted file mode 100755 index 2c02d36..0000000 --- a/.devtools/check-conventional-commit.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# Validates a message against Conventional Commits 1.0.0. -# https://www.conventionalcommits.org/en/v1.0.0/ -# -# Usage: -# check-conventional-commit.sh "feat(api): add pagination" -# check-conventional-commit.sh --file .git/COMMIT_EDITMSG -# -# ci-pr runs this on the pull request title and on every commit; the -# `conventional-commit` pre-commit hook runs it on the message being written. One -# script, so a message that passes the git hook passes CI and vice versa. -set -euo pipefail - -TYPES='build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test' - -label="commit message" -message="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --file) - message="$(head -n 1 "$2")" - shift 2 - ;; - --label) - label="$2" - shift 2 - ;; - *) - message="$1" - shift - ;; - esac -done - -# Only the first line is the subject; a body may say anything. -message="${message%%$'\n'*}" - -# Merges and reverts are generated by GitHub and git, not written by a person. -if [[ "${message}" =~ ^(Merge|Revert)\ ]]; then - exit 0 -fi - -# type(optional-scope)!: description -if [[ "${message}" =~ ^(${TYPES})(\([a-z0-9][a-z0-9._-]*\))?!?:\ .+ ]]; then - exit 0 -fi - -cat >&2 <[(scope)][!]: - Types: ${TYPES//|/, } - Example: feat(api): add cursor pagination - fix!: drop support for Node 18 - - Spec: https://www.conventionalcommits.org/en/v1.0.0/ -MSG -exit 1 diff --git a/.devtools/check-pr-size.sh b/.devtools/check-pr-size.sh index 049009f..09a531d 100755 --- a/.devtools/check-pr-size.sh +++ b/.devtools/check-pr-size.sh @@ -4,7 +4,7 @@ # Usage: check-pr-size.sh [--base ] [--head ] [--max ] # # Two callers, one script. ci-pr passes both ends of the pull request. The -# `pr-size` pre-commit hook passes nothing, so the base is worked out here: +# `pr-size` lefthook job passes nothing, so the base is worked out here: # $PR_BASE, then the branch below this one in a gh stack, then the remote's # default branch. A default branch that was worked out is read from origin, since # the local copy only moves on a pull. diff --git a/.devtools/test-hooks.sh b/.devtools/test-hooks.sh index 0ba78ef..cc66b2f 100755 --- a/.devtools/test-hooks.sh +++ b/.devtools/test-hooks.sh @@ -57,17 +57,7 @@ expect pass "falls back to the current branch" "${branch}" expect fail "reads BRANCH_NAME" env BRANCH_NAME=Bad_Name "${branch}" expect pass "prefers the argument over BRANCH_NAME" env BRANCH_NAME=Bad_Name "${branch}" fix/login-timeout -echo "check-conventional-commit" -commit="${here}/check-conventional-commit.sh" -expect pass "accepts a scoped subject" "${commit}" 'feat(api): add cursor pagination' -expect pass "accepts a breaking change" "${commit}" 'fix!: drop support for Node 18' -expect pass "accepts a generated merge" "${commit}" "Merge branch 'main' into feat/x" -expect fail "rejects an unknown type" "${commit}" 'feature: add pagination' -expect fail "rejects a missing description" "${commit}" 'feat:' printf 'fix(devtools): handle an empty diff\n\nA body may say anything at all.\n' >"${work}/MSG_OK" -printf 'handled an empty diff\n' >"${work}/MSG_BAD" -expect pass "reads a good message with --file" "${commit}" --file "${work}/MSG_OK" -expect fail "reads a bad message with --file" "${commit}" --file "${work}/MSG_BAD" # The repo fixture has no .commitlintrc.yaml, so these run on config-conventional alone. echo "pr-checks/lint.mjs" diff --git a/.github/workflows/ci-lint.yml b/.github/workflows/ci-lint.yml index 92c1643..ec88a4c 100644 --- a/.github/workflows/ci-lint.yml +++ b/.github/workflows/ci-lint.yml @@ -49,9 +49,8 @@ permissions: {} jobs: lint: - # Both lint workflows name this job `hooks`, so the check a ruleset requires is - # ` / hooks` whichever one a repository calls, and moving from - # ci-pre-commit to ci-lint does not strand a pull request waiting on the old name. + # The rulesets require `lint / hooks`: the caller's job, then this name. Renaming + # it strands every open pull request waiting on a check that never reports. name: hooks runs-on: ubuntu-latest timeout-minutes: ${{ inputs.timeout-minutes }} diff --git a/.github/workflows/ci-pre-commit.yml b/.github/workflows/ci-pre-commit.yml deleted file mode 100644 index 1f4f735..0000000 --- a/.github/workflows/ci-pre-commit.yml +++ /dev/null @@ -1,137 +0,0 @@ -name: ci-pre-commit - -# Reusable: runs a repo's own pre-commit hooks over the whole tree. -# -# The other lint workflows here (ci-workflows, ci-gitleaks) exist for repos that -# have no pre-commit config: they download actionlint, zizmor, shellcheck and -# gitleaks and run them directly. A repo that *does* keep a .pre-commit-config.yaml -# already pins those same tools, by frozen SHA, next to a dozen others -- so calling -# both would run four tools twice at two sets of version numbers that drift apart. -# Call this one instead, and the config file stays the single source of truth for -# what lints and at which version. -# -# That is also what makes local and CI results match: this runs the same command a -# developer runs, so `make lint` (or whatever the repo calls it) is genuinely proven -# here rather than approximated. -# -# uv is fetched as a pinned release tarball rather than through setup-uv, for the -# same reason the linters here are: one fewer action to trust, and the version is -# visible in this file. -# -# jobs: -# lint: -# uses: studiobimo/.github/.github/workflows/ci-pre-commit.yml@v1 -# with: -# uv-project: .devtools -# skip: spotless - -on: - workflow_call: - inputs: - uv-project: - description: >- - Directory holding the pyproject.toml and uv.lock that pin pre-commit, run as - `uv --project run pre-commit`. Empty fetches pre-commit standalone with - uvx at `pre-commit-version`. - type: string - required: false - default: '' - pre-commit-version: - description: pre-commit release to use when `uv-project` is empty. - type: string - required: false - default: '4.6.2' - uv-version: - description: uv release to download. - type: string - required: false - default: '0.12.17' - skip: - description: >- - Comma-separated hook ids to skip, passed as pre-commit's SKIP. Use it for hooks - another job already covers -- a Gradle `check` that runs spotlessCheck, say -- - rather than dragging a whole toolchain into the lint job. - type: string - required: false - default: '' - extra-args: - description: Extra arguments appended to `pre-commit run`. - type: string - required: false - default: '' - timeout-minutes: - description: Job timeout. Hook environments build from source on a cold cache. - type: number - required: false - default: 20 - -permissions: {} - -jobs: - pre-commit: - # Both lint workflows name this job `hooks`, so the check a ruleset requires is - # ` / hooks` whichever one a repository calls, and moving from - # ci-pre-commit to ci-lint does not strand a pull request waiting on the old name. - name: hooks - runs-on: ubuntu-latest - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: read - # Pushing again to a pull request supersedes the run in flight, so it is cancelled rather - # than billed to the end. Only pull requests cancel: a push to the default branch, a - # merge group and a schedule always finish. `github.workflow` is the caller's name, and - # the library suffix keeps this group distinct from any the caller declares itself. - concurrency: - group: ci-pre-commit-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - env: - UV_VERSION: ${{ inputs.uv-version }} - UV_PROJECT: ${{ inputs.uv-project }} - PRE_COMMIT_VERSION: ${{ inputs.pre-commit-version }} - EXTRA_ARGS: ${{ inputs.extra-args }} - # pre-commit reads this itself; empty means skip nothing. - SKIP: ${{ inputs.skip }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install uv - run: | - mkdir -p "${HOME}/.local/bin" - curl -fsSL \ - "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz --strip-components=1 -C "${HOME}/.local/bin" \ - uv-x86_64-unknown-linux-gnu/uv uv-x86_64-unknown-linux-gnu/uvx - # GITHUB_PATH only takes effect in later steps, so the sanity check here - # has to use the absolute path. - echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" - "${HOME}/.local/bin/uv" --version - - # Hook environments are the slow part -- several of them compile or fetch a - # toolchain. They are keyed on the config, so any `rev:` bump rebuilds them and - # nothing stale is ever reused. - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ~/.cache/pre-commit - ~/.cache/uv - key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} - restore-keys: | - pre-commit-${{ runner.os }}- - - - name: Run the hooks - run: | - if [ -n "${UV_PROJECT}" ]; then - echo "Using pre-commit pinned by ${UV_PROJECT}" - uv --project "${UV_PROJECT}" sync --frozen - set -- uv --project "${UV_PROJECT}" run pre-commit - else - echo "Using pre-commit ${PRE_COMMIT_VERSION} via uvx" - set -- uvx "pre-commit@${PRE_COMMIT_VERSION}" - fi - - # --show-diff-on-failure turns "a formatter would change this" from a bare - # exit code into the actual patch, which is the whole fix in the log. - # shellcheck disable=SC2086 # EXTRA_ARGS is a deliberate argument list - "$@" run --all-files --show-diff-on-failure --color always ${EXTRA_ARGS} diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml deleted file mode 100644 index 36e55cd..0000000 --- a/.pre-commit-hooks.yaml +++ /dev/null @@ -1,40 +0,0 @@ -# Makes this repository a pre-commit hook repo: the same scripts ci-pr.yml runs on a -# pull request, offered as git hooks so a problem surfaces before the push rather -# than in review. A consumer pins `rev:` to a release SHA and Dependabot's -# pre-commit ecosystem bumps it. -# -# `language: script` runs the entry straight from pre-commit's clone of this repo, -# with the consumer's repository as the working directory. The scripts need bash and -# git; pr-size also uses gh and jq when they are present, to find a stack's parent. - -- id: conventional-commit - name: conventional commit message - description: The commit subject is a Conventional Commit (https://www.conventionalcommits.org/en/v1.0.0/). - entry: .devtools/check-conventional-commit.sh - # pre-commit appends the path of the message file, which is what --file reads. - args: [--file] - language: script - stages: [commit-msg] - -- id: conventional-branch - name: conventional branch name - description: >- - The current branch follows Conventional Branch (https://conventionalbranch.org/). - Set BRANCH_NAME to check a name that is not checked out yet. - entry: .devtools/check-branch-name.sh - language: script - pass_filenames: false - always_run: true - stages: [pre-push, manual] - -- id: pr-size - name: PR size - description: >- - The branch changes no more files than a pull request may contain (PR_MAX_FILES, - default 20). The base is PR_BASE, else the branch below in a gh stack, else the - default branch. - entry: .devtools/check-pr-size.sh - language: script - pass_filenames: false - always_run: true - stages: [pre-push, manual] diff --git a/AGENTS.md b/AGENTS.md index ba7a708..29b5cf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,7 @@ Tooling: mise pins every tool (`mise.toml`, `mise.lock`), lefthook runs the hook `@commitlint/config-conventional` plus this repo's scopes (`.commitlintrc.yaml`). pnpm and `package.json` exist only for semantic-release. This repo releases with semantic-release, not release-please like the projects: it has no version file or changelog to maintain, and `release.yml` has to move the -floating `v1` tag. `.pre-commit-hooks.yaml` and `ci-pre-commit.yml` are deprecated and stay -until no consumer uses them. +floating `v1` tag. ## Non-negotiables diff --git a/README.md b/README.md index 5cb74d8..66d5b23 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,16 @@ everywhere at once. | `.github/workflows/ci-go.yml` | Go modules, formatting, golangci-lint, race tests, coverage, govulncheck and build | | `.github/workflows/ci-workflows.yml` | actionlint, zizmor and shellcheck over a repo's own workflows | | `.github/workflows/ci-lint.yml` | A repo's own lefthook hooks, over the whole tree, with the tools mise pins | -| `.github/workflows/ci-pre-commit.yml` | Deprecated, use `ci-lint.yml`. A repo's own pre-commit hooks | | `.github/workflows/ci-gitleaks.yml` | Secret scan of what a pull request adds | | `.github/workflows/ci-template-drift.yml` | Compares a repo with the project template and tracks the difference in one issue | | `.github/workflows/release-please.yml` | Grooms a release pull request from Conventional Commits, then tags and releases | | `.github/workflows/publish-mod.yml` | Builds, attests and publishes a Minecraft mod to Modrinth, CurseForge and GitHub | -A repo with a `.pre-commit-config.yaml` should call `ci-pre-commit.yml` **instead of** -`ci-workflows.yml` and `ci-gitleaks.yml`, not alongside them. Those two download actionlint, -zizmor, shellcheck and gitleaks for repos that have no pre-commit config; a repo that has one -already pins the same four by frozen `rev:`, so calling both runs them twice at two sets of -version numbers that drift. One source of truth per repo. +A repo with a `lefthook.yml` should call `ci-lint.yml` **instead of** `ci-workflows.yml` and +`ci-gitleaks.yml`, not alongside them. Those two download actionlint, zizmor, shellcheck and +gitleaks for repos that have no hooks of their own; a repo that has them already pins the same +four in `mise.toml`, so calling both runs them twice at two sets of version numbers that drift. +One source of truth per repo. Every `ci-*` file is `on: workflow_call` — library code, not something that runs on this repo's own pull requests. The `self-*` files are this repo's thin callers of them, which is how the @@ -55,18 +54,23 @@ tool with [mise](https://mise.jdx.dev/). A repository's own `lefthook.yml` holds The org's own rules come from here. [`lefthook/org.yml`](lefthook/org.yml) checks the branch name and the pull request size on push, with the scripts `ci-pr.yml` runs, so what passes locally -passes on the pull request. A repository pulls it in by tag: +passes on the pull request. A repository pulls it in at the floating `v1` tag, the one the +workflows are called at: ```yaml # lefthook.yml remotes: - git_url: https://github.com/studiobimo/.github - ref: v1.9.0 + ref: v1 + refetch_frequency: 24h configs: - lefthook/org.yml ``` -lefthook fetches a remote once per `ref`, so pin a release tag there, never the floating `v1`. +lefthook fetches a remote once per `ref` unless it is told to look again, so `refetch_frequency` +is what lets a moved `v1` reach the hooks: at most once a day. A fix here then lands in the hooks +and in CI together. `remotes` is only read from a repository's root `lefthook.yml`, not from a file +it `extends`. Two environment variables make the checks usable from a script, such as an agent guard that has to judge a command before it runs: @@ -84,38 +88,11 @@ PR_BASE=main lefthook run pre-push --job pr-size whose remote is gone and whose work is in: the tip is an ancestor of the default branch, or, for a squash merge, `gh` reports a merged pull request with exactly that head. A branch checked out in any worktree, or one it cannot show to be merged, is kept and named. Run it with `--dry-run` to -see what it would do. A repository gets the hook once it bumps `ref` and runs `lefthook install`. +see what it would do. A repository gets the hook the next time it runs `lefthook install`. Commit messages are not part of `org.yml`: each repository runs commitlint against its own `.commitlintrc.yaml` in a `commit-msg` job, and `ci-pr.yml` reads the same file. -## Pre-commit hooks (deprecated) - -This repo is still a [pre-commit](https://pre-commit.com/) hook repo for the repositories that -have not moved to lefthook yet. It goes away when the last one has. - -| Hook id | Stage | What it checks | -| --------------------- | -------------------- | -------------------------------------------------------------- | -| `conventional-commit` | `commit-msg` | The commit subject is a Conventional Commit | -| `conventional-branch` | `pre-push`, `manual` | The branch name follows Conventional Branch | -| `pr-size` | `pre-push`, `manual` | The branch changes at most 20 files against the layer below it | - -```yaml -# .pre-commit-config.yaml -default_install_hook_types: [pre-commit, commit-msg, pre-push] -repos: - - repo: https://github.com/studiobimo/.github - rev: # frozen: v1.5.0 - hooks: - - id: conventional-commit - - id: conventional-branch - - id: pr-size -``` - -Pin `rev:` to a release SHA with the version in a `frozen:` comment; Dependabot's `pre-commit` -ecosystem bumps both. The floating `v1` tag is for workflows only: pre-commit caches a hook repo by -`rev`, so a moving tag would never be re-fetched. - ## Calling them Call a workflow at `@v1`. The tag floats: a release moves it, so a fix lands everywhere at once. diff --git a/lefthook/org.yml b/lefthook/org.yml index 9b2f056..4d86ea4 100644 --- a/lefthook/org.yml +++ b/lefthook/org.yml @@ -1,11 +1,13 @@ # The org's own rules as git hooks: the same scripts ci-pr runs on a pull request, so # a bad branch name or an oversized branch is caught before the push, not in review. # -# A repository pulls this in by tag and never copies it: +# A repository pulls this in at the floating v1 tag, from its root lefthook.yml, and +# never copies it: # # remotes: # - git_url: https://github.com/studiobimo/.github -# ref: v1.9.0 +# ref: v1 +# refetch_frequency: 24h # configs: # - lefthook/org.yml # diff --git a/profile/README.md b/profile/README.md index 4d191e2..6c7d8aa 100644 --- a/profile/README.md +++ b/profile/README.md @@ -31,7 +31,7 @@ An independent software studio. Studio Bimo designs and builds web products end Most of the studio's work is for clients and lives in private repositories. What is public: -- [.github](https://github.com/studiobimo/.github): the reusable workflows, composite actions and pre-commit hooks behind every repository here. +- [.github](https://github.com/studiobimo/.github): the reusable workflows, composite actions and git hooks behind every repository here. - [project-template](https://github.com/studiobimo/project-template): the starting point for a new repository, in any language. - [Tally Hopper](https://github.com/studiobimo/tallyhopper) and [Datapacks](https://github.com/studiobimo/datapacks): Minecraft side projects, built for fun and held to the same standard. diff --git a/workflow-templates/ci-go.yml b/workflow-templates/ci-go.yml index 05120d7..42f0ba2 100644 --- a/workflow-templates/ci-go.yml +++ b/workflow-templates/ci-go.yml @@ -9,8 +9,8 @@ name: CI # This is deliberately not path-filtered: a skipped job reports no status, and a # required check that never reports blocks the pull request forever. # -# A repo with a .pre-commit-config.yaml should call ci-pre-commit instead of the -# `workflows` and `secrets-scan` jobs, and pass `skip:` for any Go hooks, which the +# A repo with a lefthook.yml should call ci-lint instead of the +# `workflows` and `secrets-scan` jobs, and pass `exclude:` for any Go jobs, which the # `go` job already covers. on: