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
2 changes: 2 additions & 0 deletions .brief/SIGNOFF
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,5 @@ libkrun-env-fork-substrate conforms: dispatch grading reuses the existing grader
vault-broker-oauth conforms: Claude text adds only a deadline-aware host pre-refresh through the existing TokenStore single writer. An unsent expired grant aborts intact; post-send ambiguity retains the pending marker and is never retried. Existing callers keep their timeout and rotation behavior, and the guest receives only far-future OAuth stubs with provider-bound host token swapping.
runner-image-tags-name-roles conforms: the refresh advances all seven harness pins; dev remains the moving local default, immutable vX.Y.Z roles are unchanged, and the sealed-profile qualification note stays.
docker-backend-deleted conforms: Docker remains only the image-build substrate exporting the libkrun rootfs seed; no agent-runtime container path returns.
runner-image-tags-name-roles conforms: dev remains the moving default — now republished by every main push behind the same verify gate; latest still aliases the newest stable tag; vX.Y.Z stays immutable; PR builds never publish or tag.
docs-governance conforms: the runner-image guide records the refresh pipeline (daily bot PR with qualification checklist, PR-time image build without publish, main-push dev publish) alongside the workflows that implement it.
83 changes: 83 additions & 0 deletions .github/workflows/harness-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: harness-refresh
# Daily bot: resolve every bundled harness's latest version, rewrite the pins,
# and open ONE rolling refresh PR. A human merges it (the body carries a
# qualification checklist — adapters record the harness versions they observe);
# the merge push to main then triggers runner-image to rebuild + republish dev.
#
# GITHUB_TOKEN-authored PRs do not trigger pull_request workflows (GitHub's
# recursion rule). With no REFRESH_PAT secret the refresh PR opens without
# image CI; the verify gate in runner-image's publish step still fails closed
# before dev can move. Set a REFRESH_PAT repo secret to get PR-time image CI.
on:
schedule:
- cron: '17 21 * * *' # daily 21:17 UTC — off the hour, avoids top-of-hour contention
workflow_dispatch:

permissions:
contents: write # push the bot branch
pull-requests: write # open the PR

concurrency:
group: harness-refresh
cancel-in-progress: false

jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve latest harness versions
env:
GH_TOKEN: ${{ github.token }} # `gh api` reads the codex release feed
run: |
set -euo pipefail
scripts/build-runner.sh --update --dry-run | tee /tmp/refresh-diff.txt
if grep -q '←' /tmp/refresh-diff.txt; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "All pins already latest — nothing to refresh."
fi
id: resolve
- name: Apply pins
if: steps.resolve.outputs.changed == 'true'
run: scripts/build-runner.sh --update --pins-only
- name: Open or update the rolling refresh PR
if: steps.resolve.outputs.changed == 'true'
env:
# A PAT (if configured) makes the PR CI-triggering; see header note.
GH_TOKEN: ${{ secrets.REFRESH_PAT || github.token }}
run: |
set -euo pipefail
date=$(date -u +%F)
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B bot/harness-refresh
git commit -am "Refresh runner harnesses ($date)"
git push --force origin bot/harness-refresh

