From e26f60355186af6c45aaf2768d21c466982833b2 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 5 Nov 2026 14:46:49 -0500 Subject: [PATCH 1/4] feat(agents): ship Voltage engineering skills in the agent image Remote agents ran without the engineering skills that local Codex and Claude Code sessions use, so their commits and PRs missed Voltage's conventions. On main, the publish job checks out master of the internal voltagecloud/engineering-skills with a read-only GitHub App token from the main-only `engineering-skills` environment, and passes the selected skills to the build as a named context. Pull requests build with an empty context and never receive the token. At each session and worker start, buzz-agent-profile links the skills into Claude Code, Codex, and Buzz's own agent. It manages only its own links and never blocks a session. Signed-off-by: Tee8z --- .github/workflows/voltage-agent-images.yml | 72 ++++++++++++++++++-- voltage/images/README.md | 4 +- voltage/images/buzz-agent/Dockerfile | 5 ++ voltage/images/buzz-agent/README.md | 27 +++++++- voltage/images/buzz-agent/profile.py | 40 ++++++++++++ voltage/images/buzz-agent/select-skills.sh | 53 +++++++++++++++ voltage/images/buzz-agent/test-image.sh | 17 +++++ voltage/images/buzz-agent/test-skills.py | 76 ++++++++++++++++++++++ 8 files changed, 287 insertions(+), 7 deletions(-) create mode 100755 voltage/images/buzz-agent/select-skills.sh create mode 100755 voltage/images/buzz-agent/test-skills.py diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index 53b5ebb1a56..1f3c4d05c87 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -11,13 +11,21 @@ name: Voltage images # buzz-relay the upstream root Dockerfile (target runtime) → buzz-relay # # The changes job selects only the images whose inputs changed. Manual runs -# (workflow_dispatch) build all three. +# (workflow_dispatch) build all three, or only buzz-agent with agent_only. # # Trust model: this repository and its logs are public. Pull requests run # without AWS access. Only the publish and merge jobs request an OIDC token, # and they run only on main, the only ref that the github-buzz-ecr-push role # trusts. The AWS_ACCOUNT_ID secret keeps the account ID out of the # repository and masks it in the logs. +# +# Engineering skills: the agent image ships voltagecloud/engineering-skills +# (internal) from that repository's master branch at build time. Only the +# publish job reads it, with a short-lived, read-only token from the +# voltage-agent-images GitHub App. The app's client ID and key are secrets of +# the `engineering-skills` environment, which only main may use, so pull +# requests never receive them; they build with an empty skills context. +# select-skills.sh prints counts only, never skill content. on: push: branches: [main] @@ -61,7 +69,12 @@ on: - admin-web/** - .github/workflows/voltage-agent-images.yml # Publishes only when run on main; on other refs it builds and tests. - workflow_dispatch: {} + workflow_dispatch: + inputs: + agent_only: + description: "Build only buzz-agent, for example to ship new engineering skills" + type: boolean + default: false # Pull requests share one group per ref and cancel superseded runs. Each main # commit gets its own group: a queued main run that a newer push replaced @@ -138,14 +151,15 @@ jobs: - name: Build the image matrices id: select env: - ALL: ${{ github.event_name == 'workflow_dispatch' }} + ALL: ${{ github.event_name == 'workflow_dispatch' && !inputs.agent_only }} + AGENT_ONLY: ${{ github.event_name == 'workflow_dispatch' && inputs.agent_only }} AGENT: ${{ steps.filter.outputs.agent }} REAPER: ${{ steps.filter.outputs.reaper }} RELAY: ${{ steps.filter.outputs.relay }} run: | set -euo pipefail selected=() - if [ "$ALL" = true ] || [ "$AGENT" = true ]; then selected+=(buzz-agent); fi + if [ "$ALL" = true ] || [ "$AGENT_ONLY" = true ] || [ "$AGENT" = true ]; then selected+=(buzz-agent); fi if [ "$ALL" = true ] || [ "$REAPER" = true ]; then selected+=(buzz-sandbox-reaper); fi if [ "$ALL" = true ] || [ "$RELAY" = true ]; then selected+=(buzz-relay); fi images='{ @@ -221,6 +235,9 @@ jobs: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 + # Pull requests never read the internal skills repository. + - name: Empty engineering skills context + run: sh voltage/images/buzz-agent/select-skills.sh --none "$RUNNER_TEMP/engineering-skills" - name: Build uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: @@ -229,6 +246,7 @@ jobs: target: ${{ matrix.target }} platforms: linux/${{ matrix.arch }} build-args: ${{ matrix.image == 'buzz-relay' && env.RELAY_BUILD_ARGS || '' }} + build-contexts: engineering-skills=${{ runner.temp }}/engineering-skills load: true push: false tags: ${{ env.IMAGE }} @@ -261,6 +279,7 @@ jobs: python3 test-codex-login.py "$IMAGE" python3 -O test-profile.py "$IMAGE" python3 test-cluster.py "$IMAGE" + python3 test-skills.py "$IMAGE" # main: build each platform on its native runner and push it by digest. publish: @@ -269,6 +288,8 @@ jobs: if: needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ${{ matrix.runner }} timeout-minutes: 120 + # Holds the engineering-skills app secrets; restricted to main. + environment: engineering-skills permissions: contents: read id-token: write # OIDC token for the github-buzz-ecr-push role @@ -290,6 +311,40 @@ jobs: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 + # Read-only, for engineering-skills alone, and revoked when the job ends. + - name: Engineering skills token + id: skills-token + if: matrix.image == 'buzz-agent' + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.AGENT_IMAGE_APP_CLIENT_ID }} + private-key: ${{ secrets.AGENT_IMAGE_APP_PRIVATE_KEY }} + owner: voltagecloud + repositories: engineering-skills + permission-contents: read + - name: Check out engineering skills + if: matrix.image == 'buzz-agent' + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: voltagecloud/engineering-skills + ref: master + token: ${{ steps.skills-token.outputs.token }} + path: .engineering-skills + persist-credentials: false + - name: Select engineering skills + id: skills + env: + AGENT: ${{ matrix.image == 'buzz-agent' }} + run: | + set -euo pipefail + out="$RUNNER_TEMP/engineering-skills" + if [ "$AGENT" = true ]; then + sh voltage/images/buzz-agent/select-skills.sh .engineering-skills "$out" + rm -rf .engineering-skills + else + sh voltage/images/buzz-agent/select-skills.sh --none "$out" + fi + echo "revision=$(cat "$out/REVISION")" >> "$GITHUB_OUTPUT" # Build and smoke-test before credentials exist; the push build below # reuses this build's cache. - name: Build @@ -300,6 +355,7 @@ jobs: target: ${{ matrix.target }} platforms: linux/${{ matrix.arch }} build-args: ${{ matrix.image == 'buzz-relay' && env.RELAY_BUILD_ARGS || '' }} + build-contexts: engineering-skills=${{ runner.temp }}/engineering-skills load: true push: false tags: ${{ env.IMAGE }} @@ -313,7 +369,11 @@ jobs: run: bash voltage/images/test-relay-image.sh "$IMAGE" - name: Image contract test if: matrix.image == 'buzz-agent' - run: bash voltage/images/buzz-agent/test-image.sh "$IMAGE" + env: + REQUIRE_SKILLS: "1" + run: | + bash voltage/images/buzz-agent/test-image.sh "$IMAGE" + python3 voltage/images/buzz-agent/test-skills.py "$IMAGE" - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-buzz-ecr-push @@ -328,9 +388,11 @@ jobs: target: ${{ matrix.target }} platforms: linux/${{ matrix.arch }} build-args: ${{ matrix.image == 'buzz-relay' && env.RELAY_BUILD_ARGS || '' }} + build-contexts: engineering-skills=${{ runner.temp }}/engineering-skills labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} + cloud.voltage.engineering-skills.revision=${{ steps.skills.outputs.revision }} outputs: type=image,name=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-west-2.amazonaws.com/${{ matrix.repository }},push-by-digest=true,name-canonical=true,push=true - name: Export digest env: diff --git a/voltage/images/README.md b/voltage/images/README.md index 27b180b93f9..2c34316994b 100644 --- a/voltage/images/README.md +++ b/voltage/images/README.md @@ -59,7 +59,9 @@ docker buildx build --check -f voltage/images/buzz-sandbox-reaper/Dockerfile . docker build --platform linux/amd64 -f voltage/images/buzz-sandbox-reaper/Dockerfile -t buzz-sandbox-reaper:local . docker run --rm buzz-sandbox-reaper:local --help -docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile -t buzz-agent:local . +sh voltage/images/buzz-agent/select-skills.sh --none /tmp/engineering-skills +docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile \ + --build-context engineering-skills=/tmp/engineering-skills -t buzz-agent:local . bash voltage/images/buzz-agent/test-image.sh buzz-agent:local ``` diff --git a/voltage/images/buzz-agent/Dockerfile b/voltage/images/buzz-agent/Dockerfile index def06e65203..5bf7f3abc92 100644 --- a/voltage/images/buzz-agent/Dockerfile +++ b/voltage/images/buzz-agent/Dockerfile @@ -92,6 +92,11 @@ COPY --chmod=0755 voltage/images/buzz-agent/subagent.py /usr/local/bin/buzz-suba RUN install -d -m 0755 /etc/buzz-agent COPY --chmod=0644 voltage/images/buzz-agent/subagents.md /etc/buzz-agent/subagents.md COPY --chmod=0644 voltage/images/buzz-agent/cluster.md /etc/buzz-agent/cluster.md +# Voltage engineering skills from the `engineering-skills` build context, made by +# select-skills.sh: master of voltagecloud/engineering-skills on main, empty for +# pull requests and local builds. Root owns the copy, so the agent can read but +# not change it; profile.py links each skill into the harnesses. +COPY --from=engineering-skills . /opt/engineering-skills/ COPY --chmod=0755 voltage/images/buzz-agent/heavy-bin/ /opt/buzz-heavy/bin/ COPY --chmod=0755 voltage/images/buzz-agent/gh.sh /usr/local/bin/gh COPY --chmod=0755 voltage/images/buzz-agent/via.sh /usr/local/bin/via diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index 35c75870e09..c2b1890e511 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -26,7 +26,9 @@ The build context is the repository root. `Dockerfile.dockerignore` next to the From the repository root: ```bash -docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile -t buzz-agent:local . +sh voltage/images/buzz-agent/select-skills.sh --none /tmp/engineering-skills +docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile \ + --build-context engineering-skills=/tmp/engineering-skills -t buzz-agent:local . cd voltage/images/buzz-agent bash test-image.sh buzz-agent:local bash test-harness.sh buzz-agent:local @@ -42,13 +44,36 @@ python3 test-worker.py buzz-agent:local python3 test-cache.py buzz-agent:local python3 -O test-profile.py buzz-agent:local python3 test-cluster.py buzz-agent:local +python3 test-skills.py buzz-agent:local ``` +To build with the engineering skills, replace `--none` with a local checkout: +`sh voltage/images/buzz-agent/select-skills.sh ../engineering-skills /tmp/engineering-skills`. + `test-image.sh` also runs `test-review.py`; run the Python script directly for focused review checks. These checks use synthetic tasks and accounts. Live GitHub, signing, `via`, cache, and staging behavior require end-to-end validation. Run the bundled Rust suites with `docker build --platform linux/amd64 --target package-checks -f voltage/images/buzz-agent/Dockerfile .`. Build and test `linux/arm64` on an arm64 host, or through `.github/workflows/voltage-agent-images.yml`, which builds each platform on a native runner. +## Engineering skills + +The image ships [voltagecloud/engineering-skills](https://github.com/voltagecloud/engineering-skills) (internal) in `/opt/engineering-skills`, owned by root. +That repository is internal and this one is public, so the skills reach the build only as the `engineering-skills` [build context](https://docs.docker.com/build/building/context/#named-contexts). +`select-skills.sh` makes that context. It leaves out `walkthrough-video` and `cli-demo-video`, which need a browser, narration, and VHS, and writes the source commit to `REVISION`. + +| Build | Skills | +| --- | --- | +| `main` (publish job) | `master` of engineering-skills at build time; the image label `cloud.voltage.engineering-skills.revision` records the commit | +| Pull requests, other refs, local builds | None (`select-skills.sh --none`) | + +The publish job reads the repository with a short-lived token from the `voltage-agent-images` GitHub App: read-only contents, limited to engineering-skills. +The app's client ID and private key are the `AGENT_IMAGE_APP_CLIENT_ID` and `AGENT_IMAGE_APP_PRIVATE_KEY` secrets of the `engineering-skills` environment, which only `main` may use. +A skills change reaches agents with the next published agent image. To publish one without a code change, run the `Voltage images` workflow on `main` with `agent_only`. + +At each session and worker start, `buzz-agent-profile` links every skill into `~/.claude/skills`, `$CODEX_HOME/skills`, and `~/.agents/skills`. +It manages only its own links: a skill the agent wrote keeps its name, and links to skills a newer image dropped are removed. +A linking failure prints a warning; the session still starts. + ## Sandbox contract Use an immutable registry digest in `spec.podTemplate.spec.containers[].image`. diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index 7c4d634f611..06a3fb58ae6 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -347,6 +347,42 @@ def install_subagent_guide(env): handle.write(guide) +SKILLS = Path('/opt/engineering-skills/skills') + + +def skill_directories(env): + """Where Claude Code, Codex, and Buzz's own agent discover user skills.""" + return (Path('/home/agent/.claude/skills'), + Path(env.get('CODEX_HOME', '/home/agent/.codex')) / 'skills', + Path('/home/agent/.agents/skills')) + + +def install_skills(env, source=SKILLS, targets=None): + """Link the image's engineering skills into each harness; never block the session. + + Only links into `source` are managed: a skill the agent wrote, or one a + checkpoint restored, keeps its name. Links left by an older image whose + skill is gone are removed. + """ + try: + shipped = {path.name: path for path in source.iterdir() + if (path / 'SKILL.md').is_file()} if source.is_dir() else {} + for directory in targets or skill_directories(env): + directory.mkdir(parents=True, exist_ok=True) + for link in directory.iterdir(): + if (link.is_symlink() and Path(os.readlink(link)).parent == source + and link.name not in shipped): + link.unlink() + for name, path in shipped.items(): + link = directory / name + if link.is_symlink() and Path(os.readlink(link)).parent == source: + link.unlink() + if not os.path.lexists(link): + link.symlink_to(path, target_is_directory=True) + except OSError as error: + print('Engineering skills not installed: '+type(error).__name__, file=sys.stderr) + + def main(): """Load the owner profile and cluster access before starting a session or worker.""" parser = argparse.ArgumentParser(description=__doc__) @@ -359,6 +395,8 @@ def main(): env = bootstrap() step('configuring staging cluster diagnostics') install_cluster_access(env) + step('installing engineering skills') + install_skills(env) step('starting the worker') worker = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-agent-worker'] os.execvpe(worker[0], worker, env) @@ -370,6 +408,8 @@ def main(): install_cluster_access(env) step('installing the sub-agent guide') install_subagent_guide(env) + step('installing engineering skills') + install_skills(env) step('starting the harness') arguments = args.arguments[1:] if args.arguments[:1] == ['--'] else args.arguments harness = ['/usr/local/bin/buzz-agent-cache', '/usr/local/bin/buzz-acp', *arguments] diff --git a/voltage/images/buzz-agent/select-skills.sh b/voltage/images/buzz-agent/select-skills.sh new file mode 100755 index 00000000000..0a85b66eacb --- /dev/null +++ b/voltage/images/buzz-agent/select-skills.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Prepare the `engineering-skills` build context for the agent image. +# +# select-skills.sh +# select-skills.sh --none +# +# voltagecloud/engineering-skills is internal and this repository is public, +# so the skills never enter this repository or the main build context. CI +# checks out the repository's master branch on main and passes the result to +# `docker build --build-context engineering-skills=`. Pull +# requests and local builds pass an empty context made with --none. +# +# The output holds skills// for each shipped skill and REVISION, the +# commit the skills come from ("none" for an empty context). This script +# prints counts only: the CI logs are public. +set -eu + +# Skills that need tools a Sandbox cannot run (a browser, narration, VHS). +EXCLUDE="walkthrough-video cli-demo-video" + +usage() { + echo "usage: $0 " >&2 + echo " $0 --none " >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +out=$2 +rm -rf "$out" +mkdir -p "$out/skills" + +if [ "$1" = --none ]; then + echo none > "$out/REVISION" + echo "engineering skills: none (empty build context)" + exit 0 +fi + +source=$1 +[ -d "$source/skills" ] || { echo "no skills/ directory in the checkout" >&2; exit 1; } +shipped=0 +skipped=0 +for dir in "$source"/skills/*/; do + [ -f "$dir/SKILL.md" ] || continue + name=$(basename "$dir") + case " $EXCLUDE " in + *" $name "*) skipped=$((skipped + 1)); continue ;; + esac + cp -R "$dir" "$out/skills/$name" + shipped=$((shipped + 1)) +done +[ "$shipped" -gt 0 ] || { echo "the checkout has no shippable skills" >&2; exit 1; } +git -C "$source" rev-parse HEAD > "$out/REVISION" +echo "engineering skills: $shipped shipped, $skipped excluded, revision $(cat "$out/REVISION")" diff --git a/voltage/images/buzz-agent/test-image.sh b/voltage/images/buzz-agent/test-image.sh index 6febade987d..595e3cedb4b 100755 --- a/voltage/images/buzz-agent/test-image.sh +++ b/voltage/images/buzz-agent/test-image.sh @@ -22,7 +22,24 @@ docker run --rm --entrypoint /bin/bash "$IMAGE" -ceu ' test -r /etc/buzz-agent/cluster.md ! test -e /home/agent/.codex/auth.json ! test -e /home/agent/.claude/.credentials.json + # Engineering skills (select-skills.sh): read-only, each with SKILL.md, none + # of the excluded ones. Empty in pull request and local builds. + test -r /opt/engineering-skills/REVISION + ! test -w /opt/engineering-skills/skills + for skill in /opt/engineering-skills/skills/*/; do + [ -d "$skill" ] || continue + test -r "$skill/SKILL.md" + done + ! test -e /opt/engineering-skills/skills/walkthrough-video + ! test -e /opt/engineering-skills/skills/cli-demo-video ' +# Published images must carry skills (the publish job sets REQUIRE_SKILLS=1). +if [ "${REQUIRE_SKILLS:-0}" = 1 ]; then + docker run --rm --entrypoint /bin/sh "$IMAGE" -ceu ' + grep -qE "^[0-9a-f]{40}$" /opt/engineering-skills/REVISION + ls /opt/engineering-skills/skills/*/SKILL.md >/dev/null + ' +fi for adapter in codex-acp claude-agent-acp; do docker run --rm "$IMAGE" auth-methods --agent-command "$adapter" --agent-args '' --json done diff --git a/voltage/images/buzz-agent/test-skills.py b/voltage/images/buzz-agent/test-skills.py new file mode 100755 index 00000000000..45858305262 --- /dev/null +++ b/voltage/images/buzz-agent/test-skills.py @@ -0,0 +1,76 @@ +"""Check that profile.py links engineering skills into each harness without blocking a session.""" +import subprocess +import sys + +SCRIPT = r''' +import io, os, sys, tempfile +from contextlib import redirect_stderr +from importlib.machinery import SourceFileLoader +from pathlib import Path + +profile = SourceFileLoader('profile', '/usr/local/bin/buzz-agent-profile').load_module() + +def require(condition, message): + if not condition: raise RuntimeError(message) + +root = Path(tempfile.mkdtemp()) +source = root/'opt'/'skills' +for name in ('commit-policy', 'pr-review'): + (source/name).mkdir(parents=True) + (source/name/'SKILL.md').write_text('---\nname: '+name+'\ndescription: fixture\n---\n') +(source/'not-a-skill').mkdir() +claude, codex = root/'claude'/'skills', root/'codex'/'skills' +# The agent wrote its own pr-review skill before this session started. +(claude/'pr-review').mkdir(parents=True) +(claude/'pr-review'/'SKILL.md').write_text('agent-owned\n') + +profile.install_skills({}, source=source, targets=(claude, codex)) +for directory in (claude, codex): + link = directory/'commit-policy' + require(link.is_symlink() and link.resolve() == (source/'commit-policy').resolve(), 'skill not linked') + require((link/'SKILL.md').is_file(), 'linked skill unreadable') + require(not os.path.lexists(directory/'not-a-skill'), 'a directory without SKILL.md was linked') +require(not (claude/'pr-review').is_symlink(), 'agent-owned skill replaced') +require((claude/'pr-review'/'SKILL.md').read_text() == 'agent-owned\n', 'agent-owned skill changed') +require((codex/'pr-review').is_symlink(), 'skill missing where the agent had none') + +# A rerun, as on every session start, changes nothing. +profile.install_skills({}, source=source, targets=(claude, codex)) +require((claude/'commit-policy').is_symlink() and not (claude/'pr-review').is_symlink(), 'rerun changed links') + +# A newer image drops commit-policy: its links go, the agent's skill stays. +(source/'commit-policy'/'SKILL.md').unlink() +(source/'commit-policy').rmdir() +profile.install_skills({}, source=source, targets=(claude, codex)) +for directory in (claude, codex): + require(not os.path.lexists(directory/'commit-policy'), 'stale skill link kept') +require((claude/'pr-review'/'SKILL.md').read_text() == 'agent-owned\n', 'agent-owned skill changed') + +# A failure warns and returns: skills are optional. +blocked = root/'blocked' +blocked.write_text('a file, not a directory\n') +stderr = io.StringIO() +with redirect_stderr(stderr): + profile.install_skills({}, source=source, targets=(blocked/'skills',)) +require('Engineering skills not installed' in stderr.getvalue(), 'failure not reported') + +# Default locations: Claude Code, CODEX_HOME, and Buzz's own .agents directory. +require(profile.skill_directories({'CODEX_HOME': '/home/agent/.codex-x'}) == ( + Path('/home/agent/.claude/skills'), Path('/home/agent/.codex-x/skills'), + Path('/home/agent/.agents/skills')), 'unexpected skill directories') +print('ok') +''' + + +def main(): + image = sys.argv[1] + result = subprocess.run(['docker', 'run', '--rm', '--entrypoint', 'python3', image, '-c', SCRIPT], + capture_output=True, text=True, timeout=120) + if result.returncode or result.stdout.strip() != 'ok': + print(result.stdout[-2000:], result.stderr[-4000:], file=sys.stderr) + sys.exit(1) + print('engineering skills: linking checks passed') + + +if __name__ == '__main__': + main() From 2e9ebbde366544a5cbcda0ac7dd8c1c4cdceb4e6 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 5 Nov 2026 15:00:40 -0500 Subject: [PATCH 2/4] fix(agents): give the BuildKit check the engineering-skills context docker buildx build --check resolves every COPY --from source, so without the named context it tried to pull an engineering-skills image. Pass an empty context, and mark the named-context COPY as intentional for hadolint. Signed-off-by: Tee8z --- .github/workflows/voltage-agent-images.yml | 5 ++++- voltage/images/README.md | 2 +- voltage/images/buzz-agent/Dockerfile | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index 1f3c4d05c87..2f5f668fe38 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -200,12 +200,15 @@ jobs: with: dockerfile: voltage/images/buzz-sandbox-reaper/Dockerfile failure-threshold: error + # --check resolves every COPY --from source, so the agent image needs its + # engineering-skills context; the other images ignore it. - name: BuildKit checks run: | set -euo pipefail + sh voltage/images/buzz-agent/select-skills.sh --none "$RUNNER_TEMP/engineering-skills" for file in voltage/images/buzz-agent/Dockerfile voltage/images/buzz-sandbox-reaper/Dockerfile Dockerfile; do echo "==> $file" - docker buildx build --check -f "$file" . + docker buildx build --check --build-context engineering-skills="$RUNNER_TEMP/engineering-skills" -f "$file" . done # Pull requests, and runs on refs other than main: build and test, no push. diff --git a/voltage/images/README.md b/voltage/images/README.md index 2c34316994b..92f6e36d3f5 100644 --- a/voltage/images/README.md +++ b/voltage/images/README.md @@ -53,7 +53,7 @@ The voltage-terraform admission policy admits only digest-pinned images from `vo Run from the repository root. The Rust build takes a long time; the Dockerfiles set `CARGO_BUILD_JOBS=2`. ```bash -docker buildx build --check -f voltage/images/buzz-agent/Dockerfile . +docker buildx build --check --build-context engineering-skills=/tmp/engineering-skills -f voltage/images/buzz-agent/Dockerfile . docker buildx build --check -f voltage/images/buzz-sandbox-reaper/Dockerfile . docker build --platform linux/amd64 -f voltage/images/buzz-sandbox-reaper/Dockerfile -t buzz-sandbox-reaper:local . diff --git a/voltage/images/buzz-agent/Dockerfile b/voltage/images/buzz-agent/Dockerfile index 5bf7f3abc92..267ef6429c1 100644 --- a/voltage/images/buzz-agent/Dockerfile +++ b/voltage/images/buzz-agent/Dockerfile @@ -96,6 +96,7 @@ COPY --chmod=0644 voltage/images/buzz-agent/cluster.md /etc/buzz-agent/cluster.m # select-skills.sh: master of voltagecloud/engineering-skills on main, empty for # pull requests and local builds. Root owns the copy, so the agent can read but # not change it; profile.py links each skill into the harnesses. +# hadolint ignore=DL3022 COPY --from=engineering-skills . /opt/engineering-skills/ COPY --chmod=0755 voltage/images/buzz-agent/heavy-bin/ /opt/buzz-heavy/bin/ COPY --chmod=0755 voltage/images/buzz-agent/gh.sh /usr/local/bin/gh From e4cf797e1ad0cba6bf5b5b0eb69e90ae2f8073b7 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 5 Nov 2026 15:41:57 -0500 Subject: [PATCH 3/4] fix(agents): resolve skills once per run and tag skills-only refreshes Resolve engineering-skills master once in a `skills` job, so both platform images carry the same commit; each publish job checks out that SHA and verifies it. An agent_only run republishes a commit that already has an image, so its manifest list is tagged -skills- instead of being dropped. Link each harness directory separately, so one failure leaves the others linked, and create the empty context before the README's BuildKit check. Signed-off-by: Tee8z --- .github/workflows/voltage-agent-images.yml | 47 +++++++++++++++++++--- voltage/images/README.md | 2 +- voltage/images/buzz-agent/README.md | 3 +- voltage/images/buzz-agent/profile.py | 12 ++++-- voltage/images/buzz-agent/test-skills.py | 8 ++-- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index 2f5f668fe38..b1e680aa1b9 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -284,14 +284,47 @@ jobs: python3 test-cluster.py "$IMAGE" python3 test-skills.py "$IMAGE" + # main: resolve engineering-skills master once, so every platform image of + # this run carries the same skills commit. + skills: + name: Resolve engineering skills + needs: changes + if: needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: engineering-skills + outputs: + revision: ${{ steps.resolve.outputs.revision }} + steps: + - name: Engineering skills token + id: token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ secrets.AGENT_IMAGE_APP_CLIENT_ID }} + private-key: ${{ secrets.AGENT_IMAGE_APP_PRIVATE_KEY }} + owner: voltagecloud + repositories: engineering-skills + permission-contents: read + - name: Resolve master + id: resolve + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + revision=$(gh api repos/voltagecloud/engineering-skills/commits/master --jq .sha) + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + echo "engineering skills: master is $revision" + # main: build each platform on its native runner and push it by digest. publish: name: Publish ${{ matrix.image }} (${{ matrix.arch }}) - needs: [changes, lint] + needs: [changes, lint, skills] if: needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ${{ matrix.runner }} timeout-minutes: 120 - # Holds the engineering-skills app secrets; restricted to main. + # Holds the engineering-skills app secrets; restricted to main. The + # github-buzz-ecr-push role trusts this environment's OIDC subject. environment: engineering-skills permissions: contents: read @@ -330,7 +363,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: voltagecloud/engineering-skills - ref: master + ref: ${{ needs.skills.outputs.revision }} token: ${{ steps.skills-token.outputs.token }} path: .engineering-skills persist-credentials: false @@ -338,12 +371,14 @@ jobs: id: skills env: AGENT: ${{ matrix.image == 'buzz-agent' }} + EXPECTED: ${{ needs.skills.outputs.revision }} run: | set -euo pipefail out="$RUNNER_TEMP/engineering-skills" if [ "$AGENT" = true ]; then sh voltage/images/buzz-agent/select-skills.sh .engineering-skills "$out" rm -rf .engineering-skills + [ "$(cat "$out/REVISION")" = "$EXPECTED" ] else sh voltage/images/buzz-agent/select-skills.sh --none "$out" fi @@ -415,7 +450,7 @@ jobs: # A failed platform build fails only its own image's merge. merge: name: Merge ${{ matrix.image }} manifest - needs: [changes, publish] + needs: [changes, skills, publish] if: ${{ !cancelled() && needs.publish.result != 'skipped' && needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 15 @@ -445,7 +480,9 @@ jobs: IMAGE: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.us-west-2.amazonaws.com/${{ matrix.repository }} NAME: ${{ matrix.image }} REPOSITORY: ${{ matrix.repository }} - TAG: ${{ github.sha }} + # An agent_only run republishes a commit that already has an image, + # so it tags the result with the skills commit as well. + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.agent_only && format('{0}-skills-{1}', github.sha, needs.skills.outputs.revision) || github.sha }} run: | set -euo pipefail if digest=$(aws ecr describe-images --repository-name "$REPOSITORY" \ diff --git a/voltage/images/README.md b/voltage/images/README.md index 92f6e36d3f5..a996b3d29fa 100644 --- a/voltage/images/README.md +++ b/voltage/images/README.md @@ -53,13 +53,13 @@ The voltage-terraform admission policy admits only digest-pinned images from `vo Run from the repository root. The Rust build takes a long time; the Dockerfiles set `CARGO_BUILD_JOBS=2`. ```bash +sh voltage/images/buzz-agent/select-skills.sh --none /tmp/engineering-skills docker buildx build --check --build-context engineering-skills=/tmp/engineering-skills -f voltage/images/buzz-agent/Dockerfile . docker buildx build --check -f voltage/images/buzz-sandbox-reaper/Dockerfile . docker build --platform linux/amd64 -f voltage/images/buzz-sandbox-reaper/Dockerfile -t buzz-sandbox-reaper:local . docker run --rm buzz-sandbox-reaper:local --help -sh voltage/images/buzz-agent/select-skills.sh --none /tmp/engineering-skills docker build --platform linux/amd64 -f voltage/images/buzz-agent/Dockerfile \ --build-context engineering-skills=/tmp/engineering-skills -t buzz-agent:local . bash voltage/images/buzz-agent/test-image.sh buzz-agent:local diff --git a/voltage/images/buzz-agent/README.md b/voltage/images/buzz-agent/README.md index c2b1890e511..e6ae73c858c 100644 --- a/voltage/images/buzz-agent/README.md +++ b/voltage/images/buzz-agent/README.md @@ -68,7 +68,8 @@ That repository is internal and this one is public, so the skills reach the buil The publish job reads the repository with a short-lived token from the `voltage-agent-images` GitHub App: read-only contents, limited to engineering-skills. The app's client ID and private key are the `AGENT_IMAGE_APP_CLIENT_ID` and `AGENT_IMAGE_APP_PRIVATE_KEY` secrets of the `engineering-skills` environment, which only `main` may use. -A skills change reaches agents with the next published agent image. To publish one without a code change, run the `Voltage images` workflow on `main` with `agent_only`. +A skills change reaches agents with the next published agent image. To publish one without a code change, run the `Voltage images` workflow on `main` with `agent_only`. That image is tagged `-skills-`, because the Buzz commit already has an image. +Each run resolves `master` once (the `skills` job), so both platform images carry the same skills commit. At each session and worker start, `buzz-agent-profile` links every skill into `~/.claude/skills`, `$CODEX_HOME/skills`, and `~/.agents/skills`. It manages only its own links: a skill the agent wrote keeps its name, and links to skills a newer image dropped are removed. diff --git a/voltage/images/buzz-agent/profile.py b/voltage/images/buzz-agent/profile.py index 06a3fb58ae6..e73709b948b 100644 --- a/voltage/images/buzz-agent/profile.py +++ b/voltage/images/buzz-agent/profile.py @@ -367,7 +367,12 @@ def install_skills(env, source=SKILLS, targets=None): try: shipped = {path.name: path for path in source.iterdir() if (path / 'SKILL.md').is_file()} if source.is_dir() else {} - for directory in targets or skill_directories(env): + except OSError as error: + print('Engineering skills not installed: '+type(error).__name__, file=sys.stderr) + return + # Each harness directory on its own: one failure leaves the others linked. + for directory in targets or skill_directories(env): + try: directory.mkdir(parents=True, exist_ok=True) for link in directory.iterdir(): if (link.is_symlink() and Path(os.readlink(link)).parent == source @@ -379,8 +384,9 @@ def install_skills(env, source=SKILLS, targets=None): link.unlink() if not os.path.lexists(link): link.symlink_to(path, target_is_directory=True) - except OSError as error: - print('Engineering skills not installed: '+type(error).__name__, file=sys.stderr) + except OSError as error: + print('Engineering skills not installed in '+directory.name+' of '+directory.parent.name + +': '+type(error).__name__, file=sys.stderr) def main(): diff --git a/voltage/images/buzz-agent/test-skills.py b/voltage/images/buzz-agent/test-skills.py index 45858305262..c43a38673d2 100755 --- a/voltage/images/buzz-agent/test-skills.py +++ b/voltage/images/buzz-agent/test-skills.py @@ -46,13 +46,15 @@ def require(condition, message): require(not os.path.lexists(directory/'commit-policy'), 'stale skill link kept') require((claude/'pr-review'/'SKILL.md').read_text() == 'agent-owned\n', 'agent-owned skill changed') -# A failure warns and returns: skills are optional. +# A failing directory warns and the next one is still linked: skills are optional. blocked = root/'blocked' blocked.write_text('a file, not a directory\n') +after = root/'after'/'skills' stderr = io.StringIO() with redirect_stderr(stderr): - profile.install_skills({}, source=source, targets=(blocked/'skills',)) -require('Engineering skills not installed' in stderr.getvalue(), 'failure not reported') + profile.install_skills({}, source=source, targets=(blocked/'skills', after)) +require('Engineering skills not installed in skills of blocked' in stderr.getvalue(), 'failure not reported') +require((after/'pr-review').is_symlink(), 'one failing directory stopped the others') # Default locations: Claude Code, CODEX_HOME, and Buzz's own .agents directory. require(profile.skill_directories({'CODEX_HOME': '/home/agent/.codex-x'}) == ( From edbd6e00ac7ba4fefb571db4867ebaa746973c88 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Thu, 5 Nov 2026 15:50:13 -0500 Subject: [PATCH 4/4] fix(agents): keep relay and reaper publishes independent of engineering skills Resolve engineering skills only when the run builds the agent image, and let publish run when skills was skipped or failed: the agent build then fails on its own while the relay and reaper still publish. Only the agent build uses the engineering-skills environment; the others keep the main branch OIDC subject. Signed-off-by: Tee8z --- .github/workflows/voltage-agent-images.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index b1e680aa1b9..fa30354d16a 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -97,6 +97,7 @@ jobs: outputs: any: ${{ steps.select.outputs.any }} build: ${{ steps.select.outputs.build }} + agent: ${{ steps.select.outputs.agent }} merge: ${{ steps.select.outputs.merge }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -176,6 +177,7 @@ jobs: --args "${selected[@]}") { echo "any=$([ "${#selected[@]}" -gt 0 ] && echo true || echo false)" + echo "agent=$([[ " ${selected[*]} " == *" buzz-agent "* ]] && echo true || echo false)" echo "build=$build" echo "merge=$merge" } >> "$GITHUB_OUTPUT" @@ -289,7 +291,7 @@ jobs: skills: name: Resolve engineering skills needs: changes - if: needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: needs.changes.outputs.agent == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 5 environment: engineering-skills @@ -319,13 +321,16 @@ jobs: # main: build each platform on its native runner and push it by digest. publish: name: Publish ${{ matrix.image }} (${{ matrix.arch }}) + # A skills failure fails only the agent image: the relay and reaper still + # publish. skills is skipped when this run does not build the agent. needs: [changes, lint, skills] - if: needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.lint.result == 'success' && needs.skills.result != 'cancelled' && needs.changes.outputs.any == 'true' && github.repository == 'voltagecloud/buzz' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 - # Holds the engineering-skills app secrets; restricted to main. The - # github-buzz-ecr-push role trusts this environment's OIDC subject. - environment: engineering-skills + # Only the agent image needs the engineering-skills app secrets, which this + # main-only environment holds. The github-buzz-ecr-push role trusts its + # OIDC subject; the other images keep the main branch subject. + environment: ${{ matrix.image == 'buzz-agent' && 'engineering-skills' || '' }} permissions: contents: read id-token: write # OIDC token for the github-buzz-ecr-push role @@ -347,6 +352,12 @@ jobs: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 + - name: Require the resolved skills commit + if: matrix.image == 'buzz-agent' + env: + REVISION: ${{ needs.skills.outputs.revision }} + run: | + [[ "$REVISION" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::engineering skills were not resolved (skills job: ${{ needs.skills.result }})"; exit 1; } # Read-only, for engineering-skills alone, and revoked when the job ends. - name: Engineering skills token id: skills-token