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: 11 additions & 9 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,30 +1,28 @@
# syntax=docker/dockerfile:1.28
# syntax=docker/dockerfile:1.28@sha256:bb22d9815c728170f72750f4e5b0d672e06176142e1d602c7e66c050100b7e5b
# Single-stage build: develop (the devcontainer image). Built with the repo
# root as context (see compose.yml). The repo-root Dockerfile is reserved for
# an instance's own runtime image -- see docs/TEMPLATE.md's "Contents" and
# "Instance extension points".

ARG DEBIAN_VERSION=trixie

# renovate: datasource=github-releases depName=j178/prek
ARG PREK_VERSION=0.5.7
ARG PREK_VERSION=0.5.9

# renovate: datasource=npm depName=@anthropic-ai/claude-code
ARG CLAUDE_CODE_VERSION=2.1.310
ARG CLAUDE_CODE_VERSION=2.1.316

# renovate: datasource=github-releases depName=edouard-claude/snip
ARG SNIP_VERSION=0.25.2

# renovate: datasource=github-releases depName=cli/cli
ARG GH_VERSION=2.103.2
ARG GH_VERSION=2.103.3

# uv itself isn't a project dependency manager here -- it's only the
# mechanism scripts/develop.sh uses to install an exact, checksum-verified
# CPython build (see PYTHON_VERSION below) rather than an unpinned apt
# package. Left on PATH afterward; harmless, and an instance that adds
# Python as its own application runtime can reuse it directly.
# renovate: datasource=github-releases depName=astral-sh/uv
ARG UV_VERSION=0.13.5
ARG UV_VERSION=0.13.6

# python3 is infrastructure tooling, not an application runtime this
# template assumes: prek needs an interpreter to build the venv for any
Expand All @@ -37,7 +35,7 @@ ARG PYTHON_VERSION=3.14.8
# (the clear-thought MCP server in .mcp.json) is the only thing that
# needs it.
# renovate: datasource=node-version depName=node
ARG NODE_VERSION=24.22.1
ARG NODE_VERSION=24.22.2

