Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,21 @@ updates:
directory: "/"
schedule:
interval: "weekly"

# NO LEFTHOOK ENTRY, AND THAT IS AN EXCLUSION RATHER THAN AN OVERSIGHT.
#
# A lefthook consumer pins exactly one version -- the `ref:` under `remotes:`
# in their lefthook.yml, which README.md's install block tells them to write.
# Dependabot has no ecosystem that reads that file, so no updater will ever
# raise a pull request for it: the `pre-commit` entry above covers
# `.pre-commit-config.yaml` and nothing else. Adding a `github-actions` or
# `gomod` entry would not reach it either, and listing one here to look
# covered is the failure this repository exists to catch.
#
# What does watch it is `no-stale-hook-pins`, which reads lefthook `remotes:`
# entries as pins alongside pre-commit `repo:`/`rev:` pairs and refuses one
# that has fallen behind its upstream or names no `ref:` at all. So the pin is
# watched, by a guard rather than by an updater -- meaning a lefthook consumer
# is told their pin is stale and is never handed the bump. That difference is
# stated in README.md so a consumer reads it before they need it, and it is
# the reason to prefer a manager Dependabot can see where there is a choice.
13 changes: 11 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,17 @@ jobs:
- if: matrix.tool == 'pre-commit' || matrix.tool == 'prek'
run: pipx install "${{ matrix.tool }}"

- if: matrix.tool == 'lefthook'
uses: actions/setup-go@v7
# Go on every leg, and it is not the lefthook binary's toolchain -- that
# is the step below. The consumer harness's ninth question drives the four
# published Go ids, which are `language: system` and use whatever `go` the
# consumer has, so all three runners need one to drive them with.
#
# This does not weaken the gate above. What that gate protects is the "no
# Rust toolchain needed" claim, and Rust is still absent from the two legs
# whose runners are supposed to bootstrap it themselves. A Go toolchain
# proves nothing about a Rust bootstrap in either direction, and the four
# ids it is here for run no uphold code at all.
- uses: actions/setup-go@v7
with:
go-version: '>=1.26'

Expand Down
115 changes: 109 additions & 6 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@
# the claim true. lefthook has no equivalent format; it consumes `lefthook.yml`
# from a remote repository instead, which is what `hooks/lefthook.yml` is for.
#
# `language: rust` throughout, and it is not a toolchain requirement being
# pushed onto the consumer: both runners bootstrap Rust themselves when it is
# absent. All of these hooks build ONE environment, because pre-commit and prek
# key an environment on (repo, language, version) rather than on the hook, so
# the compile is paid once for the whole file however many ids a repository
# pins.
# `language: rust` for every id that runs the binary, and it is not a toolchain
# requirement being pushed onto the consumer: both runners bootstrap Rust
# themselves when it is absent. Those hooks build ONE environment between them,
# because pre-commit and prek key an environment on (repo, language, version)
# rather than on the hook, so the compile is paid once for the whole file
# however many ids a repository pins.
#
# The four Go ids at the bottom are the exception, and they are `language:
# system`. They run a consumer's own toolchain over a consumer's own module, so
# there is no uphold code in them and nothing here for a runner to build:
# `language: golang` would have pre-commit install a Go package out of THIS
# repository, which contains none. A repository with Go in it has `go` on PATH
# by definition, and one that does not is told so by the command rather than by
# a build of the wrong thing.

# The declaration check. This fires when the declaration changes or when any
# configuration it reads changes -- the files that can turn a true enforcement
Expand Down Expand Up @@ -163,3 +171,98 @@
stages: [manual]
pass_filenames: false
always_run: true

