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
6 changes: 6 additions & 0 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@
that label the test fails until you look up the new image's `python3`
(actions/runner-images) and update `RUNNER_IMAGE_PYTHON`, `RUNNER_PYTHON` and
`ruff.toml` together.
- Formatting: base has no ruff dependency of its own, so `ruff-check`/`ruff-format`
from `astral-sh/ruff-pre-commit` in `../../.pre-commit-config.yaml`, scoped
to `^\.github/scripts/.*\.py$`, keep these scripts formatted to `ruff.toml`.
Unformatted scripts would otherwise fail every Python instance's own
`ruff format` hook after a sync. Keep that hook's `rev` equal to the ruff
the Python templates pin.
- `test_moderate_labels.py` — checks that every `create "<name>" "<color>"
"<description>"` line in `../workflows/moderate-setup.yml` has a description
of at most 100 characters (GitHub answers HTTP 422 to a longer one, and only
Expand Down
6 changes: 4 additions & 2 deletions .github/scripts/ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ line-length = 100

[lint]
# Ruff's long-standing defaults, spelled out so a newer ruff that widens
# its default rule set does not start flagging these scripts. Base itself
# does not run ruff; this keeps `ruff check` quiet in every checkout.
# its default rule set does not start flagging these scripts. Base runs
# ruff on this directory only through the ruff-pre-commit hook in
# ../../.pre-commit-config.yaml; this keeps `ruff check` quiet in every
# checkout.
select = ["E4", "E7", "E9", "F"]
12 changes: 10 additions & 2 deletions .github/scripts/test_moderate_apply_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ def test_base_sha_must_be_a_full_hex_sha(self) -> None:
(self.tmp / "log.txt").unlink(missing_ok=True)
proc = self.run_script(meta_extra={"base_sha": bad})
log = self.log()
self.assertFalse([c for c in log if c.startswith(("git fetch", "git am", "gh api"))])
self.assertFalse(
[c for c in log if c.startswith(("git fetch", "git am", "gh api"))]
)
self.assert_failed_cleanly(proc, log)

def test_denied_path_in_the_patch_is_never_pushed(self) -> None:
Expand Down Expand Up @@ -317,7 +319,13 @@ def test_ordinary_paths_pass(self) -> None:
"""Source, tests and docs (also next to look-alike names) are accepted."""
diff = "".join(
f":100644 100644 a b M\\0{p}\\0"
for p in ("src/a.py", "tests/test_a.py", "docs/Makefile.md", "scripts/other.sh", ".githubx/y")
for p in (
"src/a.py",
"tests/test_a.py",
"docs/Makefile.md",
"scripts/other.sh",
".githubx/y",
)
)
proc = self.run_script(run_env={"FAKE_DIFF": diff})
self.assertEqual(proc.returncode, 0, proc.stderr)
Expand Down
12 changes: 9 additions & 3 deletions .github/scripts/test_moderate_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ def test_key_only_from_the_environment(self) -> None:
self.assertIn("KEY:sk-test", lines)
self.assertIn("SCRUB:1", lines)
self.assertIn("AUTHOR:claude-moderator[bot]", lines)
self.assertEqual(lines[lines.index("ARG:--allowedTools") + 1], "ARG:Read,Grep,Bash(uv run pytest *)")
self.assertEqual(
lines[lines.index("ARG:--allowedTools") + 1], "ARG:Read,Grep,Bash(uv run pytest *)"
)
settings = json.loads(lines[lines.index("ARG:--settings") + 1].removeprefix("ARG:"))
self.assertIn("Read(//proc/**)", settings["permissions"]["deny"])
sandbox = settings["sandbox"]
Expand All @@ -281,14 +283,18 @@ def test_key_only_from_the_environment(self) -> None:
self.assertNotIn("enableWeakerNestedSandbox", sandbox)
self.assertEqual(sandbox["network"]["allowedDomains"], ["github.com", "api.github.com"])
self.assertIs(sandbox["network"]["strictAllowlist"], True)
self.assertIn({"name": "ANTHROPIC_API_KEY", "mode": "deny"}, sandbox["credentials"]["envVars"])
self.assertIn(
{"name": "ANTHROPIC_API_KEY", "mode": "deny"}, sandbox["credentials"]["envVars"]
)
self.assertEqual(lines[lines.index("ARG:-p") + 1], "ARG:do the thing")

def test_missing_or_bad_domains_fail_before_claude_runs(self) -> None:
"""No network allow-list, or a malformed host, means claude never starts."""
for domains in ("", ",", 'x.com","evil', "a b"):
with self.subTest(domains=domains):
proc = self.launch({"ANTHROPIC_API_KEY": "k", "MODERATION_ALLOWED_DOMAINS": domains})
proc = self.launch(
{"ANTHROPIC_API_KEY": "k", "MODERATION_ALLOWED_DOMAINS": domains}
)
self.assertEqual(proc.returncode, 1)
self.assertFalse(self.out.exists())

Expand Down
6 changes: 5 additions & 1 deletion .github/template-sync-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ ignore:
# Env-var documentation for the instance's own deployment.
- .env.example

# Each instance's README is entirely its own (image, usage, runtime); a
# merge against this template's README conflicted on every sync, so it
# is instance-owned from creation, like the Dockerfile.
- README.md

# The instance's ZAP baseline rule levels (seeded with a commented header;
# an instance adds its own FAIL/IGNORE lines, with a reason each).
- .zap/rules.tsv
Expand Down Expand Up @@ -205,7 +210,6 @@ ignore:
merge:
# Project identity (name/description/version) is interpolated per
# instance; everything else in these two files is template-owned.
- README.md
- package.json
- pnpm-workspace.yaml

Expand Down
2 changes: 1 addition & 1 deletion .github/template-sync-state.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"template_repo": "craftainer/template-docker",
"synced_tag": "v1.8.3"
"synced_tag": "v1.8.4"
}
22 changes: 22 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ repos:
- id: check-added-large-files
stages: [pre-commit, manual]
- id: check-merge-conflict
# Without this the hook only runs during an in-progress git merge, so
# conflict markers committed by a template-sync PR were never caught.
args: [--assume-in-merge]
stages: [pre-commit, manual]
- id: mixed-line-ending
stages: [pre-commit, manual]
Expand All @@ -56,6 +59,25 @@ repos:
- id: conventional-pre-commit
stages: [commit-msg]

