Files
gadfly/.gitea/workflows/build-image.yml
T
steveandClaude Opus 5 14f8533e38
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m47s
fix(ci): scrub the registry credential before running repo code
Both Claude reviewers caught this independently, and they are right. The test
job I added wrote a PUSH-CAPABLE REGISTRY_PASSWORD into a plaintext
~/.gitconfig and then ran `go build`/`go vet`/`go test` — repository code — on
pull_request events. This repo is public, so a fork PR could ship a test whose
only job is to print that file. The image build had already answered this
question correctly: its credentials are BuildKit secrets scoped to the
module-download RUN and are never present while code executes. I bolted on a
job that skipped the boundary its neighbour maintains.

Dependencies are now fetched in their own step which deletes ~/.gitconfig
before anything else runs, and asserts the scrub — against the whole home
directory, not against the file it just removed, because the credential can
also land in ~/.netrc or ~/.config/go/env. Verified the assertion is not
vacuous: planting the secret in ~/.netrc trips it. Later steps run with
GOPROXY=off, so any attempt to reach the network fails loudly rather than
quietly hunting for the credential that is now gone.

Also from round 4: TestEndpointProviderNamesAreAllAccepted pinned only
endpointProvider, while the constant is the error text for BOTH resolution
paths — it now asserts each advertised name resolves either way (break-checked
by dropping the gemini alias from resolveModel alone). preflight.sh documents
that ollama-cloud is checked on OLLAMA_API_KEY but hinted as
OLLAMA_CLOUD_API_KEY because run.sh copies one to the other first, an ordering
dependency that was invisible from the file.

And the comments that narrated this PR's own edit history ("the first version
of this change...") are rewritten as invariants. That history stops being true
the moment this merges, and the repo's doc policy says as much.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 17:28:08 -04:00

183 lines
7.4 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
- 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: |
go env -w GOPRIVATE=gitea.stevedudenhoeffer.com/*
git config --global url."https://${REGISTRY_USER}:${REGISTRY_PASSWORD}@gitea.stevedudenhoeffer.com/".insteadOf "https://gitea.stevedudenhoeffer.com/"
go mod download
rm -f "$HOME/.gitconfig"
# Prove the scrub worked, and prove it against the whole home dir —
# checking only the file just deleted would pass no matter what, and
# the credential can also reach ~/.netrc or ~/.config/go/env.
test ! -e "$HOME/.gitconfig"
if grep -rqF "${REGISTRY_PASSWORD}" "$HOME" 2>/dev/null; then
echo "::error::registry credential still present under \$HOME after scrub"
exit 1
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