# ── the Go toolchain ─────────────────────────────────────────────────
#
# Four ids that run no uphold code at all, published here for one reason: a
# fleet audit read 24 sibling repositories side by side and found these four
# declared as `repo: local` entries and hand-copied -- go-test in 24, go-vet in
# 24, gofmt in 24, go-build in 22. Byte-identical apart from one variant, and
# the variant is the whole argument: two of the gofmt copies could never exit
# nonzero, because `gofmt -l` PRINTS the files it would reformat and exits 0
# either way. Twenty-two enforced; two reported "Passed" over unformatted code
# for as long as they existed, and nothing compared a copy against its siblings.
#
# A pinned id can drift in exactly one dimension, the rev, and
# `no-stale-hook-pins` already watches that one. A copied `entry:` line can
# drift in every dimension and nothing watches any of them.
#
# The ids carry the `uphold-` prefix every id in this file carries, even though
# they name no uphold command. hooks/lefthook.yml publishes the same four, and a
# lefthook remote config is MERGED into the consumer's own: two commands sharing
# a name under one hook is one command silently replacing the other, and `gofmt`
# is a name a Go repository has very likely used already. The two files have to
# publish one id list, so the prefix is on both.
#
# `files:` rather than `always_run:`, and that is the whole of "fires in no
# repository without Go in it". The same list is spelled as a `glob:` in
# hooks/lefthook.yml, and the two are meant to be read side by side:
#
# files: '(\.go|go\.mod|go\.sum)$' <-> glob: "{*.go,*go.mod,*go.sum}"
#
# They select the same paths, nested ones included, because lefthook's `*`
# crosses a path separator -- and they OVER-select the same way, since a file
# named `cargo.mod` satisfies both. Identical over-selection is the property
# worth having here: two triggers that disagree are the forked copy this whole
# section exists to end, one level up.
#
# `pass_filenames: false` on all four, because not one of these commands takes a
# file list. `./...` is a package pattern and `gofmt -l .` is a walk; handing
# either the staged subset would ask a narrower question than the id names.
#
# `stages: [pre-commit]` and not the `[pre-commit, manual]` every other id here
# carries, which is the one place these four are deliberately narrower than they
# look. Measured against lefthook v2.1.9: `glob` is applied to the files a GIT
# HOOK is running over, and `lefthook run <group>` -- the only manual stage that
# runner has -- has no such set, so every job in the group runs whatever its
# glob says. A Go job reachable that way fires in a repository with no Go in it
# and fails for want of a module. Declaring `manual` here anyway would buy a
# sweep on two runners out of three and make the two published id lists disagree
# about what a pinned id does, which is the failure these ids exist to end.

# The broken copy, written once. `gofmt -l` is a REPORT and not a gate, so the
# EMPTINESS of its output is the verdict and emptiness is what is tested. The
# list is printed before the refusal, because a gate that fails without naming
# what failed is the next one somebody deletes.
- id: uphold-gofmt
name: gofmt
description: refuse a tree gofmt would reformat -- the check `gofmt -l` alone cannot make
entry: sh -c 'unformatted="$(gofmt -l .)"; [ -z "$unformatted" ] || { echo "gofmt would reformat these files; run gofmt -w ."; echo "$unformatted"; exit 1; }'
language: system
stages: [pre-commit]
pass_filenames: false
files: '(\.go|go\.mod|go\.sum)$'

- id: uphold-go-vet
name: go vet
description: run go vet over every package in the module
entry: go vet ./...
language: system
stages: [pre-commit]
pass_filenames: false
files: '(\.go|go\.mod|go\.sum)$'

# `-o` into a throwaway directory rather than a bare `go build ./...`, and it is
# not tidiness. Where `./...` resolves to several packages go discards what it
# built, but where it resolves to a SINGLE main package go writes the executable
# into the working directory -- so the hook that checks the tree compiles leaves
# an untracked binary in the tree it just checked, named after the module, in
# exactly the repositories small enough to have one package. The status is
# carried across the cleanup because `rm` succeeding must not become the answer.
- id: uphold-go-build
name: go build
description: refuse a module that does not compile, without leaving a binary behind
entry: sh -c 'out="$(mktemp -d)"; go build -o "$out" ./...; status=$?; rm -rf "$out"; exit $status'
language: system
stages: [pre-commit]
pass_filenames: false
files: '(\.go|go\.mod|go\.sum)$'

