Skip to content

Git workflow

How to commit, sign, and merge changes to linPEAS-flake.

Branch naming

type/description in kebab-case. Allowed types (alphabetical):

  • build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test

Examples: feat/add-arm64-image, fix/pin-url-prefix-check, chore/bump-nixpkgs-unstable.

Renovate's own renovate/* branches are bot-managed and exempt from this convention (the flake-lock auto-refresh keys on that prefix).

Commit signing

Every commit on main must be signed. The required_signatures rule is enforced by the protect-main ruleset.

  • Branch commits sign locally via your SSH or GPG key. Configure once: git config commit.gpgsign true and set user.signingkey. For an SSH key also run git config gpg.format ssh — gpg.format defaults to openpgp, and without that setting every commit fails to sign and required_signatures blocks the branch. A GPG key needs no gpg.format change.
  • Bot commits originate from REST PUT /repos/{owner}/{repo}/contents/{path} authenticated as the linpeas-flake-bumper GitHub App. GitHub web-flow-signs every such commit.

Conventional Commits

Every branch commit must independently satisfy Conventional Commits. Enforced by:

  • commitlint — required check; runs per commit on a PR.
  • lint-pr-title (workflow pr-title-lint) — required check; runs against the PR title.

How the merge commit is composed

GitHub writes the merge-commit message, not the author. The subject is the PR title (merge_commit_title=PR_TITLE) with " (#<number>)" appended. The body is the PR body (merge_commit_message=PR_BODY), rewrapped at 72 columns, with fenced code blocks left exactly as written.

That rewrap decides where each rule can be enforced:

  • Subject rules are checked by lint-pr-title, which lints the title with the " (#<number>)" suffix attached — the composed string, not the bare title. A title that fits the header limit on its own can still overflow it once the suffix lands, and that failure would appear only on main, on a commit no longer amendable.
  • Spelling is checked by the same lint-pr-title step, with typos against that same composed string. The PR title becomes the merge-commit subject, and git-cliff renders merge subjects into CHANGELOG.md; a typo frozen into that immutable, signed subject can only be corrected by a render-time rewrite, so the title is spell-checked in CI before the merge makes it permanent.
  • Body and footer line length is checked by commitlint on the pull_request event, against hand-authored branch commits, where the author controls the wrapping.
  • Body and footer line length is not checked on the merge commit. No author wrote those lines. Prose is already wrapped to 72 columns by GitHub, so a line can exceed the limit only where GitHub declined to rewrap — inside a code fence, in a table row, or in a single over-long token — all content that must not be wrapped. The commitlint job therefore lints push to main with .commitlintrc.merge.yml, which names the same base preset as .commitlintrc.yml and disables body-max-line-length and footer-max-line-length. It names the base preset directly rather than extending .commitlintrc.yml by relative path, so a resolution failure cannot first surface on main after a merge; a second commitlint step also loads it on pull_request purely to prove the action can read it while the file is still amendable.

Both configs must be named explicitly via the action's configFile input. The input defaults to a file this repo does not have, and the action then falls back to a bundled @commitlint/config-conventional, so an unset configFile makes the repo's own config a no-op with no visible symptom. scripts/check-commitlint-config-explicit.sh pins that shut, along with the two configs' agreement on the base preset they extend.

A PR body may contain fenced lines of any length. Prose in a PR body needs no manual wrapping — GitHub wraps it.

Allowed types match branch naming. Append ! after the type for breaking changes: feat!: drop x86_64-darwin from declared systems.

Local lint commands

These commands need no manual tool install — just, pre-commit, lychee, and every linter come from the flake devShells.default. Enter it with nix develop or via direnv (direnv allow). See the README Development section for details.

Run before pushing:

just verify      # lint groups + harness group + doc-freshness + standalone enforcers
just check       # nix flake check
just fmt         # nix fmt (treefmt: shfmt, prettier, …)
just lint        # pre-commit run --all-files
just lint-links  # lychee link check (inputs in the recipe, exclusions in lychee.toml)

