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
20 changes: 20 additions & 0 deletions .commitlintrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Conventional Commits for this project: commitlint's conventional config, plus the
# scopes AGENTS.md lists. A scope stays optional, but one that is not listed is
# rejected. The commit-msg hook reads this file, and so does CI, on the pull request
# title and on every commit.
extends:
- '@commitlint/config-conventional'

rules:
# A pack's slug is a scope: add each new pack here, in the first line.
# `main` is release-please's: it titles the release pull request `chore(main): ...`.
scope-enum:
- 2
- always
- - cauldron-copper
- build
- ci
- docs
- deps
- devtools
- main
1 change: 0 additions & 1 deletion .devtools/.python-version

This file was deleted.

2 changes: 1 addition & 1 deletion .devtools/Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
include base.mk

PYTHON := $(UV) run --frozen python
PYTHON := $(MISE) exec -- python

.PHONY: validate build check lock clean

Expand Down
36 changes: 18 additions & 18 deletions .devtools/base.mk
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
# Managed by studiobimo/project-template; change it there. Project targets go in Makefile.

ROOT := $(abspath ..)
UV := uv --project $(CURDIR)
PRE_COMMIT := $(UV) run pre-commit
# python.org Python builds on macOS ship without a CA bundle until "Install
# Certificates" is run; hook installs download toolchains over HTTPS, so fall back
# to certifi's bundle unless one is already configured.
export SSL_CERT_FILE ?= $(shell $(UV) run --frozen python -c 'import certifi; print(certifi.where())' 2>/dev/null)
MISE := mise
LEFTHOOK := $(MISE) exec -- lefthook

.DEFAULT_GOAL := help
.PHONY: help setup lint scan lock-tools sync drift
.PHONY: help setup lint scan lock-tools sync drift github

help: ## List targets
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'

setup: ## Install pinned tools and git hooks (pre-commit, commit-msg, pre-push)
$(UV) sync --frozen
cd $(ROOT) && $(PRE_COMMIT) install --install-hooks \
--hook-type pre-commit --hook-type commit-msg --hook-type pre-push
setup: ## Install the tools mise.toml pins and the git hooks (pre-commit, commit-msg, pre-push)
cd $(ROOT) && $(MISE) trust --quiet mise.toml && $(MISE) install --locked
cd $(ROOT) && $(LEFTHOOK) install

lint: ## Run every pre-commit hook on all files
cd $(ROOT) && $(PRE_COMMIT) run --all-files
lint: ## Run the pre-commit hook on all files
cd $(ROOT) && $(LEFTHOOK) run pre-commit --all-files

scan: ## Secret and workflow security scans
cd $(ROOT) && $(PRE_COMMIT) run gitleaks --all-files
cd $(ROOT) && $(PRE_COMMIT) run zizmor --all-files
scan: ## Secret scan of the whole history, and the workflow security audit
cd $(ROOT) && $(MISE) exec -- gitleaks git --redact
cd $(ROOT) && $(MISE) exec -- zizmor --no-progress .github

lock-tools: ## Refresh uv.lock after changing a tool version in pyproject.toml
$(UV) lock
lock-tools: ## Refresh mise.lock after changing a tool version in mise.toml
cd $(ROOT) && $(MISE) lock

# A sync can bring newer tool versions, and mise.lock has to follow mise.toml.
sync: ## Pull the files studiobimo/project-template manages (REF=<tag> for a specific release)
@$(CURDIR)/scripts/template-sync.sh $(if $(REF),--ref $(REF))
@cd $(ROOT) && { git diff --quiet -- mise.toml || $(MISE) lock; }

drift: ## Show where this repo differs from the template, without changing anything
@$(CURDIR)/scripts/template-sync.sh --check $(if $(REF),--ref $(REF))

github: ## Apply the org's GitHub settings and rulesets to this repo (CHECK=1 only compares; DESC=, ENVS=)
@DESC="$(DESC)" ENVS="$(ENVS)" $(CURDIR)/scripts/github-setup.sh $(if $(CHECK),--check) $(if $(REF),--ref $(REF))
107 changes: 107 additions & 0 deletions .devtools/lefthook-base.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# The hooks every studiobimo repo runs. Managed by studiobimo/project-template; change
# it there. A project's own jobs go in lefthook.yml at the root, which extends this.
#
# commit-msg -> Conventional Commits, with the project's scopes (.commitlintrc.yaml)
# pre-commit -> the linters below; CI's `lint` job runs this hook over every file
# pre-push -> link check. The branch name and PR size checks come from
# studiobimo/.github, through the `remotes` entry in lefthook.yml:
# lefthook only reads `remotes` from the root config.
#
# Every tool is pinned in mise.toml and run through `mise exec`, so a hook works
# whether or not the shell has mise activated, and never falls back to some other
# version that happens to be installed.

commit-msg:
jobs:
- name: commitlint
run: mise exec -- commitlint --edit {1}