ARG SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ARG SSL_CERT_DIR=/etc/ssl/certs
Expand All @@ -51,7 +49,11 @@ ARG SSL_CERT_DIR=/etc/ssl/certs
# this stage does install are infrastructure tooling only -- see the ARG
# comments above.
########################################
FROM mcr.microsoft.com/devcontainers/base:${DEBIAN_VERSION} AS develop
# A literal tag plus digest, not an ARG: Renovate's dockerfile manager then
# bumps the tag and re-pins the digest together (pinDigests in
# .github/renovate.json). The syntax frontend on line 1 is pinned the same
# way. Digests are of the multi-arch index (amd64 and arm64).
FROM mcr.microsoft.com/devcontainers/base:trixie@sha256:71b6fe7c4e07ec747a94a72b2a17f2f8c286eaa7b1c59862888df764efb8b6c6 AS develop
ARG PREK_VERSION
ARG CLAUDE_CODE_VERSION
ARG SNIP_VERSION
Expand Down
4 changes: 3 additions & 1 deletion .devcontainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
(features, mounts, editor settings).
- `Dockerfile` — the devcontainer image (a single `develop` stage), built
with the repo root as context; `Dockerfile.dockerignore` holds its
build-context excludes. The top-level `Dockerfile` is reserved for an
build-context excludes. Its `FROM` and `# syntax=` lines are pinned by
digest, written literally so Renovate can update tag and digest
together. The top-level `Dockerfile` is reserved for an
instance's runtime image (`../docs/TEMPLATE.md`).
- `compose.yml` — the dev service (`myapp`), built from `Dockerfile`'s
`develop` stage. An instance that needs backing services
Expand Down
32 changes: 31 additions & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,29 @@
"labels": ["security"]
},
"osvVulnerabilityAlerts": true,
"internalChecksFilter": "strict",
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
"groupName": "patch and minor updates",
"automerge": true
"automerge": true,
"description": "Automerged updates wait 3 days after release (the age Renovate's own security:minimumReleaseAgeNpm preset uses), so a hijacked release is usually yanked or flagged before it lands in main. The top-level internalChecksFilter strict (the docs' recommendation with minimumReleaseAge) makes a still-pending version be skipped, not merged.",
"minimumReleaseAge": "3 days"
},
{
"description": "The python-version datasource has no release timestamps, so under the default minimumReleaseAgeBehaviour (timestamp-required) a Python patch would stay pending forever. It is raised without the age check and, since nothing vouches for its age, is not automerged.",
"matchDatasources": ["python-version"],
"matchDepNames": ["python"],
"groupName": "python patch",
"minimumReleaseAgeBehaviour": "timestamp-optional",
"automerge": false
},
{
"description": "The github-runners datasource has no release timestamps either, so the 3 day age check would keep a runner image bump pending forever. Raised without the age check and not automerged.",
"matchDatasources": ["github-runners"],
"groupName": "runner images",
"minimumReleaseAgeBehaviour": "timestamp-optional",
"automerge": false
},
{
"matchUpdateTypes": ["major"],
Expand Down Expand Up @@ -101,6 +119,18 @@
"datasourceTemplate": "docker",
"depNameTemplate": "quay.io/skopeo/stable"
},
{
"description": "The syft image pinned by tag and digest in the Makefile; Renovate updates both.",
"customType": "regex",
"managerFilePatterns": [
"/^Makefile$/"
],
"matchStrings": [
"SYFT_IMAGE := anchore/syft:(?<currentValue>v[0-9][^@\\s]*)@(?<currentDigest>sha256:[a-f0-9]{64})"
],
"datasourceTemplate": "docker",
"depNameTemplate": "anchore/syft"
},
{
"description": "The hadolint image pinned in the hadolint hook.",
"customType": "regex",
Expand Down
45 changes: 40 additions & 5 deletions .github/scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@
`has_make_target.sh` runs no recipe, but make still evaluates
`$(shell ...)` and `include` while parsing the Makefile.
`test_release_scripts.py` tests both, `verify_release_tag.sh`, and that
`../../scripts/develop.sh` never nests `fetch` inside `retry`;
`../../scripts/develop.sh` never nests `fetch` inside `retry`, verifies
downloads with `fetch_checked`/`install_claude`, and runs no installer script, and
that `release.yml` gates its privileged jobs on the default branch;
`scripts-tests` runs it. Keep
`assemble_dist.py` clean under `ruff check --select ALL` (bar `COM812` and
`CPY001`), so instances that select `PL` etc. need no per-file ignores.
Expand Down Expand Up @@ -104,18 +106,51 @@
`test_release_scripts.py`.
- `run_claude.sh` — invokes `claude -p` for one issue-moderation stage
inside the devcontainer; shared by all four Claude-invoking
`../workflows/moderate-*.yml` jobs. See `../workflows/README.md`'s
"Issue moderation" section.
`../workflows/moderate-*.yml` jobs. Takes the API key from its
environment only (never a file), scrubs it from Claude's subprocesses,
appends `MODERATION_EXTRA_TOOLS` and passes inline deny rules. The
workflows run the trusted copy installed at `.moderation-tools/` by
`moderate_prepare.sh`, never the one in the tree Claude edits. See
`../workflows/README.md`'s "Issue moderation" section and
`https://github.com/craftainer/template-base/blob/main/docs/adrs/0001-harden-the-issue-moderation-workflows.md`.
- `moderate_lib.sh` — sourced by the moderation scripts below: `is_sha`,
`is_ancestor` (compare API), `check_result_tree` (the commits a patch
produced against the path allow-list and no symlink or submodule),
`agent_file` (a result file must be a regular file really under
`.moderation-out/`) and the size caps.
- `moderate_check_paths.sh` — the path allow-list: reads NUL-separated
paths on stdin and fails on any under `.github/`, `.devcontainer/`,
`.claude/`, a `Makefile`/`*.mk`, `Dockerfile*`, `compose*.y*ml`/`docker-compose*.y*ml`,
`.pre-commit-config.yaml`, `scripts/release*` and the other files that
decide what runs with credentials.
- `moderate_prepare.sh` / `moderate_verify_branch.sh` — run on the runner
from a snapshot of the default branch before Claude starts: install the
trusted launcher, prompts and hook config, and refuse a repro/plan
branch that differs from the default branch in a denied path.
- `moderate_package_result.sh` — the Claude job's last step: reads the
agent's commits through a fresh bare repository and its note or triage
file only if a regular file under `.moderation-out/`, and writes
`meta.json` and `patch.diff` for the `apply` job.
- `moderate_apply_result.sh` / `moderate_apply_triage.sh` — the `apply`
jobs' scripts (fix/build, and triage): validate the artifact, then push,
open the PR or comment, and label. The only scripts that write; they
never run Claude or anything from the artifact.
- `test_moderate_apply_result.py` / `test_moderate_apply_triage.py` /
`test_moderate_tools.py` — fake-`gh`/`git` tests of the two apply scripts
(argument order, validation, the allow-list, failure paths), and tests of
the allow-list, the packager (real git, symlinks, rewritten history) and
the launcher; `scripts-tests` runs them.
- `moderate_cleanup.sh` — closes any open PR for a moderation branch and
deletes the branch; used by `../workflows/moderate-cleanup.yml` (issue
closed) and `reset_issue_state.sh` (issue reopened). Idempotent.
- `reset_issue_state.sh` — strips stage labels back to the bare
`bug`/`enhancement` label and reconciles any leftover branch/PR, on
issue reopen; used by `../workflows/moderate-bug-triage.yml` and
issue reopen; used by the `apply` jobs of
`../workflows/moderate-bug-triage.yml` and
`../workflows/moderate-feature-triage.yml`.
- `prompts/` — the Claude prompt for each moderation stage
(`bug-triage.md`, `bug-fix.md`, `feature-triage.md`,
`feature-build.md`), read by `run_claude.sh`.
`feature-build.md`), read by `run_claude.sh` from `.moderation-tools/`.