Entering the devShell — nix develop or direnv — wires the git hooks for you, so the same hooks run on every commit — against staged files, and for commitlint against the commit message. To run them against every file in the repo instead, use just lint. Only when working outside the devShell do you need to install them yourself, once:

pre-commit install --hook-type pre-commit --hook-type commit-msg

The second --hook-type matters: commitlint runs at the commit-msg stage, and a bare pre-commit install writes only the pre-commit hook, silently skipping the local Conventional-Commits gate.

Merge policy

Merge-commit only. Enforced repo-wide (allow_merge_commit=true, allow_rebase_merge=false, allow_squash_merge=false) AND by the protect-main ruleset (pull_request.allowed_merge_methods=["merge"]).

Why: rebase and squash rewrite commits and break signatures. Merge-commit preserves each branch commit verbatim (with its existing signature) and adds a web-flow-signed merge commit on top.

Clean a branch with git rebase --interactive <base> before opening the PR. Don't leak WIP commits onto main.

Pre-commit hooks

Hooks (alphabetical):

Hook What it checks
actionlint GitHub Actions workflow syntax (shellcheck pinned).
actionlint-pyflakes-active actionlint pyflakes integration canary.
actionlint-shellcheck-active actionlint shellcheck integration canary.
auto-merge-decline-gate Auto-merge run-blocks carry the CLOSED/MERGED decline gate.
bump-script-integrity scripts/bump-linpeas.sh keeps its URL-prefix, .digest, and atomic-write guards.
check-cron-table Cron schedule table in docs/architecture/ci.md matches workflow cron triggers, and the ordering paragraph matches the table's daily rows.
check-doc-anchors Every markdown #anchor link resolves to a heading slug or an explicit <a name> anchor in its target file.
check-doc-cron-restatement Docs outside ci.md must link the cron schedule table, not restate literal workflow times.
check-ephemeral-refs Markdown prose and shell/Nix/YAML comments carry no ephemeral references (PR/issue refs, prose dates, planning/review labels, literal .claude/ paths).
check-jsonschema Schema-shape validation of repo config (renovate.json, workflows, actions).
check-notify-arms Declared notify-arms lists in docs and issue bodies match the arms each scanner notify job's if: gate and result: input file on.
check-orphan-invariants Every docs/ file has an invariant-index entry and vice versa.
check-prose-ci-names A CI job or required check named in prose must resolve to a real job or, when called a job, a whole workflow.
check-required-check-counts Declared prose counts of the required set, and undeclared ones the backstop reads, match the Required contexts table in required-checks.md.
check-scripts-reference-roundtrip Every script header's text appears as written in the rendered docs/reference/scripts.md.
checkout-persist-credentials Every actions/checkout sets with.persist-credentials: false.
ci-dag-fresh docs/architecture/ci-dag.md matches .github/workflows/ci.yml needs graph.
ci-job-in-summary ci.yml jobs cross-checked against docs/_data/ci-check-categories.yml.
ci-summary-fresh README CI summary matches required-checks.md and the category map.
commitlint Commit message satisfies Conventional Commits (CI parity via .commitlintrc.yml).
commitlint-config-explicit Every commitlint action step names an existing configFile; merge ruleset stays minimal.
cosign-identity-pinned cosign verify* pins --certificate-identity[-regexp] + --certificate-oidc-issuer.
deadnix Unused Nix bindings.
editorconfig-checker .editorconfig compliance (charset, line endings, trailing whitespace, final newline).
egress-allowlist Every job's allowed-endpoints list matches its tool inventory and carries no denylisted host.
enforcement-matrix-fresh docs/security/enforcement-matrix.md matches the annotated invariant index, real enforcers, and the harness roster.
ephemeral-refs-gap-fresh The scope-gap block in docs/development/linting.md matches the file types no ephemeral-refs extractor claims, and none of them carries a blocking shape.
flake-show-fresh flake-show block in docs/reference/flake-outputs.md matches current flake outputs.
fork-guard-release Release-grade jobs include github.repository fork guard.
freshness-hook-watches-modules Every flake-evaluating freshness hook watches every source its evaluation reads.
gh-api-version-header Every gh api / api.github.com call in scripts passes an X-GitHub-Api-Version header.
gh-attestation-repo gh attestation verify pins --repo rvenutolo/linPEAS-flake.
harden-runner-block Every harden-runner step uses egress-policy: block with non-empty allowed-endpoints.
harden-runner-first Every workflow job's first step is step-security/harden-runner.
harness-assert-wired Every output-asserting tests/*.test.sh is wired to the discrimination gate.
job-timeout-minutes Every workflow job declares an explicit timeout-minutes.
just-recipes-fresh just-recipes blocks in README.md and docs/reference/just-recipes.md match the justfile.
lint-shell-tools devShells.lint declares every tool the .#lint lint groups need.
lock-derived-docs Every lock-writing workflow regenerates the docs a lock bump can make stale.
manifest-digest-pinned docker manifest/imagetools create sources are digest-pinned.
manifest-hook-watches-nix Every hook reaching the Nix hook manifest watches nix/hooks in its files filter.
markdownlint Markdown style + structure.
min-permissions Top-level workflow permissions empty; each job declares its own scopes.
mypy-strict Strict type check of the repo's Python (files and options in mypy.ini).
nix-run-pinned No unpinned nix run nixpkgs#; use nix shell .# or pin a rev.
nixfmt Nix file formatting.
nixpkgs-hammering nixpkgs idiom checker for the linpeas derivation.
no-opaque-procsub No scripts/*.sh feeds a redirection from a process substitution.
notify-label-descriptions Every notify label description fits the labels API cap and agrees across callers.
octoscan synacktiv/octoscan workflow vulnerability scanner.
patch-tag-pins SHA-pinned uses: comments name a versioned tag (vX.Y[.Z]), not a bare major (vX).
permission-scopes Per-job GITHUB_TOKEN write scopes are allowlisted in .github/permission-scopes.yml.
pin-diff-isolated Only scripts/bump-linpeas.sh mutates linpeas-pin.json.
pin-parity-fresh The pin-parity block in docs/architecture/auto-update.md names every tracked file carrying the canonical pin-shape regex.
pre-commit-hooks-sha-parity The pre-commit-hooks input SHA in flake.nix matches flake.lock locked.rev.
precommit-table-fresh Hook table in docs/development/git.md matches the flake hook manifest.
pull-request-target-absent No workflow uses the pull_request_target trigger.
renovate-config-validator Validate renovate.json against the upstream Renovate config schema.
run-block-strict Block-scalar and newline-carrying run: blocks in workflows and composite actions start with set -Eeuo pipefail.
script-has-test Every scripts/check-.sh paired with tests/check-.test.sh.
script-shebang-pipefail Every scripts/*.sh has portable shebang + set -Eeuo pipefail.
scripts-reference-fresh docs/reference/scripts.md matches in-script annotations.
shellcheck Shell-script static analysis.
size-label-ignores The size-label ignore list matches what the generators declare they write.
statix Nix anti-pattern lint.
test-harnesses-fresh The harness census in docs/reference/test-harnesses.md matches the harnesses in tests/.
treefmt Multi-language formatter aggregator (shfmt, prettier, etc).
treefmt-config-fresh treefmt-config block in docs/reference/treefmt-config.md matches the evaluated treefmt config.
typos Spell-check across the repo.
upload-artifact-strict Every actions/upload-artifact sets with.if-no-files-found: error.
uses-sha-pinned Every uses: reference is SHA-pinned.
verify-reason-ladder Every verify-job step id is mapped to a documented reason token, in execution order.
workflow-concurrency Every workflow declares a top-level concurrency.group.
workflow-on-branches pull_request: and push: declare branches: [main] explicitly.
yamllint YAML style.
zizmor GitHub Actions security audit.

Lychee is not a pre-commit hook (it hits the network and can flake on offline work). Run it manually with just lint-links; CI runs it on a weekly cron only (plus manual workflow_dispatch); it is not a required check.