pre-commit:
parallel: true
jobs:
# These three rewrite files, so they run one after another, never side by side.
- name: whitespace
group:
jobs:
- name: trailing-whitespace
file_types: text
stage_fixed: true
run: mise exec -- trailing-whitespace-fixer --markdown-linebreak-ext=md {staged_files}
- name: end-of-file
file_types: text
stage_fixed: true
run: mise exec -- end-of-file-fixer {staged_files}
- name: line-endings
file_types: text
stage_fixed: true
run: mise exec -- mixed-line-ending --fix=lf {staged_files}

- name: merge-conflict
file_types: text
run: mise exec -- check-merge-conflict {staged_files}

- name: large-files
run: mise exec -- check-added-large-files --maxkb=512 {staged_files}

- name: yaml
glob: '*.{yml,yaml}'
run: mise exec -- check-yaml {staged_files}

- name: json
glob: '*.json'
run: mise exec -- check-json {staged_files}

- name: toml
glob: '*.toml'
run: mise exec -- check-toml {staged_files}

- name: executables-have-shebangs
file_types: [text, executable]
run: mise exec -- check-executables-have-shebangs {staged_files}

- name: shebang-scripts-are-executable
file_types: text
run: mise exec -- check-shebang-scripts-are-executable {staged_files}

- name: gitleaks
run: mise exec -- gitleaks git --pre-commit --redact --staged

- name: actionlint
glob: '.github/workflows/*.{yml,yaml}'
run: mise exec -- actionlint {staged_files}

# Exit 3 is "no inputs collected": none of the files were a workflow or an action.
- name: zizmor
glob: '.github/**/*.{yml,yaml}'
run: mise exec -- zizmor --no-progress {staged_files} || [ "$?" -eq 3 ]

- name: github-workflows
glob: '.github/workflows/*.{yml,yaml}'
run: mise exec -- check-jsonschema --builtin-schema vendor.github-workflows {staged_files}

- name: dependabot
glob: '.github/dependabot.{yml,yaml}'
run: mise exec -- check-jsonschema --builtin-schema vendor.dependabot {staged_files}

- name: shellcheck
glob: '*.sh'
run: mise exec -- shellcheck {staged_files}

- name: markdownlint
glob: '*.md'
run: mise exec -- markdownlint-cli2 {staged_files}

# --force-exclude keeps _typos.toml's excludes in force for files named outright.
- name: typos
file_types: text
run: mise exec -- typos --force-exclude {staged_files}

- name: editorconfig
file_types: text
run: mise exec -- editorconfig-checker {staged_files}

pre-push:
jobs:
- name: lychee
glob: '*.md'
run: mise exec -- lychee --no-progress --config lychee.toml {push_files}
12 changes: 0 additions & 12 deletions .devtools/pyproject.toml

This file was deleted.

26 changes: 12 additions & 14 deletions .devtools/scripts/agent-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,27 @@
# - gh pr create / gh stack submit / git push -> PR size limit
# - git checkout -b / git switch -c / git branch <name> -> Conventional Branch
#
# The rules themselves are the org's pre-commit hooks (studiobimo/.github), run by
# id, so this file only decides when to ask. CI (pr-checks) remains the
# The rules themselves are the org's lefthook jobs (studiobimo/.github), run by
# name, so this file only decides when to ask. CI (pr-checks) remains the
# authoritative gate; this is a guardrail.
#
# Managed by studiobimo/project-template; change it there.
set -euo pipefail

devtools="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

command -v jq >/dev/null 2>&1 || exit 0
command -v uv >/dev/null 2>&1 || exit 0
command -v mise >/dev/null 2>&1 || exit 0
cmd="$(jq -r '.tool_input.command // empty' 2>/dev/null || true)"
[[ -n "${cmd}" ]] || exit 0

