From 2149376857a1c349594c6491cbd7c346582739a8 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Wed, 2 Sep 2026 22:11:19 +0200 Subject: [PATCH] fix(ci): restore Validate Main by reverting the GHCR tag-existence check Reverts #3533. That change made the signing-revision report require that the CI credential can LIST current GHCR package versions for every consumer, and fail closed when it cannot. The credential can list the public packages but not every private one, so `ascoachingogvaner` reports UNRESOLVED and the job fails -- on main, for a credential-scope reason unrelated to what the report checks. The consumer it fails on is demonstrably healthy: Flux resolves oci://ghcr.io/devantler-tech/ascoachingogvaner/manifests at tag 1.13.4 in prod right now (Ready=True, artifact 1.13.4@sha256:ec90c928...). So the new check is a false negative, not a caught defect. This restores the three files byte-identically to 77bc6737, the commit on which Validate Main last ran green. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/validate-main.yaml | 1 - ...port-publish-workflow-signing-revisions.sh | 111 +----------- ...test-publish-workflow-signing-revisions.sh | 164 +----------------- 3 files changed, 16 insertions(+), 260 deletions(-) diff --git a/.github/workflows/validate-main.yaml b/.github/workflows/validate-main.yaml index d8ce132672..64893af24a 100644 --- a/.github/workflows/validate-main.yaml +++ b/.github/workflows/validate-main.yaml @@ -113,7 +113,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read # checkout repository - packages: read # report current GHCR package tags for signing-revision attribution steps: - name: 📑 Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/scripts/report-publish-workflow-signing-revisions.sh b/scripts/report-publish-workflow-signing-revisions.sh index 307b33378b..d0c8c18d5e 100755 --- a/scripts/report-publish-workflow-signing-revisions.sh +++ b/scripts/report-publish-workflow-signing-revisions.sh @@ -24,12 +24,6 @@ # EVERY FAILURE MODE HERE IS "REPORTS CLEAN WHILE HAVING CHECKED NOTHING" # That is the only way this script can do harm, so each guard below exists against one # measured instance of it. They are noted where they sit rather than listed here. -# -# This report reads current GHCR package metadata as well as repository and Actions data. -# The `gh` credential must therefore be allowed to list package versions for the -# devantler-tech organization (`read:packages` on a classic PAT). Without that scope the -# report fails closed: historical git tags and successful runs do not prove that an OCI -# version still exists for Flux to resolve. set -euo pipefail @@ -135,36 +129,6 @@ gh_retry() { return 1 } -# The GHCR package belonging to one source repository. `.github` is the one deliberate -# translation: a leading dot is not a valid OCI path component, so it publishes as -# github-config. All current consumers publish their manifests under the same suffix. -ghcr_package_for_repo() { - case "$1" in - .github) printf '%s\n' 'github-config/manifests' ;; - *) printf '%s/manifests\n' "$1" ;; - esac -} - -# Current registry tags for one consumer. GitHub's package API names a nested GHCR -# package with an encoded slash. Its response is authoritative for CURRENT existence; -# git tags and workflow runs below are deliberately historical evidence. -current_registry_tags() { - local package encoded - package="$(ghcr_package_for_repo "$1")" - encoded="${package//\//%2F}" - gh_retry api --paginate \ - "orgs/devantler-tech/packages/container/${encoded}/versions?per_page=100" \ - --jq '.[].metadata.container.tags[]?' -} - -# Translate the source Git ref to the OCI tag emitted by the shared publish workflows: -# they strip the conventional v prefix, and OCI tags encode SemVer's `+` build-metadata -# delimiter as `_` because `+` is not valid in an OCI tag. -registry_tag_for_git_tag() { - local tag="${1#v}" - printf '%s\n' "${tag//+/_}" -} - # Print "\t\t" per consumer. # # 🔴 ITERATES DOCUMENTS, NOT FILES. Reading one value per FILE drops every consumer after @@ -503,18 +467,12 @@ tag_was_published() { # that actually signed the running artifact. Closing that needs an applied Flux revision, # which means reading a cluster; this script reads manifests, git tags and Actions runs only. deployed_tag() { - local repo="$1" version="$2" tags registry_tags registry_package registry_version candidate bare + local repo="$1" version="$2" tags candidate bare # --paginate: the endpoint caps at 100 per page and these repos already carry 50+ tags. # Past the cap an un-paginated read returns an arbitrary subset, which either misses a # real tag (false UNRESOLVED) or picks the newest of a truncated page (silently wrong). tags="$(gh_retry api --paginate "repos/devantler-tech/${repo}/tags?per_page=100" --jq '.[].name')" || return 1 [ -n "$tags" ] || return 1 - registry_package="$(ghcr_package_for_repo "$repo")" - if ! registry_tags="$(current_registry_tags "$repo")"; then - printf 'could not list current GHCR versions for %s; the gh credential needs package metadata access (read:packages on a classic PAT), and historical tags alone are not deployment evidence\n' \ - "$registry_package" >&2 - return 1 - fi if [ -n "$version" ]; then # 🔴 BOTH SPELLINGS OF ONE VERSION ARE TWO DISTINCT GIT REFS HERE TOO. The semver walk # below refuses that ambiguity by name, but this branch RETURNED ON THE FIRST MATCH, so @@ -563,15 +521,6 @@ deployed_tag() { return 1 fi [ "$exact_pub" -eq 0 ] || return 1 - # An exact OCIRepository tag is not normalized by Flux. The source workflow may have - # published Git tag v2.0.0 as OCI tag 2.0.0, but a manifest that literally pins - # v2.0.0 still asks the registry for v2.0.0 and must not be cleared by the other spelling. - registry_version="$version" - if ! printf '%s\n' "$registry_tags" | grep -qxF -- "$registry_version"; then - printf 'tag %s has a successful publish run but exact registry tag %s from the manifest no longer exists in GHCR package %s; it may have been deleted or published under a different spelling, and neither accepting it nor walking backward is deployment evidence\n' \ - "$candidate" "$registry_version" "$registry_package" >&2 - return 1 - fi # The VERIFIED commit travels with the tag. `pin_at_ref` used to be handed the tag NAME, # which is mutable: if the tag moves after the publication check above and before that # read, publication is verified for this commit while cd.yaml is read from a different @@ -754,53 +703,6 @@ deployed_tag() { return 1 fi if [ "$walk_pub" -eq 0 ]; then - registry_version="$(registry_tag_for_git_tag "$candidate")" - if ! printf '%s\n' "$registry_tags" | grep -qxF -- "$registry_version"; then - printf 'tag %s has a successful publish run but registry tag %s no longer exists in GHCR package %s; it may have been deleted, and walking backward would attribute an older release to what Flux resolves today\n' \ - "$candidate" "$registry_version" "$registry_package" >&2 - return 1 - fi - # Flux chooses a semver candidate from CURRENT REGISTRY TAGS, not from Git refs. - # A deleted Git tag can leave its GHCR version behind, and a still-present Git - # tag can have no successful publication evidence. Either way, if the registry - # version ranks ABOVE the candidate this Git-and-Actions walk selected, silently - # returning the lower release would attribute the wrong signing revision. A - # distinct registry tag at the SAME precedence is ambiguous for the same reason. - local registry_seen registry_semver registry_core selected_core registry_high - selected_core="${variants%%+*}" - while IFS= read -r registry_seen; do - [ -n "$registry_seen" ] || continue - [ "$registry_seen" = 'latest' ] && continue - registry_semver="${registry_seen/_/+}" - if [[ "$registry_semver" =~ $strict_re ]] || [[ "$registry_semver" =~ $loose_re ]]; then - registry_core="$(printf '%s' "$registry_semver" | sed -e 's/^v//' -e 's/+.*$//')" - # Masterminds SemVer (and therefore Flux) coerces partial versions before - # ordering: 2 == 2.0 == 2.0.0. Keep the registry tag's original spelling - # for identity and diagnostics, but compare its normalized three-component - # core so a distinct partial tag cannot hide a same-precedence ambiguity. - case "$registry_core" in - *.*.*) ;; - *.*) registry_core="${registry_core}.0" ;; - *) registry_core="${registry_core}.0.0" ;; - esac - else - continue - fi - registry_high="$(printf '%s\n%s\n' "$registry_core" "$selected_core" | sort -V -r | head -1)" - if [ "$registry_core" = "$selected_core" ]; then - if [ "$registry_seen" != "$registry_version" ]; then - printf 'registry tag %s has the same SemVer precedence as selected registry tag %s; Flux can select either, and choosing needs Flux-compatible ordering plus publication evidence for both\n' \ - "$registry_seen" "$registry_version" >&2 - return 1 - fi - continue - fi - if [ "$registry_high" = "$registry_core" ]; then - printf 'registry tag %s ranks above selected release %s but this resolver cannot map the higher selection to one Git tag and successful publication run; refusing instead of attributing an older release to what Flux resolves\n' \ - "$registry_seen" "$registry_version" >&2 - return 1 - fi - done <<<"$registry_tags" # The early refusal above compares against the highest STRICT tag, which is not # necessarily the one being reported: when that tag never published, the walk steps # past it to an older release, and a loose tag sitting between the two was cleared @@ -976,9 +878,14 @@ main() { fi local mark='' # 🔴 NEITHER ORIGIN IS A MEASUREMENT OF WHAT FLUX HAS APPLIED, and both say so. This - # script reads manifests, git tags, Actions runs and current GHCR package metadata; - # nothing here reads a cluster. Saying so is the difference between an allow-list built - # on evidence and one built on a guess that reads identically. + # script reads manifests, git tags and Actions runs; nothing here reads a cluster, and + # nothing here reads the REGISTRY either. Saying so is the difference between an + # allow-list built on evidence and one built on a guess that reads identically. + # + # The registry gap is its own limitation, tracked separately: candidates come only from + # git tags, so a GHCR version deleted after a successful run still reads as publishable + # while Flux's selector can no longer resolve it. Closing that needs a package read, + # which is outside the scope #3048 set for this script. # # `pinned` was called `exact`, which overclaimed in a way that is reachable rather than # theoretical: a version bump landing by DIRECT PUSH to main runs validate-main.yaml diff --git a/scripts/tests/test-publish-workflow-signing-revisions.sh b/scripts/tests/test-publish-workflow-signing-revisions.sh index 6a441ec626..a788343a8d 100755 --- a/scripts/tests/test-publish-workflow-signing-revisions.sh +++ b/scripts/tests/test-publish-workflow-signing-revisions.sh @@ -558,7 +558,7 @@ mkdir -p "$bm_bin" # executed fragments of those comments. Values it needs come through the environment. cat >"$bm_bin/gh" <<'GHSTUB' #!/usr/bin/env bash -# Minimal forge stub. Only the five call shapes the default resolver makes are answered; +# Minimal forge stub. Only the four call shapes the default resolver makes are answered; # anything else exits non-zero, so an unstubbed call surfaces as a failure rather than as # an empty string the script might read as data. args="$*" @@ -586,16 +586,6 @@ case "$args" in ;; esac case "$args" in - *"/packages/container/"*"/versions"*) - # Current GHCR metadata, deliberately separate from git tags and successful workflow - # runs. A deleted package version leaves both of those historical facts behind. The - # real publish workflows strip the source tag's v prefix for the OCI version. - case "$args" in - *wedding-app*) printf "${BM_WEDDING_REGISTRY_TAGS:-${BM_WEDDING_TAGS:-v1.9.0\n}}" | sed -e 's/^v//' -e 's/+/_/' ;; - *aws*) printf "${BM_AWS_REGISTRY_TAGS:-${BM_AWS_TAGS:-v1.9.0\n}}" | sed -e 's/^v//' -e 's/+/_/' ;; - *) printf '1.9.0\n' ;; - esac - ;; *"/tags?per_page=100"*) # Per-repo tag lists, so each case can put the interesting shape on exactly ONE # consumer and leave the rest clean: a refusal or divergence can then only have come @@ -838,7 +828,10 @@ if BM_AWS_TAGS='v2.0.0+build.1\nv1.9.0\n' BM_OWNCOMMIT_TAG='v2.0.0+build.1' \ fail 'the newest release exists only as a build-metadata tag, and the report resolved an older one instead — it walked silently past it' pass 'a version published only with build metadata is resolved as itself, not walked past' else - fail "the metadata-only release exists in GHCR as 2.0.0_build.1 but did not resolve: $(grep -E '^(UNRESOLVED|could not|tag )' "$bm2_out" | tr '\n' ' ')" + # Refusing by name is acceptable; succeeding while naming an older tag is not. + grep -qF '2.0.0+build.1' "$bm2_out" || + fail 'the run neither resolved the metadata-only release nor refused by name' + pass 'a version published only with build metadata is never silently walked past' fi # The run lookup must pass the tag as a PARAMETER. Checked explicitly because the failure @@ -1252,7 +1245,7 @@ fi nw_root="$WORK/never-published-walk-root" cp -R "$bm_root" "$nw_root" nw_out="$WORK/never-published-walk.out" -if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_WEDDING_REGISTRY_TAGS='1.9.0\n' BM_UNPUBLISHED_TAG='v2.0.0' \ +if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_UNPUBLISHED_TAG='v2.0.0' \ BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ PUBLISH_CONSUMER_ROOT="$nw_root" "$SCRIPT" >"$nw_out" 2>&1; then nw_insync="$(grep -c '^IN-SYNC' "$nw_out" || true)" @@ -1328,151 +1321,8 @@ else fail "the divergence control failed outright: $(cat "$tc2_out")" fi -# --------------------------------------------------------------------------- -# 23. A SUCCESSFUL HISTORICAL RUN IS NOT A CURRENT REGISTRY VERSION. (#3331) -# Deleting a GHCR version leaves both its git tag and its successful cd.yaml run. -# Walking past the missing version would be equally wrong: Flux resolves against -# the registry, so the report must refuse by name instead of attributing either the -# deleted version or an older fallback to what is deployed. -# --------------------------------------------------------------------------- -rg_root="$WORK/registry-deleted-root" -cp -R "$bm_root" "$rg_root" -rg_out="$WORK/registry-deleted.out" -if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_WEDDING_REGISTRY_TAGS='1.9.0\n' \ - BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ - PUBLISH_CONSUMER_ROOT="$rg_root" "$SCRIPT" >"$rg_out" 2>&1; then - fail 'a tag with a successful publish run but no current GHCR version was accepted or silently walked past' -else - grep -q 'no longer exists in GHCR' "$rg_out" || - fail 'the run failed but did not name the missing current registry version' - grep -qF 'v2.0.0' "$rg_out" || - fail 'the registry refusal does not name the deleted tag' - rg_unresolved="$(grep -c '^UNRESOLVED' "$rg_out" || true)" - rg_insync="$(grep -c '^IN-SYNC' "$rg_out" || true)" - [ "$rg_unresolved" -eq 1 ] || - fail "expected exactly ONE unresolved consumer (the deleted registry version), got $rg_unresolved" - [ "$rg_insync" -eq "$expected_insync" ] || - fail "expected the other FOUR consumers to resolve IN-SYNC through the same stub, got $rg_insync" - pass 'a successfully published tag deleted from GHCR refuses by name instead of walking backward' -fi - -# CONTROL: when the same candidate still exists in GHCR it must resolve normally. -rg2_root="$WORK/registry-present-root" -cp -R "$bm_root" "$rg2_root" -rg2_out="$WORK/registry-present.out" -if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_WEDDING_REGISTRY_TAGS='2.0.0\n1.9.0\n' \ - BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ - PUBLISH_CONSUMER_ROOT="$rg2_root" "$SCRIPT" >"$rg2_out" 2>&1; then - rg2_insync="$(grep -c '^IN-SYNC' "$rg2_out" || true)" - [ "$rg2_insync" -eq "$consumer_count" ] || - fail "the registry-present control resolved only $rg2_insync of $consumer_count consumers — the registry check rejects valid versions" - pass 'a successfully published tag still present in GHCR resolves normally' -else - fail "the registry-present control failed outright: $(cat "$rg2_out")" -fi - -# A partial registry tag is a distinct OCI artifact selector even when Flux coerces it -# to the same SemVer precedence as the selected strict tag. The Git-side tie guard above -# cannot see this shape when the partial source ref was deleted after publication, so the -# current registry set must be normalized independently before comparing precedence. -rg3_root="$WORK/registry-partial-tie-root" -cp -R "$bm_root" "$rg3_root" -rg3_out="$WORK/registry-partial-tie.out" -if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_WEDDING_REGISTRY_TAGS='2.0.0\n2.0\n1.9.0\n' \ - BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ - PUBLISH_CONSUMER_ROOT="$rg3_root" "$SCRIPT" >"$rg3_out" 2>&1; then - fail 'a partial registry tag tied with the selected strict tag was silently ignored' -else - grep -q 'registry tag 2.0 has the same SemVer precedence' "$rg3_out" || - fail 'the registry partial-tie refusal does not name the distinct current tag and shared precedence' - rg3_unresolved="$(grep -c '^UNRESOLVED' "$rg3_out" || true)" - rg3_insync="$(grep -c '^IN-SYNC' "$rg3_out" || true)" - [ "$rg3_unresolved" -eq 1 ] || - fail "expected exactly ONE unresolved consumer (the registry partial tie), got $rg3_unresolved" - [ "$rg3_insync" -eq "$expected_insync" ] || - fail "expected the other FOUR consumers to resolve IN-SYNC through the same stub, got $rg3_insync" - pass 'a partial registry tag tied with the selected strict tag refuses instead of guessing' -fi - -# --------------------------------------------------------------------------- -# 24. THE LIVE MAIN-BRANCH REPORT MUST RECEIVE PACKAGE-READ AUTHORITY. (#3331) -# The hermetic PR test stubs GHCR, but validate-main runs the real resolver with -# GITHUB_TOKEN. Without this job-level permission every package request returns 403, -# every consumer becomes UNRESOLVED, and the report makes main red by construction. -# --------------------------------------------------------------------------- -registry_permission="$(yq eval -r '.jobs.validate-shared-publish-pin.permissions.packages // ""' \ - "$REPO_ROOT/.github/workflows/validate-main.yaml")" -if [ "$registry_permission" = 'read' ]; then - pass 'the live main-branch report receives packages: read for current GHCR metadata' -else - fail "validate-main gives the live report packages: ${registry_permission:-none}; GHCR metadata will return 403" -fi - -# --------------------------------------------------------------------------- -# 25. REGISTRY-ONLY HIGHER VERSIONS MUST NOT BE SILENTLY OUTRANKED. (#3331 review) -# Flux selects from registry tags. If the highest current version survives in GHCR -# after its Git ref is deleted, a git-only walk must not fall back to an older version -# and attribute that older release's signer to what Flux resolves. -# --------------------------------------------------------------------------- -ro_root="$WORK/registry-only-root" -cp -R "$bm_root" "$ro_root" -ro_out="$WORK/registry-only.out" -if BM_WEDDING_TAGS='v1.9.0\n' BM_WEDDING_REGISTRY_TAGS='2.0.0\n1.9.0\n' \ - BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ - PUBLISH_CONSUMER_ROOT="$ro_root" "$SCRIPT" >"$ro_out" 2>&1; then - fail 'a higher registry-only version was silently outranked by an older Git tag' -else - grep -q 'registry tag 2.0.0' "$ro_out" || - fail 'the registry-only refusal does not name the higher current version' - grep -q 'Git tag' "$ro_out" || - fail 'the registry-only refusal does not explain that publication evidence cannot be mapped' - ro_unresolved="$(grep -c '^UNRESOLVED' "$ro_out" || true)" - [ "$ro_unresolved" -eq 1 ] || - fail "expected exactly ONE unresolved consumer (the registry-only higher version), got $ro_unresolved" - pass 'a higher registry-only version refuses instead of attributing an older release signer' -fi - -# A higher registry version with a surviving Git tag is still not safe when this -# resolver cannot establish a successful publication at that tag. Registry selection -# wins; falling through to the lower candidate would remain a confident wrong answer. -rp_root="$WORK/registry-publication-gap-root" -cp -R "$bm_root" "$rp_root" -rp_out="$WORK/registry-publication-gap.out" -if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_WEDDING_REGISTRY_TAGS='2.0.0\n1.9.0\n' BM_UNPUBLISHED_TAG='v2.0.0' \ - BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ - PUBLISH_CONSUMER_ROOT="$rp_root" "$SCRIPT" >"$rp_out" 2>&1; then - fail 'a higher current registry version without established publication evidence was silently walked past' -else - grep -q 'registry tag 2.0.0' "$rp_out" || - fail 'the publication-gap refusal does not name the higher registry version' - grep -q 'publication' "$rp_out" || - fail 'the publication-gap refusal does not explain why the higher version cannot be attributed' - pass 'a higher registry version without established publication evidence refuses instead of walking backward' -fi - -# --------------------------------------------------------------------------- -# 26. AN EXACT MANIFEST TAG IS LOOKED UP EXACTLY IN THE REGISTRY. (#3331 review) -# The publisher strips v from its source Git tag, but Flux does not rewrite an exact -# spec.ref.tag. A manifest pinning v2.0.0 therefore cannot be cleared by GHCR tag 2.0.0. -# --------------------------------------------------------------------------- -et_root="$WORK/exact-registry-spelling-root" -make_exact_root "$et_root" 'v2.0.0' -et_out="$WORK/exact-registry-spelling.out" -if BM_WEDDING_TAGS='v2.0.0\nv1.9.0\n' BM_WEDDING_REGISTRY_TAGS='2.0.0\n1.9.0\n' \ - BM_SHA_A="$SHA_A" BM_SHA_B="$SHA_B" BM_SHA_C="$SHA_C" BM_VIOLATION_LOG="$WORK/interpolated.log" PATH="$bm_bin:$PATH" \ - PUBLISH_CONSUMER_ROOT="$et_root" "$SCRIPT" >"$et_out" 2>&1; then - fail 'an exact manifest pin was cleared by a differently-spelled GHCR tag' -else - grep -q 'exact registry tag v2.0.0' "$et_out" || - fail 'the exact-tag refusal does not name the manifest spelling Flux requests' - et_unresolved="$(grep -c '^UNRESOLVED' "$et_out" || true)" - [ "$et_unresolved" -eq 1 ] || - fail "expected exactly ONE unresolved consumer (the exact missing tag), got $et_unresolved" - pass 'an exact manifest tag must exist under the exact spelling Flux requests' -fi - if [ "$failures" -ne 0 ]; then printf '\n%d failure(s)\n' "$failures" >&2 exit 1 fi -printf '\nPASS: publish-workflow signing-revision report (28 cases)\n' +printf '\nPASS: publish-workflow signing-revision report (22 cases)\n'