diff --git a/.github/workflows/docker-publish-x402.yml b/.github/workflows/docker-publish-x402.yml index 3a575186..288b0f89 100644 --- a/.github/workflows/docker-publish-x402.yml +++ b/.github/workflows/docker-publish-x402.yml @@ -182,93 +182,3 @@ jobs: with: sarif_file: 'trivy-results.sarif' if: always() - - # --------------------------------------------------------------------------- - # Repin the embedded infrastructure manifests to the images just built, so - # the manifests the obol CLI applies always reference images built from the - # same source (closes the rc14 stale-pin trap; rc11 pattern, automated). - # - # Branch refs only: a tag build cannot receive a pin-bump commit, and the - # release workflow's verify-image-pins gate ensures tags are cut AFTER the - # bump landed. Pushing with GITHUB_TOKEN never triggers other workflows, - # so this job cannot recurse into another build. - # - # The bump is committed through the GraphQL createCommitOnBranch API, NOT - # `git push`: API commits are signed by GitHub itself (verified, - # github-actions bot), which keeps this job compatible with the repo - # ruleset rejecting unsigned commits — a workflow `git push` can never - # produce a verified commit. API commits made with GITHUB_TOKEN also do - # not trigger other workflows, so this job cannot recurse into a build. - # --------------------------------------------------------------------------- - repin-embedded-pins: - needs: build - if: startsWith(github.ref, 'refs/heads/') - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout branch head - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - - name: Repin embedded images to this build - env: - BUILD_SHA: ${{ github.sha }} - run: .github/scripts/repin-x402-images.sh "$BUILD_SHA" - - - name: Commit pin bump via API (GitHub-signed) - # Context values are bound via env, never interpolated into the - # script text: branch names may contain shell metacharacters, and - # the runner substitutes ${{ }} before bash parses the line. - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - BUILD_SHA: ${{ github.sha }} - REF_NAME: ${{ github.ref_name }} - run: | - changed="$(git diff --name-only)" - if [ -z "$changed" ]; then - echo "embedded pins already current; nothing to push" - exit 0 - fi - # The repin script may only touch the two pin-carrying templates. - while IFS= read -r f; do - case "$f" in - internal/embed/infrastructure/base/templates/x402.yaml) ;; - internal/embed/infrastructure/base/templates/llm.yaml) ;; - *) echo "::error::repin produced an unexpected change: $f"; exit 1 ;; - esac - done <<< "$changed" - short="$(printf '%s' "$BUILD_SHA" | cut -c1-7)" - # createCommitOnBranch: GitHub signs the commit (verified) — a - # plain `git push` from a workflow never is, and the repo - # ruleset rejects unsigned commits. expectedHeadOid is the LIVE - # remote head (the branch may have advanced while images - # built); on a race the mutation fails cleanly and we retry - # once against the new head. Only the two guarded files are - # sent, so replaying onto a newer head cannot clobber anything - # else. - commit_via_api() { - head_oid="$(gh api "repos/$REPO/git/ref/heads/$REF_NAME" --jq .object.sha)" - jq -n \ - --arg repo "$REPO" --arg branch "$REF_NAME" --arg head "$head_oid" \ - --arg msg "chore(ci): repin x402 images to ${short} [auto]" \ - --arg body "Automated by docker-publish-x402/repin-embedded-pins after the image build at ${BUILD_SHA}. Committed via the GitHub API so the commit is verified." \ - --arg x402 "$(base64 < internal/embed/infrastructure/base/templates/x402.yaml | tr -d '\n')" \ - --arg llm "$(base64 < internal/embed/infrastructure/base/templates/llm.yaml | tr -d '\n')" \ - '{ - query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", - variables: {input: { - branch: {repositoryNameWithOwner: $repo, branchName: $branch}, - expectedHeadOid: $head, - message: {headline: $msg, body: $body}, - fileChanges: {additions: [ - {path: "internal/embed/infrastructure/base/templates/x402.yaml", contents: $x402}, - {path: "internal/embed/infrastructure/base/templates/llm.yaml", contents: $llm} - ]} - }} - }' | gh api graphql --input - - } - commit_via_api || { echo "branch head moved during commit; retrying once"; sleep 5; commit_via_api; } diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml new file mode 100644 index 00000000..fb007208 --- /dev/null +++ b/.github/workflows/release-prep.yml @@ -0,0 +1,240 @@ +# Release Prep — repin embedded x402 image pins via a reviewed PR. +# +# Replaces the old push-time `repin-embedded-pins` job in +# docker-publish-x402.yml, which committed the pin bump directly to protected +# `main` and was rejected on every run (BRANCH_PROTECTION_RULE_VIOLATION: +# changes must go through a PR + lint-test). That design fought branch +# protection on every push; this one works with it. +# +# Run this once when preparing a release (the natural moment the pins need to be +# fresh — release.yml's verify-x402-pins gate is also release-time). It: +# 1. builds the four x402 images for the release commit, and +# 2. opens an auto-merging PR that repins the embedded manifests to them. +# +# The pin bump lands through the normal review path — NO ruleset bypass, no +# privileged push to `main`. The commit is made with the GraphQL +# createCommitOnBranch API onto a *feature* branch (unprotected), so it is +# GitHub-verified (satisfies the no-unsigned-commits rule) without any bypass. +# +# CI-on-the-PR caveat: GitHub does not trigger workflows for commits authored by +# the default GITHUB_TOKEN, so the opened PR's required checks won't run on their +# own. Two ways to handle it (see docs/release-x402-pins.md): +# - Zero-touch: set repo variable REPIN_APP_ID + secret REPIN_APP_PRIVATE_KEY +# for a minimal GitHub App (contents:write + pull-requests:write, NOT on any +# ruleset bypass list). The job mints a short-lived token; the PR's checks +# run and auto-merge engages after a maintainer approval. +# - No-secret: leave them unset. The verified PR is still opened with +# GITHUB_TOKEN; a maintainer closes & reopens it to fire CI, approves, and it +# auto-merges. + +name: Release Prep - repin x402 pins + +on: + workflow_dispatch: + inputs: + ref: + description: 'Release target commit or branch. Images are built from it and the pins point at it.' + required: true + default: main + type: string + base: + description: 'Branch to open the repin PR against.' + required: true + default: main + type: string + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + +jobs: + # --------------------------------------------------------------------------- + # Build the four x402 images for the release commit so the pins can reference + # them by digest. Mirrors docker-publish-x402.yml's build matrix; images are + # tagged by the release commit's short and long SHA (repin-x402-images.sh + # resolves the digest from the short-SHA tag). + # --------------------------------------------------------------------------- + build-images: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - { image: obolnetwork/x402-verifier, dockerfile: Dockerfile.x402-verifier } + - { image: obolnetwork/x402-buyer, dockerfile: Dockerfile.x402-buyer } + - { image: obolnetwork/serviceoffer-controller, dockerfile: Dockerfile.serviceoffer-controller } + - { image: obolnetwork/demo-server, dockerfile: Dockerfile.demo-server } + steps: + - name: Checkout release ref + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Resolve release SHA + id: sha + run: | + { + echo "long=$(git rev-parse HEAD)" + echo "short=$(git rev-parse --short=7 HEAD)" + } >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push (tagged by release SHA) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ matrix.image }}:${{ steps.sha.outputs.short }} + ${{ env.REGISTRY }}/${{ matrix.image }}:${{ steps.sha.outputs.long }} + cache-from: type=gha,scope=${{ matrix.image }} + provenance: true + sbom: true + + # --------------------------------------------------------------------------- + # Repin the embedded manifests to the images just built and open an + # auto-merging PR. Verified commit onto a feature branch (no bypass); the PR + # goes through normal review. + # --------------------------------------------------------------------------- + open-repin-pr: + needs: build-images + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # Optional: a minimal, NO-BYPASS GitHub App so the PR's CI triggers and + # auto-merge can engage. Skipped cleanly when the variable is unset. + - name: Mint app token (optional) + id: app + if: ${{ vars.REPIN_APP_ID != '' }} + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ vars.REPIN_APP_ID }} + private-key: ${{ secrets.REPIN_APP_PRIVATE_KEY }} + + - name: Select token + id: tok + env: + APP_TOKEN: ${{ steps.app.outputs.token }} + DEFAULT_TOKEN: ${{ github.token }} + run: | + if [ -n "$APP_TOKEN" ]; then + echo "value=$APP_TOKEN" >> "$GITHUB_OUTPUT" + echo "is_app=true" >> "$GITHUB_OUTPUT" + else + echo "value=$DEFAULT_TOKEN" >> "$GITHUB_OUTPUT" + echo "is_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout release ref + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + token: ${{ steps.tok.outputs.value }} + + - name: Repin embedded images to the release build + id: repin + run: | + SHORT="$(git rev-parse --short=7 HEAD)" + echo "short=$SHORT" >> "$GITHUB_OUTPUT" + echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + .github/scripts/repin-x402-images.sh "$SHORT" + changed="$(git diff --name-only)" + if [ -z "$changed" ]; then + echo "embedded pins already current for $SHORT; nothing to do" + echo "noop=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # The repin script may only ever touch the two pin-carrying templates. + while IFS= read -r f; do + case "$f" in + internal/embed/infrastructure/base/templates/x402.yaml) ;; + internal/embed/infrastructure/base/templates/llm.yaml) ;; + *) echo "::error::repin produced an unexpected change: $f"; exit 1 ;; + esac + done <<< "$changed" + echo "noop=false" >> "$GITHUB_OUTPUT" + + - name: Open verified, auto-merging repin PR + if: steps.repin.outputs.noop == 'false' + env: + GH_TOKEN: ${{ steps.tok.outputs.value }} + REPO: ${{ github.repository }} + BASE: ${{ inputs.base }} + SHORT: ${{ steps.repin.outputs.short }} + HEAD_OID: ${{ steps.repin.outputs.head }} + IS_APP: ${{ steps.tok.outputs.is_app }} + run: | + set -euo pipefail + BRANCH="chore/repin-x402-${SHORT}" + + # Create (or reset) the feature branch at the release commit. A feature + # branch is unprotected, so this needs no bypass. + if gh api "repos/$REPO/git/ref/heads/$BRANCH" >/dev/null 2>&1; then + gh api -X PATCH "repos/$REPO/git/refs/heads/$BRANCH" -f sha="$HEAD_OID" -F force=true >/dev/null + else + gh api "repos/$REPO/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$HEAD_OID" >/dev/null + fi + + # Verified commit of the two pin files via createCommitOnBranch. + jq -n \ + --arg repo "$REPO" --arg branch "$BRANCH" --arg head "$HEAD_OID" \ + --arg msg "chore(ci): repin x402 images to ${SHORT}" \ + --arg body "Automated by release-prep for the release at ${HEAD_OID}. Verified via the GitHub API; lands through PR review (no ruleset bypass)." \ + --arg x402 "$(base64 < internal/embed/infrastructure/base/templates/x402.yaml | tr -d '\n')" \ + --arg llm "$(base64 < internal/embed/infrastructure/base/templates/llm.yaml | tr -d '\n')" \ + '{ + query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }", + variables: {input: { + branch: {repositoryNameWithOwner: $repo, branchName: $branch}, + expectedHeadOid: $head, + message: {headline: $msg, body: $body}, + fileChanges: {additions: [ + {path: "internal/embed/infrastructure/base/templates/x402.yaml", contents: $x402}, + {path: "internal/embed/infrastructure/base/templates/llm.yaml", contents: $llm} + ]} + }} + }' | gh api graphql --input - >/dev/null + + # Open the PR (idempotent: reuse if it already exists for this branch). + if [ "$IS_APP" = "true" ]; then + NUDGE="CI runs automatically (app-authored)." + else + NUDGE="GitHub does not run CI on GITHUB_TOKEN-authored PRs. A maintainer: **close & reopen** this PR to fire checks, then approve." + fi + PR_BODY=$(printf '%s\n' \ + "Repins the embedded x402 image pins to the \`${SHORT}\` build for the upcoming release." \ + "" \ + "- Verified commit (createCommitOnBranch), feature branch -> PR review. No ruleset bypass." \ + "- ${NUDGE}" \ + "- After merge, tag the release on the merge commit; release.yml's verify-x402-pins gate will pass.") + if ! gh pr view "$BRANCH" --repo "$REPO" >/dev/null 2>&1; then + gh pr create --repo "$REPO" --base "$BASE" --head "$BRANCH" \ + --title "chore(ci): repin x402 images to ${SHORT}" --body "$PR_BODY" + fi + + # Enable auto-merge so it lands the moment review + checks are satisfied. + gh pr merge "$BRANCH" --repo "$REPO" --squash --auto \ + || echo "::warning::could not enable auto-merge (repo auto-merge disabled, or checks not yet runnable); merge manually after approval" diff --git a/docs/release-x402-pins.md b/docs/release-x402-pins.md new file mode 100644 index 00000000..3c933bac --- /dev/null +++ b/docs/release-x402-pins.md @@ -0,0 +1,70 @@ +# Releasing: x402 image pins + +The embedded infrastructure manifests pin the x402 images +(`x402-verifier`, `serviceoffer-controller`, `x402-buyer`) by tag **and** +digest, in: + +- `internal/embed/infrastructure/base/templates/x402.yaml` +- `internal/embed/infrastructure/base/templates/llm.yaml` + +`release.yml`'s `verify-image-pins` gate refuses to tag a release whose pins are +stale — i.e. whose pinned build commit predates a change to anything in the +x402 binaries' import graph (which includes all of `internal/embed/**`). So the +pins must point at images built from the release commit before you tag. + +## How the pins get bumped + +Run the **Release Prep - repin x402 pins** workflow (`release-prep.yml`, +`workflow_dispatch`) with `ref` = the release commit (usually `main`). It: + +1. builds the four x402 images for that commit (tagged by its SHA), then +2. opens an **auto-merging PR** that repins `x402.yaml` / `llm.yaml` to them. + +The pin commit is made with the GraphQL `createCommitOnBranch` API onto a +feature branch, so it is **GitHub-verified** (satisfies the no-unsigned-commits +rule) and lands through **normal PR review — no branch-protection bypass**. + +Then: + +1. A maintainer approves the repin PR; it auto-merges. +2. `main` HEAD is now the repin commit (the release commit + correct pins). +3. Tag `vX.Y.Z` on that commit. `release.yml` passes `verify-image-pins`, + builds binaries, and creates the draft release. + +> This replaced the old push-time `repin-embedded-pins` job in +> `docker-publish-x402.yml`, which committed directly to protected `main` and +> was rejected on every run (`BRANCH_PROTECTION_RULE_VIOLATION`). Repinning is a +> release-time concern, so it now runs at release time, the right way. + +## CI on the repin PR + +GitHub does **not** trigger workflows for commits authored by the default +`GITHUB_TOKEN`, so the repin PR's required checks don't run on their own. Pick one: + +- **Zero-touch (recommended):** create a minimal GitHub App — permissions + **`contents: write`** + **`pull requests: write`**, installed on this repo, + **not added to any ruleset bypass list** (so it has only normal-contributor + power and can never push to protected `main`). Store its id in repo variable + **`REPIN_APP_ID`** and its private key in secret **`REPIN_APP_PRIVATE_KEY`**. + `release-prep` mints a short-lived token, the PR's checks run, and it + auto-merges after one maintainer approval. +- **No secret:** leave those unset. The verified PR is still opened with + `GITHUB_TOKEN`; a maintainer **closes & reopens** it to fire CI, approves, and + it auto-merges. + +Either way there is no bypass of `main` and the commit stays verified — the only +difference is whether CI on the PR starts automatically. + +## Manual fallback + +If you ever need to repin by hand (e.g. images for the release commit already +exist from a prior build), run the same script locally and open the PR yourself: + +```bash +.github/scripts/repin-x402-images.sh +# commit the two changed templates, push a branch, open a PR to main +``` + +`verify-x402-pins.sh []` validates the result (pins share one build commit, +digests match GHCR, pin commit is an ancestor, no x402 import-graph drift after +the pin).