# --body-file + single-quoted echos: markdown backticks must reach the
# PR body literally (an unquoted heredoc would command-substitute them).
{
echo "## Runner harness refresh — $date"
echo
echo "Resolved by 'build-runner.sh --update' (all seven harnesses):"
echo
echo '```'
cat /tmp/refresh-diff.txt
echo '```'
echo
echo '### Qualification checklist (merge is the deliberate act)'
echo '- [ ] Which `pillbox.text/2` lanes observe new versions here? (claude / codex / opencode / pi / prime-agent)'
echo '- [ ] Update the "qualified against" notes in `docs/runner-image.md` if a catalog needs re-qualification'
echo '- [ ] Smoke the touched harnesses (`scripts/smoke/`)'
echo '- [ ] Sealed profiles on immutable `vX.Y.Z` images are unaffected — confirm no bump must be re-qualified before the next tag'
echo
echo 'Merging triggers runner-image on main, which rebuilds both architectures,'
echo 'verifies them, and republishes `dev`. A failed verify leaves an untagged'
echo 'digest — `dev` never moves on a failed probe.'
} > /tmp/pr-body.md
gh pr create --base main --head bot/harness-refresh \
--title "Refresh runner harnesses ($date)" \
--body-file /tmp/pr-body.md \
|| echo "Rolling refresh PR already open — branch update pushed to it."
14 changes: 13 additions & 1 deletion .github/workflows/runner-image.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
name: runner-image
# Version tags publish releases; manual main dispatch refreshes dev only.
# Tag pushes publish immutable vX.Y.Z + the latest alias; pushes to main
# (refresh-PR merges, harness/Dockerfile changes, in-sandbox pillbox src
# changes) rebuild and republish the moving dev tag; PRs that touch runner
# packaging build both architectures WITHOUT publishing, so a refresh PR is
# image-tested before anyone merges it.
on:
push:
branches: [main]
tags: ['v*']
pull_request:
paths: ['runner/**', 'scripts/build-runner.sh']
workflow_dispatch:

permissions:
Expand All @@ -15,6 +22,9 @@ concurrency:

jobs:
build:
# Fork PRs get a read-only token — they cannot push digests to ghcr, so
# they skip image CI entirely (same-repo PRs run it).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -67,6 +77,8 @@ jobs:

publish:
needs: build
# PRs build-only: nothing is ever tagged from a pull request.
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
Expand Down
39 changes: 30 additions & 9 deletions docs/runner-image.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ plus immutable versions:

| Tag | Moving? | Cadence | Use it for |
|---|---|---|---|
| `dev` | moving | local `build-runner.sh`; manual CI dispatch on main | day-to-day dev — pin it in your dev `pillbox.toml` |
| `dev` | moving | CI on every push to main (build + verify, then publish) | day-to-day dev — pin it in your dev `pillbox.toml` |
| `latest` | moving | CI on stable release (alias of newest `vX.Y.Z`) | prod / fresh installs — the built-in default |
| `vX.Y.Z` | **immutable** | CI per release | reproducibility — pin this (and *only* this) for a frozen eval/σ̂ baseline |

Expand Down Expand Up @@ -233,14 +233,35 @@ image, pillbox CLI assumes:

## Harness updates

Renovate watches supported package/release pins in `runner/Dockerfile`
(via `# renovate:` hint comments) and opens PRs on upstream bumps. Cursor and
Amp are refreshed by `build-runner.sh --update` from their official installer
and npm metadata respectively. The current runner-image workflow runs only on version tags or manual dispatch;
it does not build pull requests or republish on every merge. Verify a local build
before relying on changed pins. The next tagged release publishes the immutable
version and stable `:latest` alias.

`scripts/build-runner.sh --update` is the resolver: it rewrites every pin in
`runner/Dockerfile` from upstream (`npm view` for amp/opencode/pi, the codex
GitHub release, the Cursor installer probe, the Prime stable feed). Three
automations carry a refresh from upstream to the published image:

1. **`harness-refresh`** (daily cron) resolves versions and opens ONE rolling
PR (`bot/harness-refresh`) whose body carries the version table and a
qualification checklist. No automerge: merging is the deliberate act —
the `pillbox.text/2` lanes record the harness versions they observe, and
the docs note what each model catalog was qualified against.
2. **`runner-image`** builds both architectures on every PR that touches
`runner/**`, without publishing — a refresh PR is image-tested before
anyone merges it. Fork PRs skip image CI (their token cannot push digests).
A failed probe leaves an untagged digest, never a tag alias.
3. **Publishing**: every push to main (a refresh-PR merge, a Dockerfile edit,
or an `src/**` change that moves the in-sandbox pillbox binary) rebuilds
and republishes `dev`. Tag pushes publish the immutable `vX.Y.Z` image and
move the `latest` alias, as before. Manual dispatch on main still works.