- id: uphold-go-test
name: go test
description: run the module's tests
entry: go test ./...
language: system
stages: [pre-commit]
pass_filenames: false
files: '(\.go|go\.mod|go\.sum)$'
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,43 @@ remotes:
cargo install --git https://github.com/HackingGate/uphold --tag v1.1.1
```

That `ref:` is the one version a lefthook consumer pins, and **Dependabot does
not watch it**: there is no ecosystem that reads a lefthook config, so no
updater will raise a pull request when a newer tag lands. What watches it is
`no-stale-hook-pins`, which reads lefthook `remotes:` as pins alongside
pre-commit `repo:`/`rev:` pairs and refuses one that has fallen behind its
upstream or names no `ref:` at all — so the pin is watched by a guard rather
than by an updater, and you are told it is stale rather than handed the bump. It
reads `lefthook.yml`, `lefthook.yaml`, `.lefthook.yml` and `.lefthook.yaml` at
any depth; it does not read `lefthook.toml`, `lefthook.json` or the `-local`
overlay files, so a pin written in one of those is watched by nothing.

**Go repositories** — four toolchain ids ship here too. They run no uphold code
and need no uphold binary; pin them instead of transcribing them.

```yaml
- id: uphold-gofmt # a tree gofmt would reformat
- id: uphold-go-vet
- id: uphold-go-build
- id: uphold-go-test
```

`language: system`, so they use the `go` a Go repository already has on PATH and
add no toolchain and no build, and a `files:` regex keeps all four silent in a
repository with no Go in it. A lefthook consumer gets the same four ids from
`hooks/lefthook.yml` with nothing extra to write. They are `pre-commit` only,
and not `manual` as the uphold ids are: lefthook applies a job's `glob` to what
a git hook is running over, and a named group has no such set, so a Go job
reachable that way would fire in a repository with no Go in it.

They exist because 24 sibling repositories declared these four by hand, and two
of the `gofmt` copies could never fail — `gofmt -l` prints the files it would
reformat and exits `0` regardless, so twenty-two enforced and two reported
"Passed" over unformatted code until someone read all 24 side by side.
`uphold-gofmt` tests the *emptiness* of that output, which is where the verdict
actually is. A pinned id can drift in one dimension, the rev, and that dimension
has a check; a copied `entry:` line can drift in every dimension and has none.

## Declare what enforces what

```toml
Expand Down
29 changes: 21 additions & 8 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,14 +496,27 @@ that will not decode is a surface this run did not examine, and saying so is
the whole contract. A submodule is enumerated by path and never read as a blob:
its content is another repository's.

`no-stale-hook-pins` reaches every `.pre-commit-config.yaml` and every
`lefthook.yml` in the tree, not just the ones at the root, and reads lefthook
`remotes:` entries as pins alongside pre-commit `repo:`/`rev:` pairs. A tree
with no pin file at all is a pass that says why — the lefthook-only install path
is documented and pins nothing. A pin whose remote could not be reached is exit
`2`: a runner with no network fails this guard where it used to pass it, and
`UPHOLD_ALLOW=no-stale-hook-pins` is the deliberate bypass, named in the
refusal.
`no-stale-hook-pins` reaches every `.pre-commit-config.yaml` and every lefthook
config in the tree — `lefthook.yml`, `lefthook.yaml`, `.lefthook.yml`,
`.lefthook.yaml`, at any depth, gitignored files and submodules excluded — and
reads lefthook `remotes:` entries as pins alongside pre-commit `repo:`/`rev:`
pairs. A `remotes:` entry with no `ref:` is refused as unpinned, because it
follows the upstream's default branch. `lefthook.toml`, `lefthook.json` and the
`-local` overlay files are **not** read, so a pin written in one of those is
watched by nothing here.

Three trees that look alike from the outside and are three different answers:

| the tree | the answer |
|---|---|
| a lefthook config and no `.pre-commit-config.yaml` | `0` with a note. That is the documented lefthook-only install path, and any `remotes:` the lefthook config pins *were* read |
| a hook config naming no remote pin — every entry `repo: local` or `repo: meta`, or a lefthook config with no `remotes:` | `0` with a note. These files were read, and what they say is that this repository pins nothing remote |
| no hook configuration of **either** manager, anywhere under the root | `2`. Zero pins found is not zero pins to find: a config renamed, moved above this root, or added to `.gitignore` — ignored files are not walked — arrives here as an empty tree, and used to read as clean |

A pin whose remote could not be reached is exit `2` for the same reason: a
runner with no network fails this guard where it used to pass it.
`UPHOLD_ALLOW=no-stale-hook-pins` is the deliberate bypass in each of those
cases, and every refusal names it.

### Overriding one

Expand Down
61 changes: 61 additions & 0 deletions hooks/lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,56 @@ pre-commit:
run: uphold check
glob: "{policy/upheld.toml,policy/principles.toml,.pre-commit-config.yaml,lefthook.yml}"