## Do

Expand Down
66 changes: 47 additions & 19 deletions .github/scripts/moderate_apply_result.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
# moderate-bug-fix.yml / moderate-feature-build.yml.
set -euo pipefail

# shellcheck source=moderate_lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/moderate_lib.sh"

# Needs, from the workflow's env: GH_TOKEN, GITHUB_REPOSITORY and
# DEFAULT_BRANCH (github.event.repository.default_branch).
: "${DEFAULT_BRANCH:?DEFAULT_BRANCH must be set}"

result_dir="$1"
branch_prefix="$2" # "feature" or "bug"
branch_suffix="$3" # "plan" or "repro"
Expand Down Expand Up @@ -44,6 +51,11 @@ if ! [[ "$issue_number" =~ ^[0-9]+$ ]]; then
echo "::error::issue_number in meta.json isn't numeric: $issue_number" >&2
exit 1
fi
# The artifact must belong to the issue this run is for.
if [ -n "${ISSUE_NUMBER:-}" ] && [ "$issue_number" != "$ISSUE_NUMBER" ]; then
echo "::error::issue_number in meta.json ($issue_number) is not this run's issue ($ISSUE_NUMBER)" >&2
exit 1
fi

# Re-adding the confirm label during a run queues a second run behind the
# first (the workflow's concurrency group serializes them). If the ready
Expand All @@ -60,33 +72,49 @@ outcome="$(jq -r '.outcome' "$meta")"