The refresh PR is authored by `github-actions[bot]`; GitHub does not run
`pull_request` workflows for its PRs. Set a `REFRESH_PAT` repo secret to get
PR-time image CI on bot PRs — without it, rely on the verify gate in the
publish run, which fails closed before `dev` can move.

Renovate's regex manager over the same `# renovate:` hint comments is
configured but the Renovate app is not installed on this repo — the bot above
covers all seven harnesses instead (Renovate cannot track cursor/amp/prime
without custom datasources). Install Renovate for the cargo dependency PRs
and prune the harness custom manager if you do; the hints stay as metadata.

## Proposed launch-time updates (not implemented)

Expand Down
16 changes: 14 additions & 2 deletions scripts/build-runner.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ cd "$(dirname "$0")/.."
DOCKERFILE=runner/Dockerfile
TAG=pillbox-runner:dev # moving dev tag — every local script + a dev pillbox.toml default to it
ROOTFS_CACHE_VERSION=v5 # mirrors ROOTFS_CACHE_VERSION in src/sandbox/libkrun/mod.rs
DO_UPDATE=0 DRY_RUN=0 NO_CACHE=0 PRUNE=0 PRINT_ROOTFS_NAMESPACE=
DO_UPDATE=0 DRY_RUN=0 NO_CACHE=0 PINS_ONLY=0 PRUNE=0 PRINT_ROOTFS_NAMESPACE=

usage() {
cat <<'EOF'
Expand All @@ -31,6 +31,8 @@ Usage: scripts/build-runner.sh [options]
(no options) rebuild the current pins (layer-cached) and verify
-u, --update resolve each agent's latest version, rewrite the pins in
runner/Dockerfile, rebuild, verify
--pins-only with --update: rewrite the pins and exit — no build, no
verify (CI builds the image; keep local loops short)
--dry-run with --update: print what would change, don't write or build
-t, --tag TAG image tag to build (default: pillbox-runner:dev)
--no-cache force a clean rebuild (pass --no-cache to docker)
Expand All @@ -49,6 +51,7 @@ while [ $# -gt 0 ]; do
-u|--update) DO_UPDATE=1 ;;
--dry-run) DRY_RUN=1 ;;
--no-cache) NO_CACHE=1 ;;
--pins-only) PINS_ONLY=1 ;;
--prune-rootfs) PRUNE=1 ;;
--print-rootfs-cache-namespace)
PRINT_ROOTFS_NAMESPACE="${2:?--print-rootfs-cache-namespace needs an image}"
Expand Down Expand Up @@ -86,8 +89,16 @@ if [ -n "$PRINT_ROOTFS_NAMESPACE" ]; then
exit 0
fi

# --pins-only rewrites pins and stops; it never builds, so it also never needs
# docker (the refresh bot runs it where no builder exists). --dry-run still
# wins: print, don't write.
if [ "$PINS_ONLY" = 1 ]; then
[ "$DO_UPDATE" = 1 ] || { echo "✗ --pins-only needs --update" >&2; usage >&2; exit 2; }
SKIP_BUILD=1
fi

need() { command -v "$1" >/dev/null 2>&1 || { echo "✗ missing dependency: $1" >&2; exit 1; }; }
need docker
[ "${SKIP_BUILD:-0}" = 1 ] || need docker

# Current pinned value of an ARG (e.g. `cur CLAUDE_VERSION` → 2.1.185).
cur() { grep -E "^ARG ${1}=" "$DOCKERFILE" | head -1 | sed -E "s/^ARG ${1}=//"; }
Expand Down Expand Up @@ -136,6 +147,7 @@ if [ "$DO_UPDATE" = 1 ]; then
[ "$changed" = 1 ] \
&& echo "→ bumped runner/Dockerfile pins; review \`git diff $DOCKERFILE\` and commit" \
|| echo "→ all pins already latest — runner/Dockerfile unchanged"
[ "${SKIP_BUILD:-0}" = 1 ] && exit 0
fi

echo "▶ building $TAG (native arch, layer-cached)…"
Expand Down
Loading