# ── the Go toolchain ─────────────────────────────────────────────
#
# Four jobs that run no uphold code and need no uphold binary. They are
# published because a fleet audit read 24 sibling repositories side by side
# and found these four hand-copied as local declarations -- and found that
# two of the 24 gofmt copies could never exit nonzero, because `gofmt -l`
# prints the files it would reformat and exits 0 either way. The reasoning
# is in .pre-commit-hooks.yaml, which publishes the same four ids under the
# same names; this file is the lefthook half of that one list.
#
# `glob:` is what makes them fire in no repository without Go in it, and it
# is the same list .pre-commit-hooks.yaml spells as a `files:` regex:
#
# glob: "{*.go,*go.mod,*go.sum}" <-> files: '(\.go|go\.mod|go\.sum)$'
#
# The two select the same paths. lefthook's `*` crosses a path separator, so
# `*.go` reaches `internal/x/y.go` and `*go.mod` reaches a nested module's
# `go.mod`; the regex is anchored only at the end, so it reaches both as
# well -- and both spellings over-select a file named `cargo.mod`, in the
# same way. Two triggers that disagreed would be the forked copy this
# section exists to end, one level up.
#
# Jobs rather than commands for the reason uphold-check is one: `glob` is a
# job key, and a Go hook with no condition on it is the full test suite in
# front of every commit in the repository, Go or not.
- name: uphold-gofmt
# `gofmt -l` is a REPORT and not a gate, so the EMPTINESS of its output is
# the verdict and emptiness is what is tested here. The list is printed
# before the refusal, because a gate that fails without naming what failed
# is the next one somebody deletes.
run: unformatted="$(gofmt -l .)"; [ -z "$unformatted" ] || { echo "gofmt would reformat these files; run gofmt -w ."; echo "$unformatted"; exit 1; }
glob: "{*.go,*go.mod,*go.sum}"
- name: uphold-go-vet
run: go vet ./...
glob: "{*.go,*go.mod,*go.sum}"
- name: uphold-go-build
# `-o` into a throwaway directory rather than a bare `go build ./...`, and
# it is not tidiness. Where `./...` resolves to several packages go
# discards what it built; where it resolves to a SINGLE main package go
# writes the executable into the working directory -- so the job that
# checks the tree compiles leaves an untracked binary in the tree it just
# checked, in exactly the repositories small enough to have one package.
# The status is carried across the cleanup because `rm` succeeding must
# not become the answer.
run: out="$(mktemp -d)"; go build -o "$out" ./...; status=$?; rm -rf "$out"; exit $status
glob: "{*.go,*go.mod,*go.sum}"
- name: uphold-go-test
run: go test ./...
glob: "{*.go,*go.mod,*go.sum}"

commit-msg:
commands:
uphold-scan-text:
Expand Down Expand Up @@ -89,3 +139,14 @@ uphold-manual:
run: uphold scan
uphold-guard:
run: uphold guard --stage manual

# The four Go jobs are NOT repeated here, and .pre-commit-hooks.yaml declares
# them `stages: [pre-commit]` for the same reason rather than the
# `[pre-commit, manual]` every other id there carries. Measured against lefthook
# v2.1.9: `glob` is applied to the files a GIT HOOK is running over, and a named
# group like this one has no such set -- every job in it runs, glob or no glob.
# So a Go job here fires in a repository with no Go in it and fails for want of
# a module, which is the one thing these four are published not to do. Making
# the two files disagree about which stages the ids reach, to keep a manual
# sweep this runner cannot condition, would be the forked declaration the ids
# exist to end.
11 changes: 10 additions & 1 deletion policy/principles.toml
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,16 @@ exec = "uphold scan --text -"
# place through a pull-request body, through a branch name in a push, and
# through a package's metadata, and none of those is more forgiving than the
# others.
command.before = ["gh", "glab", "git push", "npm publish"]
#
# `npm` bare, not `npm publish`: the `[[shim]]` below matches `pack:*` as well,
# because the tarball's metadata is built by `pack` and merely uploaded by
# `publish` -- the same subject, one step earlier. A checker named for `publish`
# alone stands in front of only half of what the shim says it checks, and the
# shim now refuses an invocation it matches with no checker in front of it
# rather than passing it in silence, so `npm pack` was exit 2. Widening the
# checker keeps both subcommands checked; narrowing the shim would have bought
# the same green by looking at less.
command.before = ["gh", "glab", "git push", "npm"]

[rule.no-published-markers]
message = """
Expand Down
Loading
Loading