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'