# Everything that can fail on a "built" result. Explicit `|| return 1`, not
# errexit: the caller runs this in an `if !` condition, which suspends it.
# Nothing from meta.json reaches git or gh unvalidated: base_sha must be a full
# hex sha, the branch name is built from fixed parts plus a numeric issue
# number, and every ref follows a `--`.
apply_built() {
local remote_sha pr_url
is_sha "$base_sha" || { echo "::error::base_sha is not a 40-character sha" >&2; return 1; }
if [ -f "$result_dir/patch.diff" ] && [ "$(wc -c < "$result_dir/patch.diff")" -gt "$MAX_PATCH_BYTES" ]; then
echo "::error::patch.diff is over $MAX_PATCH_BYTES bytes" >&2
return 1
fi

# The branch is bot-owned: triage's apply step creates `<prefix>/<n>-<suffix>`
# (the repro/plan branch this result builds on) and only the apply scripts
# write to it afterwards. The lease pins the tip read here, so a push by
# anyone in between still fails. base_sha must be that tip or an ancestor of
# it (an earlier attempt's commits sit on top); with no such branch it must
# be an ancestor of the default branch. A tip that does not descend from
# base_sha is foreign work: refuse.
remote_sha="$(git ls-remote -- origin "refs/heads/$branch" | cut -f1)" || return 1
if [ -n "$remote_sha" ]; then
is_sha "$remote_sha" || return 1
is_ancestor "$base_sha" "$remote_sha" || return 1
else
is_ancestor "$base_sha" "$DEFAULT_BRANCH" || return 1
fi

git config user.name "claude-moderator[bot]" || return 1
git config user.email "claude-moderator[bot]@users.noreply.github.com" || return 1
git fetch --depth=1 origin "$base_sha" || return 1
git checkout -B "$branch" FETCH_HEAD || return 1
git am "$result_dir/patch.diff" || return 1

# The branch is bot-owned: triage's Claude run creates `<prefix>/<n>-<suffix>`
# (the repro/plan branch this result builds on) and only this script writes
# to it afterwards. The lease pins the tip read here, so a push by anyone
# in between still fails. A tip other than base_sha must be a descendant of
# it (an earlier attempt's commits), otherwise it's foreign work: refuse.
remote_sha="$(git ls-remote origin "refs/heads/$branch" | cut -f1)" || return 1
if [ -n "$remote_sha" ] && [ "$remote_sha" != "$base_sha" ]; then
git fetch --depth=100 origin "$base_sha" "$remote_sha" || return 1
git merge-base --is-ancestor "$base_sha" "$remote_sha" || return 1
fi
git push --force-with-lease="$branch:$remote_sha" origin "$branch" || return 1
git fetch --depth=1 -- origin "$base_sha" || return 1
git checkout -B "$branch" FETCH_HEAD -- || return 1
git am -- "$result_dir/patch.diff" || return 1
# The applied commits, not the patch text, are what gets checked: renames,
# mode changes and binary files all show up in the diff.
check_result_tree "$base_sha" || return 1
git push --force-with-lease="$branch:$remote_sha" -- origin "$branch" || return 1

pr_url="$(gh pr list --head "$branch" --state open --json number,url --jq '.[0].url // ""')" || return 1
if [ -n "$pr_url" ]; then
echo "Reusing open PR $pr_url for $branch."
else
gh pr create --title "$subject" --body "$body

$closes_verb #$issue_number" --base main --head "$branch" || return 1
$closes_verb #$issue_number" --base "$DEFAULT_BRANCH" --head "$branch" || return 1
fi

# Only now that the PR exists does the stage move on.
Expand All @@ -96,16 +124,16 @@ $closes_verb #$issue_number" --base main --head "$branch" || return 1
if [ "$outcome" = built ]; then
base_sha="$(jq -r '.base_sha' "$meta")"
branch="$branch_prefix/$issue_number-$branch_suffix"
subject="$(jq -r '.subject' "$meta")"
body="$(jq -r '.body' "$meta")"
subject="$(jq -r --argjson n "$MAX_TITLE_CHARS" '(.subject // "") | .[0:$n]' "$meta")"
body="$(jq -r --argjson n "$MAX_TEXT_CHARS" '(.body // "") | .[0:$n] | gsub("@"; "@\u200b")' "$meta")"

if ! apply_built; then
fail "Applying the automated result (patch, push, PR or relabel) failed."
fi
else
# Not built: the stage stays at "ready" on purpose, so a maintainer's
# retry (re-adding the confirm label) re-runs the build.
body="$(jq -r '.body' "$meta")"
body="$(jq -r --argjson n "$MAX_TEXT_CHARS" '(.body // "") | .[0:$n] | gsub("@"; "@\u200b")' "$meta")"
gh issue edit "$issue_number" --add-label "needs-human" || true
gh issue comment "$issue_number" --body "$body"
fi
Loading
Loading