`docker-build.yml` triggers on `pull_request:`, which Gitea resolves from the PR HEAD, so that
run executes contributor-authored YAML and every `secrets.*` it names is materialised into it.
Six jobs held `REGISTRY_PASSWORD` that way — `toolchain-preflight`, `test`, `migrations`,
`functional-e2e`, `api-docs`, `format` — two of them branch-protection required contexts.
The read-only pull PAT the issue asked to cost first was REJECTED, and the measurement is the
reason: this registry already issues an anonymous pull token for `timothy/ersatztv-ci`
(`GET /v2/token?scope=repository:timothy/ersatztv-ci:pull` -> 200), that token reads the pinned
manifest and its config blob (200/200), and the combined-status GET answers 200 unauthenticated.
A read-only PAT would grant exactly what anonymity grants while adding one more credential to the
store head-supplied YAML reaches. So the stronger form was implemented instead: no PR-route job
names a stored secret at all.
- `.gitea/workflows/docker-build.yml`: the five `container: credentials:` blocks, the
`ETV_REGISTRY_AUTH` step env and the three `ETV_STATUS_AUTH` step envs are gone. `build` keeps
the PAT; it is gated `if: github.event_name != 'pull_request'`.
- `scripts/ci-toolchain-image-resolves.sh`: reads `realm` out of the `Www-Authenticate` challenge,
exchanges it once per run for an anonymous pull token, retries with the bearer. Every refusal
direction is preserved — a 401/403 after the token leg, a token endpoint yielding no token, and
one that cannot be reached all `fail` rather than degrading to could-not-tell — and the message
now names the cause an operator can act on (the repo or package has stopped being public).
- `scripts/ci-detect-already-validated.sh`: the status GET is anonymous. No credential override is
kept: the URL names one instance, that instance is public, and an unusable `":"` would draw a 401
and turn a working read into a permanent skip=false.
- `scripts/tests/test_workflow_persist_credentials.py`: the invariant, derived from the git index by
"every job of a `pull_request`-triggered workflow that names a `secrets.*`" — never the six-name
list, and never "every `container:` job", which names five of six because `toolchain-preflight` is
container-free. Witnessed red against the unfixed workflow naming all six jobs; green after.
Live tag protection applied and read back: `POST /repos/timothy/ersatztv/tag_protections`
`{"name_pattern": "v*", "whitelist_usernames": ["timothy"]}` -> id 1. A non-`v*` probe tag pushed
and deleted proves tag pushes still work at all. The POSITIVE release-cut verification is DEFERRED
to the operator's next real cut: pushing a `v*` tag publishes the `:prod` image, which is a release,
not a verification step.
What this does not close, stated so the records are not cited as a boundary: `REGISTRY_PASSWORD`
stays in the Actions store for `build`, and head YAML can still name it, `RENOVATE_TOKEN` or
`SERVERMGMT_DEPLOY_KEY`. Blast radius, not the route.
New records `ci.pr-route-carries-no-stored-credential` and `release.tag-protection-v-star`;
`ci.workflow-dispatch-ref-unrestricted`, `ci.actions-credential-scoping` and
`release.main-direct-push-disabled` updated to match; catalog regenerated. Closes #885.
Decisions-Edit: yes
Proves: scripts/tests/test_workflow_persist_credentials.py::test_no_PULL_REQUEST_route_job_names_a_STORED_secret
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
96 lines
4.7 KiB
Bash
Executable File
96 lines
4.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/ci-detect-already-validated.sh — emit `skip=true|false` to $GITHUB_OUTPUT for the
|
|
# docker-build.yml cross-run tree-identity gate (ersatztv#420). On a merge-to-main push, if the
|
|
# merged tree is byte-identical to a PR head that ALREADY passed `test`+`migrations` (a GREEN
|
|
# combined commit status), the heavy compile/test/migrations work is redundant -- the exact same
|
|
# source was already validated. `build` still runs and still builds+pushes the image, so no image
|
|
# ever ships from unvalidated source.
|
|
#
|
|
# The bias is ALWAYS toward running MORE, never less: a false 'true' would ship (or claim to
|
|
# validate) unreviewed/unvalidated source, so every ambiguous or unverifiable case resolves to
|
|
# skip=false. It is fine (just wasteful) to re-run on an identical tree; it is a correctness bug
|
|
# to skip validation on a tree that differs or was never proven green.
|
|
#
|
|
# Runs identically locally and in CI. Locally (no $GITHUB_OUTPUT) it prints the decision to
|
|
# stdout; e.g. GITHUB_EVENT_NAME=push GITHUB_REF=refs/heads/main scripts/ci-detect-already-validated.sh
|
|
set -euo pipefail
|
|
|
|
out="${GITHUB_OUTPUT:-/dev/stdout}"
|
|
event="${GITHUB_EVENT_NAME:-}"
|
|
ref="${GITHUB_REF:-}"
|
|
|
|
emit() {
|
|
echo "skip=$1" >> "$out"
|
|
echo "-> skip=$1"
|
|
}
|
|
|
|
# Only a push directly to main can possibly be a merge-to-main we can cross-check against an
|
|
# already-validated PR head. Everything else (pull_request, tag push, workflow_dispatch, a push
|
|
# to any other branch) -> always run.
|
|
if [ "$event" != "push" ] || [ "$ref" != "refs/heads/main" ]; then
|
|
echo "event='${event:-<none>}' ref='${ref:-<none>}' (need push to refs/heads/main); running full validation (safe default)"
|
|
emit false
|
|
exit 0
|
|
fi
|
|
|
|
# Deepen history so HEAD's second parent (and its tree) are resolvable -- a shallow checkout may
|
|
# have neither. Mirrors the docs-only/docs-reminder jobs' fetch style. Non-fatal: a failed
|
|
# deepen still falls through to the HEAD^2 check below, which then fails safe.
|
|
git fetch --deepen=2 origin 2>/dev/null || git fetch --unshallow origin 2>/dev/null || true
|
|
|
|
# HEAD must be a real merge commit with a second parent -- that second parent is the PR head CI
|
|
# actually validated. No second parent (a direct/fast-forward/squash push) -> nothing to compare
|
|
# against -> always run.
|
|
pr_head="$(git rev-parse --verify -q HEAD^2 || true)"
|
|
if [ -z "$pr_head" ]; then
|
|
echo "HEAD has no second parent (not a merge commit); running full validation (safe default)"
|
|
emit false
|
|
exit 0
|
|
fi
|
|
|
|
merge_tree="$(git rev-parse --verify -q 'HEAD^{tree}' || true)"
|
|
pr_tree="$(git rev-parse --verify -q "${pr_head}^{tree}" || true)"
|
|
if [ -z "$merge_tree" ] || [ -z "$pr_tree" ]; then
|
|
echo "could not resolve HEAD or HEAD^2 tree; running full validation (safe default)"
|
|
emit false
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$merge_tree" != "$pr_tree" ]; then
|
|
echo "merged tree ($merge_tree) != PR head $pr_head tree ($pr_tree) -- main advanced since the PR was validated; running full validation"
|
|
emit false
|
|
exit 0
|
|
fi
|
|
|
|
# Merge tree matches the PR head tree exactly. Confirm that PR head was actually validated green
|
|
# before trusting it -- query the Gitea combined commit status API.
|
|
#
|
|
# THE READ IS ANONYMOUS (ersatztv#885, `ci.pr-route-carries-no-stored-credential`). The three jobs
|
|
# that call this run on the `pull_request` route as well as on the main push, and Gitea resolves a
|
|
# `pull_request` run from the PR HEAD -- so those jobs may hold no stored secret, and the
|
|
# `ETV_STATUS_AUTH` they used to carry is gone. `timothy/ersatztv` is public and answers this GET
|
|
# unauthenticated (measured 2026-09-04: HTTP 200 carrying the combined state). No credential
|
|
# override is kept in its place: the URL below names ONE instance, that instance is public, so an
|
|
# override would be a code path with no caller -- and an unusable one (":", the shape an absent
|
|
# secret interpolates to) would draw a 401 and turn a working read into a permanent skip=false.
|
|
#
|
|
# Every failure direction here is safe: a missing, failing or non-success response falls through to
|
|
# skip=false, which re-runs validation. Nothing about this step can cause a skip that was not earned.
|
|
status_url="http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/commits/${pr_head}/status"
|
|
status_json="$(curl -sf "$status_url" || true)"
|
|
if [ -z "$status_json" ]; then
|
|
echo "status API request for PR head ${pr_head} failed; running full validation (safe default)"
|
|
emit false
|
|
exit 0
|
|
fi
|
|
|
|
state="$(printf '%s' "$status_json" | jq -r '.state // empty' 2>/dev/null || true)"
|
|
if [ "$state" != "success" ]; then
|
|
echo "PR head ${pr_head} combined status is '${state:-<unknown>}', not 'success'; running full validation (safe default)"
|
|
emit false
|
|
exit 0
|
|
fi
|
|
|
|
echo "merged tree == green PR head ${pr_head} (status=success) -> skipping re-validation"
|
|
emit true
|