From 18f94613a2394ddbe70238e7fd39cbc606c7e5b8 Mon Sep 17 00:00:00 2001 From: david morales <118213696+davidmoralesz@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:43:50 -0600 Subject: [PATCH] chore(devtools): adopt studiobimo/project-template Shared Makefile targets move to base.mk, the branch, PR-size and commit checks come from the studiobimo/.github hook repo instead of local copies, and the files the template manages are marked and synced. --- .devtools/Makefile | 34 +--- .devtools/base.mk | 36 +++++ .devtools/scripts/agent-guard.sh | 66 +++++--- .devtools/scripts/check-branch-name.sh | 53 ------ .devtools/scripts/check-pr-size.sh | 74 --------- .devtools/scripts/template-sync.sh | 215 +++++++++++++++++++++++++ .editorconfig | 17 +- .gitattributes | 8 +- .github/dependabot.yml | 3 +- .github/workflows/lint.yml | 10 -- .github/workflows/pr-checks.yml | 5 - .github/workflows/template-drift.yml | 21 +++ .github/zizmor.yml | 16 +- .gitignore | 21 +-- .markdownlint-cli2.yaml | 1 + .pre-commit-config.yaml | 31 +--- .template-version | 1 + AGENTS.md | 60 +++++-- CONTRIBUTING.md | 27 +++- lychee.toml | 2 - 20 files changed, 429 insertions(+), 272 deletions(-) create mode 100644 .devtools/base.mk delete mode 100755 .devtools/scripts/check-branch-name.sh delete mode 100755 .devtools/scripts/check-pr-size.sh create mode 100755 .devtools/scripts/template-sync.sh create mode 100644 .github/workflows/template-drift.yml create mode 100644 .template-version diff --git a/.devtools/Makefile b/.devtools/Makefile index e5011b7..6c117d0 100644 --- a/.devtools/Makefile +++ b/.devtools/Makefile @@ -1,29 +1,8 @@ -# Single entry point for local development. Run from the repo root with -# make -C .devtools -# CI runs the same hooks `check` runs, so local and CI results match. +include base.mk -ROOT := $(abspath ..) -UV := uv --project $(CURDIR) -PRE_COMMIT := $(UV) run pre-commit PYTHON := $(UV) run --frozen python -# 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) -.DEFAULT_GOAL := help -.PHONY: help setup lint validate build check scan lock clean - -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 - -lint: ## Run every pre-commit hook on all files (includes validate) - cd $(ROOT) && $(PRE_COMMIT) run --all-files +.PHONY: validate build check lock clean validate: ## Check every pack's layout, namespaces, JSON and references $(PYTHON) $(CURDIR)/scripts/check-packs.py @@ -31,14 +10,9 @@ validate: ## Check every pack's layout, namespaces, JSON and references build: ## Package every pack into dist/ (PACK= for one) $(PYTHON) $(CURDIR)/scripts/build.py $(PACK) -check: lint build ## Everything CI runs, plus a build - -scan: ## Secret and workflow security scans - cd $(ROOT) && $(PRE_COMMIT) run gitleaks --all-files - cd $(ROOT) && $(PRE_COMMIT) run zizmor --all-files +check: lint build ## Everything CI runs (lint includes validate), plus a build -lock: ## Refresh uv.lock after changing a tool version in pyproject.toml - $(UV) lock +lock: lock-tools ## Refresh every lockfile after a version change clean: ## Remove build outputs rm -rf $(ROOT)/dist diff --git a/.devtools/base.mk b/.devtools/base.mk new file mode 100644 index 0000000..a31e57a --- /dev/null +++ b/.devtools/base.mk @@ -0,0 +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) + +.DEFAULT_GOAL := help +.PHONY: help setup lint scan lock-tools sync drift + +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 + +lint: ## Run every pre-commit hook on all files + cd $(ROOT) && $(PRE_COMMIT) run --all-files + +scan: ## Secret and workflow security scans + cd $(ROOT) && $(PRE_COMMIT) run gitleaks --all-files + cd $(ROOT) && $(PRE_COMMIT) run zizmor --all-files + +lock-tools: ## Refresh uv.lock after changing a tool version in pyproject.toml + $(UV) lock + +sync: ## Pull the files studiobimo/project-template manages (REF= for a specific release) + @$(CURDIR)/scripts/template-sync.sh $(if $(REF),--ref $(REF)) + +drift: ## Show where this repo differs from the template, without changing anything + @$(CURDIR)/scripts/template-sync.sh --check $(if $(REF),--ref $(REF)) diff --git a/.devtools/scripts/agent-guard.sh b/.devtools/scripts/agent-guard.sh index 18e08fc..a776f88 100755 --- a/.devtools/scripts/agent-guard.sh +++ b/.devtools/scripts/agent-guard.sh @@ -7,46 +7,70 @@ # - gh pr create / gh stack submit / git push -> PR size limit # - git checkout -b / git switch -c / git branch -> Conventional Branch # -# CI (pr-checks) remains the authoritative gate; this is a guardrail. +# 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 +# authoritative gate; this is a guardrail. +# +# Managed by studiobimo/project-template; change it there. set -euo pipefail -here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +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 cmd="$(jq -r '.tool_input.command // empty' 2>/dev/null || true)" [[ -n "${cmd}" ]] || exit 0 -block() { - printf '%s\n' "$1" >&2 - exit 2 +# run_hook [VAR=value ...]: blocks the call when the hook fails. +run_hook() { + local rule="$1" id="$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 + printf 'Blocked by agent-guard (%s):\n%s\n' "${rule}" "${out}" >&2 + exit 2 + fi } # Only the first line matters for matching; heredoc bodies can contain anything. first_line="${cmd%%$'\n'*}" -# Help output never changes anything. if [[ "${first_line}" =~ [[:space:]](--help|-h)([[:space:]]|$) ]]; then exit 0 fi -# --- PR size ------------------------------------------------------------- if [[ "${first_line}" =~ (^|[[:space:]\;\&\|])(gh[[:space:]]+pr[[:space:]]+create|gh[[:space:]]+stack[[:space:]]+submit|git[[:space:]]+push)([[:space:]]|$) ]]; then - size_args=() + size_env=() if [[ "${first_line}" =~ gh[[:space:]]+pr[[:space:]]+create.*(--base|-B)[[:space:]=]+[\"\']?([^[:space:]\"\']+) ]]; then - size_args=(--base "${BASH_REMATCH[2]}") - fi - if ! out="$("${here}/check-pr-size.sh" ${size_args[@]+"${size_args[@]}"} 2>&1)"; then - block "Blocked by agent-guard (PR size rule): -${out}" + size_env=("PR_BASE=${BASH_REMATCH[2]}") fi + run_hook "PR size rule" pr-size ${size_env[@]+"${size_env[@]}"} fi -# --- Branch names -------------------------------------------------------- new_branch="" if [[ "${first_line}" =~ git[[:space:]]+(checkout|switch)[[:space:]]+(-b|-B|-c|-C|--create)[[:space:]]+[\"\']?([^[:space:];&|\"\']+) ]]; then new_branch="${BASH_REMATCH[3]}" -elif [[ "${first_line}" =~ git[[:space:]]+branch[[:space:]]+(-m[[:space:]]+)?[\"\']?([^-[:space:];&|\"\'][^[:space:];&|\"\']*)[\"\']?([[:space:]]|$) ]]; then - new_branch="${BASH_REMATCH[2]}" +elif [[ "${first_line}" =~ git[[:space:]]+branch[[:space:]]+(-[mMcC]|--move|--copy)[[:space:]]+(.*)$ ]]; then + # Rename/copy: `` alone renames the current branch; with ` ` + # the second name is the one being created. + args="${BASH_REMATCH[2]%%[;&|<>]*}" + args="${args% [0-9]}" + names=() + for b in ${args}; do + b="${b//[\"\']/}" + [[ -z "${b}" || "${b}" == -* ]] && continue + names+=("${b}") + done + if ((${#names[@]} >= 2)); then + new_branch="${names[1]}" + elif ((${#names[@]} == 1)); then + new_branch="${names[0]}" + fi +elif [[ "${first_line}" =~ git[[:space:]]+branch[[:space:]]+[\"\']?([^-[:space:];&|\"\'][^[:space:];&|\"\']*)[\"\']?([[:space:]]|$) ]]; then + new_branch="${BASH_REMATCH[1]}" elif [[ "${first_line}" =~ gh[[:space:]]+stack[[:space:]]+(init|add)[[:space:]]+(.*)$ ]]; then # Stop at the next command separator or redirection. args="${BASH_REMATCH[2]%%[;&|<>]*}" @@ -54,18 +78,12 @@ elif [[ "${first_line}" =~ gh[[:space:]]+stack[[:space:]]+(init|add)[[:space:]]+ for b in ${args}; do b="${b//[\"\']/}" [[ -z "${b}" || "${b}" == -* ]] && continue - if ! out="$("${here}/check-branch-name.sh" "${b}" 2>&1)"; then - block "Blocked by agent-guard (branch naming rule): -${out}" - fi + run_hook "branch naming rule" conventional-branch "BRANCH_NAME=${b}" done fi if [[ -n "${new_branch}" ]]; then - if ! out="$("${here}/check-branch-name.sh" "${new_branch}" 2>&1)"; then - block "Blocked by agent-guard (branch naming rule): -${out}" - fi + run_hook "branch naming rule" conventional-branch "BRANCH_NAME=${new_branch}" fi exit 0 diff --git a/.devtools/scripts/check-branch-name.sh b/.devtools/scripts/check-branch-name.sh deleted file mode 100755 index 61b3f11..0000000 --- a/.devtools/scripts/check-branch-name.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# Validates a branch name against Conventional Branch (https://conventionalbranch.org/). -# -# Usage: check-branch-name.sh [branch] (defaults to the current branch) -# -# Interim local copy; canonical version will live in studiobimo/.github -# (pre-commit hook `conventional-branch`, see studiobimo/.github#10). -set -euo pipefail - -branch="${1:-$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)}" - -# Detached HEAD (e.g. during rebase) has nothing to validate. -if [[ -z "${branch}" ]]; then - exit 0 -fi - -# Bot branches are not ours to name. Dependabot's format is fixed -- -# dependabot//-, with underscores and an extra path -# segment -- and there is no setting to change it, so every dependency PR would -# fail this check forever and the check would be the thing that gets disabled. -# Nothing is lost: the title and commits a bot writes are Conventional, and the -# title is what becomes the squash commit. -# -# release-please is the same story with a different shape: -# release-please--branches--, plus --components-- in a monorepo. -# The double hyphen is its separator and cannot be reconfigured, so it can never -# satisfy the single-hyphen rule below. Its PR title -- chore(main): release -# X.Y.Z -- is Conventional, and that title is what the tag and changelog are cut -# from, so the branch name carries no meaning worth enforcing. -case "${branch}" in - dependabot/* | renovate/* | release-please--*) exit 0 ;; -esac - -types='feature|feat|bugfix|fix|hotfix|release|chore|ai|claude|codex|copilot|cursor' -segment='[a-z0-9]+(\.[a-z0-9]+)*' -pattern="^(main|master|develop|(${types})/${segment}(-${segment})*)$" - -if [[ "${branch}" =~ ${pattern} ]]; then - exit 0 -fi - -cat >&2 </ - Types: ${types//|/, } - Rules: lowercase a-z, 0-9 and single hyphens; dots only in release versions - Example: feat/cauldron-copper-tools, fix/waxed-chests, release/v1.2.0 - - Rename with: git branch -m - Spec: https://conventionalbranch.org/ -MSG -exit 1 diff --git a/.devtools/scripts/check-pr-size.sh b/.devtools/scripts/check-pr-size.sh deleted file mode 100755 index 618e5b7..0000000 --- a/.devtools/scripts/check-pr-size.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# Fails when the current branch changes more files than a PR may contain. -# -# Usage: check-pr-size.sh [--base ] [--max ] -# -# Base resolution: --base, then $PR_BASE, then this branch's parent in a -# gh stack, then the remote default branch. Each stack layer is therefore -# measured against the layer below it. -# -# Interim local copy; canonical version will live in studiobimo/.github -# (pre-commit hook `pr-size`, see studiobimo/.github#10). -set -euo pipefail - -max="${PR_MAX_FILES:-20}" -base="${PR_BASE:-}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --base) base="$2"; shift 2 ;; - --max) max="$2"; shift 2 ;; - *) echo "unknown argument: $1" >&2; exit 64 ;; - esac -done - -branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" - -stack_parent() { - command -v gh >/dev/null 2>&1 || return 0 - # `gh stack view --json` lists branches bottom-to-top; a layer's base is the one below it. - gh stack view --json 2>/dev/null | jq -r --arg b "${branch}" ' - ([.branches[].name] | index($b)) as $i - | if $i == null then empty - elif $i == 0 then .trunk - else .branches[$i - 1].name end' 2>/dev/null || true -} - -default_branch() { - git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || echo main -} - -[[ -n "${base}" ]] || base="$(stack_parent)" -[[ -n "${base}" ]] || base="$(default_branch)" -[[ -n "${base}" ]] || base="main" - -# Prefer the local ref (stack parents are local branches), fall back to origin. -if git rev-parse --verify --quiet "${base}" >/dev/null; then - base_ref="${base}" -elif git rev-parse --verify --quiet "origin/${base}" >/dev/null; then - base_ref="origin/${base}" -else - echo "⚠ Base '${base}' not found; skipping PR size check." >&2 - exit 0 -fi - -merge_base="$(git merge-base "${base_ref}" HEAD)" -count="$(git diff --name-only "${merge_base}" HEAD | wc -l | tr -d ' ')" - -if (( count <= max )); then - echo "✔ ${count}/${max} files changed vs ${base_ref}" - exit 0 -fi - -cat >&2 < # or adopt existing branches - gh stack add # commit a slice, repeat - gh stack submit # push and open linked PRs - - Docs: https://docs.github.com/en/pull-requests/get-started/about-stacked-prs -MSG -exit 1 diff --git a/.devtools/scripts/template-sync.sh b/.devtools/scripts/template-sync.sh new file mode 100755 index 0000000..7fb428a --- /dev/null +++ b/.devtools/scripts/template-sync.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Keeps the files studiobimo/project-template manages in step with it. +# +# Usage: template-sync.sh [--check] [--from ] [--ref ] +# +# (no flag) overwrite the managed files and blocks with the template's +# --check change nothing; print what differs and exit 1 if anything does +# --from use a template checkout already on disk instead of cloning one +# --ref template release to use (default: the latest); with --from it only +# names the version in the output +# +# The template's .template/manifest says what is managed: whole files, and marked +# blocks inside files the project otherwise owns. Paths listed in this repo's +# .template-ignore are left alone. +# +# Exit codes: 0 in step (or synced), 1 drift found by --check, 2 anything else. +# The template-drift workflow depends on that split, so a failure of this script is +# never mistaken for "no drift". +# +# Bash 3.2 and git only, so it runs on a stock macOS and on a CI runner alike. +# Managed by studiobimo/project-template; change it there. +set -euo pipefail + +# Bash reads a brace group whole before running it. Without the braces, syncing a +# newer copy of this file over itself would change the script mid-run. +{ +template_repo="${TEMPLATE_REPO:-studiobimo/project-template}" +mode=sync +from="" +ref="" + +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}"; shift 2 ;; + -h | --help) sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository" +cd "${root}" + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +if [[ -z "${from}" ]]; then + url="https://github.com/${template_repo}" + if [[ -z "${ref}" ]]; then + # Highest version tag; `^{}` lines are the peeled side of annotated tags. + ref="$(git ls-remote --tags --sort=-v:refname "${url}" 'v*' 2>/dev/null \ + | grep -v '\^{}$' | head -n 1 | sed 's#.*refs/tags/##')" || true + [[ -n "${ref}" ]] || die "cannot find a release tag on ${url} (no access, or nothing released yet)" + fi + from="${work}/template" + git clone --quiet --depth 1 --branch "${ref}" "${url}" "${from}" 2>/dev/null \ + || die "cannot clone ${url} at ${ref}" +fi + +manifest="${from}/.template/manifest" +[[ -f "${manifest}" ]] || die "${from} has no .template/manifest; is it the template?" +if [[ -z "${ref}" ]]; then + ref="$(git -C "${from}" describe --tags --always 2>/dev/null || echo unknown)" +fi + +ignored() { + [[ -f .template-ignore ]] || return 1 + grep -v '^[[:space:]]*#' .template-ignore | sed 's/[[:space:]]*$//' | grep -Fxq "$1" +} + +# block_of : the marked region, marker lines included. +block_of() { + awk -v start=">>> template:$2" -v finish="<<< template:$2" ' + index($0, start) { inside = 1 } + inside { print } + index($0, finish) { inside = 0 }' "$1" +} + +# with_block : the file, with its region replaced. +with_block() { + awk -v start=">>> template:$2" -v finish="<<< template:$2" -v src="$3" ' + index($0, start) { + while ((getline line < src) > 0) print line + close(src) + inside = 1 + } + !inside { print } + index($0, finish) { inside = 0 }' "$1" +} + +drift=0 +changed=0 + +# report [ ]: one finding, with a diff when there is one. +report() { + drift=$((drift + 1)) + echo "✖ $1: $2" + if [[ $# -eq 4 ]]; then + diff -u --label "a/$1" --label "b/$1 (template ${ref})" "$3" "$4" || true + echo + fi +} + +sync_file() { + local path="$1" src="${from}/$1" + [[ -f "${src}" ]] || die "manifest lists ${path}, but the template does not have it" + + local same=true + if [[ ! -f "${path}" ]]; then + same=false + elif ! cmp -s "${path}" "${src}"; then + same=false + elif [[ -x "${src}" && ! -x "${path}" ]] || [[ ! -x "${src}" && -x "${path}" ]]; then + same=false + fi + [[ "${same}" == false ]] || return 0 + + if [[ "${mode}" == check ]]; then + if [[ ! -f "${path}" ]]; then + report "${path}" "missing" + elif cmp -s "${path}" "${src}"; then + report "${path}" "executable bit differs" + else + report "${path}" "differs" "${path}" "${src}" + fi + return 0 + fi + + mkdir -p "$(dirname "${path}")" + cp "${src}" "${path}" + if [[ -x "${src}" ]]; then chmod +x "${path}"; else chmod -x "${path}"; fi + changed=$((changed + 1)) + echo "✔ ${path}: updated" +} + +sync_block() { + local path="$1" name="$2" src="${from}/$1" + [[ -f "${src}" ]] || die "manifest lists ${path}, but the template does not have it" + + block_of "${src}" "${name}" >"${work}/theirs" + [[ -s "${work}/theirs" ]] || die "the template's ${path} has no '${name}' block" + + if [[ -f "${path}" ]]; then + block_of "${path}" "${name}" >"${work}/ours" + else + : >"${work}/ours" + fi + if cmp -s "${work}/ours" "${work}/theirs"; then + return 0 + fi + + if [[ "${mode}" == check ]]; then + if [[ ! -s "${work}/ours" ]]; then + report "${path}" "block '${name}' is missing" + else + report "${path}" "block '${name}' differs" "${work}/ours" "${work}/theirs" + fi + return 0 + fi + + if [[ ! -f "${path}" ]]; then + mkdir -p "$(dirname "${path}")" + cp "${work}/theirs" "${path}" + echo "✔ ${path}: created with block '${name}'" + elif [[ ! -s "${work}/ours" ]]; then + # No markers to aim at, so the end of the file is the only safe place. + { echo; cat "${work}/theirs"; } >>"${path}" + echo "✔ ${path}: block '${name}' appended; move it to where it belongs" + else + with_block "${path}" "${name}" "${work}/theirs" >"${work}/merged" + cat "${work}/merged" >"${path}" + echo "✔ ${path}: block '${name}' updated" + fi + changed=$((changed + 1)) +} + +while read -r kind path name _; do + case "${kind}" in + '' | '#'*) continue ;; + esac + if ignored "${path}"; then + echo "– ${path}: ignored by .template-ignore" + continue + fi + case "${kind}" in + file) sync_file "${path}" ;; + block) sync_block "${path}" "${name:?manifest: block ${path} needs a name}" ;; + *) die "manifest: unknown entry '${kind} ${path}'" ;; + esac +done <"${manifest}" + +recorded="$(cat .template-version 2>/dev/null || true)" + +if [[ "${mode}" == check ]]; then + if ((drift > 0)); then + echo "${drift} difference(s) from ${template_repo}@${ref} (last synced: ${recorded:-never})." + echo "Run: make -C .devtools sync" + exit 1 + fi + echo "✔ In step with ${template_repo}@${ref}" + exit 0 +fi + +if [[ "${recorded}" != "${ref}" ]]; then + echo "${ref}" >.template-version +fi +echo "✔ Synced with ${template_repo}@${ref} (${changed} updated). Review with: git diff" +exit 0 +} diff --git a/.editorconfig b/.editorconfig index 9fb70d6..8aacca1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,6 @@ root = true +# >>> template:base [*] charset = utf-8 end_of_line = lf @@ -8,17 +9,21 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true -[*.{yml,yaml,json,json5,toml,mcmeta}] +[*.{yml,yaml,json,json5,toml}] indent_size = 2 -[*.mcfunction] -# One command per line, however long; the game has no line continuation we use. -max_line_length = off - [*.md] trim_trailing_whitespace = false # List continuation and nested content follow Markdown, not code, indentation. indent_size = unset -[Makefile] +[{Makefile,*.mk}] indent_style = tab +# <<< template:base + +[*.mcmeta] +indent_size = 2 + +[*.mcfunction] +# One command per line, however long; the game has no line continuation we use. +max_line_length = off diff --git a/.gitattributes b/.gitattributes index 0662448..f0aaf0d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,13 @@ +# >>> template:base * text=auto eol=lf +*.png binary +*.jpg binary +*.zip binary +# <<< template:base + *.mcfunction text eol=lf *.mcmeta text eol=lf linguist-language=JSON -*.png binary *.ogg binary *.nbt binary -*.zip binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c25cf75..ddc14fc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ -# Dependency updates. Commit messages follow Conventional Commits (chore(deps): ...). version: 2 updates: + # >>> template:base - package-ecosystem: github-actions directory: / schedule: @@ -39,3 +39,4 @@ updates: groups: devtools: patterns: ["*"] + # <<< template:base diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e7d07e2..b293b95 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,12 +1,5 @@ name: Lint -# Everything `make -C .devtools lint` runs: every pre-commit hook, over every file. -# That includes check-packs, so pack layout, namespaces, JSON and references are -# validated here too. -# -# It calls the org workflow rather than listing tools, so .pre-commit-config.yaml -# stays the only place a linter's version is written down. - on: push: branches: [main] @@ -21,11 +14,8 @@ concurrency: jobs: lint: - # A called workflow can never hold more than its caller, so the caller has to - # grant what it needs. `permissions: {}` above keeps every other job at nothing. permissions: contents: read uses: studiobimo/.github/.github/workflows/ci-pre-commit.yml@v1 with: - # Where pre-commit itself is pinned, the same project `make -C .devtools lint` uses. uv-project: .devtools diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c2d49f3..5d0b131 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -1,8 +1,5 @@ name: PR checks -# Thin wrapper. The checks live in studiobimo/.github so a fix lands in every -# repo at once; see that repo's README for the inputs. - on: pull_request: types: [opened, edited, synchronize, reopened] @@ -11,8 +8,6 @@ permissions: {} jobs: pr-checks: - # A called workflow can never hold more than its caller, so the caller has to - # grant what it needs. `permissions: {}` above keeps every other job at nothing. permissions: contents: read uses: studiobimo/.github/.github/workflows/ci-pr.yml@v1 diff --git a/.github/workflows/template-drift.yml b/.github/workflows/template-drift.yml new file mode 100644 index 0000000..4829bfd --- /dev/null +++ b/.github/workflows/template-drift.yml @@ -0,0 +1,21 @@ +name: Template drift + +on: + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: {} + +concurrency: + group: template-drift + cancel-in-progress: false + +jobs: + drift: + # The template has nothing to drift from. + if: ${{ github.repository != 'studiobimo/project-template' }} + permissions: + contents: read + issues: write + uses: studiobimo/.github/.github/workflows/ci-template-drift.yml@v1 diff --git a/.github/zizmor.yml b/.github/zizmor.yml index d71bb13..7da1fcc 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,17 +1,5 @@ -# Pinning policy, split by who controls the tag. -# -# Third-party actions are hash-pinned: a tag there can be moved under us, and -# Dependabot bumps the SHA in place from the `# vX.Y.Z` comment. -# -# studiobimo's own reusable workflows ride the floating `v1` tag. That is the -# delivery mechanism -- a fix in studiobimo/.github reaches this repo by the tag -# moving, with no SHA bump here. GitHub's own `sha_pinning_required` policy -# exempts reusable-workflow refs for the same reason. -# -# Note this permits `@v1` on a studiobimo *composite action* too, which GitHub -# would reject: that policy exempts workflows but not actions. Nothing here -# references one directly, and the org repo's own check-action-pins.sh is what -# guards that case at the source. +# Third-party actions are hash-pinned. studiobimo's own reusable workflows ride the +# floating v1 tag, which is how a fix there reaches this repo without a bump here. rules: unpinned-uses: config: diff --git a/.gitignore b/.gitignore index dce4df7..516fbc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,22 @@ -# Build output: packaged datapacks (make -C .devtools build) -dist/ -*.zip - +# >>> template:base # Python (.devtools) .venv/ __pycache__/ -# Editors -.idea/ -.vscode/ - # OS .DS_Store __MACOSX/ Thumbs.db -# Local agent settings (shared settings are committed) +# Local agent state (shared settings are committed) .claude/settings.local.json +.claude/worktrees/ +# <<< template:base + +# Build output: packaged datapacks (make -C .devtools build) +dist/ +*.zip + +# Editors +.idea/ +.vscode/ diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml index 145710f..7858c11 100644 --- a/.markdownlint-cli2.yaml +++ b/.markdownlint-cli2.yaml @@ -5,4 +5,5 @@ config: siblings_only: true MD033: false # inline HTML (e.g.
) is fine ignores: + - "**/build/**" - "**/CHANGELOG.md" # generated by release-please diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc181f9..24080a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,5 @@ -# Hook revs are frozen to commit SHAs (`pre-commit autoupdate --freeze`); -# Dependabot's pre-commit ecosystem keeps them current. -# Install: make -C .devtools setup default_install_hook_types: [pre-commit, commit-msg, pre-push] default_stages: [pre-commit] -# Toolchains pre-commit provisions for hooks (python: shellcheck/typos/zizmor/…, -# node: markdownlint-cli2, golang: gitleaks/actionlint). Pinned for reproducibility. default_language_version: python: python3.14 node: 24.21.0 @@ -85,12 +80,14 @@ repos: types: [markdown] stages: [pre-push, manual] - - repo: https://github.com/compilerla/conventional-pre-commit - rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0 + # The org's own rules, from the same scripts CI runs on the pull request. + # agent-guard.sh runs the last two by id, so keep them even if a stage changes. + - repo: https://github.com/studiobimo/.github + rev: 0ec35109583b97c453323ae40412505a16398f32 # frozen: v1.5.0 hooks: - - id: conventional-pre-commit - stages: [commit-msg] - args: [--strict, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] + - id: conventional-commit + - id: conventional-branch + - id: pr-size - repo: local hooks: @@ -100,17 +97,3 @@ repos: language: script files: ^(packs/|\.devtools/scripts/.*\.py$) pass_filenames: false - - id: conventional-branch - name: conventional branch name - entry: .devtools/scripts/check-branch-name.sh - language: script - pass_filenames: false - always_run: true - stages: [pre-push, manual] - - id: pr-size - name: PR size (max 20 files) - entry: .devtools/scripts/check-pr-size.sh - language: script - pass_filenames: false - always_run: true - stages: [pre-push, manual] diff --git a/.template-version b/.template-version new file mode 100644 index 0000000..49fecf0 --- /dev/null +++ b/.template-version @@ -0,0 +1 @@ +c0b5090 diff --git a/AGENTS.md b/AGENTS.md index 883077c..6ff200d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ No mods, no resource packs, no build step beyond zipping. - Catalog and install instructions: `README.md` - Player-facing docs for a pack: `packs//README.md` - Workflow and conventions in full: `CONTRIBUTING.md` +- Commit scopes: a pack's slug (`cauldron-copper`), or `build`, `ci`, `docs`, `deps`, `devtools` ## Layout @@ -21,22 +22,57 @@ packs// one datapack; is kebab-case README.md player-facing docs data// the pack's own content; is in snake_case data/minecraft/tags/ only to hook into vanilla tags (load, tick) -.devtools/ Makefile, pinned Python tools, build, check and guard scripts +.devtools/ Makefile (project targets), base.mk (shared targets), pinned Python tools + scripts/ build.py, check-packs.py, agent-guard.sh, template-sync.sh dist/ build output, ignored ``` + ## Non-negotiables -- **Commits:** [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/). - Scopes: a pack's slug (`cauldron-copper`), or `build`, `ci`, `docs`, `deps`, `devtools`. -- **Branches:** [Conventional Branch](https://conventionalbranch.org/), e.g. `feat/cauldron-copper-tools`. - Agents may use `claude/…` or `codex/…`. +These hold in every studiobimo repo. Git hooks, CI and an agent hook all enforce them, so a +violation is caught before it is reviewed. + +- **Commits:** [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/), + `(): `. PRs are squash-merged, so the **PR title** must be one too. +- **Branches:** [Conventional Branch](https://conventionalbranch.org/), `/` + in lowercase with single hyphens, e.g. `feat/short-description`. Agents may use `claude/…` or + `codex/…`. - **PR size:** at most 20 changed files. Split bigger work with `gh stack` (`gh stack init`, `gh stack add`, `gh stack submit`). -- **Versioning:** SemVer per pack, managed by release-please. Never edit `version` in a - `pack.toml`, `.release-please-manifest.json` or a `CHANGELOG.md` by hand. -- **Pinning:** GitHub Actions pinned to full SHAs; Python tools locked in `.devtools/uv.lock`. - After changing a tool version, run `make -C .devtools lock`. +- **Versioning:** SemVer, managed by release-please. Never edit a version, a + `.release-please-manifest.json` or a `CHANGELOG.md` by hand. +- **Pinning:** third-party GitHub Actions and pre-commit hooks are pinned to full commit SHAs with + the version in a comment; studiobimo's own reusable workflows are called at `@v1`. Python tools + are locked in `.devtools/uv.lock`. +- **Workflows:** `permissions: {}` at the top, the minimum per job, `persist-credentials: false` + on every checkout, secrets passed explicitly and never with `secrets: inherit`. +- **Say what you tested.** State what you ran and what it showed. If something could not be + tested, say so plainly rather than implying it was. + +A PreToolUse hook (`.devtools/scripts/agent-guard.sh`) blocks `gh pr create`, `gh stack submit` +and `git push` when the PR-size rule is violated, and blocks non-conventional branch names. + +## Where shared things live + +Some files here are not this repo's to edit. Changing them locally only creates drift, which a +weekly workflow reports as an issue. + +| To change | Edit it in | It reaches this repo by | +| --- | --- | --- | +| CI behaviour (lint, PR checks, release) | `studiobimo/.github`, `.github/workflows/` | the `@v1` tag moving | +| Commit, branch and PR-size rules | `studiobimo/.github`, `.devtools/` | a `rev:` bump in `.pre-commit-config.yaml` | +| Files and blocks listed in the template's `.template/manifest` | `studiobimo/project-template` | `make -C .devtools sync` | + +A managed block sits between `>>> template:` and `<<< template:` marker lines, like +this section. Edit outside the markers freely; inside them, change the template instead. If a +difference is deliberate, list the path in `.template-ignore` with a comment saying why. + + +## Pack rules + +- **Versioning is per pack.** release-please owns `version` in every `pack.toml`; one release + PR and one tag (`-v`) per pack. - **Namespaces:** a pack writes only to its own namespace and, for tags, to `minecraft`. It never ships another project's namespace. - **Original work only:** never copy functions, assets, names or branding from third-party @@ -45,9 +81,6 @@ dist/ build output, ignored left in the world (schedules, scoreboards, storage, entities). - **Build output stays out of git:** zips are built into `dist/` and attached to releases. -A PreToolUse hook (`.devtools/scripts/agent-guard.sh`) blocks `gh pr create`, `gh stack submit` -and `git push` when the PR-size rule is violated, and blocks non-conventional branch names. - ## Commands ```sh @@ -55,6 +88,9 @@ make -C .devtools setup # once: pinned tools + git hooks make -C .devtools check # everything CI runs (lint + validate), plus a build make -C .devtools validate # pack layout, namespaces, JSON and references only make -C .devtools build # zip every pack into dist/ (PACK= for one) +make -C .devtools lock # after changing a tool version in .devtools/pyproject.toml +make -C .devtools drift # where this repo differs from studiobimo/project-template +make -C .devtools sync # pull the template's managed files ``` ## Conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19f8456..afffd22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Thanks for helping! This project follows a few strict conventions, and tooling e | `packs//pack.toml` | Release metadata: display name, version, target Minecraft version | | `packs//pack.mcmeta`, `pack.png`, `data/` | The datapack itself. Only these ship in the zip, plus `LICENSE`. | | `packs//README.md` | Player-facing docs, reused for the download page | -| `.devtools/` | Makefile, pinned Python tools, build, check and guard scripts | +| `.devtools/` | Makefile, shared `base.mk`, pinned Python tools, build, check and guard scripts | | `dist/` | Build output. Ignored by git. | ## Adding a pack @@ -43,23 +43,23 @@ exists. It cannot check command syntax; only the game can. Before opening a PR: 2. `/reload` and read the log: a function that fails to parse is reported there and nowhere else. 3. Exercise the feature, then run `/function :uninstall` and confirm it stops. + ## Workflow ### Branches: [Conventional Branch](https://conventionalbranch.org/) `/`, lowercase, with single hyphens. Types: `feature`/`feat`, `bugfix`/`fix`, `hotfix`, `release`, `chore`, plus `claude`/`codex`/`ai` for agent-authored work. -Examples: `feat/cauldron-copper-tools`, `fix/waxed-chests`. +Examples: `feat/short-description`, `fix/what-was-broken`. ### Commits: [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) `(): `. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, -`test`, `build`, `ci`, `chore`, `revert`. Scopes: a pack's slug (`cauldron-copper`), or `build`, -`ci`, `docs`, `deps`, `devtools`. Breaking changes use `!` or a `BREAKING CHANGE:` footer. +`test`, `build`, `ci`, `chore`, `revert`. Breaking changes use `!` or a `BREAKING CHANGE:` footer. +The scopes this project uses are listed in `AGENTS.md`. PRs are **squash-merged**, so the **PR title** must also be a Conventional Commit. -It becomes the commit on `main` that release-please reads. release-please assigns a commit to a -pack by the files it touches, so keep a PR to one pack. +It becomes the commit on `main` that release-please reads. ### Pull requests: at most 20 files @@ -77,6 +77,21 @@ gh stack sync # after a lower layer merges Each layer is measured against the layer below it. The limit is enforced in the `pre-push` hook, in CI, and for AI agents through a PreToolUse hook (`.devtools/scripts/agent-guard.sh`). +### Files the template manages + +Some files, and the regions of others between `>>> template:` and `<<< template:` +markers, are kept in step with `studiobimo/project-template`. Change them there, not here; then +`make -C .devtools sync` brings the change in. `make -C .devtools drift` shows what differs, and a +weekly workflow opens an issue when something does. A deliberate difference goes in +`.template-ignore`, with a comment saying why. + + +### One pack per pull request + +Scopes are a pack's slug (`cauldron-copper`), or `build`, `ci`, `docs`, `deps`, `devtools`. +release-please assigns a commit to a pack by the files it touches, so keep a PR to one pack. +Example branches: `feat/cauldron-copper-tools`, `fix/waxed-chests`. + ## Pack standards - **Namespaces:** a pack writes to its own namespace and, for tags only, to `minecraft`. diff --git a/lychee.toml b/lychee.toml index ede0f39..3aa6f62 100644 --- a/lychee.toml +++ b/lychee.toml @@ -1,8 +1,6 @@ -# Link checking for Markdown (pre-push and CI). max_retries = 2 timeout = 20 accept = ["200..=299", "429"] exclude = [ - # Private until public launch; re-enable when the repo is public. "^https://github\\.com/studiobimo/", ]