Files
gadfly/.gitea/workflows/build-image.yml
T
steveandClaude Opus 5 3af0f09387
Build & push image / build-and-push (pull_request) Successful in 5s
Build & push image / test (pull_request) Successful in 9m40s
fix(qwen): one credential rule for both paths — seven findings said so
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]>
2026-08-12 18:09:18 -04:00

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