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
152 changes: 152 additions & 0 deletions .github/workflows/pins-updater.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# yamllint disable rule:line-length
---
name: Pins updater

"on":
schedule:
# Mondays, 05:23 UTC. Deliberately offset from Dependabot's weekly window
# so an actions/docker bump and a pin refresh don't land in the same minute.
- cron: 23 5 * * 1
workflow_dispatch:
inputs:
soak:
description: Soak window in days (update_pins.py --soak)
required: false
default: "7"
block-major-bumps:
description: Stay within each tool's current major version
required: false
type: boolean
default: false

permissions:
contents: read

concurrency:
# One refresh at a time — two concurrent runs would force-push the same
# branch. Never cancel a run in flight; it may be mid-push.
group: pins-updater
cancel-in-progress: false

env:
# A single long-lived branch that is force-pushed on every run, rather than a
# dated branch per run: a stale pins PR is worthless (the pins it proposes
# have been superseded), so the newest refresh should replace the open PR
# instead of stacking another one next to it.
BRANCH: bump/pins

jobs:
update-pins:
name: Refresh pins and open a PR
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# No persisted credentials: the push below authenticates explicitly
# with the token it wants to use, which may be a PAT rather than the
# job's GITHUB_TOKEN (see the PR step).
persist-credentials: false

- name: Refresh pins
id: refresh
env:
# Raises GitHub's API rate limit for the release lookups.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Dispatch inputs go through env, never string-interpolated into the
# script body (that would be a template-injection sink). Both are
# empty on the schedule trigger, hence the defaults.
SOAK: ${{ inputs.soak || '7' }}
BLOCK_MAJOR: ${{ inputs.block-major-bumps || 'false' }}
run: |
set -euo pipefail
args=(--soak "$SOAK")
if [ "$BLOCK_MAJOR" = "true" ]; then
args+=(--block-major-bumps)
fi
# python3, not `uv run`: update_pins.py is stdlib-only
# (`dependencies = []`) and the runner's python3.12 already satisfies
# its `requires-python = ">=3.11"`, so uv would have nothing to
# resolve — this is the same invocation ci.yml uses. README documents
# `uv run` for operators because the image ships uv.
# tee so the report lands both in the job log and in the PR body.
# A non-zero exit from update_pins.py fails the job (pipefail); it
# leaves pins/ untouched on any resolve/download failure.
python3 update_pins.py "${args[@]}" 2>&1 | tee "$RUNNER_TEMP/report.txt"
echo "soak=$SOAK" >> "$GITHUB_OUTPUT"

