From 556134f0c019f866064cb88ca35c2854d881a051 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Thu, 14 May 2026 10:52:58 -0700 Subject: [PATCH 1/3] fix(release-ceremony): restore annotated tag identity Restore annotated tag refs after checkout, verify restored tag identity against the triggering event commit, and harden release-check metadata validation so future workflow edits preserve the trust ordering. Also reject explicit empty --container-image values at parse time so artifact validation cannot be silently bypassed. Closes #16 Refs tesserine/commons#34 --- .github/workflows/release.yml | 12 +++++++ CHANGELOG.md | 5 +++ RELEASING.md | 13 +++---- scripts/release-check | 65 ++++++++++++++++++++++++++++++++++- scripts/test-release-check | 57 ++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aba9d7f..3f42d2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,18 @@ jobs: with: fetch-depth: 0 + - name: Restore annotated tag refs + run: git fetch --tags --force origin + + - name: Verify restored tag matches event + run: | + set -euo pipefail + restored_commit=$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}") + if [ "$restored_commit" != "$GITHUB_SHA" ]; then + echo "Tag $GITHUB_REF_NAME moved since trigger: expected $GITHUB_SHA, got $restored_commit" >&2 + exit 1 + fi + - name: Require annotated tag run: | test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag diff --git a/CHANGELOG.md b/CHANGELOG.md index cc94aa9..ff4c6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- GitHub Release publication now restores annotated tag refs after checkout and + verifies the restored tag still targets the triggering event commit before + running repository release code. +- Release artifact validation now rejects an explicit empty `--container-image` + value instead of silently treating it as omitted. - Release tag validation now rejects `rc.0` release candidates across tag, changelog heading, `RUNA_REF`, and GitHub Release classification surfaces to match the ADR-0012 release grammar. diff --git a/RELEASING.md b/RELEASING.md index 5a7a634..6f1e8bb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -80,12 +80,13 @@ corrected by cutting the next `rc.N`, not by rewriting the existing tag. ## Post-Release Gate -The tag push runs `.github/workflows/release.yml`. That workflow verifies the -annotated tag and main-branch ancestry with git-only checks before running -repository release code, builds a local container image with `BASE_REF` set to -the tag, verifies image identity, extracts release notes from `CHANGELOG.md`, -and publishes the GitHub Release. Only `vX.Y.Z-rc.N` tags are published as -GitHub prereleases. +The tag push runs `.github/workflows/release.yml`. That workflow restores +annotated tag refs after checkout, verifies the restored tag target still +matches the triggering event commit, and verifies annotated-tag and main-branch +ancestry with git-only checks before running repository release code. It then +builds a local container image with `BASE_REF` set to the tag, verifies image +identity, extracts release notes from `CHANGELOG.md`, and publishes the GitHub +Release. Only `vX.Y.Z-rc.N` tags are published as GitHub prereleases. Manual GitHub Release creation, when needed after a workflow failure, uses the same notes source and release classification. diff --git a/scripts/release-check b/scripts/release-check index 92a54d7..7bb5645 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -224,6 +224,46 @@ workflow_executable_lines() { ' "$workflow" } +workflow_uses_lines() { + local workflow="$1" + + awk ' + function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value + } + + function unquote(value) { + if ((value ~ /^".*"$/) || (value ~ /^\047.*\047$/)) { + return substr(value, 2, length(value) - 2) + } + return value + } + + { + line = $0 + sub(/\r$/, "", line) + stripped = trim(line) + + if (stripped ~ /^#/) { + next + } + + if (line ~ /^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*/) { + value = line + sub(/^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*/, "", value) + value = trim(value) + sub(/[[:space:]]+#.*/, "", value) + value = trim(value) + if (value != "") { + print NR "\t" unquote(value) + } + } + } + ' "$workflow" +} + check_release_workflow_surface() { local workflow="$repo_root/.github/workflows/release.yml" [[ -f "$workflow" ]] || die ".github/workflows/release.yml not found" @@ -235,15 +275,37 @@ check_release_workflow_surface() { ! grep -Eq '^ paths:' "$workflow" \ || die ".github/workflows/release.yml tag publication must not use paths filters" - local validation_line container_setup_line annotated_tag_line main_ancestry_line repository_code_line + local validation_line container_setup_line checkout_line tag_ref_restore_line event_identity_verify_line annotated_tag_line main_ancestry_line repository_code_line validation_line="$(workflow_executable_lines "$workflow" | awk -F '\t' 'index($2, "./scripts/release-check release \"$GITHUB_REF_NAME\"") { print $1; exit }')" container_setup_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 ~ /sudo apt-get install -y podman|podman build/ { print $1; exit }')" [[ -n "$validation_line" ]] && [[ -n "$container_setup_line" ]] && [[ "$validation_line" -lt "$container_setup_line" ]] \ || die ".github/workflows/release.yml must validate the release tag before container setup" + checkout_line="$(workflow_uses_lines "$workflow" | awk -F '\t' '$2 ~ /^actions\/checkout(@|$)/ { print $1; exit }')" + tag_ref_restore_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 == "git fetch --tags --force origin" { print $1; exit }')" + event_identity_verify_line="$(workflow_executable_lines "$workflow" | awk -F '\t' ' + $2 == "restored_commit=$(git rev-parse \"refs/tags/$GITHUB_REF_NAME^{commit}\")" { + saw_restored_commit = 1 + next + } + saw_restored_commit && $2 == "if [ \"$restored_commit\" != \"$GITHUB_SHA\" ]; then" { + print $1 + exit + } + ')" annotated_tag_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 ~ /git cat-file -t/ { print $1; exit }')" main_ancestry_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 ~ /git merge-base --is-ancestor/ { print $1; exit }')" repository_code_line="$(workflow_executable_lines "$workflow" | awk -F '\t' 'index($2, "./scripts/") || $2 ~ /podman build/ { print $1; exit }')" + + [[ -n "$tag_ref_restore_line" ]] \ + || die ".github/workflows/release.yml must restore annotated tag refs before checking tag type" + [[ -n "$checkout_line" ]] && [[ "$checkout_line" -lt "$tag_ref_restore_line" ]] \ + || die ".github/workflows/release.yml must restore annotated tag refs after checkout and before checking tag type" + [[ -z "$annotated_tag_line" ]] || [[ "$tag_ref_restore_line" -lt "$annotated_tag_line" ]] \ + || die ".github/workflows/release.yml must restore annotated tag refs before checking tag type" + [[ -n "$event_identity_verify_line" ]] && [[ "$tag_ref_restore_line" -lt "$event_identity_verify_line" ]] \ + && { [[ -z "$annotated_tag_line" ]] || [[ "$event_identity_verify_line" -lt "$annotated_tag_line" ]]; } \ + || die ".github/workflows/release.yml must verify the restored tag matches the triggering event before checking tag type" [[ -n "$annotated_tag_line" ]] && [[ -n "$main_ancestry_line" ]] && [[ -n "$repository_code_line" ]] \ && [[ "$annotated_tag_line" -lt "$repository_code_line" ]] && [[ "$main_ancestry_line" -lt "$repository_code_line" ]] \ || die ".github/workflows/release.yml must establish tag trust before running repository code" @@ -363,6 +425,7 @@ run_release() { case "$1" in --container-image) [[ "$#" -ge 2 ]] || die "--container-image requires an image" + [[ -n "$2" ]] || die "--container-image requires a non-empty image" container_image="$2" shift 2 ;; diff --git a/scripts/test-release-check b/scripts/test-release-check index f81938b..f55432c 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -66,6 +66,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - run: git fetch --tags --force origin + - run: | + restored_commit=$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}") + if [ "$restored_commit" != "$GITHUB_SHA" ]; then + echo "Tag $GITHUB_REF_NAME moved since trigger: expected $GITHUB_SHA, got $restored_commit" >&2 + exit 1 + fi - run: test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = tag - run: git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main - run: ./scripts/release-check release "$GITHUB_REF_NAME" @@ -453,6 +460,12 @@ EOF assert_failure_contains "${FUNCNAME[0]}" "org.tesserine.base.ref" run_check_with_runtime "$root" "$root/fake-runtime" release v1.2.3 --container-image localhost/base:v1.2.3 } +release_rejects_empty_container_image_values() { + local root + root="$(new_fixture release-empty-container-image 1.2.3)" + assert_failure_contains "${FUNCNAME[0]}" "--container-image requires a non-empty image" run_check "$root" release v1.2.3 --container-image "" +} + release_workflow_uses_broad_tag_filter_with_early_validation() { local root root="$(new_fixture workflow-patterns 1.2.3)" @@ -507,6 +520,44 @@ release_workflow_rejects_validation_before_tag_trust() { assert_failure_contains "${FUNCNAME[0]}" "establish tag trust before running repository code" run_check "$root" metadata } +release_workflow_rejects_missing_tag_ref_restore() { + local root + root="$(new_fixture workflow-missing-tag-ref-restore 1.2.3)" + sed -i '/git fetch --tags --force origin/d' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "restore annotated tag refs before checking tag type" run_check "$root" metadata +} + +release_workflow_rejects_late_tag_ref_restore() { + local root + root="$(new_fixture workflow-late-tag-ref-restore 1.2.3)" + sed -i '/git fetch --tags --force origin/d' "$root/.github/workflows/release.yml" + sed -i '/git cat-file -t/a\ - run: git fetch --tags --force origin' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "restore annotated tag refs before checking tag type" run_check "$root" metadata +} + +release_workflow_rejects_precheckout_tag_ref_restore() { + local root + root="$(new_fixture workflow-precheckout-tag-ref-restore 1.2.3)" + sed -i '/git fetch --tags --force origin/d' "$root/.github/workflows/release.yml" + sed -i '/uses: actions\/checkout@v4/i\ - run: git fetch --tags --force origin' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "restore annotated tag refs after checkout and before checking tag type" run_check "$root" metadata +} + +release_workflow_rejects_missing_event_identity_verification() { + local root + root="$(new_fixture workflow-missing-event-identity 1.2.3)" + sed -i '/restored_commit=/,/fi/d' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "verify the restored tag matches the triggering event before checking tag type" run_check "$root" metadata +} + +release_workflow_rejects_event_identity_before_tag_ref_restore() { + local root + root="$(new_fixture workflow-early-event-identity 1.2.3)" + sed -i '/restored_commit=/,/fi/d' "$root/.github/workflows/release.yml" + sed -i '/git fetch --tags --force origin/i\ - run: |\n restored_commit=$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")\n if [ "$restored_commit" != "$GITHUB_SHA" ]; then\n echo "Tag $GITHUB_REF_NAME moved since trigger: expected $GITHUB_SHA, got $restored_commit" >&2\n exit 1\n fi' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "verify the restored tag matches the triggering event before checking tag type" run_check "$root" metadata +} + release_workflow_ignores_shell_comments_when_establishing_tag_trust() { local root root="$(new_fixture workflow-shell-comment-trust 1.2.3)" @@ -576,12 +627,18 @@ release_rejects_missing_matching_changelog_heading release_heading_lookup_uses_literal_matching release_checks_container_label_identity release_rejects_container_label_mismatches +release_rejects_empty_container_image_values release_workflow_uses_broad_tag_filter_with_early_validation release_workflow_rejects_missing_early_validation release_workflow_rejects_late_validation release_workflow_ignores_yaml_comments_when_ordering_validation release_workflow_ignores_inline_shell_comments_when_ordering_validation release_workflow_rejects_validation_before_tag_trust +release_workflow_rejects_missing_tag_ref_restore +release_workflow_rejects_late_tag_ref_restore +release_workflow_rejects_precheckout_tag_ref_restore +release_workflow_rejects_missing_event_identity_verification +release_workflow_rejects_event_identity_before_tag_ref_restore release_workflow_ignores_shell_comments_when_establishing_tag_trust release_workflow_ignores_inline_shell_comments_when_establishing_tag_trust release_workflow_establishes_tag_trust_before_repository_code_execution From 9da942ca19e123dd30a80120b2e2c5278b4d42f5 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Thu, 14 May 2026 11:47:46 -0700 Subject: [PATCH 2/3] fix(release-ceremony): require post-fetch tag identity capture --- scripts/release-check | 22 +++++++++++++++------- scripts/test-release-check | 11 ++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/scripts/release-check b/scripts/release-check index 7bb5645..90a598f 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -275,7 +275,7 @@ check_release_workflow_surface() { ! grep -Eq '^ paths:' "$workflow" \ || die ".github/workflows/release.yml tag publication must not use paths filters" - local validation_line container_setup_line checkout_line tag_ref_restore_line event_identity_verify_line annotated_tag_line main_ancestry_line repository_code_line + local validation_line container_setup_line checkout_line tag_ref_restore_line event_identity_line_numbers event_identity_assignment_line event_identity_if_line annotated_tag_line main_ancestry_line repository_code_line validation_line="$(workflow_executable_lines "$workflow" | awk -F '\t' 'index($2, "./scripts/release-check release \"$GITHUB_REF_NAME\"") { print $1; exit }')" container_setup_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 ~ /sudo apt-get install -y podman|podman build/ { print $1; exit }')" [[ -n "$validation_line" ]] && [[ -n "$container_setup_line" ]] && [[ "$validation_line" -lt "$container_setup_line" ]] \ @@ -283,16 +283,21 @@ check_release_workflow_surface() { checkout_line="$(workflow_uses_lines "$workflow" | awk -F '\t' '$2 ~ /^actions\/checkout(@|$)/ { print $1; exit }')" tag_ref_restore_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 == "git fetch --tags --force origin" { print $1; exit }')" - event_identity_verify_line="$(workflow_executable_lines "$workflow" | awk -F '\t' ' + event_identity_assignment_line="" + event_identity_if_line="" + event_identity_line_numbers="$(workflow_executable_lines "$workflow" | awk -F '\t' ' $2 == "restored_commit=$(git rev-parse \"refs/tags/$GITHUB_REF_NAME^{commit}\")" { - saw_restored_commit = 1 + restored_commit_line = $1 next } - saw_restored_commit && $2 == "if [ \"$restored_commit\" != \"$GITHUB_SHA\" ]; then" { - print $1 + restored_commit_line != "" && $2 == "if [ \"$restored_commit\" != \"$GITHUB_SHA\" ]; then" { + print restored_commit_line "\t" $1 exit } ')" + if [[ -n "$event_identity_line_numbers" ]]; then + IFS=$'\t' read -r event_identity_assignment_line event_identity_if_line <<<"$event_identity_line_numbers" + fi annotated_tag_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 ~ /git cat-file -t/ { print $1; exit }')" main_ancestry_line="$(workflow_executable_lines "$workflow" | awk -F '\t' '$2 ~ /git merge-base --is-ancestor/ { print $1; exit }')" repository_code_line="$(workflow_executable_lines "$workflow" | awk -F '\t' 'index($2, "./scripts/") || $2 ~ /podman build/ { print $1; exit }')" @@ -303,9 +308,12 @@ check_release_workflow_surface() { || die ".github/workflows/release.yml must restore annotated tag refs after checkout and before checking tag type" [[ -z "$annotated_tag_line" ]] || [[ "$tag_ref_restore_line" -lt "$annotated_tag_line" ]] \ || die ".github/workflows/release.yml must restore annotated tag refs before checking tag type" - [[ -n "$event_identity_verify_line" ]] && [[ "$tag_ref_restore_line" -lt "$event_identity_verify_line" ]] \ - && { [[ -z "$annotated_tag_line" ]] || [[ "$event_identity_verify_line" -lt "$annotated_tag_line" ]]; } \ + [[ -n "$event_identity_assignment_line" ]] && [[ -n "$event_identity_if_line" ]] \ || die ".github/workflows/release.yml must verify the restored tag matches the triggering event before checking tag type" + [[ "$tag_ref_restore_line" -lt "$event_identity_assignment_line" ]] && [[ "$tag_ref_restore_line" -lt "$event_identity_if_line" ]] \ + || die ".github/workflows/release.yml must capture the restored tag target after restoring annotated tag refs" + [[ -z "$annotated_tag_line" ]] || { [[ "$event_identity_assignment_line" -lt "$annotated_tag_line" ]] && [[ "$event_identity_if_line" -lt "$annotated_tag_line" ]]; } \ + || die ".github/workflows/release.yml must compare the restored tag target before checking tag type" [[ -n "$annotated_tag_line" ]] && [[ -n "$main_ancestry_line" ]] && [[ -n "$repository_code_line" ]] \ && [[ "$annotated_tag_line" -lt "$repository_code_line" ]] && [[ "$main_ancestry_line" -lt "$repository_code_line" ]] \ || die ".github/workflows/release.yml must establish tag trust before running repository code" diff --git a/scripts/test-release-check b/scripts/test-release-check index f55432c..6526cac 100755 --- a/scripts/test-release-check +++ b/scripts/test-release-check @@ -555,7 +555,15 @@ release_workflow_rejects_event_identity_before_tag_ref_restore() { root="$(new_fixture workflow-early-event-identity 1.2.3)" sed -i '/restored_commit=/,/fi/d' "$root/.github/workflows/release.yml" sed -i '/git fetch --tags --force origin/i\ - run: |\n restored_commit=$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")\n if [ "$restored_commit" != "$GITHUB_SHA" ]; then\n echo "Tag $GITHUB_REF_NAME moved since trigger: expected $GITHUB_SHA, got $restored_commit" >&2\n exit 1\n fi' "$root/.github/workflows/release.yml" - assert_failure_contains "${FUNCNAME[0]}" "verify the restored tag matches the triggering event before checking tag type" run_check "$root" metadata + assert_failure_contains "${FUNCNAME[0]}" "capture the restored tag target after restoring annotated tag refs" run_check "$root" metadata +} + +release_workflow_rejects_event_identity_assignment_before_tag_ref_restore() { + local root + root="$(new_fixture workflow-early-event-identity-assignment 1.2.3)" + sed -i '/restored_commit=$(git rev-parse/d' "$root/.github/workflows/release.yml" + sed -i '/git fetch --tags --force origin/i\ - run: restored_commit=$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")' "$root/.github/workflows/release.yml" + assert_failure_contains "${FUNCNAME[0]}" "capture the restored tag target after restoring annotated tag refs" run_check "$root" metadata } release_workflow_ignores_shell_comments_when_establishing_tag_trust() { @@ -639,6 +647,7 @@ release_workflow_rejects_late_tag_ref_restore release_workflow_rejects_precheckout_tag_ref_restore release_workflow_rejects_missing_event_identity_verification release_workflow_rejects_event_identity_before_tag_ref_restore +release_workflow_rejects_event_identity_assignment_before_tag_ref_restore release_workflow_ignores_shell_comments_when_establishing_tag_trust release_workflow_ignores_inline_shell_comments_when_establishing_tag_trust release_workflow_establishes_tag_trust_before_repository_code_execution From daeae4102412ffdc39832524e04772047a5b9153 Mon Sep 17 00:00:00 2001 From: pentaxis93 <13393192+pentaxis93@users.noreply.github.com> Date: Thu, 14 May 2026 12:01:58 -0700 Subject: [PATCH 3/3] docs(release-ceremony): reference commons release fix --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff4c6e5..2a24df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GitHub Release publication now restores annotated tag refs after checkout and verifies the restored tag still targets the triggering event commit before - running repository release code. + running repository release code, addressing the cross-repo `commons#34` + release fix. - Release artifact validation now rejects an explicit empty `--container-image` value instead of silently treating it as omitted. - Release tag validation now rejects `rc.0` release candidates across tag,