diff --git a/.agents/skills/rustfs-release-publish/SKILL.md b/.agents/skills/rustfs-release-publish/SKILL.md index 9c246398d..9bf8ab297 100644 --- a/.agents/skills/rustfs-release-publish/SKILL.md +++ b/.agents/skills/rustfs-release-publish/SKILL.md @@ -57,6 +57,8 @@ Rules: - Preview Release assets are versioned and intentionally visible on the Releases page. Do not label them Latest or use them to update any latest distribution channel. - Tags have no `v` prefix. Always annotated: `git tag -a -m "Release "`. - The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final. +- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used. +- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs. - Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash. - If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision. - User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display. @@ -153,6 +155,7 @@ On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first - Find and watch the tag build: `gh run list --workflow build.yml --branch "" --limit 1` then `gh run watch `. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64). - Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped. - Verify `gh release view "" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return ``. +- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/...`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback. - Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets. ## Phase 4 — Run the artifact locally, verify the console @@ -217,6 +220,7 @@ git push origin "" - CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports ``. - Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view ""` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to ``. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target. +- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/...` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`. - Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report ``. ## Output contract diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index f33240860..a7a5c914f 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -23,6 +23,7 @@ on: - 'deny.toml' - '.github/actions/**' - '.github/workflows/**' + - 'scripts/release/create_or_update_release.sh' - 'scripts/security/check_preview_release_workflow.sh' - 'scripts/security/check_workflow_pins.sh' pull_request: @@ -34,6 +35,7 @@ on: - 'deny.toml' - '.github/actions/**' - '.github/workflows/**' + - 'scripts/release/create_or_update_release.sh' - 'scripts/security/check_preview_release_workflow.sh' - 'scripts/security/check_workflow_pins.sh' schedule: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2274763d0..cfb6cd337 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -781,6 +781,7 @@ jobs: VERSION="${{ needs.build-check.outputs.version }}" IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}" BUILD_TYPE="${{ needs.build-check.outputs.build_type }}" + TARGET_COMMITISH=$(git rev-parse --verify "refs/tags/${TAG}^{commit}") # Determine release type for title if [[ "$BUILD_TYPE" == "preview" ]]; then @@ -799,49 +800,18 @@ jobs: RELEASE_TYPE="release" fi - # Check if release already exists - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release $TAG already exists" - RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId') - RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url') + # Create release title + if [[ "$IS_PRERELEASE" == "true" ]]; then + TITLE="RustFS $VERSION (${RELEASE_TYPE})" else - # Get release notes from tag message - RELEASE_NOTES=$(git tag -l --format='%(contents)' "${TAG}") - if [[ -z "$RELEASE_NOTES" || "$RELEASE_NOTES" =~ ^[[:space:]]*$ ]]; then - if [[ "$IS_PRERELEASE" == "true" ]]; then - RELEASE_NOTES="Pre-release ${VERSION} (${RELEASE_TYPE})" - else - RELEASE_NOTES="Release ${VERSION}" - fi - fi - - # Create release title - if [[ "$IS_PRERELEASE" == "true" ]]; then - TITLE="RustFS $VERSION (${RELEASE_TYPE})" - else - TITLE="RustFS $VERSION" - fi - - # Create the release - PRERELEASE_FLAG="" - if [[ "$IS_PRERELEASE" == "true" ]]; then - PRERELEASE_FLAG="--prerelease" - fi - - gh release create "$TAG" \ - --title "$TITLE" \ - --notes "$RELEASE_NOTES" \ - $PRERELEASE_FLAG \ - --latest=false \ - --draft - - RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId') - RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url') + TITLE="RustFS $VERSION" fi - echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT" - echo "release_url=$RELEASE_URL" >> "$GITHUB_OUTPUT" - echo "Created release: $RELEASE_URL" + ./scripts/release/create_or_update_release.sh \ + "$TAG" \ + "$TARGET_COMMITISH" \ + "$TITLE" \ + "$IS_PRERELEASE" # Prepare and upload release assets upload-release-assets: @@ -1002,10 +972,7 @@ jobs: permissions: contents: write steps: - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - - name: Update release notes and publish + - name: Publish release env: GH_TOKEN: ${{ github.token }} shell: bash diff --git a/scripts/release/create_or_update_release.sh b/scripts/release/create_or_update_release.sh new file mode 100755 index 000000000..506ba9953 --- /dev/null +++ b/scripts/release/create_or_update_release.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +MANAGED_NOTES_MARKER='' + +select_previous_release_tag() { + local current_tag="$1" + local current_release_at="${2:-}" + + jq -r --arg current_tag "$current_tag" --arg current_release_at "$current_release_at" ' + (add // []) + | ($current_tag | sub("-preview\\.[0-9]+$"; "")) as $deliverable_tag + | map( + select( + .draft == false + and .published_at != null + and .tag_name != $current_tag + and .tag_name != $deliverable_tag + and (.tag_name | test("-preview\\.[0-9]+$") | not) + and ($current_release_at == "" or .published_at < $current_release_at) + ) + ) + | sort_by(.published_at) + | last + | .tag_name // empty + ' +} + +release_notes_action() { + local existing_release_json="$1" + local tag="$2" + local body + + if [[ ! -s "$existing_release_json" ]]; then + printf '%s\n' create + return + fi + + body=$(jq -r '.body // ""' "$existing_release_json") + if [[ -z "${body//[[:space:]]/}" ]] || [[ "$body" == "Release $tag" ]] || + [[ "$body" == "Pre-release ${tag} ("*")" ]] || grep -Fq "$MANAGED_NOTES_MARKER" <<<"$body"; then + printf '%s\n' update + else + printf '%s\n' preserve + fi +} + +validate_release_notes() { + local notes_file="$1" + local repository="$2" + local tag="$3" + local previous_tag="$4" + + if ! grep -Fxq "## What's Changed" "$notes_file"; then + echo "release notes are missing the What's Changed section" >&2 + return 1 + fi + + if [[ -n "$previous_tag" ]]; then + local expected_changelog="**Full Changelog**: https://github.com/${repository}/compare/${previous_tag}...${tag}" + if ! grep -Fqx "$expected_changelog" "$notes_file"; then + echo "release notes use an unexpected changelog baseline" >&2 + return 1 + fi + elif ! grep -Fq '**Full Changelog**:' "$notes_file"; then + echo "release notes are missing the Full Changelog link" >&2 + return 1 + fi +} + +write_generated_release_notes() { + local response_json="$1" + local notes_file="$2" + local repository="$3" + local tag="$4" + local previous_tag="$5" + + printf '%s\n' "$MANAGED_NOTES_MARKER" > "$notes_file" + jq -er '.body | strings | select(length > 0)' "$response_json" >> "$notes_file" + validate_release_notes "$notes_file" "$repository" "$tag" "$previous_tag" +} + +write_generate_notes_request() { + local tag="$1" + local target_commitish="$2" + local previous_tag="$3" + local request_json="$4" + + jq -n \ + --arg tag_name "$tag" \ + --arg target_commitish "$target_commitish" \ + --arg previous_tag_name "$previous_tag" \ + '{tag_name: $tag_name, target_commitish: $target_commitish} + + (if $previous_tag_name == "" then {} else {previous_tag_name: $previous_tag_name} end)' \ + > "$request_json" +} + +generate_release_notes() { + local tag="$1" + local target_commitish="$2" + local notes_file="$3" + local work_dir="$4" + local previous_tag="$5" + local request_json="${work_dir}/generate-notes-request.json" + local response_json="${work_dir}/generate-notes-response.json" + + write_generate_notes_request "$tag" "$target_commitish" "$previous_tag" "$request_json" + + if [[ -n "$previous_tag" ]]; then + echo "Generating release notes from previous deliverable $previous_tag" + else + echo "No previous deliverable release found; using GitHub's default baseline" + fi + + gh api --method POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ + --input "$request_json" > "$response_json" + write_generated_release_notes "$response_json" "$notes_file" "$GITHUB_REPOSITORY" "$tag" "$previous_tag" +} + +emit_release_outputs() { + local release_json="$1" + local release_id + local release_url + + release_id=$(jq -er '.databaseId | tostring' "$release_json") + release_url=$(jq -er '.url | strings | select(length > 0)' "$release_json") + if [[ "$release_id" == *$'\n'* || "$release_url" == *$'\n'* ]]; then + echo "release metadata contains an unexpected newline" >&2 + return 1 + fi + + { + printf 'release_id=%s\n' "$release_id" + printf 'release_url=%s\n' "$release_url" + } >> "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" +} + +create_or_update_release() { + local tag="$1" + local target_commitish="$2" + local title="$3" + local is_prerelease="$4" + local work_dir + local existing_release_json + local release_json + local notes_file + local view_error + local action + local current_release_at + local previous_tag + + if [[ "$is_prerelease" != "true" && "$is_prerelease" != "false" ]]; then + echo "is_prerelease must be true or false" >&2 + return 1 + fi + + work_dir=$(mktemp -d "${RUNNER_TEMP:-/tmp}/rustfs-release-notes.XXXXXX") + existing_release_json="${work_dir}/existing-release.json" + release_json="${work_dir}/release.json" + notes_file="${work_dir}/release-notes.md" + view_error="${work_dir}/release-view-error.txt" + + if ! gh release view "$tag" --json databaseId,url,body,createdAt,publishedAt > "$existing_release_json" 2> "$view_error"; then + if grep -Fqx "release not found" "$view_error"; then + : > "$existing_release_json" + else + echo "Unable to determine whether release $tag exists:" >&2 + sed 's/^/ /' "$view_error" >&2 + rm -rf -- "$work_dir" + return 1 + fi + fi + + action=$(release_notes_action "$existing_release_json" "$tag") + current_release_at="" + if [[ -s "$existing_release_json" ]]; then + current_release_at=$(jq -r '.publishedAt // .createdAt // empty' "$existing_release_json") + fi + gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "${work_dir}/releases.json" + previous_tag=$(select_previous_release_tag "$tag" "$current_release_at" < "${work_dir}/releases.json") + if [[ "$action" == "preserve" ]]; then + jq -er '.body | strings | select(length > 0)' "$existing_release_json" > "$notes_file" + if ! validate_release_notes "$notes_file" "$GITHUB_REPOSITORY" "$tag" "$previous_tag"; then + echo "Refusing to overwrite or publish non-managed release notes for $tag" >&2 + rm -rf -- "$work_dir" + return 1 + fi + echo "Release $tag has non-managed notes; preserving them" + emit_release_outputs "$existing_release_json" + rm -rf -- "$work_dir" + return + fi + + generate_release_notes "$tag" "$target_commitish" "$notes_file" "$work_dir" "$previous_tag" + + if [[ "$action" == "update" ]]; then + gh release edit "$tag" --notes-file "$notes_file" >/dev/null + echo "Updated managed release notes for $tag" + else + local create_args=(release create "$tag" --title "$title" --notes-file "$notes_file" --latest=false --draft) + if [[ "$is_prerelease" == "true" ]]; then + create_args+=(--prerelease) + fi + gh "${create_args[@]}" >/dev/null + echo "Created draft release $tag with generated notes" + fi + + gh release view "$tag" --json databaseId,url > "$release_json" + emit_release_outputs "$release_json" + rm -rf -- "$work_dir" +} + +main() { + if [[ "$#" -ne 4 ]]; then + echo "usage: $0 <is-prerelease>" >&2 + exit 2 + fi + + create_or_update_release "$1" "$2" "$3" "$4" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/security/check_preview_release_workflow.sh b/scripts/security/check_preview_release_workflow.sh index 1ad081643..638d68b7f 100755 --- a/scripts/security/check_preview_release_workflow.sh +++ b/scripts/security/check_preview_release_workflow.sh @@ -4,6 +4,10 @@ set -euo pipefail build_workflow=".github/workflows/build.yml" docker_workflow=".github/workflows/docker.yml" helm_workflow=".github/workflows/helm-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" @@ -16,6 +20,17 @@ require_line() { 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 +} + extract_job_if() { local file="$1" local job="$2" @@ -125,24 +140,12 @@ done latest_guard="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" -require_line "$build_workflow" " --latest=false \\" "new releases must start outside the latest channel" -prerelease_flag_block=$(awk ' - $0 == " PRERELEASE_FLAG=\"\"" { in_block = 1 } - in_block { print } - in_block && $0 == " fi" { exit } -' "$build_workflow") -IFS= read -r -d '' expected_prerelease_flag_block <<'EOF' || true - PRERELEASE_FLAG="" - if [[ "$IS_PRERELEASE" == "true" ]]; then - PRERELEASE_FLAG="--prerelease" - fi -EOF -expected_prerelease_flag_block=${expected_prerelease_flag_block%$'\n'} -if [[ "$prerelease_flag_block" != "$expected_prerelease_flag_block" ]]; then - echo "missing preview release workflow contract: prerelease flag propagation" >&2 - exit 1 -fi -require_line "$build_workflow" " \$PRERELEASE_FLAG \\" "GitHub prerelease creation flag" +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 } @@ -168,6 +171,20 @@ if [[ "$release_channel_block" != "$expected_release_channel_block" ]]; then 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' || @@ -202,4 +219,186 @@ EOF expected_helm_guard=${expected_helm_guard%$'\n'} require_job_if "$helm_workflow" "build-helm-package" "$expected_helm_guard" +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."