Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ 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, 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,
changelog heading, `RUNA_REF`, and GitHub Release classification surfaces to
match the ADR-0012 release grammar.
Expand Down
13 changes: 7 additions & 6 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 72 additions & 1 deletion scripts/release-check
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -235,15 +275,45 @@ 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_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" ]] \
|| 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_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}\")" {
restored_commit_line = $1
next
}
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 }')"

[[ -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_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"
Expand Down Expand Up @@ -363,6 +433,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
;;
Expand Down
66 changes: 66 additions & 0 deletions scripts/test-release-check
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -507,6 +520,52 @@ 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]}" "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() {
local root
root="$(new_fixture workflow-shell-comment-trust 1.2.3)"
Expand Down Expand Up @@ -576,12 +635,19 @@ 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_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
Expand Down
Loading