Fourteen findings, and seven of them from all four models are the same one: endpointProvider was missing the no-cross-vendor-fallback guard I had just added to resolveModel. I fixed a credential leak on one path and left its sibling leaking, in the commit whose own message argued those two paths must move together. That is the third time in this PR. So it is no longer a rule written twice. openAICompatOptions owns it and both paths call it; builtinCompatProviders names the vendors that must never inherit OPENAI_API_KEY, replacing a `provider == "kimi" || provider == "qwen"` literal that was a fourth uncounted copy of the list. The test drives a real request at a local server and demands two things: that no request arrives carrying the OpenAI key, AND that the call fails closed naming the variable to set — the second half because my first draft pointed the provider at vendor.example, so the server saw nothing and the assertion held for a reason unrelated to the fix. Break-checked: removing the guard puts "Bearer sk-openai-must-not-travel" on the wire to the other vendor. The scrub check failed open. As a bare condition, a grep ERROR (exit >= 2) reads as "not found" and skips the guard — a credential check that passes precisely when it cannot see the filesystem it is searching. It now distinguishes 0/1/>=2 and refuses to continue on error. A bare "claude-code" spec has no "/", so the provider fell back to ollama-cloud and the pre-flight would skip a reviewer that authenticates with CLAUDE_CODE_OAUTH_TOKEN and needs no Ollama key. Engine specs are now exempt. preflight.sh's provider list duplicated its own case arms; both now read one table. And its comment claimed the Go cross-check fails if either list misses an entry from the other, when only one direction is checked — the reverse is not even desirable, since ollama-cloud and anthropic belong in that table and not in the Go one. The comment now says what is enforced. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
225 lines
9.6 KiB
YAML
225 lines
9.6 KiB
YAML
name: Build & push image
|
|
|
|
# Builds the Gadfly reviewer container and pushes it to the Gitea container
|
|
# registry. Mirrors mort-ci.yml's build-and-push (BuildKit secrets for private
|
|
# module access + the LAN --add-host so the builder can reach the registry).
|
|
#
|
|
# push to main -> :latest + :sha-<short>
|
|
# push tag v* -> :<tag> + :latest
|
|
# other branch push -> :branch-<safe> + :sha-<short>
|
|
# pull_request -> build only (no push), as a sanity check
|
|
#
|
|
# Required repo secrets:
|
|
# REGISTRY_USER / REGISTRY_PASSWORD Gitea creds with registry push + read
|
|
# access to the private majordomo module.
|
|
# Optional:
|
|
# DISCORD_WEBHOOK_URL build notifications (unset => silent).
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
tags: ["v*"]
|
|
# Docs/example-only changes don't change the image — skip the rebuild.
|
|
# (Path filters are not applied to tag pushes, so `v*` releases always build.)
|
|
paths-ignore:
|
|
- "**.md"
|
|
- "examples/**"
|
|
- "LICENSE"
|
|
- ".gitignore"
|
|
- ".dockerignore"
|
|
pull_request:
|
|
types: [opened, synchronize, reopened]
|
|
paths-ignore:
|
|
- "**.md"
|
|
- "examples/**"
|
|
- "LICENSE"
|
|
- ".gitignore"
|
|
- ".dockerignore"
|
|
workflow_dispatch: {}
|
|
|
|
concurrency:
|
|
group: gadfly-image-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
IMAGE_NAME: gitea.stevedudenhoeffer.com/steve/gadfly
|
|
|
|
jobs:
|
|
# Runs alongside the image build rather than gating it: a red test should be
|
|
# loud on the PR without standing between Steve and a rebuild. Added because
|
|
# this repo had NO test job at all — `go test` and scripts/preflight_test.sh
|
|
# both existed and neither was ever executed by CI, which is worse than
|
|
# having no tests, since it reads as coverage.
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Scrubbing the registry credential while leaving the checkout token
|
|
# in .git/config would just move the prize: `go test` below runs
|
|
# repository code with the workspace readable. Nothing in this job
|
|
# talks to git after checkout, so the token has no reason to persist.
|
|
persist-credentials: false
|
|
- uses: actions/setup-go@v5
|
|
with:
|
|
go-version-file: go.mod
|
|
# Fetch dependencies, then DESTROY the credential before any step that
|
|
# executes repository code. REGISTRY_PASSWORD is push-capable, this repo
|
|
# is public so pull_request runs can carry attacker-authored code, and
|
|
# `go test` runs that code — a plaintext ~/.gitconfig left in place is a
|
|
# credential any test could print. The image build faces the same
|
|
# question and answers it the same way: its creds are BuildKit secrets
|
|
# scoped to the module-download RUN, never present while code runs.
|
|
- name: Fetch private modules
|
|
env:
|
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
# Own the config path outright. `git config --global` writes to
|
|
# GIT_CONFIG_GLOBAL, else $XDG_CONFIG_HOME/git/config when that
|
|
# directory exists, else ~/.gitconfig — so "delete ~/.gitconfig"
|
|
# scrubs a file the credential may never have been in. Naming the
|
|
# path leaves exactly one file to remove.
|
|
export GIT_CONFIG_GLOBAL="$(mktemp)"
|
|
|
|
# Scrub on ANY exit, not just success. `set -e` means a failed
|
|
# `go mod download` aborts this step, and a cleanup written as the
|
|
# next line would never run — leaving a push-capable credential on a
|
|
# long-lived self-hosted runner for whatever job lands there next.
|
|
trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT
|
|
|
|
go env -w GOPRIVATE=gitea.stevedudenhoeffer.com/*
|
|
# Basic-auth header rather than credentials inside the URL: a
|
|
# password containing @ : / or # breaks URL parsing, and the failure
|
|
# would look like a bad password rather than a quoting bug.
|
|
git config --global \
|
|
"http.https://gitea.stevedudenhoeffer.com/.extraheader" \
|
|
"Authorization: Basic $(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
|
|
go mod download
|
|
|
|
rm -f "$GIT_CONFIG_GLOBAL"
|
|
test ! -e "$GIT_CONFIG_GLOBAL"
|
|
|
|
# Prove the scrub across the whole home dir, not just the file we
|
|
# deleted — that check would pass no matter what, and git/go can also
|
|
# write ~/.netrc or ~/.config/go/env. Guarded on a non-empty secret:
|
|
# `grep -F ""` matches every file, so a secretless run (fork PR) would
|
|
# fail here with a message accusing it of leaking nothing.
|
|
# -e, so a password beginning with "-" is a pattern and not options.
|
|
# And distinguish grep's three exits: 0 found, 1 clean, >=2 ERROR. As
|
|
# a bare condition an error reads as "not found" and the guard is
|
|
# skipped — a check that fails OPEN in exactly the case where it can no
|
|
# longer see the filesystem it is supposed to be searching.
|
|
if [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
|
set +e
|
|
grep -rqF -e "$REGISTRY_PASSWORD" "$HOME" 2>/dev/null
|
|
rc=$?
|
|
set -e
|
|
case "$rc" in
|
|
0) echo "::error::registry credential still present under \$HOME after scrub"; exit 1 ;;
|
|
1) : ;; # clean
|
|
*) echo "::error::credential scrub check could not run (grep exit $rc); refusing to continue"; exit 1 ;;
|
|
esac
|
|
fi
|
|
|
|
# GOPROXY=off from here on: the module cache is already warm, so any
|
|
# attempt to reach the network is a bug — and it fails loudly instead of
|
|
# quietly looking for the credential that is now gone.
|
|
- name: go build
|
|
env: { GOPROXY: "off" }
|
|
run: go build ./...
|
|
- name: go vet
|
|
env: { GOPROXY: "off" }
|
|
run: go vet ./...
|
|
- name: gofmt
|
|
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
|
|
- name: go test
|
|
env: { GOPROXY: "off" }
|
|
run: go test -count=1 ./...
|
|
- name: pre-flight credential table
|
|
run: bash scripts/preflight_test.sh
|
|
|
|
build-and-push:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Set up Docker Buildx
|
|
run: docker buildx create --use --name gadfly-builder --driver docker-container 2>/dev/null || docker buildx use gadfly-builder
|
|
|
|
- name: Log in to the registry
|
|
if: github.event_name != 'pull_request'
|
|
env:
|
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
run: echo "${REGISTRY_PASSWORD}" | docker login gitea.stevedudenhoeffer.com -u "${REGISTRY_USER}" --password-stdin
|
|
|
|
- name: Compute tags
|
|
id: meta
|
|
run: |
|
|
SHA_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
|
PUSH=true
|
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
# Build-only sanity check; nothing published.
|
|
TAGS="${IMAGE_NAME}:pr-${{ github.event.pull_request.number }}"
|
|
PUSH=false
|
|
elif [ "${{ github.ref_type }}" = "tag" ]; then
|
|
TAGS="${IMAGE_NAME}:${GITHUB_REF_NAME},${IMAGE_NAME}:latest"
|
|
elif [ "${GITHUB_REF_NAME}" = "main" ]; then
|
|
TAGS="${IMAGE_NAME}:latest,${IMAGE_NAME}:sha-${SHA_SHORT}"
|
|
else
|
|
BRANCH_SAFE=$(echo "${GITHUB_REF_NAME}" | sed 's/[^a-zA-Z0-9._-]/-/g; s/--*/-/g; s/^-//; s/-$//')
|
|
TAGS="${IMAGE_NAME}:branch-${BRANCH_SAFE},${IMAGE_NAME}:sha-${SHA_SHORT}"
|
|
fi
|
|
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
|
echo "push=${PUSH}" >> "$GITHUB_OUTPUT"
|
|
echo "Tags: ${TAGS} (push=${PUSH})"
|
|
|
|
- name: Notify Discord (started)
|
|
if: github.event_name != 'pull_request'
|
|
env:
|
|
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
run: |
|
|
[ -z "$WEBHOOK_URL" ] && exit 0
|
|
MSG="🪰 Gadfly image build #${{ github.run_number }} started on \`${{ github.ref_name }}\` (${{ github.sha }})."
|
|
curl -sS -H 'Content-Type: application/json' -d "{\"content\": \"$MSG\"}" "$WEBHOOK_URL" || true
|
|
|
|
- name: Build and push
|
|
env:
|
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
run: |
|
|
TAG_FLAGS=""
|
|
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.meta.outputs.tags }}"
|
|
for tag in "${TAG_ARRAY[@]}"; do TAG_FLAGS="$TAG_FLAGS --tag $tag"; done
|
|
|
|
PUSH_FLAG="--push"
|
|
[ "${{ steps.meta.outputs.push }}" = "false" ] && PUSH_FLAG="--output=type=cacheonly"
|
|
|
|
docker buildx build \
|
|
$PUSH_FLAG \
|
|
--platform linux/amd64 \
|
|
$TAG_FLAGS \
|
|
--add-host gitea.stevedudenhoeffer.com:192.168.0.134 \
|
|
--secret id=REGISTRY_USER,env=REGISTRY_USER \
|
|
--secret id=REGISTRY_PASSWORD,env=REGISTRY_PASSWORD \
|
|
--file ./Dockerfile \
|
|
.
|
|
|
|
- name: Notify Discord (result)
|
|
if: always() && github.event_name != 'pull_request'
|
|
env:
|
|
WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
|
run: |
|
|
[ -z "$WEBHOOK_URL" ] && exit 0
|
|
if [ "${{ job.status }}" = "success" ]; then
|
|
MSG="✅ Gadfly image build #${{ github.run_number }} succeeded. Tags: \`${{ steps.meta.outputs.tags }}\`."
|
|
else
|
|
MSG="❌ Gadfly image build #${{ github.run_number }} failed. Check Actions logs."
|
|
fi
|
|
curl -sS -H 'Content-Type: application/json' -d "{\"content\": \"$MSG\"}" "$WEBHOOK_URL" || true
|