# Guard for the upstream-owned scripts in .github/scripts/: base ships
# them to every instance, whose own ruff-format hook then checks them
# against .github/scripts/ruff.toml (the closest config wins), so an
# unformatted script here fails every Python instance after a sync. Base
# has no ruff dependency of its own, hence this pinned hook, scoped to
# that directory only. Keep the rev equal to the ruff the Python
# templates pin in pyproject.toml.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.8
hooks:
- id: ruff-check
name: ruff check (.github/scripts)
files: ^\.github/scripts/.*\.py$
stages: [pre-commit, manual]
- id: ruff-format
name: ruff format (.github/scripts)
files: ^\.github/scripts/.*\.py$
stages: [pre-commit, manual]

# oxlint, prettier, tsc and vitest are already project dev-dependencies
# (see package.json) — running them via `pnpm` keeps their pinned
# versions as the single source of truth instead of a second, separate
Expand Down
18 changes: 16 additions & 2 deletions docs/TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,20 @@ instance's own toolchain needs.
`.pre-commit-config.yaml` defines whitespace/EOF fixers, YAML/TOML/JSON
checks, `conventional-pre-commit` (Conventional Commits, enforced at the
`commit-msg` stage), the `template-sync-manifest` completeness check, and
the `devcontainer-lock` lockfile-consistency check, and three security
scanners (below). An instance layers
the `devcontainer-lock` lockfile-consistency check, `ruff-check`/`ruff-format`
(`astral-sh/ruff-pre-commit`, scoped to `.github/scripts/*.py` so base can't
ship scripts that fail a Python instance's own ruff hook; keep its `rev` equal
to the ruff the Python templates pin), and three security scanners (below). An instance layers
its own language-specific hooks on top (lint,
format, type check, test suite, dependency-vulnerability scan, ...),
matching the shape `template-fastapi`'s own `.pre-commit-config.yaml`
already establishes for `ruff`/`mypy`/`pytest`/`pip-audit`.

`check-merge-conflict` runs with `--assume-in-merge`, so it flags committed
conflict markers (a line starting `<<<<<<< `, `=======` or `>>>>>>> `) in any
file, not just during an in-progress git merge; a template sync PR that
commits a merge-tier conflict is then caught in CI.

### Security scanners

| Hook | Scans | Stage | Network | OWASP (2025) |
Expand Down Expand Up @@ -882,6 +889,13 @@ the place for their own runtime (FastAPI, axum, React) and their own ADRs.
A mid-layer classifies its own tree for its instances in its own
`.github/template-sync-manifest.yml`, which is `merge` tier in every
instance: base's additions are resolved by hand there.
In `template-docker`, `README.md` is `ignore` tier: each instance's
README is entirely its own, so a merge conflicted on every sync. The
instance owns it from creation, and later changes to the template's
README no longer reach instances by sync. This applies through the
chain: the README of `template-fastapi`, `template-axum` and
`template-react` becomes owned by that app template, and its own
instances follow that template's manifest.
An instance lists extra paths of its own under `ignore:` in
`.github/template-sync-manifest.local.yml`. Which template an instance
follows is recorded in its `.github/template-sync-state.json`
Expand Down
Loading