ci: give the Claude review agents semcode (#44651)

The Claude review agents navigate the systemd and kernel sources with
grep
and by reading whole files. semcode
(https://github.com/masoncl/semcode-devel)
indexes C code with tree-sitter and answers where a function is defined,
who
calls it and what a type looks like from the index, in one call, through
an
MCP server. This gives the agents semcode for the checkout and for the
kernel
tree, and makes them use it.

- A new semcode workflow builds semcode at a pinned commit of
semcode-devel
and indexes mainline once a week, both into the actions cache. A cold
build
  takes about twenty minutes and the kernel index is about a gigabyte,
neither fits into a review. systemd uses no actions cache so far, so the
  whole quota is available.
- The review job restores both, indexes the pull request (the head plus
the
range from the merge base, so lookups work before and after the change
and
per commit; about a minute on a runner) and gives every lens a systemd
and
a linux server. When there is a kernel index, the kernel is fetched at
the
  indexed commit instead of the latest mainline.
- Everything semcode runs in CI runs under bubblewrap: the indexers with
only
  the index writable, the servers read-only and without network, and no
server starts if bwrap doesn't work. semcode-mcp runs outside the Claude
  Code sandbox, so this gives it one.
- A PreToolUse hook denies recursive grep over src/ and over the kernel
tree
  and names the semcode tools instead. Single files, the rest of the
  checkout, Documentation/, the other trees and "define" lookups stay
  grep-able, since object-like macros aren't indexed.
- Each lens reports how many semcode calls succeeded and how many grep
  commands it ran, so we can see whether the agents actually use it.

Nothing changes for reviews until the semcode workflow has run on main
once
and filled the cache. Until then the review runs as before.
This commit is contained in:
Christian Brauner authored and GitHub committed 2026-11-05 16:27:40 +01:00
commit c53e3873c5
2 files changed
+495 -17

No files matched your search

+360 -17
View File
@@ -289,12 +289,61 @@ jobs:
git fetch origin "$PREVIOUS_HEAD" || echo "Previous head ${PREVIOUS_HEAD} is not fetchable, reviewing the full diff."
fi
# Claude Code sandboxes the agents with bubblewrap. So does the review for
# everything semcode runs on the pull request, hence this comes first.
- name: Install sandbox dependencies
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sudo apt-get update && sudo apt-get install -y bubblewrap socat
# The semcode workflow builds semcode and indexes the kernel with it, both
# into the actions cache, under a key that ends in the commit of each. The
# newest entry for the prefix is restored. The path is part of the cache
# version, so it is the one semcode.yml saves. A review without semcode is
# still a review, so a miss only logs.
- name: Restore semcode
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ runner.temp }}/semcode
key: semcode-bin-${{ runner.os }}-${{ runner.arch }}-
restore-keys: semcode-bin-${{ runner.os }}-${{ runner.arch }}-
- name: Check semcode
id: semcode
run: |
semcode="$RUNNER_TEMP/semcode"
ref="$(cat "$semcode/ref" 2>/dev/null || :)"
if [[ ! -x "$semcode/bin/semcode-index" || ! -x "$semcode/bin/semcode-mcp" || ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then
echo "No usable semcode in the cache, reviewing without it."
rm -rf "$semcode"
exit 0
fi
echo "Using semcode ${ref}."
echo "ref=${ref}" >>"$GITHUB_OUTPUT"
# An index is only readable by the semcode that wrote it, so the kernel
# index is looked up under the commit of the restored binaries.
- name: Restore the kernel index
if: steps.semcode.outputs.ref != ''
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ runner.temp }}/semcode-linux
key: semcode-linux-${{ runner.os }}-${{ runner.arch }}-${{ steps.semcode.outputs.ref }}-
restore-keys: semcode-linux-${{ runner.os }}-${{ runner.arch }}-${{ steps.semcode.outputs.ref }}-
# The model's knowledge of the kernel, glibc, musl, and UAPI specifications can
# be wrong or out of date. The review agents therefore check details in the
# cloned repositories. A review without a repository is still useful, so a
# failed clone does not fail the job. git clones into a temporary directory
# first. If the timeout kills git, the final directory does not exist, and
# the agents are not told about it.
#
# The kernel index only answers for the commit it was built from, so with an
# index in the cache the kernel is fetched at that commit, which mainline
# keeps, instead of the latest. The fetch has depth two: at startup
# semcode-mcp compares HEAD with its parent to see that the index covers
# HEAD, and without the parent it checks every file instead. When the fetch
# fails, the latest mainline is cloned and the index stays unused.
- name: Clone the Linux kernel, glibc, musl, and UAPI specifications
continue-on-error: true
timeout-minutes: 10
@@ -306,8 +355,24 @@ jobs:
mv "$dir.tmp" "$dir" &&
git -C "$dir" log -1 --format="Cloned $name at %H (%s)"
}
clone_linux() {
local url=https://github.com/torvalds/linux.git dir="$RUNNER_TEMP/claude-review/linux" commit
commit="$(cat "$RUNNER_TEMP/semcode-linux/commit" 2>/dev/null || :)"
if [[ "$commit" =~ ^[0-9a-f]{40}$ ]]; then
if git init --quiet "$dir.tmp" &&
git -C "$dir.tmp" fetch --quiet --depth 2 "$url" "$commit" &&
git -C "$dir.tmp" checkout --quiet --detach FETCH_HEAD; then
mv "$dir.tmp" "$dir" &&
git -C "$dir" log -1 --format="Cloned linux at %H (%s), the commit of the semcode index"
return
fi
rm -rf "$dir.tmp"
echo "Fetching the indexed kernel commit ${commit} failed, cloning the latest mainline instead."
fi
clone linux "$url" --depth 1 --single-branch
}
pids=()
clone linux https://github.com/torvalds/linux.git --depth 1 --single-branch &
clone_linux &
pids+=($!)
clone glibc https://sourceware.org/git/glibc.git --depth 1 --single-branch &
pids+=($!)
@@ -357,10 +422,39 @@ jobs:
}' "$INPUT/pr-context.json" >"$INPUT/discussion.json"
rm "$INPUT/pr-context.json"
- name: Install sandbox dependencies
# The first run indexes the files of the head. The second adds the files
# each commit of the pull request changed, at every commit in the range and
# at the merge base, and the commits themselves. Lookups at the head, at
# the merge base and at any commit in between then answer from the index.
# semcode-index parses the pull request, so it runs in a sandbox like the
# agents do: every file read-only but the index, nothing under /run, no
# network, none of the job's environment. A read-only bind does not stop
# a connect, and the docker socket lives in /run. The index moves into
# place once both runs are done, so a run the timeout kills leaves nothing
# behind to serve.
- name: Index the pull request with semcode
if: steps.semcode.outputs.ref != ''
continue-on-error: true
timeout-minutes: 10
env:
MERGE_BASE: ${{ steps.inputs.outputs.merge_base }}
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sudo apt-get update && sudo apt-get install -y bubblewrap socat
index="$RUNNER_TEMP/claude-review/semcode-systemd"
rm -rf "$index.tmp"
mkdir -p "$index.tmp"
semcode_index() {
bwrap --ro-bind / / --tmpfs /run --dev /dev --proc /proc --tmpfs /tmp --bind "$index.tmp" "$index.tmp" \
--clearenv --setenv PATH /usr/bin:/bin --setenv HOME "$HOME" \
--unshare-all --die-with-parent --new-session -- \
"$RUNNER_TEMP/semcode/bin/semcode-index" \
--source "$GITHUB_WORKSPACE/source" --database "$index.tmp/.semcode.db" "$@"
}
if ! semcode_index || ! semcode_index --git "${MERGE_BASE}..${HEAD_SHA}"; then
rm -rf "$index.tmp"
exit 1
fi
mv "$index.tmp" "$index"
du -sh "$index"
- name: Install Claude Code
run: |
@@ -402,12 +496,12 @@ jobs:
cat > ~/.claude/settings.json << 'SETTINGS'
{
"permissions": {
"allow": ["Bash", "Read", "Grep", "Glob"]
"allow": ["Bash", "Read", "Grep", "Glob", "mcp__systemd__*", "mcp__linux__*"]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Grep|Glob",
"matcher": "Bash|Read|Grep|Glob|mcp__.*",
"hooks": [
{
"type": "command",
@@ -472,6 +566,7 @@ jobs:
--max-turns 100 \
--setting-sources user \
--tools Bash,Read,Grep,Glob \
"${mcp_args[@]}" \
--output-format stream-json \
--verbose \
--json-schema "$schema" \
@@ -483,21 +578,31 @@ jobs:
# got at least one tool result other than its own structured output.
# When the sandbox is broken, every Bash call fails while Read and Grep
# still work. If the agent ran Bash, also require that one Bash call
# succeeded.
local worked bash_calls bash_worked
read -r worked bash_calls bash_worked < <(jq -Rnr '
# succeeded. The same goes for the semcode servers. A call a hook denied
# or one that ran into the time limit is an error too, but says nothing
# about either, so both are left out. The grep calls are counted to see
# how much searching still bypasses semcode.
local worked bash_calls bash_worked semcode_calls semcode_worked greps denied timeouts
read -r worked bash_calls bash_worked semcode_calls semcode_worked greps denied timeouts < <(jq -Rnr '
def text: (.content // "") | if type == "string" then . else map(.text? // "") | join("") end;
[inputs | fromjson?] as $events
| [$events[] | select(.type == "assistant") | .message.content[]?
| select(.type == "tool_use")] as $calls
| [$calls[] | select(.name == "StructuredOutput") | .id] as $ignore
| [$calls[] | select(.name == "Bash") | .id] as $bash
| [$events[] | select(.type == "user") | .message.content[]?
| select(.type == "tool_result" and .is_error != true) | .tool_use_id] as $ok
| "\($ok - $ignore | length) \($bash | length) \($ok - ($ok - $bash) | length)"' <"$OUTPUT/$name.log")
if [[ -s "$OUTPUT/$name.json" ]] && (( worked > 0 )) && (( bash_calls == 0 || bash_worked > 0 )); then
echo "${name}: done (status ${status})"
| select(.type == "tool_result")] as $results
| [$results[] | select(text | startswith("PreToolUse:")) | .tool_use_id] as $denied
| [$results[] | select(text | test("^MCP server .* timed out after")) | .tool_use_id] as $timeouts
| [$calls[] | select(.name == "StructuredOutput") | .id] as $ignore
| ([$calls[] | select(.name == "Bash") | .id] - $denied) as $bash
| ([$calls[] | select(.name | startswith("mcp__")) | .id] - $denied - $timeouts) as $semcode
| [$calls[] | select(.name == "Grep" or (.name == "Bash" and (.input.command // ""
| test("(^|[^[:alnum:]_./-])(grep|egrep|fgrep|rg|ag|ack|git\\s+(-C\\s+\\S+\\s+)?grep)(\\s|$)"))))] as $greps
| [$results[] | select(.is_error != true) | .tool_use_id] as $ok
| "\($ok - $ignore | length) \($bash | length) \($ok - ($ok - $bash) | length) \($semcode | length) \($ok - ($ok - $semcode) | length) \($greps | length) \($denied | length) \($timeouts | length)"' <"$OUTPUT/$name.log")
if [[ -s "$OUTPUT/$name.json" ]] && (( worked > 0 )) && (( bash_calls == 0 || bash_worked > 0 )) && (( semcode_calls == 0 || semcode_worked > 0 )); then
echo "${name}: done (status ${status}, ${semcode_worked} of ${semcode_calls} semcode call(s) succeeded, ${timeouts} timed out, ${greps} grep call(s), ${denied} call(s) denied by hooks)"
else
echo "${name}: no result (status ${status}, ${worked} tool call(s) succeeded, ${bash_worked} of ${bash_calls} Bash call(s) succeeded)"
echo "${name}: no result (status ${status}, ${worked} tool call(s) succeeded, ${bash_worked} of ${bash_calls} Bash call(s) succeeded, ${semcode_worked} of ${semcode_calls} semcode call(s) succeeded)"
rm -f "$OUTPUT/$name.json"
fi
}
@@ -522,7 +627,7 @@ jobs:
if [[ -d "$linux" ]]; then
context+="
A shallow clone of the latest mainline Linux kernel is at ${linux}. It has no history. When a
A shallow clone of a recent mainline Linux kernel is at ${linux}. It has no history. When a
finding depends on kernel behavior, such as system call semantics, flags, error codes, or
ioctl and netlink interfaces, check the kernel source there instead of relying on memory."
fi
@@ -557,6 +662,244 @@ jobs:
the specification in ${uapi}/specs instead of relying on memory."
fi
# semcode answers lookups by name and call graph from the indexes built
# above, with one server per indexed tree. semcode-mcp runs outside the
# Claude Code sandbox, so it gets one of its own, or does not run at all:
# every file read-only, nothing under /run (a read-only bind does not
# stop a connect, and the docker socket lives there), no network, none
# of the job's environment, gone with the agent. Each index is complete
# for the commit its tree is checked out at, so the indexing pass
# semcode-mcp runs at startup has nothing to write. The server files
# live in $RUNNER_TEMP, which the sandbox denies writes to, so a
# sandboxed shell cannot replace them. A server that does not answer a
# probe is not offered, so the agents are never pointed at tools that
# do not exist.
#
# A call that runs longer than the timeout fails and the agent carries
# on. Without one, Claude Code waits half an hour for a stdio server,
# longer than the agent has. The server is not told, so it finishes the
# call anyway; it answers requests concurrently, so the next call does
# not queue behind it.
mcp_args=()
servers='{}'
sandbox=(bwrap --ro-bind / / --tmpfs /run --dev /dev --proc /proc --tmpfs /tmp --clearenv --setenv PATH /usr/bin:/bin --setenv HOME "$HOME" --unshare-all --die-with-parent --new-session)
if ! "${sandbox[@]}" -- /bin/true; then
echo "bubblewrap does not work, reviewing without semcode."
rm -rf "$RUNNER_TEMP/claude-review/semcode-systemd" "$RUNNER_TEMP/semcode-linux"
fi
add_server() {
local name=$1 db=$2 repo=$3 wrapper="$RUNNER_TEMP/claude-review/semcode-mcp-$1"
cat >"$wrapper" <<EOF
#!/bin/bash
exec ${sandbox[*]} -- \\
"$RUNNER_TEMP/semcode/bin/semcode-mcp" --database "$db" --git-repo "$repo"
EOF
chmod +x "$wrapper"
# The pipe stays open long enough for the answers. A server whose
# index is unusable lists its tools all the same but answers a lookup
# with an error text instead of a search.
local answer
answer="$({ printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"grep_functions","arguments":{"pattern":"probe_no_such_symbol_","limit":1}}}'
sleep 3; } | timeout 30 "$wrapper" 2>/dev/null)" || answer=""
if ! grep -c '"find_function"' <<<"$answer" >/dev/null || ! grep -c 'Searching function bodies' <<<"$answer" >/dev/null; then
echo "The semcode server for $name does not answer, reviewing without it."
return 1
fi
servers="$(jq -c --arg name "$name" --arg command "$wrapper" '. + {($name): {command: $command, timeout: 120000}}' <<<"$servers")"
}
systemd_index="$RUNNER_TEMP/claude-review/semcode-systemd"
systemd_indexed=""
if [[ -d "$systemd_index/.semcode.db" ]] && add_server systemd "$systemd_index/.semcode.db" "$SOURCE"; then
systemd_indexed="$SOURCE"
context+="
The checkout is indexed with semcode; its tools are the mcp__systemd__* tools. Use
find_function, find_type, find_callers, find_calls, find_callchain and grep_functions to read
and search the C code of the checkout instead of grep: one call answers what grep and reading
files answer in many. Lookups default to the head; pass git_sha=${MERGE_BASE} for the code
before the pull request, and find_commit with git_range=${MERGE_BASE}..${HEAD_SHA} for the
commits under review. Object-like macros, global variables and files that are not C are not
indexed; read those files directly."
fi
linux_index="$RUNNER_TEMP/semcode-linux"
linux_indexed=""
if [[ -d "$linux_index/.semcode.db" && -d "$linux" ]] &&
[[ "$(cat "$linux_index/commit" 2>/dev/null)" =~ ^[0-9a-f]{40}$ ]] &&
[[ "$(git -C "$linux" rev-parse HEAD)" == "${BASH_REMATCH[0]}" ]] &&
add_server linux "$linux_index/.semcode.db" "$linux"; then
linux_indexed="$linux"
context+="
The kernel tree is indexed with semcode; its tools are the mcp__linux__* tools. Use
find_function, find_type, find_callers, find_calls, find_callchain and grep_functions to read
and search kernel code instead of grep."
fi
if [[ "$servers" != "{}" ]]; then
jq -n --argjson servers "$servers" '{mcpServers: $servers}' >"$RUNNER_TEMP/claude-review/mcp.json"
mcp_args=(--mcp-config "$RUNNER_TEMP/claude-review/mcp.json" --strict-mcp-config)
# A recursive search of indexed C code is what the semcode tools are
# for, so the hook below denies it and names them: the checkout and
# its src/, and the kernel tree outside Documentation/, when their
# index is served. Single files, the rest of the checkout, searches
# restricted to other file types and object-like macros, which are
# not indexed, stay searchable.
guard="$RUNNER_TEMP/claude-review/semcode-guard"
cat >"$guard" <<'GUARD'
#!/bin/bash
# PreToolUse hook: the tool call arrives as JSON on stdin, exit status 2
# denies it and shows stderr to the agent. $1 is the checkout and $2 the
# kernel tree, each empty when its index is not served.
set -uf
source=$1 linux=$2
input="$(cat)"
tool="$(jq -r '.tool_name' <<<"$input")"
# A directory of indexed C code: the checkout as a whole or below src/,
# the kernel tree outside Documentation/.
indexed() {
local path
[[ -d "$1" ]] || return 1
path="$(realpath -m -- "$1")"
[[ -n "$source" && ("$path" == "$source" || "$path" == "$source/src" || "$path" == "$source/src/"*) ]] && return 0
[[ -n "$linux" && ("$path" == "$linux" || "$path" == "$linux/"*) &&
"$path" != "$linux/Documentation" && "$path" != "$linux/Documentation/"* ]] && return 0
return 1
}
# A file pattern that selects no indexed C: a suffix other than .c, .h,
# .rs and .zig, or a build file.
non_c() {
local name=${1##*/}
[[ "$name" == *\{* ]] && return 1
[[ "$name" =~ \.(c|h|rs|zig)$ ]] && return 1
[[ "$name" =~ \.[[:alnum:]_]+$ || "$name" =~ ^(Makefile|Kconfig|Kbuild)[*]?$ ]]
}
deny() {
echo "Recursive searches of indexed C code are denied: the semcode tools answer them in one call. The checkout and its src/ are served by the mcp__systemd__* tools, the kernel tree outside Documentation/ by the mcp__linux__* tools: find_function and find_type for a definition, find_callers, find_calls and find_callchain for the call graph, grep_functions with path_pattern for a regex over function bodies. grep stays available for single files, for the parts of the checkout outside src/, for searches restricted to other file types with --include, --glob or the glob and type parameters, and for patterns containing define, as object-like macros are not indexed." >&2
exit 2
}
case "$tool" in
Grep)
path="$(jq -r '.tool_input.path // ""' <<<"$input")"
glob="$(jq -r '.tool_input.glob // ""' <<<"$input")"
type="$(jq -r '.tool_input.type // ""' <<<"$input")"
[[ -n "$glob" ]] && non_c "$glob" && exit 0
[[ -n "$type" && ! "$type" =~ ^(c|cpp|rust|zig)$ ]] && exit 0
[[ -n "$path" ]] && indexed "$path" && deny
;;
Bash)
command="$(jq -r '.tool_input.command // ""' <<<"$input")"
[[ "${command,,}" == *define* ]] && exit 0
grep_re='(^|[^[:alnum:]_./-])(grep|egrep|fgrep|rg|ag|ack)([[:space:]]|$)'
gitgrep_re='(^|[^[:alnum:]_./-])git[[:space:]]+(-C[[:space:]]+([^[:space:]]+)[[:space:]]+)?grep([[:space:]]|$)'
# One segment per simple command, after joining continued lines and
# blanking the separators inside quotes. A segment after a pipe reads
# it instead of files. Only the operands of a segment that searches
# count, resolved against the directory of a cd before it or of git -C.
masked="" rest=${command//\\$'\n'/ }
while [[ "$rest" =~ ^([^\'\"]*)(\'[^\']*\'|\"[^\"]*\")(.*)$ ]]; do
quoted=${BASH_REMATCH[2]}
masked+="${BASH_REMATCH[1]}${quoted//[|&;$'\n']/ }"
rest=${BASH_REMATCH[3]}
done
masked+=$rest
segments=${masked//$'\n'/;}
segments=${segments//'||'/;}
segments=${segments//'&&'/;}
segments=${segments//'|'/'; __pipe__ '}
segments=${segments//'&'/;}
cwd=""
IFS=';' read -ra parts <<<"$segments"
for segment in "${parts[@]}"; do
# shellcheck disable=SC2206
words=($segment)
piped=0
if [[ "${words[0]-}" == __pipe__ ]]; then
piped=1
words=("${words[@]:1}")
fi
(( ${#words[@]} )) || continue
if [[ "${words[0]}" == cd ]]; then
target=${words[1]-}
target=${target//[\"\']/}
[[ "$target" == /* || -z "$cwd" ]] || target="$cwd/$target"
cwd=$target
continue
fi
base=$cwd
rest=("${words[@]:1}")
recursive=1
if [[ "$segment" =~ $gitgrep_re ]]; then
[[ -n "${BASH_REMATCH[3]}" ]] && base=${BASH_REMATCH[3]//[\"\']/}
[[ "$base" == /* || -z "$cwd" || -z "$base" ]] || base="$cwd/$base"
while (( ${#rest[@]} )) && [[ "${rest[0]}" != grep ]]; do rest=("${rest[@]:1}"); done
rest=("${rest[@]:1}")
elif [[ "$segment" =~ $grep_re ]]; then
# grep descends into directories only when told to, the others
# always do.
[[ "${BASH_REMATCH[2]}" == *grep ]] && recursive=0
else
continue
fi
# The first word that is not an option is the pattern unless -e or -f
# gave it, the rest are the files. A search restricted to other file
# types is not one semcode answers.
operands=() expect="" pattern=0 restricted=0 selects_c=0
for word in "${rest[@]}"; do
word=${word//[\"\']/}
case "$expect:$word" in
filepattern:*) restricted=1; non_c "$word" || selects_c=1; expect="" ;;
filetype:*) restricted=1; [[ "$word" =~ ^(c|cpp|rust|zig)$ ]] && selects_c=1; expect="" ;;
regexp:*|patternfile:*) pattern=1; expect="" ;;
:--include=*|:--glob=*) restricted=1; non_c "${word#*=}" || selects_c=1 ;;
:-g?*) restricted=1; non_c "${word#-g}" || selects_c=1 ;;
:--type=*) restricted=1; [[ "${word#*=}" =~ ^(c|cpp|rust|zig)$ ]] && selects_c=1 ;;
:-t?*) restricted=1; [[ "${word#-t}" =~ ^(c|cpp|rust|zig)$ ]] && selects_c=1 ;;
:--include|:--glob|:-g) expect=filepattern ;;
:-t|:--type) expect=filetype ;;
:-e|:--regexp) expect=regexp ;;
:-f|:--file) expect=patternfile ;;
:--regexp=*|:--file=*|:-e?*|:-f?*) pattern=1 ;;
:--recursive|:--dereference-recursive) recursive=1 ;;
:--*) ;;
:-*) [[ "$word" == *[rR]* ]] && recursive=1 ;;
*) if (( pattern )); then operands+=("$word"); else pattern=1; fi ;;
esac
done
(( restricted && ! selects_c )) && continue
if (( ! ${#operands[@]} )); then
# Without files a recursive search covers its directory, any other
# reads its input.
(( recursive && ! piped )) || continue
operands=("${base:-.}")
fi
for word in "${operands[@]}"; do
word=${word%%\**}
[[ "$word" == /* || -z "$base" ]] || word="$base/$word"
indexed "$word" && deny
done
done
;;
esac
exit 0
GUARD
chmod +x "$guard"
jq --arg command "$guard \"$systemd_indexed\" \"$linux_indexed\"" \
'.hooks.PreToolUse += [{matcher: "Bash|Grep", hooks: [{type: "command", command: $command}]}]' \
~/.claude/settings.json >~/.claude/settings.json.new
mv ~/.claude/settings.json.new ~/.claude/settings.json
fi
previous=""
if [[ -n "$PREVIOUS_HEAD" ]] && git -C "$SOURCE" cat-file -e "$PREVIOUS_HEAD" 2>/dev/null; then
git -C "$SOURCE" diff "$PREVIOUS_HEAD" "$HEAD_SHA" >"$INPUT/incremental.patch"
+135
View File
@@ -0,0 +1,135 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# Builds semcode for the Claude review and indexes the Linux kernel with it.
#
# The review agents look up functions, types and call graphs with semcode
# instead of grepping through the trees. A cold build of semcode takes about
# twenty minutes and a kernel index longer than that, far more than a review
# may spend on them. Both go into the actions cache, from which the review job
# restores them:
#
# semcode-bin-<os>-<arch>-<semcode commit> the binaries
# semcode-linux-<os>-<arch>-<semcode commit>-<kernel commit> the kernel index
#
# The review restores the newest entry for the prefix it knows. The kernel
# index only answers for the commit it was built from, which the review
# therefore clones instead of the latest mainline. An index is tied to the
# semcode that wrote it, so a new SEMCODE_REF rebuilds both. A cache nobody
# restored for a week is evicted, and the kernel moves on, so this also runs
# weekly.
name: semcode
on:
push:
branches: [main]
paths: [.github/workflows/semcode.yml]
schedule:
# Mondays 03:00 UTC, before the European morning's reviews.
- cron: '0 3 * * 1'
workflow_dispatch:
concurrency:
group: semcode
permissions:
contents: read
env:
# masoncl/semcode-devel main plus its pull request 10, which lets semcode-mcp
# answer requests concurrently, from the fork the pull request comes from.
SEMCODE_REPOSITORY: https://github.com/brauner/semcode-devel.git
SEMCODE_REF: 08a0feefd4f97fce906420ee25961c44727efac8
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 60
# The review only runs in these two, so nobody else needs the caches.
if: contains(fromJSON('["systemd/systemd","daandemeyer/systemd"]'), github.repository)
outputs:
key: ${{ steps.key.outputs.key }}
steps:
- name: Compute the cache key
id: key
run: echo "key=semcode-bin-${RUNNER_OS}-${RUNNER_ARCH}-${SEMCODE_REF}" >>"$GITHUB_OUTPUT"
- name: Restore semcode
id: cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ runner.temp }}/semcode
key: ${{ steps.key.outputs.key }}
# protoc and the .proto files it reads: lance imports one of protobuf's
# well-known types, which ships in libprotobuf-dev and not with the
# compiler. gix reaches the network through curl built against openssl.
- name: Install build dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
protobuf-compiler libprotobuf-dev pkg-config libssl-dev libcurl4-openssl-dev
rustup update --no-self-update stable
- name: Build semcode
if: steps.cache.outputs.cache-hit != 'true'
run: |
src="$RUNNER_TEMP/semcode-src"
git init --quiet "$src"
git -C "$src" fetch --quiet --depth 1 "$SEMCODE_REPOSITORY" "$SEMCODE_REF"
git -C "$src" checkout --quiet --detach FETCH_HEAD
cargo build --manifest-path "$src/Cargo.toml" --release --locked --bin semcode-index --bin semcode-mcp
install -D -s -t "$RUNNER_TEMP/semcode/bin" "$src/target/release/semcode-index" "$src/target/release/semcode-mcp"
echo "$SEMCODE_REF" >"$RUNNER_TEMP/semcode/ref"
ls -l "$RUNNER_TEMP/semcode/bin"
linux:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 120
steps:
- name: Restore semcode
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ runner.temp }}/semcode
key: ${{ needs.build.outputs.key }}
fail-on-cache-miss: true
# The review runs semcode in a bubblewrap sandbox and so does this, for
# one less difference between the two. Ubuntu restricts the unprivileged
# user namespaces it needs.
- name: Install the sandbox
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
sudo apt-get update && sudo apt-get install -y bubblewrap
- name: Clone the Linux kernel
id: linux
run: |
git clone --quiet --depth 1 --single-branch https://github.com/torvalds/linux.git "$RUNNER_TEMP/linux"
git -C "$RUNNER_TEMP/linux" log -1 --format="Cloned linux at %H (%s)"
echo "commit=$(git -C "$RUNNER_TEMP/linux" rev-parse HEAD)" >>"$GITHUB_OUTPUT"
# Two analysis threads keep the indexer below nine gigabytes, which a
# runner has; with four it peaks above eleven.
- name: Index the Linux kernel
env:
LINUX_COMMIT: ${{ steps.linux.outputs.commit }}
run: |
index="$RUNNER_TEMP/semcode-linux"
mkdir -p "$index"
bwrap --ro-bind / / --tmpfs /run --dev /dev --proc /proc --tmpfs /tmp --bind "$index" "$index" \
--clearenv --setenv PATH /usr/bin:/bin --setenv HOME "$HOME" \
--unshare-all --die-with-parent --new-session -- \
"$RUNNER_TEMP/semcode/bin/semcode-index" --jobs 2 --source "$RUNNER_TEMP/linux" --database "$index/.semcode.db"
echo "$LINUX_COMMIT" >"$index/commit"
du -sh "$index"
- name: Save the kernel index
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ runner.temp }}/semcode-linux
key: semcode-linux-${{ runner.os }}-${{ runner.arch }}-${{ env.SEMCODE_REF }}-${{ steps.linux.outputs.commit }}