Files
rustfs/scripts/security/check_preview_release_workflow.sh
T
Hauser c7848df9a0 fix(build): enforce GNU ABI floor and canonical target metadata (#8524)
* fix(build): pin gnu glibc baseline to 2.31 and report compile target in version metadata

gnu release binaries were built natively on Ubuntu 24.04, embedding
GLIBC_2.38/2.39 symbol references while the RPM/DEB metadata promised
glibc >= 2.31, so Rocky/RHEL 9 (glibc 2.34) could not run them (#8062).
The two linux-gnu legs now build through cargo-zigbuild pinned to the
2.31 baseline (same-arch; zig acts as the linker), with post-build
guards asserting both the embedded version metadata and the highest
referenced GLIBC symbol version, keeping the package declarations
honest.

Version metadata itself now comes from cargo's TARGET cfg instead of
shadow-rs' host stamping, so builds whose host differs from their
target (cross compilation, musl targets on a glibc-hosted toolchain)
report the platform they were compiled for (#8048 follow-up).

The cross toolchain (zig 0.14.x, cargo-zigbuild 0.23.4) is pinned in
the setup action now that it is load-bearing for releases, the build
job gets an explicit matrix-independent name so the connect-*
acceptance workflows keep matching it when matrix fields change, and
the preview-release workflow contract is updated for the TARGET_ARG
build commands.

Fixes #8062
Follow-up on #8048

* fix(build): enforce GNU ABI floor and canonical target metadata

Pin Zig 0.17.0 and cargo-zigbuild, isolate cross-toolchain caches, and
build GNU releases against the declared glibc 2.31 floor. Validate both
release ELF binaries using their loader version requirements.

Generate target metadata once through shadow-rs and check the actual
release version output without adding a parallel metadata facade.

Refs: rustfs/backlog#2824

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(build): reuse GNU release profile across binaries

Switching the CLI back to the default strip profile restarted 419
compilation units after the server build. Keep the profiling build
profile for both binaries, then strip only the CLI artifact.

Retain serial links, server profiling symbols, and post-strip ABI gates.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(build): limit release artifacts to the rustfs server

Build only rustfs in the release workflow and remove the CLI build,
strip, and packaging paths. Preserve GNU profiling symbols and catalog
sidecars, and align strict acceptance archive readers with the new set.

Exercise all six build targets, Linux package checks, and all four
strict acceptance extractors using their actual workflow scripts.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: majinghe <42570491+majinghe@users.noreply.github.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-13 12:00:40 -07:00

421 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
build_workflow=".github/workflows/build.yml"
docker_workflow=".github/workflows/docker.yml"
helm_workflow=".github/workflows/helm-package.yml"
package_workflow=".github/workflows/package.yml"
release_script="scripts/release/create_or_update_release.sh"
# shellcheck source=scripts/release/create_or_update_release.sh
source "$release_script"
require_line() {
local file="$1"
local line="$2"
local description="$3"
if ! grep -Fxq "$line" "$file"; then
echo "missing preview release workflow contract: $description" >&2
exit 1
fi
}
require_absent() {
local file="$1"
local text="$2"
local description="$3"
if grep -Fq -- "$text" "$file"; then
echo "invalid preview release workflow contract: $description" >&2
exit 1
fi
}
require_line "$build_workflow" " cargo zigbuild --release --locked --target \"\$TARGET_ARG\" \"\${FEATURE_ARGS[@]}\" \"\${PROFILE_ARGS[@]}\" -p rustfs --bin rustfs" "cross builds must keep matrix features"
require_line "$build_workflow" " cargo build --release --locked --target \"\$TARGET_ARG\" \"\${FEATURE_ARGS[@]}\" \"\${PROFILE_ARGS[@]}\" -p rustfs --bin rustfs" "native builds must keep matrix features"
require_absent "$build_workflow" " cargo zigbuild --release --locked --target \"\$TARGET_ARG\" -p rustfs --bin rustfs" "cross builds must not drop matrix features"
require_absent "$build_workflow" " cargo build --release --locked --target \"\$TARGET_ARG\" -p rustfs --bin rustfs" "native builds must not drop matrix features"
extract_job_if() {
local file="$1"
local job="$2"
awk -v job="$job" '
$0 == " " job ":" { in_job = 1 }
in_job && /^ if:/ { in_if = 1 }
in_if && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ if:/ { exit }
in_if { print }
in_job && $0 ~ /^ [A-Za-z0-9_-]+:$/ && $0 != " " job ":" { exit }
' "$file"
}
require_job_if() {
local file="$1"
local job="$2"
local expected="$3"
local actual
actual=$(extract_job_if "$file" "$job")
if [[ "$actual" != "$expected" ]]; then
echo "missing preview release workflow contract: $job condition" >&2
exit 1
fi
}
require_assignment() {
local block="$1"
local name="$2"
local expected="$3"
local count
count=$(printf '%s\n' "$block" | grep -Ec "^[[:space:]]+${name}=")
if [[ "$count" -ne 1 || "$block" != *"${name}=${expected}"* ]]; then
echo "missing preview release workflow contract: $name=$expected" >&2
exit 1
fi
}
require_no_assignment() {
local block="$1"
local name="$2"
if printf '%s\n' "$block" | grep -Eq "^[[:space:]]+${name}="; then
echo "invalid preview release workflow contract: unexpected $name override" >&2
exit 1
fi
}
tag_classification=$(awk '
/if \[\[ "\$tag_name" =~ -preview\\\.\[0-9\]\+\$ \]\]; then/ { in_preview = 1 }
in_preview { print }
in_preview && $0 == " fi" { exit }
' "$build_workflow")
invalid_preview_condition="elif [[ \"\$tag_name\" == *\"-preview\"* ]]; then"
prerelease_condition="elif [[ \"\$tag_name\" == *\"alpha\"* ]] || [[ \"\$tag_name\" == *\"beta\"* ]] || [[ \"\$tag_name\" == *\"rc\"* ]]; then"
preview_branch=$(printf '%s\n' "$tag_classification" | awk -v boundary="$invalid_preview_condition" 'index($0, boundary) { exit } { print }')
invalid_preview_branch=$(printf '%s\n' "$tag_classification" | awk -v start="$invalid_preview_condition" -v boundary="$prerelease_condition" 'index($0, start) { in_branch = 1 } in_branch && index($0, boundary) { exit } in_branch { print }')
prerelease_branch=$(printf '%s\n' "$tag_classification" | awk -v start="$prerelease_condition" 'index($0, start) { in_branch = 1 } in_branch && $0 == " else" { exit } in_branch { print }')
release_branch=$(printf '%s\n' "$tag_classification" | awk '$0 == " else" { in_branch = 1; next } in_branch && $0 == " fi" { exit } in_branch { print }')
if [[ -z "$preview_branch" || -z "$invalid_preview_branch" || -z "$prerelease_branch" || -z "$release_branch" ]]; then
echo "missing preview release workflow contract: ordered preview, invalid-preview, prerelease, and release branches" >&2
exit 1
fi
require_assignment "$preview_branch" "build_type" '"preview"'
require_assignment "$preview_branch" "is_prerelease" "true"
require_no_assignment "$invalid_preview_branch" "build_type"
if [[ "$invalid_preview_branch" != *"exit 1"* ]]; then
echo "missing preview release workflow contract: invalid preview tags must fail closed" >&2
exit 1
fi
require_assignment "$prerelease_branch" "build_type" '"prerelease"'
require_assignment "$prerelease_branch" "is_prerelease" "true"
require_assignment "$release_branch" "build_type" '"release"'
require_no_assignment "$release_branch" "is_prerelease"
initial_prerelease_line=$(grep -Fn " is_prerelease=false" "$build_workflow")
preview_condition_line=$(grep -Fn " if [[ \"\$tag_name\" =~ -preview\\.[0-9]+\$ ]]; then" "$build_workflow")
if [[ $(printf '%s\n' "$initial_prerelease_line" | wc -l) -ne 1 || "${initial_prerelease_line%%:*}" -ge "${preview_condition_line%%:*}" ]]; then
echo "missing preview release workflow contract: is_prerelease=false must initialize before tag classification" >&2
exit 1
fi
tag_branch_tail=$(awk '
/if \[\[ "\$tag_name" =~ -preview\\\.\[0-9\]\+\$ \]\]; then/ { in_classification = 1 }
in_classification && $0 == " fi" { after_classification = 1; next }
after_classification && /^ elif / { exit }
after_classification { print }
' "$build_workflow")
post_strategy=$(awk '
/# Determine build type based on trigger/ { in_strategy = 1 }
in_strategy && $0 == " fi" { after_strategy = 1; next }
after_strategy && $0 == " {" { exit }
after_strategy { print }
' "$build_workflow")
for block in "$tag_branch_tail" "$post_strategy"; do
require_no_assignment "$block" "build_type"
require_no_assignment "$block" "is_prerelease"
done
require_line "$build_workflow" " if [[ \"\$BUILD_TYPE\" == \"release\" ]] || [[ \"\$BUILD_TYPE\" == \"prerelease\" ]]; then" "latest artifact guard"
require_line "$build_workflow" " if: needs.build-check.outputs.should_publish == 'true' && env.R2_ACCESS_KEY_ID != '' && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease' || needs.build-check.outputs.build_type == 'development')" "R2 publication guard"
require_line "$package_workflow" " if [[ \"\$TAG\" =~ ^\${semver_core}(-\${prerelease_id})?-preview\\.(0|[1-9][0-9]*)$ ]]; then" "stable and prerelease preview package classification"
require_line "$package_workflow" " if: needs.resolve.outputs.build_type != 'preview' && env.R2_ACCESS_KEY_ID != ''" "preview packages must not publish to R2"
release_guard="needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')"
for job in create-release upload-release-assets publish-release; do
require_job_if "$build_workflow" "$job" " if: $release_guard"
done
latest_guard="needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')"
require_job_if "$build_workflow" "update-latest-version" " if: $latest_guard"
require_line "$build_workflow" " needs: [ build-check, publish-release ]" "latest update must follow release publication"
# Preview releases are internal validation artifacts: once the deliverable
# release is published they are deleted, while their tags stay behind.
require_job_if "$build_workflow" "cleanup-preview-releases" " if: $latest_guard"
require_line "$build_workflow" " gh release delete \"\$preview_tag\" --repo \"\${GITHUB_REPOSITORY}\" --yes" "preview release cleanup after publication"
require_line "$build_workflow" " | select(.tag_name | startswith(\$tag + \"-preview.\"))" "cleanup must match the target's own preview tags"
require_line "$build_workflow" " | select(.tag_name | ltrimstr(\$tag + \"-preview.\") | test(\"^[0-9]+\$\"))" "cleanup must match a numeric preview iteration"
require_absent "$build_workflow" "--cleanup-tag" "preview tags must survive their release cleanup"
require_line "$build_workflow" " TARGET_COMMITISH=\$(git rev-parse --verify \"refs/tags/\${TAG}^{commit}\")" "release target commit resolution"
require_line "$build_workflow" " ./scripts/release/create_or_update_release.sh \\" "managed release creation"
require_absent "$build_workflow" "git tag -l --format='%(contents)'" "annotated tag messages must not become release notes"
require_absent "$build_workflow" "--generate-notes" "GitHub must not choose the release notes baseline implicitly"
require_line "$release_script" " gh api --method POST \"repos/\${GITHUB_REPOSITORY}/releases/generate-notes\" \\" "Generate Release Notes API"
require_line "$release_script" " local create_args=(release create \"\$tag\" --title \"\$title\" --notes-file \"\$notes_file\" --latest=false --draft)" "draft release creation with a notes file"
release_channel_block=$(awk '
$0 == " if [[ \"$BUILD_TYPE\" == \"release\" ]]; then" { in_block = 1 }
in_block { print }
in_block && $0 == " fi" { exit }
' "$build_workflow")
IFS= read -r -d '' expected_release_channel_block <<'EOF' || true
if [[ "$BUILD_TYPE" == "release" ]]; then
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=false \
-F prerelease=false \
-f make_latest=true >/dev/null
else
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=false \
-F prerelease=true \
-f make_latest=false >/dev/null
fi
EOF
expected_release_channel_block=${expected_release_channel_block%$'\n'}
if [[ "$release_channel_block" != "$expected_release_channel_block" ]]; then
echo "missing preview release workflow contract: only stable releases may become GitHub Latest" >&2
exit 1
fi
publish_release_block=$(awk '
$0 == " publish-release:" { in_job = 1 }
in_job { print }
in_job && /^ [A-Za-z0-9_-]+:$/ && $0 != " publish-release:" { exit }
' "$build_workflow")
if [[ "$publish_release_block" != *" - name: Publish release"* ]]; then
echo "missing preview release workflow contract: publish job must only publish the prepared release" >&2
exit 1
fi
if grep -Eq -- '--notes|generate-notes|create_or_update_release' <<<"$publish_release_block"; then
echo "invalid preview release workflow contract: publish job must not replace release notes" >&2
exit 1
fi
IFS= read -r -d '' expected_docker_automatic_guard <<'EOF' || true
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main')
EOF
expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'}
require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard"
require_line "$docker_workflow" ' source_ref: ${{ steps.check.outputs.source_ref }}' "Docker source ref output"
require_line "$docker_workflow" ' source_ref="$HEAD_SHA"' "automatic Docker source ref"
require_line "$docker_workflow" ' source_ref="$tag_ref"' "manual Docker source ref"
require_line "$docker_workflow" ' ref: ${{ needs.build-check.outputs.source_ref }}' "Docker release source checkout"
require_line "$docker_workflow" ' SOURCE_REVISION="$(git rev-parse HEAD)"' "Docker source revision resolution"
require_line "$docker_workflow" ' LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"' "Docker revision label"
require_absent "$docker_workflow" 'org.opencontainers.image.revision=${{ github.sha }}' "Docker revision must not use the workflow branch SHA"
python3 scripts/test_docker_workflow.py
IFS= read -r -d '' expected_helm_guard <<'EOF' || true
if: |
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
(
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
contains(github.event.workflow_run.head_branch, '.') &&
!contains(github.event.workflow_run.head_branch, '-preview')
)
EOF
expected_helm_guard=${expected_helm_guard%$'\n'}
require_job_if "$helm_workflow" "build-helm-package" "$expected_helm_guard"
require_line "$helm_workflow" " ref: \${{ github.event.workflow_run.head_sha || (startsWith(inputs.version, 'refs/tags/') && inputs.version || format('refs/tags/{0}', inputs.version)) }}" "Helm release source checkout"
python3 scripts/test_helm_release_workflow.py
assert_equal() {
local expected="$1"
local actual="$2"
local description="$3"
if [[ "$actual" != "$expected" ]]; then
echo "invalid preview release workflow contract: $description (expected '$expected', got '$actual')" >&2
exit 1
fi
}
IFS= read -r -d '' release_fixture <<'EOF' || true
[
[
{"tag_name":"1.0.0-beta.12-preview.1","created_at":"2026-07-30T02:30:00Z","published_at":"2026-07-30T02:36:43Z","draft":false},
{"tag_name":"1.0.0-beta.12","created_at":"2026-07-30T04:00:00Z","published_at":"2026-07-30T04:13:04Z","draft":false},
{"tag_name":"1.0.0-beta.12-preview.0","created_at":"2026-07-29T10:00:00Z","published_at":"2026-07-29T10:05:00Z","draft":false}
],
[
{"tag_name":"1.0.0-beta.10","created_at":"2026-07-17T05:50:00Z","published_at":"2026-07-17T05:58:19Z","draft":false},
{"tag_name":"1.0.0-beta.11","created_at":"2026-07-23T04:00:00Z","published_at":"2026-07-23T04:07:31Z","draft":false},
{"tag_name":"9.0.0","created_at":"2025-01-01T00:00:00Z","published_at":"2025-01-01T00:00:00Z","draft":false},
{"tag_name":"1.0.0-beta.13","created_at":"2026-08-01T00:00:00Z","published_at":null,"draft":true}
]
]
EOF
preview_previous=$(select_previous_release_tag "1.0.0-beta.12-preview.1" "2026-07-30T02:36:43Z" <<<"$release_fixture")
assert_equal "1.0.0-beta.11" "$preview_previous" "preview notes baseline must exclude preview releases and its final deliverable"
final_previous=$(select_previous_release_tag "1.0.0-beta.12" "2026-07-30T04:13:04Z" <<<"$release_fixture")
assert_equal "1.0.0-beta.11" "$final_previous" "final notes baseline must exclude the same-commit preview release"
no_previous=$(select_previous_release_tag "1.0.0-beta.1-preview.1" "2026-01-01T00:00:00Z" <<'EOF'
[[{"tag_name":"1.0.0-beta.1-preview.1","created_at":"2026-01-01T00:00:00Z","published_at":"2026-01-01T00:00:00Z","draft":false}]]
EOF
)
assert_equal "" "$no_previous" "repositories without a previous deliverable must use GitHub's fallback baseline"
delayed_draft_previous=$(select_previous_release_tag "1.0.0-beta.12" "2026-07-30T04:00:00Z" <<'EOF'
[[
{"tag_name":"1.0.0-beta.12","created_at":"2026-07-30T04:00:00Z","published_at":null,"draft":true},
{"tag_name":"1.0.0-beta.13","created_at":"2026-08-01T00:00:00Z","published_at":"2026-08-01T00:05:00Z","draft":false},
{"tag_name":"1.0.0-beta.11","created_at":"2026-07-23T04:00:00Z","published_at":"2026-07-23T04:07:31Z","draft":false}
]]
EOF
)
assert_equal "1.0.0-beta.11" "$delayed_draft_previous" "draft rerun must exclude deliverables published after the draft was created"
release_test_dir=$(mktemp -d "${TMPDIR:-/tmp}/rustfs-release-workflow-test.XXXXXX")
trap 'rm -rf -- "$release_test_dir"' EXIT
request_json="${release_test_dir}/request.json"
write_generate_notes_request "1.0.0-beta.12" "0123456789abcdef" "1.0.0-beta.11" "$request_json"
assert_equal "1.0.0-beta.11" "$(jq -r .previous_tag_name "$request_json")" "explicit previous_tag_name request field"
write_generate_notes_request "1.0.0-beta.1" "0123456789abcdef" "" "$request_json"
assert_equal "false" "$(jq 'has("previous_tag_name")' "$request_json")" "fallback request must omit previous_tag_name"
existing_release_json="${release_test_dir}/existing-release.json"
jq -n --arg body "Pre-release 1.0.0-beta.12 (beta)" '{body: $body}' > "$existing_release_json"
assert_equal "update" "$(release_notes_action "$existing_release_json" "1.0.0-beta.12")" "rerun must repair legacy prerelease placeholder notes"
jq -n --arg body "${MANAGED_NOTES_MARKER}
## What's Changed" '{body: $body}' > "$existing_release_json"
assert_equal "update" "$(release_notes_action "$existing_release_json" "1.0.0-beta.12")" "rerun must refresh managed notes"
generated_body="## What's Changed
* Fix release notes by @alice in #123
* First contribution by @bob in #124
## New Contributors
* @bob made their first contribution in #124
**Full Changelog**: https://github.com/rustfs/rustfs/compare/1.0.0-beta.11...1.0.0-beta.12"
response_json="${release_test_dir}/response.json"
notes_file="${release_test_dir}/notes.md"
jq -n --arg body "$generated_body" '{body: $body}' > "$response_json"
write_generated_release_notes "$response_json" "$notes_file" "rustfs/rustfs" "1.0.0-beta.12" "1.0.0-beta.11"
expected_notes="${MANAGED_NOTES_MARKER}
${generated_body}"
assert_equal "$expected_notes" "$(<"$notes_file")" "generated multiline Markdown must remain intact"
mock_log="${release_test_dir}/gh.log"
mock_notes="${release_test_dir}/captured-notes.md"
mock_generate_calls="${release_test_dir}/generate-calls"
MOCK_RELEASES_JSON="$release_fixture"
MOCK_GENERATED_BODY="$generated_body"
MOCK_RELEASE_EXISTS=true
MOCK_RELEASE_BODY="Release 1.0.0-beta.12"
MOCK_RELEASE_VIEW_ERROR=""
gh() {
local input_file=""
local notes_path=""
if [[ "$1" == "api" && "$*" == *"--paginate --slurp"* ]]; then
printf '%s\n' "$MOCK_RELEASES_JSON"
return
fi
if [[ "$1" == "api" && "$*" == *"releases/generate-notes"* ]]; then
while [[ "$#" -gt 0 ]]; do
if [[ "$1" == "--input" ]]; then
input_file="$2"
break
fi
shift
done
jq -e '.tag_name == "1.0.0-beta.12" and .target_commitish == "0123456789abcdef" and .previous_tag_name == "1.0.0-beta.11"' "$input_file" >/dev/null
printf '%s\n' called >> "$mock_generate_calls"
jq -n --arg body "$MOCK_GENERATED_BODY" '{body: $body}'
return
fi
if [[ "$1" == "release" && "$2" == "view" ]]; then
if [[ -n "$MOCK_RELEASE_VIEW_ERROR" ]]; then
printf '%s\n' "$MOCK_RELEASE_VIEW_ERROR" >&2
return 1
fi
if [[ "$MOCK_RELEASE_EXISTS" != "true" ]]; then
echo "release not found" >&2
return 1
fi
jq -n --arg body "$MOCK_RELEASE_BODY" '{databaseId: 123, url: "https://github.com/rustfs/rustfs/releases/tag/test", body: $body, createdAt: "2026-07-30T04:00:00Z", publishedAt: "2026-07-30T04:13:04Z"}'
return
fi
if [[ "$1" == "release" && ("$2" == "edit" || "$2" == "create") ]]; then
local action="$2"
shift 2
while [[ "$#" -gt 0 ]]; do
if [[ "$1" == "--notes-file" ]]; then
notes_path="$2"
break
fi
shift
done
cp "$notes_path" "$mock_notes"
MOCK_RELEASE_BODY=$(<"$mock_notes")
MOCK_RELEASE_EXISTS=true
printf '%s\n' "$action" >> "$mock_log"
return
fi
echo "unexpected mock gh invocation: $*" >&2
return 1
}
GITHUB_REPOSITORY="rustfs/rustfs"
GITHUB_OUTPUT="${release_test_dir}/github-output"
RUNNER_TEMP="$release_test_dir"
create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true
assert_equal "edit" "$(<"$mock_log")" "existing placeholder release must be updated on rerun"
assert_equal "$expected_notes" "$(<"$mock_notes")" "rerun must update notes through --notes-file"
: > "$mock_log"
MOCK_RELEASE_EXISTS=false
MOCK_RELEASE_BODY=""
create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true
assert_equal "create" "$(<"$mock_log")" "first run must create the draft release"
: > "$mock_log"
: > "$mock_generate_calls"
MOCK_RELEASE_EXISTS=true
MOCK_RELEASE_BODY="$generated_body"
create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true
assert_equal "" "$(<"$mock_log")" "manual notes must not trigger a release edit"
assert_equal "" "$(<"$mock_generate_calls")" "manual notes must be preserved without regeneration"
MOCK_RELEASE_BODY="Manually curated release guidance"
if create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true; then
echo "invalid preview release workflow contract: incomplete manual notes must block publication" >&2
exit 1
fi
assert_equal "" "$(<"$mock_log")" "incomplete manual notes must not be overwritten"
MOCK_RELEASE_VIEW_ERROR="gh: service unavailable (HTTP 503)"
if create_or_update_release "1.0.0-beta.12" "0123456789abcdef" "RustFS 1.0.0-beta.12 (beta)" true; then
echo "invalid preview release workflow contract: transient release lookup failures must fail closed" >&2
exit 1
fi
assert_equal "" "$(<"$mock_log")" "release lookup failures must not attempt create or edit"
echo "Preview release workflow contract ok."