# run_hook <rule> <hook-id> [VAR=value ...]: blocks the call when the hook fails.
# run_hook <rule> <job> [VAR=value ...]: blocks the call when the job fails.
run_hook() {
local rule="$1" id="$2" out status=0
local rule="$1" job="$2" out status=0
shift 2
out="$(env "$@" uv --project "${devtools}" run --frozen \
pre-commit run "${id}" --hook-stage manual 2>&1)" || status=$?
# 1 is the hook saying no. Anything else is pre-commit itself failing (tools not
# set up yet, the hook repo unreachable), which is not a reason to stop the agent.
if [[ "${status}" -eq 1 ]]; then
# Not set up yet (no tools, or the org's config never fetched) is not a reason to
# stop the agent, so only a job that ran and said no blocks the call.
mise exec -- lefthook version >/dev/null 2>&1 || return 0
out="$(env "$@" mise exec -- lefthook run pre-push --job "${job}" --no-tty 2>&1)" || status=$?
if [[ "${status}" -ne 0 && "${out}" == *"🥊 ${job}"* ]]; then
printf 'Blocked by agent-guard (%s):\n%s\n' "${rule}" "${out}" >&2
exit 2
fi
Expand Down Expand Up @@ -78,12 +76,12 @@ elif [[ "${first_line}" =~ gh[[:space:]]+stack[[:space:]]+(init|add)[[:space:]]+
for b in ${args}; do
b="${b//[\"\']/}"
[[ -z "${b}" || "${b}" == -* ]] && continue
run_hook "branch naming rule" conventional-branch "BRANCH_NAME=${b}"
run_hook "branch naming rule" branch-name "BRANCH_NAME=${b}"
done
fi

if [[ -n "${new_branch}" ]]; then
run_hook "branch naming rule" conventional-branch "BRANCH_NAME=${new_branch}"
run_hook "branch naming rule" branch-name "BRANCH_NAME=${new_branch}"
fi

exit 0
116 changes: 116 additions & 0 deletions .devtools/scripts/github-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Makes this repository's GitHub side match the organization's: merge options,
# Actions permissions, security features, rulesets, and deployment environments.
#
# Usage: github-setup.sh [--check] [--from <dir>] [--ref <ref>]
#
# (no flag) apply
# --check change nothing; print what differs and exit 1 if anything does
# --from <dir> use a checkout of the settings repo already on disk
# --ref <ref> tag or branch of the settings repo to use (default: v1)
#
# DESC="..." also set the repository description
# ENVS="staging production" also create these deployment environments
#
# The settings and rulesets are not in this file. They live in studiobimo/.github
# (settings/ and rulesets/), next to the script that applies them, so every
# repository gets the same ones. This only points that script at this repository
# and adds the parts that are the project's own.
#
# Needs gh, signed in as an admin of the repository, and jq.
# Exit codes: 0 in step (or applied), 1 differences found by --check, 2 anything else.
#
# Managed by studiobimo/project-template; change it there.
set -euo pipefail

settings_repo="${SETTINGS_REPO:-studiobimo/.github}"
desc="${DESC:-}"
envs="${ENVS:-}"
mode=apply
from=""
ref=v1

die() {
echo "✖ $*" >&2
exit 2
}

while [[ $# -gt 0 ]]; do
case "$1" in
--check) mode=check; shift ;;
--from) from="${2:?--from needs a directory}"; shift 2 ;;
--ref) ref="${2:?--ref needs a tag or branch}"; shift 2 ;;
-h | --help) sed -n '2,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done

command -v gh >/dev/null 2>&1 || die "gh is not installed (brew install gh)"
command -v jq >/dev/null 2>&1 || die "jq is not installed (brew install jq)"

info="$(gh repo view --json nameWithOwner,description,viewerCanAdminister 2>/dev/null)" \
|| die "cannot find this repository on GitHub: does it have a remote, and is gh signed in?"
repo="$(jq -r .nameWithOwner <<<"${info}")"
[[ "$(jq -r .viewerCanAdminister <<<"${info}")" == true ]] \
|| die "settings and rulesets need an admin of ${repo}, and you are not one"

for env in ${envs}; do
[[ "${env}" =~ ^[A-Za-z0-9._-]+$ ]] || die "not an environment name: ${env}"
done

work="$(mktemp -d)"
trap 'rm -rf "${work}"' EXIT

if [[ -z "${from}" ]]; then
from="${work}/settings"
git clone --quiet --depth 1 --branch "${ref}" "https://github.com/${settings_repo}" "${from}" 2>/dev/null \
|| die "cannot clone ${settings_repo} at ${ref}"
fi

differences=0
skipped=""

# differs <what>: one finding, in the shared script's layout.
differs() {
differences=$((differences + 1))
echo " $*"
}

shared="${from}/.devtools/repo-settings.sh"
if [[ -f "${shared}" ]]; then
flags=()
[[ "${mode}" == apply ]] || flags=(--check)
status=0
ORG="${repo%%/*}" bash "${shared}" ${flags[@]+"${flags[@]}"} "${repo#*/}" || status=$?
if [[ "${mode}" == check && "${status}" -eq 1 ]]; then
differences=$((differences + 1))
elif [[ "${status}" -ne 0 ]]; then
die "${settings_repo}@${ref} could not apply its settings to ${repo} (exit ${status})"
fi
else
echo "${repo}"
echo " ! ${settings_repo}@${ref} has no .devtools/repo-settings.sh, so the shared" >&2
echo " settings and rulesets were skipped; try another --ref" >&2
skipped=", apart from the shared settings and rulesets"
fi

if [[ -n "${desc}" && "$(jq -r '.description // ""' <<<"${info}")" != "${desc}" ]]; then
differs "description: $(jq '.description // ""' <<<"${info}") -> $(jq -n --arg d "${desc}" '$d')"
if [[ "${mode}" == apply ]]; then
gh repo edit "${repo}" --description "${desc}" >/dev/null
fi
fi

for env in ${envs}; do
gh api "repos/${repo}/environments/${env}" >/dev/null 2>&1 && continue
differs "environment ${env}: missing"
if [[ "${mode}" == apply ]]; then
gh api -X PUT "repos/${repo}/environments/${env}" >/dev/null
fi
done

if ((differences > 0)) && [[ "${mode}" == check ]]; then
echo "✖ ${repo} differs. Apply with: make -C .devtools github"
exit 1
fi
echo "✔ ${repo} is set up${skipped}"
Loading
Loading