- name: Detect pin changes
id: diff
run: |
set -euo pipefail
if [ -z "$(git status --porcelain -- pins)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No pin changes — every tool is already on its newest soaked version." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
git --no-pager diff --stat -- pins
fi

- name: Open or update the pull request
if: steps.diff.outputs.changed == 'true'
env:
# PINS_UPDATER_TOKEN (a fine-grained PAT or GitHub App token scoped
# to this repo with contents:write + pull-requests:write) is what you
# want here. A PR opened with the default GITHUB_TOKEN does not
# trigger `pull_request` workflows, so CI — the docker build and the
# smoke matrix that actually validate a pin bump — never runs on it;
# and because the `main protection` ruleset requires the `Validate`
# and `Docker build (validate, no push)` contexts, those stay stuck
# on "expected — waiting for status to be reported" and the PR cannot
# be merged at all. The fallback below keeps the job working without
# the secret, but every weekly PR then needs a human to push an empty
# commit or close/reopen it before it can merge.
GH_TOKEN: ${{ secrets.PINS_UPDATER_TOKEN || secrets.GITHUB_TOKEN }}
# Resolve the repo explicitly rather than from the git remote.
GH_REPO: ${{ github.repository }}
SOAK: ${{ steps.refresh.outputs.soak }}
run: |
set -euo pipefail

git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -B "$BRANCH"
git add pins
git commit -m "build(deps): refresh tool pins" -m \
"Automated refresh by .github/workflows/pins-updater.yml (python3 update_pins.py --soak ${SOAK}). Full report in the PR body."

# Force-push: the branch is regenerated from main every run, so its
# previous head is an obsolete refresh with no history worth keeping.
git push --force \
"https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \
"HEAD:refs/heads/${BRANCH}"

# Absolute link: GitHub rewrites repo-relative markdown links only in
# files rendered from the repo, never in an issue or PR body — a
# relative one would resolve against /<org>/<repo>/pull/ and 404.
workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/blob/main/.github/workflows/pins-updater.yml"

{
echo "Automated pin refresh — \`python3 update_pins.py --soak ${SOAK}\`, run by [\`pins-updater.yml\`](${workflow_url})."
echo
echo "Review the diff and let CI build the image before merging. The \`⚠ needs your eyes\` block below covers the manual pins (nodejs, task, go, ubuntu base) that the script reports but never rewrites — act on those in a separate commit."
echo
echo '<details><summary>update_pins.py report</summary>'
echo
echo '```text'
cat "$RUNNER_TEMP/report.txt"
echo '```'
echo
echo '</details>'
} > "$RUNNER_TEMP/pr-body.md"

title="build(deps): refresh tool pins"
number=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
if [ -n "$number" ]; then
gh pr edit "$number" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
echo "Updated PR #${number}" >> "$GITHUB_STEP_SUMMARY"
else
gh pr create --base main --head "$BRANCH" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
fi
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ When a token is found:
- The agent container gets a placeholder `GH_TOKEN=claude-docker-proxy` — enough for `gh` to consider itself authenticated. `gh auth token` inside the container returns this placeholder, not your real token, and it doesn't appear anywhere in the container's environment or filesystem.
- `github.com`, `api.github.com`, and `uploads.github.com` resolve to the sidecar via `--add-host`. `objects.githubusercontent.com` and `codeload.github.com` (pre-signed release/archive URLs) are **not** intercepted — they resolve normally and never see the token.
- The sidecar terminates TLS for those three hostnames with a CA generated fresh for the session; the private key never leaves the sidecar and is destroyed with it at teardown. The public root is installed into the agent container's trust store by the entrypoint (`update-ca-certificates`, before privilege drop), and `NODE_EXTRA_CA_CERTS` points at it, so `git`, `gh`, and node-based tooling all trust it natively. `UV_SYSTEM_CERTS=1` is set for the same reason: `uv`'s rustls client reads neither the OS bundle nor `NODE_EXTRA_CA_CERTS` by default, so without it every `uv` fetch from `github.com` fails `invalid peer certificate: UnknownIssuer` while `git`/`gh`/`curl` work. Verification stays on — `uv` just checks against the same session root.
- The sidecar injects the real `Authorization` header in transit — `Basic base64(x-access-token:<token>)` for `github.com` (git smart-HTTP), `Bearer <token>` for `api.github.com` / `uploads.github.com` — replacing anything the client sent. One deliberate exception: a **`HEAD` on `github.com/<owner>/<repo>/releases/download/…`** is forwarded with the header *removed*. GitHub routes a release-asset `HEAD` carrying any `Authorization` to a legacy `objects.githubusercontent.com` pre-signed URL that answers `401` to every method, while an anonymous `HEAD` gets the working `release-assets.githubusercontent.com` CDN — so tools that probe with `HEAD` before `GET` (`uv`, `pip`) could not install from a release-asset URL at all. The credential buys nothing there: that endpoint doesn't accept token auth in the first place (see the limitation on private release assets below). `GET` on the same path keeps its credential, as does everything else. `/root/.config/gh` stays tmpfs-masked while the sidecar is active: the placeholder token already satisfies `gh`, so persisted in-container login state would just be a second, unneeded secret.
- The sidecar injects the real `Authorization` header in transit — `Basic base64(x-access-token:<token>)` for `github.com` (git smart-HTTP), `Bearer <token>` for `api.github.com` / `uploads.github.com` — replacing anything the client sent. One deliberate exception: a **`HEAD` on `github.com/<owner>/<repo>/releases/download/…`** is forwarded with the header _removed_. GitHub routes a release-asset `HEAD` carrying any `Authorization` to a legacy `objects.githubusercontent.com` pre-signed URL that answers `401` to every method, while an anonymous `HEAD` gets the working `release-assets.githubusercontent.com` CDN — so tools that probe with `HEAD` before `GET` (`uv`, `pip`) could not install from a release-asset URL at all. The credential buys nothing there: that endpoint doesn't accept token auth in the first place (see the limitation on private release assets below). `GET` on the same path keeps its credential, as does everything else. `/root/.config/gh` stays tmpfs-masked while the sidecar is active: the placeholder token already satisfies `gh`, so persisted in-container login state would just be a second, unneeded secret.

**Isolation and lifecycle.** Each invocation gets its own network (`claude-gh-<id>`) and sidecar (`claude-gh-proxy-<id>`), so concurrent sessions never share a token copy, a CA, or traffic. Teardown happens in `run.sh`'s existing `EXIT` trap, extended and installed _before_ any sidecar or network is created, so a failure mid-startup can't leak either resource. Startup is **fail-closed**: if the sidecar won't start or its CA can't be retrieved in time, `run.sh` tears everything down and exits with an error — it never falls back to forwarding the real token. `run.sh` prints the sidecar's container name at startup.

Expand Down Expand Up @@ -275,7 +275,11 @@ uv run update_pins.py --pin uv=0.12.3 # pin one tool to a specific version

For each tool it selects the newest stable version at least 7 days old, downloads the `amd64` and `arm64` artifacts, computes their sha256s, and rewrites `pins/<tool>.env` — the soak window gives a release time to be vetted (and a bad one pulled) before it enters the image. The script prints a report — each `old → new` bump with its age, a `⬆ MAJOR` marker on major-version jumps, `held` lines for versions still inside the soak window, and `⚠` reminders for the manual pins — then review the diff, build to test, and commit. By default a major-version bump is taken once it has soaked; `--block-major-bumps` keeps a run within each tool's current major. Set `GITHUB_TOKEN` (or `GH_TOKEN`) to avoid GitHub's unauthenticated rate limit.

`nodejs` (from NodeSource's signed apt repo), `task`, the Go toolchain, and the `ubuntu` base-image digest are pinned manually: the script reports base-digest drift, how the Go pin compares to the latest stable release, and whether `task` is the newest version its apt repo serves — but rewrites none of them, since moving the base OS is a deliberate, separately-reviewed change. Go stays manual for a different reason — go.dev's release feed carries no publish dates, so the soak window can't be evaluated from it; the pin lives in the Dockerfile as `ARG GO_VERSION` plus a per-arch sha256, and the comment above it carries the exact `curl | jq` to read the new version and both hashes when bumping. `task` is manual for the same reason at one remove: it installs from Cloudsmith's signed apt repo, and a Debian `Packages` index carries no publish dates either. Its reminder reads that index for the suite the pinned base image names, so it can only ever report a version the build's own `apt-get install` could actually resolve; bump `ARG TASK_VERSION` in the Dockerfile to take it.
A weekly GitHub Actions run does the same thing unattended: [`pins-updater.yml`](.github/workflows/pins-updater.yml) fires every Monday (and on demand via _Run workflow_, where you can override the soak window or ask for `--block-major-bumps`), runs the same script, and — if any pin moved — force-pushes the `bump/pins` branch and opens (or refreshes) a single PR carrying the script's full report. One long-lived branch on purpose: a stale pins PR proposes versions that the next refresh has already superseded, so the newest run replaces the open PR rather than stacking another one beside it. The workflow invokes the script as `python3 update_pins.py` on the runner's preinstalled interpreter — it is stdlib-only, so there is nothing for `uv` to resolve and no toolchain to install first. Review the diff and let CI build the image before merging; the manual pins the report flags under `⚠ needs your eyes` still need a separate, hand-written commit.

> **CI on the automated PR — set `PINS_UPDATER_TOKEN` before enabling this.** Without that secret the PR is opened with the job's `GITHUB_TOKEN`, and GitHub deliberately does **not** trigger `pull_request` workflows for those. Since `main`'s ruleset requires the `Validate` and `Docker build (validate, no push)` checks, they never report and the PR **can't be merged** — not merely "unverified" — until a human pushes an empty commit or closes and reopens it. Add a fine-grained PAT scoped to this repo with `contents: write` + `pull requests: write` (or a GitHub App token) as the `PINS_UPDATER_TOKEN` repo secret and the workflow uses it instead, so CI runs on the PR as it would for a human.

`nodejs` (from NodeSource's signed apt repo), the Go toolchain, and the `ubuntu` base-image digest are pinned manually: the script reports base-digest drift and how the Go pin compares to the latest stable release, but does not rewrite either, since moving the base OS is a deliberate, separately-reviewed change. Go stays manual for a different reason — go.dev's release feed carries no publish dates, so the soak window can't be evaluated from it; the pin lives in the Dockerfile as `ARG GO_VERSION` plus a per-arch sha256, and the comment above it carries the exact `curl | jq` to read the new version and both hashes when bumping.

The [GitHub auth proxy](#github-auth-proxy) sidecar's Caddy image is pinned manually too, but lives outside this whole mechanism: the digest is a default in `run.sh` (`CLAUDE_DOCKER_PROXY_IMAGE`), not a file under `pins/`, and `update_pins.py` never touches it. That's deliberate — a Caddy upgrade can change Caddyfile directive semantics, i.e. the security-critical config this feature generates, so bumping it means reading the changelog and validating the generated Caddyfile against the new version by hand, not taking an automated version bump on faith.

Expand Down
2 changes: 1 addition & 1 deletion openspec/changes/automate-version-pins/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@
## 8. Deferred to dedicated follow-up changes

- [ ] 8.1 Independent hash cross-checks at record time — verify uv release attestations (`gh attestation verify` / cosign) and the aws-cli PGP-signed installer before trusting a self-computed sha. Deferred because it adds new tool dependencies + per-tool verification logic (a new capability, not "finishing" the resolver)
- [ ] 8.2 Weekly automated refresh (CI/cron) opening a PR, so freshness doesn't depend on someone remembering to run the script — the fragments + script were built to support it
- [x] 8.2 Weekly automated refresh (CI/cron) opening a PR, so freshness doesn't depend on someone remembering to run the script — the fragments + script were built to support it. Done as the dedicated follow-up change `schedule-pin-refresh`, which adds the `Scheduled unattended refresh` requirement to this capability
2 changes: 2 additions & 0 deletions openspec/changes/schedule-pin-refresh/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-28
Loading
Loading