diff --git a/.cspellignore b/.cspellignore index e113c4cd8d..a55741d63f 100644 --- a/.cspellignore +++ b/.cspellignore @@ -1343,3 +1343,5 @@ repointed canonicality syft repoints +korthout +serialising diff --git a/.github/scripts/collect-release-backports.sh b/.github/scripts/collect-release-backports.sh new file mode 100644 index 0000000000..cecf872a66 --- /dev/null +++ b/.github/scripts/collect-release-backports.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +GH="${GH:-gh}" +REPOSITORY="" +CHANNEL="" +EXPLICIT_PRS="" +OUTPUT_FILE="" +TEMP_DIR="" + +cleanup() { + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi +} +trap cleanup EXIT + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat << 'EOF' +Usage: collect-release-backports.sh --repository --channel \ + --output [--explicit-prs ] +EOF +} + +collect_explicit_prs() { + local output="$1" + local pr metadata + local -a numbers=() + + printf '[]\n' > "${output}" + [[ -n "${EXPLICIT_PRS}" ]] || return + + IFS=',' read -r -a numbers <<< "${EXPLICIT_PRS}" + for pr in "${numbers[@]}"; do + pr="${pr//[[:space:]]/}" + if [[ ! "${pr}" =~ ^[1-9][0-9]*$ ]]; then + fail "invalid explicit pull request number: ${pr}" + fi + metadata="$( + "${GH}" pr view "${pr}" --repo "${REPOSITORY}" \ + --json number,title,url,mergeCommit,mergedAt,baseRefName + )" + if ! jq -e '.mergedAt != null and .baseRefName == "main"' \ + <<< "${metadata}" > /dev/null; then + fail "explicit pull request #${pr} is not merged into main" + fi + jq --argjson item "${metadata}" '. + [$item]' "${output}" \ + > "${output}.tmp" + mv "${output}.tmp" "${output}" + done +} + +main() { + local label release_branch + local labeled_file explicit_file sources_file backports_file + + while [[ $# -gt 0 ]]; do + case "$1" in + --repository) + REPOSITORY="${2:-}" + shift 2 + ;; + --channel) + CHANNEL="${2:-}" + shift 2 + ;; + --explicit-prs) + EXPLICIT_PRS="${2:-}" + shift 2 + ;; + --output) + OUTPUT_FILE="${2:-}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac + done + + if [[ ! "${REPOSITORY}" =~ ^[^/]+/[^/]+$ ]]; then + fail "repository must use owner/name format" + fi + if [[ ! "${CHANNEL}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "channel must use X.Y format" + fi + [[ -n "${OUTPUT_FILE}" ]] || fail "output path is required" + command -v "${GH}" > /dev/null || fail "required command not found: ${GH}" + command -v jq > /dev/null || fail "required command not found: jq" + + TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/release-backports-XXXXXX")" + labeled_file="${TEMP_DIR}/labeled.json" + explicit_file="${TEMP_DIR}/explicit.json" + sources_file="${TEMP_DIR}/sources.json" + backports_file="${TEMP_DIR}/backports.json" + label="backport release/${CHANNEL}" + release_branch="release/${CHANNEL}" + + "${GH}" pr list --repo "${REPOSITORY}" --state merged --base main \ + --label "${label}" --limit 1000 \ + --json number,title,url,mergeCommit,mergedAt,baseRefName \ + > "${labeled_file}" + collect_explicit_prs "${explicit_file}" + jq -s 'add | unique_by(.number) | sort_by(.number)' \ + "${labeled_file}" "${explicit_file}" > "${sources_file}" + + "${GH}" pr list --repo "${REPOSITORY}" --state all \ + --base "${release_branch}" --limit 1000 \ + --json number,url,body,state,mergedAt,mergeCommit,commits \ + > "${backports_file}" + + mkdir -p "$(dirname "${OUTPUT_FILE}")" + jq --slurpfile backports "${backports_file}" ' + map( + . as $source | + ($backports[0] | + map(select( + # A body naming more than one source cannot identify + # which backport it is, so it is never trusted. + ((.body // "") | + [scan("")] | + length) == 1 and + ((.body // "") | + contains( + "" + )) + )) | + sort_by([(.mergedAt != null), .number]) | + last + ) as $backport | + (($backport.commits // []) | + any(.[]; + ((.messageBody // "") | split("\n")) | + any(.[]; + gsub("\\r$"; "") == + "(cherry picked from commit " + + "\($source.mergeCommit.oid))" + ) + ) + ) as $has_source_trailer | + { + source_pr: $source.number, + source_commit: $source.mergeCommit.oid, + source_title: $source.title, + source_url: $source.url, + backport_pr: ($backport.number // null), + backport_url: ($backport.url // null), + backport_merged: ( + (($backport.mergedAt // null) != null) and + $has_source_trailer + ), + backport_commit: ($backport.mergeCommit.oid // null) + } + ) + ' "${sources_file}" > "${OUTPUT_FILE}" +} + +main "$@" diff --git a/.github/scripts/collect-release-backports_test.sh b/.github/scripts/collect-release-backports_test.sh new file mode 100644 index 0000000000..66e21b21ee --- /dev/null +++ b/.github/scripts/collect-release-backports_test.sh @@ -0,0 +1,181 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/collect-release-backports.sh" + +TEST_ROOT="" +PASS=0 +FAIL=0 + +cleanup() { + if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then + rm -rf "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail_test() { + echo " ASSERT FAILED: $1" + ((++FAIL)) +} + +setup_fake_gh() { + cat > "${TEST_ROOT}/gh" << 'EOF' +#!/bin/bash +set -euo pipefail + +if [[ "$1 $2" == "pr view" ]]; then + case "$3" in + 102) + printf '%s\n' '{"number":102,"title":"feat: explicit",'\ +'"url":"https://example.test/102","mergeCommit":{"oid":"source-102"},'\ +'"mergedAt":"2026-08-20T00:00:00Z","baseRefName":"main"}' + ;; + 103) + printf '%s\n' '{"number":103,"title":"fix: open",'\ +'"url":"https://example.test/103","mergeCommit":null,'\ +'"mergedAt":null,"baseRefName":"main"}' + ;; + 104) + printf '%s\n' '{"number":104,"title":"fix: placeholder",'\ +'"url":"https://example.test/104","mergeCommit":{"oid":"source-104"},'\ +'"mergedAt":"2026-08-20T00:00:00Z","baseRefName":"main"}' + ;; + 105) + printf '%s\n' '{"number":105,"title":"fix: ambiguous",'\ +'"url":"https://example.test/105","mergeCommit":{"oid":"source-105"},'\ +'"mergedAt":"2026-08-20T00:00:00Z","baseRefName":"main"}' + ;; + esac + exit 0 +fi + +base="" +while [[ $# -gt 0 ]]; do + if [[ "$1" == "--base" ]]; then + base="$2" + break + fi + shift +done + +if [[ "${base}" == "main" ]]; then + printf '%s\n' '[{"number":101,"title":"fix: labeled",'\ +'"url":"https://example.test/101","mergeCommit":{"oid":"source-101"},'\ +'"mergedAt":"2026-08-19T00:00:00Z","baseRefName":"main"}]' +else + printf '%s\n' '[{"number":201,"url":"https://example.test/201",'\ +'"body":"",'\ +'"state":"MERGED","mergedAt":"2026-08-21T00:00:00Z",'\ +'"mergeCommit":{"oid":"backport-101"},'\ +'"commits":[{"messageBody":"(cherry picked from commit source-101)"}]},'\ +'{"number":202,"url":"https://example.test/202",'\ +'"body":"",'\ +'"state":"OPEN","mergedAt":null,"mergeCommit":null,"commits":[]},'\ +'{"number":204,"url":"https://example.test/204",'\ +'"body":"",'\ +'"state":"MERGED","mergedAt":"2026-08-21T00:00:00Z",'\ +'"mergeCommit":{"oid":"placeholder-104"},'\ +'"commits":[{"messageBody":"conflict handoff only"}]},'\ +'{"number":205,"url":"https://example.test/205",'\ +'"body":"\n'\ +'",'\ +'"state":"MERGED","mergedAt":"2026-08-21T00:00:00Z",'\ +'"mergeCommit":{"oid":"backport-105"},'\ +'"commits":[{"messageBody":"(cherry picked from commit source-105)"}]}]' +fi +EOF + chmod +x "${TEST_ROOT}/gh" +} + +test_collects_labeled_and_explicit_prs() { + local output="${TEST_ROOT}/backports.json" + + GH="${TEST_ROOT}/gh" bash "${SCRIPT}" \ + --repository radius-project/radius --channel 0.60 \ + --explicit-prs '102,104' --output "${output}" + + if [[ "$(jq 'length' "${output}")" != "3" ]]; then + fail_test "expected three selected pull requests" + return + fi + if [[ "$(jq -r '.[] | select(.source_pr == 101) | .backport_merged' \ + "${output}")" != "true" ]]; then + fail_test "labeled pull request should have a merged backport" + return + fi + if [[ "$(jq -r '.[] | select(.source_pr == 102) | .backport_merged' \ + "${output}")" != "false" ]]; then + fail_test "explicit pull request should report its open backport" + return + fi + if [[ "$(jq -r '.[] | select(.source_pr == 104) | .backport_merged' \ + "${output}")" != "false" ]]; then + fail_test "merged conflict placeholder must not satisfy the backport" + return + fi + ((++PASS)) +} + +test_rejects_unmerged_explicit_pr() { + if GH="${TEST_ROOT}/gh" bash "${SCRIPT}" \ + --repository radius-project/radius --channel 0.60 \ + --explicit-prs '103' --output "${TEST_ROOT}/invalid.json" \ + > /dev/null 2>&1; then + fail_test "expected an unmerged explicit pull request to fail" + return + fi + ((++PASS)) +} + +test_ambiguous_backport_body_is_not_trusted() { + local output="${TEST_ROOT}/ambiguous.json" + + GH="${TEST_ROOT}/gh" bash "${SCRIPT}" \ + --repository radius-project/radius --channel 0.60 \ + --explicit-prs '105' --output "${output}" + + if [[ "$(jq -r '.[] | select(.source_pr == 105) | .backport_merged' \ + "${output}")" != "false" ]]; then + fail_test "a body naming two sources must not satisfy the backport" + return + fi + ((++PASS)) +} + +main() { + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/collect-backports-test-XXXXXX")" + setup_fake_gh + + test_collects_labeled_and_explicit_prs + test_rejects_unmerged_explicit_pr + test_ambiguous_backport_body_is_not_trusted + + if ((FAIL > 0)); then + echo "collect release backports tests failed: ${PASS} passed, ${FAIL} failed" + exit 1 + fi + + echo "collect release backports tests passed (${PASS} tests)" +} + +main "$@" diff --git a/.github/scripts/create-release-backport.sh b/.github/scripts/create-release-backport.sh new file mode 100644 index 0000000000..517c85a8bb --- /dev/null +++ b/.github/scripts/create-release-backport.sh @@ -0,0 +1,249 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SOURCE_PR="" +SOURCE_COMMIT="" +SOURCE_TITLE="" +SOURCE_URL="" +CHANNEL="" +OUTPUT_DIR="" +EXPECTED_BASE="" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat << 'EOF' +Usage: create-release-backport.sh --source-pr --source-commit \ + --source-title --source-url <url> --channel <X.Y> \ + --output-dir <path> [--expected-base <sha>] +EOF +} + +write_outputs() { + local status="$1" + local branch="$2" + local body_file="$3" + local commit_message_file="${OUTPUT_DIR}/commit-message.txt" + + if [[ "${status}" == "conflict" ]]; then + printf 'chore(backport): hand off #%s conflict\n' "${SOURCE_PR}" \ + > "${commit_message_file}" + # The placeholder is the bot's own work, so it keeps the bot as author + # and the caller substitutes the bot identity for this empty file. + : > "${OUTPUT_DIR}/author.txt" + else + { + git show -s --format=%B "${SOURCE_COMMIT}" + echo + echo "(cherry picked from commit ${SOURCE_COMMIT})" + } > "${commit_message_file}" + git show -s --format='%an <%ae>' "${SOURCE_COMMIT}" \ + > "${OUTPUT_DIR}/author.txt" + fi + + printf '%s\n' "${status}" > "${OUTPUT_DIR}/status.txt" + printf '%s\n' "${branch}" > "${OUTPUT_DIR}/branch.txt" + printf '%s\n' "${SOURCE_TITLE}" > "${OUTPUT_DIR}/title.txt" + printf '%s\n' "${body_file}" > "${OUTPUT_DIR}/body-path.txt" +} + +write_pr_body() { + local body_file="$1" + local status="$2" + local release_branch="$3" + local conflict_file="${4:-}" + local base_commit="$5" + + { + echo "<!-- radius-backport-source: #${SOURCE_PR} -->" + echo "<!-- radius-backport-base: ${base_commit} -->" + echo "<!-- radius-backport-commit: ${SOURCE_COMMIT} -->" + echo + echo "Backport of [#${SOURCE_PR}](${SOURCE_URL})" + echo "to \`${release_branch}\`." + echo + echo "Source commit: \`${SOURCE_COMMIT}\`" + echo + if [[ "${status}" == "conflict" ]]; then + echo "Conflict handoff: \`${conflict_file}\`." + echo "Follow that file's commands, force-push the resolved branch," + echo "and mark this pull request ready for review." + else + echo "The source squash commit was cherry-picked with \`-x\`." + echo "Rebase-merge this pull request to preserve its commit title." + fi + } > "${body_file}" +} + +write_conflict_handoff() { + local handoff_file="$1" + local branch="$2" + local release_branch="$3" + local base_commit="$4" + shift 4 + local -a conflicts=("$@") + + mkdir -p "$(dirname "${handoff_file}")" + { + echo "# Backport conflict for #${SOURCE_PR}" + echo + echo "The automated cherry-pick of \`${SOURCE_COMMIT}\` onto" + echo "\`${release_branch}\` conflicted in:" + echo + printf -- "- \`%s\`\n" "${conflicts[@]}" + echo + echo "Resolve the backport with:" + echo + echo '```bash' + printf 'git fetch origin %s \\\n' "${SOURCE_COMMIT}" + printf ' refs/heads/%s:refs/remotes/origin/%s \\\n' \ + "${branch}" "${branch}" + printf ' refs/heads/%s:refs/remotes/origin/%s\n' \ + "${release_branch}" "${release_branch}" + echo "git checkout -B ${branch} origin/${branch}" + echo "git reset --hard ${base_commit}" + echo "git cherry-pick -x ${SOURCE_COMMIT}" + echo "# Resolve the files listed by git, then:" + echo "git add <resolved-files>" + echo "git cherry-pick --continue" + echo "git push --force-with-lease origin ${branch}" + echo '```' + echo + echo "The hard reset removes this handoff commit before applying the" + echo "real backport. Do not merge this placeholder commit." + } > "${handoff_file}" +} + +main() { + local release_branch branch body_file handoff_file base_commit + local status conflict + local -a conflicts=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --source-pr) + SOURCE_PR="${2:-}" + shift 2 + ;; + --source-commit) + SOURCE_COMMIT="${2:-}" + shift 2 + ;; + --source-title) + SOURCE_TITLE="${2:-}" + shift 2 + ;; + --source-url) + SOURCE_URL="${2:-}" + shift 2 + ;; + --channel) + CHANNEL="${2:-}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:-}" + shift 2 + ;; + --expected-base) + EXPECTED_BASE="${2:-}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac + done + + if [[ ! "${SOURCE_PR}" =~ ^[1-9][0-9]*$ ]]; then + fail "source PR must be a positive number" + fi + if ! git rev-parse --verify --quiet \ + "${SOURCE_COMMIT}^{commit}" > /dev/null; then + fail "source commit does not exist: ${SOURCE_COMMIT}" + fi + [[ -n "${SOURCE_TITLE}" ]] || fail "source title is required" + [[ "${SOURCE_URL}" =~ ^https:// ]] || fail "source URL must use HTTPS" + if [[ ! "${CHANNEL}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "channel must use X.Y format" + fi + [[ -n "${OUTPUT_DIR}" ]] || fail "output directory is required" + [[ -z "$(git status --porcelain)" ]] || fail "working tree is not clean" + + release_branch="release/${CHANNEL}" + if ! git rev-parse --verify --quiet \ + "refs/remotes/origin/${release_branch}^{commit}" \ + > /dev/null; then + fail "remote release branch does not exist: ${release_branch}" + fi + base_commit="$( + git rev-parse "refs/remotes/origin/${release_branch}^{commit}" + )" + if [[ -n "${EXPECTED_BASE}" ]]; then + if [[ ! "${EXPECTED_BASE}" =~ ^[0-9a-f]{40}$ ]]; then + fail "expected base must be a full commit SHA" + fi + if [[ "${base_commit}" != "${EXPECTED_BASE}" ]]; then + fail "release/${CHANNEL} advanced beyond its approved base" + fi + base_commit="${EXPECTED_BASE}" + fi + + branch="automation/backport-${SOURCE_PR}-to-${CHANNEL}" + mkdir -p "${OUTPUT_DIR}" + body_file="${OUTPUT_DIR}/pull-request-body.md" + git checkout -q --detach "${base_commit}" + + set +e + git cherry-pick --no-commit "${SOURCE_COMMIT}" > /dev/null 2>&1 + status=$? + set -e + if ((status == 0)); then + write_pr_body "${body_file}" success "${release_branch}" "" \ + "${base_commit}" + write_outputs success "${branch}" "${body_file}" + return + fi + + while IFS= read -r conflict; do + [[ -n "${conflict}" ]] && conflicts+=("${conflict}") + done < <(git diff --name-only --diff-filter=U) + git cherry-pick --abort > /dev/null 2>&1 || true + git reset --hard -q "${base_commit}" + if ((${#conflicts[@]} == 0)); then + fail "cherry-pick failed without conflicts: ${SOURCE_COMMIT}" + fi + + handoff_file=".github/backport-conflicts/${SOURCE_PR}-to-${CHANNEL}.md" + write_conflict_handoff "${handoff_file}" "${branch}" \ + "${release_branch}" "${base_commit}" "${conflicts[@]}" + git add "${handoff_file}" + write_pr_body "${body_file}" conflict "${release_branch}" \ + "${handoff_file}" "${base_commit}" + cp "${handoff_file}" "${OUTPUT_DIR}/conflict-handoff.md" + write_outputs conflict "${branch}" "${body_file}" +} + +main "$@" diff --git a/.github/scripts/create-release-backport_test.sh b/.github/scripts/create-release-backport_test.sh new file mode 100644 index 0000000000..cb55cc26ec --- /dev/null +++ b/.github/scripts/create-release-backport_test.sh @@ -0,0 +1,240 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/create-release-backport.sh" + +TEST_ROOT="" +REMOTE="" +REPO="" +SOURCE_COMMIT="" +PASS=0 +FAIL=0 + +cleanup() { + if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then + rm -rf "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail_test() { + echo " ASSERT FAILED: $1" + ((++FAIL)) +} + +setup_repo() { + local conflict="$1" + local seed="${TEST_ROOT}/seed" + + rm -rf "${seed}" "${REMOTE}" "${REPO}" + mkdir -p "${seed}" + git -C "${seed}" init -q -b main + git -C "${seed}" config user.name "Radius Test" + git -C "${seed}" config user.email "test@example.com" + git -C "${seed}" config commit.gpgsign false + printf 'base\n' > "${seed}/file.txt" + git -C "${seed}" add file.txt + git -C "${seed}" commit -q -m "chore: initial" + git -C "${seed}" branch release/0.60 + + if [[ "${conflict}" == "true" ]]; then + git -C "${seed}" checkout -q release/0.60 + printf 'release change\n' > "${seed}/file.txt" + git -C "${seed}" commit -qam "fix: release branch change" + git -C "${seed}" checkout -q main + fi + + printf 'source change\n' > "${seed}/file.txt" + git -C "${seed}" commit -qam "fix: source change" \ + --author "Source Author <source@example.test>" \ + -m $'BREAKING CHANGE: preserve this source footer\n\nEOF\nbranch=attacker' + SOURCE_COMMIT="$(git -C "${seed}" rev-parse HEAD)" + git clone -q --bare "${seed}" "${REMOTE}" + git clone -q "${REMOTE}" "${REPO}" + git -C "${REPO}" config user.name "Radius Test" + git -C "${REPO}" config user.email "test@example.com" + git -C "${REPO}" config commit.gpgsign false + git -C "${REPO}" fetch -q origin \ + '+refs/heads/*:refs/remotes/origin/*' +} + +run_backport() { + rm -rf "${REPO}/out" + git -C "${REPO}" config --unset user.name || true + git -C "${REPO}" config --unset user.email || true + pushd "${REPO}" > /dev/null + bash "${SCRIPT}" --source-pr 123 --source-commit "${SOURCE_COMMIT}" \ + --source-title 'fix: source change' \ + --source-url 'https://example.test/pull/123' --channel 0.60 \ + --output-dir out + popd > /dev/null +} + +test_successful_backport() { + local message base_commit + + setup_repo false + run_backport + if [[ "$(cat "${REPO}/out/status.txt")" != "success" ]]; then + fail_test "expected a successful backport" + return + fi + message="$(cat "${REPO}/out/commit-message.txt")" + if [[ "${message}" != *"cherry picked from commit ${SOURCE_COMMIT}"* ]]; then + fail_test "successful backport did not preserve -x traceability" + return + fi + if [[ "${message}" != *"BREAKING CHANGE: preserve this source footer"* ]]; then + fail_test "successful backport dropped the source commit body" + return + fi + if [[ "${message}" != *$'EOF\nbranch=attacker'* ]]; then + fail_test "successful backport did not preserve hostile body lines" + return + fi + if [[ -d "${REPO}/.github/backport-conflicts" ]]; then + fail_test "successful backport created a conflict handoff" + return + fi + if [[ "$(git -C "${REPO}" rev-parse HEAD)" != "$(git -C "${REPO}" rev-parse origin/release/0.60)" ]]; then + fail_test "script created an unsigned local commit" + return + fi + if git -C "${REPO}" diff --cached --quiet; then + fail_test "successful backport did not leave staged changes" + return + fi + base_commit="$(git -C "${REPO}" rev-parse origin/release/0.60)" + if ! grep -Fq "<!-- radius-backport-base: ${base_commit} -->" \ + "${REPO}/out/pull-request-body.md"; then + fail_test "backport PR body did not bind the release base" + return + fi + if [[ "$(cat "${REPO}/out/author.txt")" != "Source Author <source@example.test>" ]]; then + fail_test "successful backport did not preserve the source author" + return + fi + ((++PASS)) +} + +test_conflict_creates_safe_handoff() { + local handoff + local fresh="${TEST_ROOT}/fresh" + local cherry_pick_status + + setup_repo true + run_backport + if [[ "$(cat "${REPO}/out/status.txt")" != "conflict" ]]; then + fail_test "expected a conflict handoff" + return + fi + if git -C "${REPO}" grep -nE '^(<<<<<<<|=======|>>>>>>>)' HEAD -- \ + ':!*.md' > /dev/null; then + fail_test "conflict markers were committed" + return + fi + handoff="${REPO}/out/conflict-handoff.md" + local base_commit + base_commit="$(git -C "${REPO}" rev-parse origin/release/0.60)" + if ! grep -Fq "git reset --hard ${base_commit}" "${handoff}" \ + || ! grep -Fq "git cherry-pick -x ${SOURCE_COMMIT}" "${handoff}"; then + fail_test "handoff does not contain exact recovery commands" + return + fi + if [[ "$(git -C "${REPO}" show HEAD:file.txt)" != "release change" ]]; then + fail_test "conflict branch did not preserve the release base" + return + fi + + git -C "${REPO}" config user.name "Radius Test" + git -C "${REPO}" config user.email "test@example.com" + git -C "${REPO}" commit -q -F out/commit-message.txt + git -C "${REPO}" push -q origin \ + "HEAD:refs/heads/automation/backport-123-to-0.60" + git clone -q --single-branch --branch release/0.60 "${REMOTE}" "${fresh}" + git -C "${fresh}" config user.name "Radius Test" + git -C "${fresh}" config user.email "test@example.com" + git -C "${fresh}" config commit.gpgsign false + git -C "${fresh}" fetch -q origin "${SOURCE_COMMIT}" \ + 'refs/heads/automation/backport-123-to-0.60:refs/remotes/origin/automation/backport-123-to-0.60' \ + 'refs/heads/release/0.60:refs/remotes/origin/release/0.60' + git -C "${fresh}" checkout -q -B automation/backport-123-to-0.60 \ + origin/automation/backport-123-to-0.60 + git -C "${fresh}" reset --hard -q "${base_commit}" + set +e + git -C "${fresh}" cherry-pick -x "${SOURCE_COMMIT}" > /dev/null 2>&1 + cherry_pick_status=$? + set -e + if ((cherry_pick_status == 0)); then + fail_test "fresh-clone handoff did not reproduce the expected conflict" + return + fi + git -C "${fresh}" cherry-pick --abort + if [[ -s "${REPO}/out/author.txt" ]]; then + fail_test "conflict handoff must stay authored by the bot" + return + fi + ((++PASS)) +} + +test_rejects_advanced_release_branch() { + local stale_base + + setup_repo false + stale_base="$(git -C "${REPO}" rev-parse origin/release/0.60)" + git -C "${REPO}" checkout -q release/0.60 + git -C "${REPO}" commit -q --allow-empty -m "fix: advance release branch" + git -C "${REPO}" push -q origin release/0.60 + git -C "${REPO}" fetch -q origin \ + 'refs/heads/release/0.60:refs/remotes/origin/release/0.60' + + if ( + cd "${REPO}" + bash "${SCRIPT}" --source-pr 123 --source-commit "${SOURCE_COMMIT}" \ + --source-title 'fix: source change' \ + --source-url 'https://example.test/pull/123' --channel 0.60 \ + --output-dir out --expected-base "${stale_base}" + ) > /dev/null 2>&1; then + fail_test "expected an advanced release branch to fail" + return + fi + ((++PASS)) +} + +main() { + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/release-backport-test-XXXXXX")" + REMOTE="${TEST_ROOT}/remote.git" + REPO="${TEST_ROOT}/repo" + + test_successful_backport + test_conflict_creates_safe_handoff + test_rejects_advanced_release_branch + + if ((FAIL > 0)); then + echo "create release backport tests failed: ${PASS} passed, ${FAIL} failed" + exit 1 + fi + + echo "create release backport tests passed (${PASS} tests)" +} + +main "$@" diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh new file mode 100644 index 0000000000..c84c2cafa1 --- /dev/null +++ b/.github/scripts/prepare-release.sh @@ -0,0 +1,624 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +# shellcheck source=.github/scripts/release-version.sh +source "${SCRIPT_DIR}/release-version.sh" + +RELEASE_TYPE="" +CHANNEL="" +BACKPORTS_FILE="" +OUTPUT_DIR="" +VERSIONS_FILE="versions.yaml" +CHANGELOG_FILE="CHANGELOG.md" +RELEASE_NOTES_TEMPLATE="docs/release-notes/template.md" +PATCH_NOTES_TEMPLATE="docs/release-notes/template_patch.md" +TARGETS_FILE=".github/release-parity/targets.json" +CLIFF_CONFIG="cliff.toml" +GIT_CLIFF="${GIT_CLIFF:-git-cliff}" +CHANGELOG_RANGE_SCRIPT="${CHANGELOG_RANGE_SCRIPT:-}" +if [[ -z "${CHANGELOG_RANGE_SCRIPT}" ]]; then + CHANGELOG_RANGE_SCRIPT="${SCRIPT_DIR}/changelog-range.sh" +fi +VERSION_ONLY=false +RELEASE_DATE="${RELEASE_DATE:-$(date -u +%F)}" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat << 'EOF' +Usage: prepare-release.sh --release-type <rc|final|patch> --channel <X.Y> \ + --backports-file <path> --output-dir <path> [file options] + +File options: + --versions-file <path> + --changelog-file <path> + --release-notes-template <path> + --patch-notes-template <path> + --targets-file <path> + --cliff-config <path> + --release-date <YYYY-MM-DD> + +Modes: + --version-only Calculate the policy version without changing files. +EOF +} + +require_command() { + command -v "$1" > /dev/null || fail "required command not found: $1" +} + +channel_version() { + yq -r ".supported[] | select(.channel == \"${CHANNEL}\") | .version" \ + "${VERSIONS_FILE}" | head -1 +} + +latest_supported_version() { + yq -r '.supported[0].version // ""' "${VERSIONS_FILE}" +} + +release_branch_ref() { + local branch="release/${CHANNEL}" + + if git rev-parse --verify --quiet "refs/remotes/origin/${branch}^{commit}" \ + > /dev/null; then + printf 'refs/remotes/origin/%s\n' "${branch}" + return 0 + fi + if git rev-parse --verify --quiet "refs/heads/${branch}^{commit}" \ + > /dev/null; then + printf 'refs/heads/%s\n' "${branch}" + return 0 + fi + return 1 +} + +# Channel-scoped patterns are built from the shared policy constants so the +# RC and patch number rules stay defined in exactly one place. +channel_rc_pattern() { + printf '^v%s\\.0-rc\\.?(%s)$' "${CHANNEL//./\\.}" "${RADIUS_RC_NUMBER}" +} + +channel_patch_pattern() { + printf '^v%s\\.%s$' "${CHANNEL//./\\.}" "${RADIUS_SEMVER_NUMBER}" +} + +is_stable_release_tag() { + local number="${1#v}" + + [[ "${number}" != *-* ]] && is_radius_release_version "${number}" +} + +highest_rc_number() { + local tag + local highest=0 + local pattern + + pattern="$(channel_rc_pattern)" + while IFS= read -r tag; do + if [[ "${tag}" =~ ${pattern} ]]; then + if ((10#${BASH_REMATCH[1]} > highest)); then + highest=$((10#${BASH_REMATCH[1]})) + fi + fi + done < <(git tag --list "v${CHANNEL}.0-rc*" --sort=version:refname) + + printf '%s\n' "${highest}" +} + +newest_stable_tag() { + local tag + + while IFS= read -r tag; do + if is_stable_release_tag "${tag}"; then + printf '%s\n' "${tag}" + return 0 + fi + done < <(git tag --list 'v*' --sort=-version:refname) + return 1 +} + +next_channel() { + local version="$1" + local stable="${version#v}" + local major minor + + stable="${stable%%-*}" + IFS='.' read -r major minor _ <<< "${stable}" + printf '%s.%s\n' "${major}" "$((10#${minor} + 1))" +} + +calculate_version() { + local current latest rc_number branch_ref current_rc_pattern + + current="$(channel_version)" + latest="$(latest_supported_version)" + branch_ref="$(release_branch_ref || true)" + + case "${RELEASE_TYPE}" in + rc) + rc_number="$(highest_rc_number)" + if ((rc_number == 0)); then + if [[ -n "${branch_ref}" ]]; then + fail "release/${CHANNEL} exists but has no RC tags" + fi + if ! is_stable_release_tag "${latest}"; then + fail "latest supported version must be stable" + fi + if [[ "${CHANNEL}" != "$(next_channel "${latest}")" ]]; then + fail "first RC channel must follow ${latest}" + fi + printf 'v%s.0-rc.1\n' "${CHANNEL}" + return + fi + if [[ -z "${branch_ref}" ]]; then + fail "release/${CHANNEL} is required for another RC" + fi + current_rc_pattern="$(channel_rc_pattern)" + if [[ ! "${current}" =~ ${current_rc_pattern} ]]; then + fail "versions.yaml has no current RC for ${CHANNEL}" + fi + if ((10#${BASH_REMATCH[1]} != rc_number)); then + fail "versions.yaml RC does not match the highest tag" + fi + if ! git merge-base --is-ancestor "refs/tags/${current}" \ + "${branch_ref}"; then + fail "${current} is not reachable from release/${CHANNEL}" + fi + printf 'v%s.0-rc.%s\n' "${CHANNEL}" "$((rc_number + 1))" + ;; + final) + if [[ -z "${branch_ref}" ]]; then + fail "release/${CHANNEL} is required for a final release" + fi + local rc_pattern rc_commit branch_commit + rc_pattern="$(channel_rc_pattern)" + if [[ ! "${current}" =~ ${rc_pattern} ]]; then + fail "versions.yaml has no RC for ${CHANNEL}" + fi + if ! rc_commit="$( + git rev-parse --verify "refs/tags/${current}^{commit}" + )"; then + fail "RC tag does not exist: ${current}" + fi + branch_commit="$(git rev-parse "${branch_ref}^{commit}")" + if [[ "${branch_commit}" != "${rc_commit}" ]]; then + fail "release/${CHANNEL} advanced; validate another RC" + fi + printf 'v%s.0\n' "${CHANNEL}" + ;; + patch) + if [[ -z "${branch_ref}" ]]; then + fail "release/${CHANNEL} is required for a patch" + fi + local patch_pattern + patch_pattern="$(channel_patch_pattern)" + if [[ ! "${current}" =~ ${patch_pattern} ]]; then + fail "versions.yaml has no stable ${CHANNEL} release" + fi + if ! git rev-parse --verify --quiet \ + "refs/tags/${current}^{commit}" > /dev/null; then + fail "stable tag does not exist: ${current}" + fi + if ! git merge-base --is-ancestor "refs/tags/${current}" \ + "${branch_ref}"; then + fail "${current} is not reachable from release/${CHANNEL}" + fi + printf 'v%s.%s\n' "${CHANNEL}" \ + "$((10#${BASH_REMATCH[1]} + 1))" + ;; + esac +} + +validate_backports() { + local branch_ref="$1" + local missing + + if ! jq -e 'type == "array"' "${BACKPORTS_FILE}" > /dev/null; then + fail "backports file must contain a JSON array" + fi + + if [[ -z "${branch_ref}" ]]; then + if [[ "$(jq 'length' "${BACKPORTS_FILE}")" != "0" ]]; then + fail "a first RC already contains main; omit backports" + fi + return + fi + + missing="$(jq -r \ + '.[] | select(.backport_merged != true) | "#\(.source_pr)"' \ + "${BACKPORTS_FILE}" | paste -sd, -)" + if [[ -n "${missing}" ]]; then + fail "backports missing from release/${CHANNEL}: ${missing}" + fi +} + +replace_marker() { + local document="$1" + local marker="$2" + local replacement="$3" + local output="${document}.tmp" + + awk -v marker="${marker}" -v replacement="${replacement}" ' + $0 == marker { + while ((getline line < replacement) > 0) { + print line + } + close(replacement) + next + } + { print } + ' "${document}" > "${output}" + mv "${output}" "${document}" +} + +extract_section() { + local document="$1" + local heading="$2" + local output="$3" + + awk -v heading="${heading}" ' + $0 == heading { active = 1; next } + active && /^### / { exit } + active { print } + ' "${document}" > "${output}" + + if [[ ! -s "${output}" ]]; then + printf 'None.\n' > "${output}" + fi +} + +render_changelog() { + local version="$1" + local ref="$2" + local body_file="$3" + local range + local -a remote_options=() + + range="$(bash "${CHANGELOG_RANGE_SCRIPT}" --ref "${ref}")" + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + remote_options+=(--offline) + fi + + "${GIT_CLIFF}" --config "${CLIFF_CONFIG}" --tag "${version}" \ + --strip all --output "${body_file}" "${remote_options[@]}" \ + "${range}" + [[ -s "${body_file}" ]] || fail "git-cliff rendered an empty changelog" + + awk -v heading="## [${version#v}] - ${RELEASE_DATE}" ' + NR == 1 && /^## \[/ { print heading; next } + { print } + ' "${body_file}" > "${body_file}.tmp" + mv "${body_file}.tmp" "${body_file}" +} + +update_versions() { + local version="$1" + local current="$2" + + if [[ -z "${current}" ]]; then + # yq reads these environment variables through strenv(). + # shellcheck disable=SC2016 + CHANNEL="${CHANNEL}" VERSION="${version}" yq -i ' + .supported as $supported | + .supported = ( + [{"channel": strenv(CHANNEL), + "version": strenv(VERSION)}] + $supported[0:-1] + ) | + .deprecated = ([$supported[-1]] + .deprecated) + ' "${VERSIONS_FILE}" + else + CHANNEL="${CHANNEL}" VERSION="${version}" yq -i ' + (.supported[] | select(.channel == strenv(CHANNEL)) | .version) = + strenv(VERSION) + ' "${VERSIONS_FILE}" + fi +} + +update_changelog() { + local version="$1" + local previous_version="$2" + local body_file="$3" + local display_version="${version#v}" + local output="${CHANGELOG_FILE}.tmp" + + if ! grep -Fqx '## [Unreleased]' "${CHANGELOG_FILE}"; then + fail "CHANGELOG.md has no Unreleased section" + fi + if grep -Fq "## [${display_version}] -" "${CHANGELOG_FILE}"; then + fail "CHANGELOG.md already contains ${version}" + fi + + awk -v body="${body_file}" ' + { print } + $0 == "## [Unreleased]" { + print "" + while ((getline line < body) > 0) { + print line + } + close(body) + } + ' "${CHANGELOG_FILE}" > "${output}" + mv "${output}" "${CHANGELOG_FILE}" + + awk -v current="${display_version}" -v version="${version}" \ + -v previous="${previous_version}" \ + -v base="https://github.com/radius-project/radius/compare/" ' + /^\[Unreleased\]:/ { + print "[Unreleased]: " base version "...HEAD" + print "[" current "]: " base previous "..." version + next + } + { print } + ' "${CHANGELOG_FILE}" > "${output}" + mv "${output}" "${CHANGELOG_FILE}" +} + +generate_release_notes() { + local version="$1" + local body_file="$2" + local notes_file="$3" + local template="${RELEASE_NOTES_TEMPLATE}" + local generated="${OUTPUT_DIR}/notes-changelog.md" + local breaking="${OUTPUT_DIR}/breaking-changes.md" + local contributors="${OUTPUT_DIR}/new-contributors.md" + + if [[ "${RELEASE_TYPE}" == "patch" ]]; then + template="${PATCH_NOTES_TEMPLATE}" + fi + cp "${template}" "${notes_file}" + sed -i "s/vX\.Y\.Z/${version}/g" "${notes_file}" + sed -i '/REMINDER TO UPDATE THE VERSION ABOVE AND DELETE THIS COMMENT/d' \ + "${notes_file}" + + awk 'NR == 1 { next } { lines[++count] = $0 } + END { + start = 1 + while (start <= count && lines[start] == "") { start++ } + for (cursor = start; cursor <= count; cursor++) { + print lines[cursor] + } + } + ' "${body_file}" > "${generated}" + replace_marker "${notes_file}" \ + '<!-- PASTE THE OUTPUT OF THE GENERATED CHANGELOG HERE -->' \ + "${generated}" + + if [[ "${RELEASE_TYPE}" != "patch" ]]; then + extract_section "${body_file}" '### Breaking changes' "${breaking}" + extract_section "${body_file}" '### New contributors' \ + "${contributors}" + replace_marker "${notes_file}" \ + '<!-- ADD ANY BREAKING CHANGES HERE, IF ANY -->' "${breaking}" + replace_marker "${notes_file}" \ + '<!-- PASTE THE OUTPUT OF THE GENERATED CONTRIBUTOR LIST HERE -->' \ + "${contributors}" + fi +} + +write_release_plan() { + local version="$1" + local previous_version="$2" + local source_ref="$3" + local source_commit="$4" + local plan_file="${OUTPUT_DIR}/release-plan.yaml" + local body_file="${OUTPUT_DIR}/release-pr-body.md" + local requires_backport="$5" + local release_commit_resolution="release PR squash commit on main" + + if [[ "${source_ref}" != "HEAD" ]]; then + release_commit_resolution="generated release backport commit" + release_commit_resolution+=" on release/${CHANNEL}" + fi + + VERSION="${version}" RELEASE_TYPE="${RELEASE_TYPE}" \ + CHANNEL="${CHANNEL}" RELEASE_DATE="${RELEASE_DATE}" \ + PREVIOUS_VERSION="${previous_version}" \ + SOURCE_REF="${source_ref}" SOURCE_COMMIT="${source_commit}" \ + RELEASE_COMMIT_RESOLUTION="${release_commit_resolution}" \ + TARGETS_FILE="${TARGETS_FILE}" BACKPORTS_FILE="${BACKPORTS_FILE}" \ + yq -n ' + { + "schemaVersion": 1, + "version": strenv(VERSION), + "releaseType": strenv(RELEASE_TYPE), + "channel": strenv(CHANNEL), + "releaseDate": strenv(RELEASE_DATE), + "chartVersion": (strenv(VERSION) | sub("^v"; "")), + "source": { + "productRef": strenv(SOURCE_REF), + "productCommit": strenv(SOURCE_COMMIT), + "releaseCommit": null, + "releaseCommitResolution": + strenv(RELEASE_COMMIT_RESOLUTION) + }, + "releaseBranch": "release/" + strenv(CHANNEL), + "previousVersion": strenv(PREVIOUS_VERSION), + "expectedOutputs": load(strenv(TARGETS_FILE)), + "includedBackports": load(strenv(BACKPORTS_FILE)) + } | ... style = "" + ' > "${plan_file}" + + { + echo "## Release plan" + echo + echo "This pull request was generated by the Prepare Release workflow." + echo "The plan below is the reviewable release-controller input." + echo + echo '<!-- radius-release-plan:start -->' + echo '```yaml' + cat "${plan_file}" + echo '```' + echo '<!-- radius-release-plan:end -->' + echo + echo "## Maintainer review" + echo + echo "- [ ] Curate Highlights in the generated release notes." + echo "- [ ] Review and update the Upgrading guidance." + echo "- [ ] Verify the source commit and included backports." + if [[ "${requires_backport}" == "true" ]]; then + echo "- [ ] Rebase-merge this PR's generated backport after merge." + fi + echo + echo "Generated for #12814." + } > "${body_file}" + + printf 'chore(release): prepare %s\n' "${version}" \ + > "${OUTPUT_DIR}/pr-title.txt" + printf 'automation/prepare-release-%s\n' "${version#v}" \ + > "${OUTPUT_DIR}/pr-branch.txt" + printf '%s\n' "${requires_backport}" \ + > "${OUTPUT_DIR}/requires-backport.txt" +} + +main() { + local version branch_ref + + while [[ $# -gt 0 ]]; do + case "$1" in + --release-type) + RELEASE_TYPE="${2:-}" + shift 2 + ;; + --channel) + CHANNEL="${2:-}" + shift 2 + ;; + --backports-file) + BACKPORTS_FILE="${2:-}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:-}" + shift 2 + ;; + --versions-file) + VERSIONS_FILE="${2:-}" + shift 2 + ;; + --changelog-file) + CHANGELOG_FILE="${2:-}" + shift 2 + ;; + --release-notes-template) + RELEASE_NOTES_TEMPLATE="${2:-}" + shift 2 + ;; + --patch-notes-template) + PATCH_NOTES_TEMPLATE="${2:-}" + shift 2 + ;; + --targets-file) + TARGETS_FILE="${2:-}" + shift 2 + ;; + --cliff-config) + CLIFF_CONFIG="${2:-}" + shift 2 + ;; + --release-date) + RELEASE_DATE="${2:-}" + shift 2 + ;; + --version-only) + VERSION_ONLY=true + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac + done + + if [[ ! "${RELEASE_TYPE}" =~ ^(rc|final|patch)$ ]]; then + fail "release type must be rc, final, or patch" + fi + if [[ ! "${CHANNEL}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "channel must use X.Y format" + fi + if [[ ! "${RELEASE_DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + fail "release date must use YYYY-MM-DD format" + fi + [[ -n "${OUTPUT_DIR}" ]] || fail "output directory is required" + [[ -f "${VERSIONS_FILE}" ]] || fail "versions file not found" + require_command git + require_command yq + + branch_ref="$(release_branch_ref || true)" + version="$(calculate_version)" + mkdir -p "${OUTPUT_DIR}" + printf '%s\n' "${version}" > "${OUTPUT_DIR}/version.txt" + printf 'automation/prepare-release-%s\n' "${version#v}" \ + > "${OUTPUT_DIR}/pr-branch.txt" + if [[ "${VERSION_ONLY}" == "true" ]]; then + printf 'Selected %s (%s) for release/%s.\n' \ + "${version}" "${RELEASE_TYPE}" "${CHANNEL}" + return + fi + + [[ -f "${BACKPORTS_FILE}" ]] || fail "backports file not found" + [[ -f "${CHANGELOG_FILE}" ]] || fail "changelog file not found" + if [[ ! -f "${RELEASE_NOTES_TEMPLATE}" ]]; then + fail "release notes template not found" + fi + [[ -f "${PATCH_NOTES_TEMPLATE}" ]] \ + || fail "patch notes template not found" + [[ -f "${TARGETS_FILE}" ]] || fail "release targets file not found" + [[ -f "${CLIFF_CONFIG}" ]] || fail "git-cliff config not found" + require_command jq + require_command "${GIT_CLIFF}" + validate_backports "${branch_ref}" + + local current previous_version source_ref source_commit requires_backport + local changelog_base_version changelog_body notes_file + current="$(channel_version)" + previous_version="${current:-$(latest_supported_version)}" + if [[ "${RELEASE_TYPE}" == "patch" ]]; then + changelog_base_version="${current}" + else + if ! changelog_base_version="$(newest_stable_tag)"; then + fail "no stable Radius release tag was found" + fi + fi + source_ref="${branch_ref:-HEAD}" + source_commit="$(git rev-parse "${source_ref}^{commit}")" + requires_backport="false" + [[ -z "${branch_ref}" ]] || requires_backport="true" + changelog_body="${OUTPUT_DIR}/changelog-section.md" + notes_file="docs/release-notes/${version}.md" + + render_changelog "${version}" "${source_ref}" "${changelog_body}" + update_versions "${version}" "${current}" + update_changelog "${version}" "${changelog_base_version}" \ + "${changelog_body}" + generate_release_notes "${version}" "${changelog_body}" "${notes_file}" + write_release_plan "${version}" "${previous_version}" "${source_ref}" \ + "${source_commit}" "${requires_backport}" + printf 'Prepared %s (%s) for release/%s.\n' \ + "${version}" "${RELEASE_TYPE}" "${CHANNEL}" +} + +main "$@" diff --git a/.github/scripts/prepare-release_test.sh b/.github/scripts/prepare-release_test.sh new file mode 100644 index 0000000000..59e7f6b948 --- /dev/null +++ b/.github/scripts/prepare-release_test.sh @@ -0,0 +1,535 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/prepare-release.sh" +readonly PRESERVE_SCRIPT="${SCRIPT_DIR}/preserve-release-note-sections.sh" + +TEST_ROOT="" +REPO="" +PASS=0 +FAIL=0 +LAST_OUTPUT="" +LAST_STATUS=0 + +cleanup() { + if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then + rm -rf "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail_test() { + echo " ASSERT FAILED: $1" + ((++FAIL)) +} + +commit() { + git -C "${REPO}" commit -q --allow-empty -m "$1" +} + +setup_repo() { + local version="$1" + local previous_changelog_version="0.60.0" + + if [[ "${version}" == *-rc* ]]; then + previous_changelog_version="0.59.0" + fi + + REPO="${TEST_ROOT}/repo" + rm -rf "${REPO}" + mkdir -p "${REPO}/out" "${REPO}/docs/release-notes" \ + "${REPO}/.github/release-parity" + git -C "${REPO}" init -q -b main + git -C "${REPO}" config user.name "Radius Test" + git -C "${REPO}" config user.email "test@example.com" + git -C "${REPO}" config commit.gpgsign false + printf "supported:\n - channel: '0.60'\n version: '%s'\n" \ + "${version}" > "${REPO}/versions.yaml" + printf 'deprecated:\n - channel: '\''0.59'\''\n version: '\''v0.59.0'\''\n' \ + >> "${REPO}/versions.yaml" + printf '[]\n' > "${REPO}/backports.json" + cat > "${REPO}/CHANGELOG.md" << EOF +# Changelog + +## [Unreleased] + +## [${previous_changelog_version}] - 2026-08-19 + +Previous release. + +[Unreleased]: https://example.test/compare/v${previous_changelog_version}...HEAD +[${previous_changelog_version}]: https://example.test/releases/v${previous_changelog_version} +EOF + cat > "${REPO}/docs/release-notes/template.md" << 'EOF' +## Announcing Radius vX.Y.Z +<!-- REMINDER TO UPDATE THE VERSION ABOVE AND DELETE THIS COMMENT --> + +## Highlights + +<!-- CURATE HIGHLIGHTS --> + +## Breaking changes + +<!-- ADD ANY BREAKING CHANGES HERE, IF ANY --> + +## New contributors + +<!-- PASTE THE OUTPUT OF THE GENERATED CONTRIBUTOR LIST HERE --> + +## Upgrading to Radius vX.Y.Z + +<!-- CURATE UPGRADING --> + +## Full changelog + +<!-- PASTE THE OUTPUT OF THE GENERATED CHANGELOG HERE --> +EOF + cat > "${REPO}/docs/release-notes/template_patch.md" << 'EOF' +## Radius vX.Y.Z + +## Changelog + +<!-- PASTE THE OUTPUT OF THE GENERATED CHANGELOG HERE --> +EOF + printf '{"cliAssets":[{"name":"rad_linux_amd64"}]}\n' \ + > "${REPO}/.github/release-parity/targets.json" + printf '[changelog]\n' > "${REPO}/cliff.toml" + cat > "${REPO}/fake-git-cliff" << 'EOF' +#!/bin/bash +set -euo pipefail +output="" +tag="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) output="$2"; shift 2 ;; + --tag) tag="$2"; shift 2 ;; + *) shift ;; + esac +done +cat >"${output}" <<BODY +## [${tag#v}] - 2026-08-24 + +### Breaking changes + +- Replace a legacy contract + +### Fixed + +- Fix release preparation + +### New contributors + +- @first made their first contribution +BODY +EOF + chmod +x "${REPO}/fake-git-cliff" + cat > "${REPO}/fake-range.sh" << 'EOF' +#!/bin/bash +printf '%s\n' 'HEAD~1..HEAD' +EOF + chmod +x "${REPO}/fake-range.sh" + git -C "${REPO}" add . + commit "initial" + git -C "${REPO}" tag v0.59.0 + commit "fix: release preparation" +} + +run_prepare() { + local release_type="$1" + local channel="$2" + + set +e + LAST_OUTPUT="$( + cd "${REPO}" \ + && GIT_CLIFF="${REPO}/fake-git-cliff" \ + CHANGELOG_RANGE_SCRIPT="${REPO}/fake-range.sh" \ + bash "${SCRIPT}" \ + --release-type "${release_type}" \ + --channel "${channel}" \ + --backports-file backports.json \ + --output-dir out \ + --release-date 2026-08-24 2>&1 + )" + LAST_STATUS=$? + set -e +} + +assert_version() { + local expected="$1" + local actual + + if ((LAST_STATUS != 0)); then + fail_test "expected success, got: ${LAST_OUTPUT}" + return 1 + fi + actual="$(cat "${REPO}/out/version.txt")" + if [[ "${actual}" != "${expected}" ]]; then + fail_test "got version ${actual}; expected ${expected}" + return 1 + fi + return 0 +} + +assert_file_contains() { + local file="$1" + local expected="$2" + + if ! grep -Fq "${expected}" "${file}"; then + fail_test "${file} does not contain: ${expected}" + return 1 + fi + return 0 +} + +assert_yq_value() { + local file="$1" + local expression="$2" + local expected="$3" + local actual + + actual="$(yq -r "${expression}" "${file}")" + if [[ "${actual}" != "${expected}" ]]; then + fail_test "${file} query ${expression} returned ${actual}; expected ${expected}" + return 1 + fi + return 0 +} + +make_release_branch() { + git -C "${REPO}" branch "release/$1" +} + +test_first_rc() { + setup_repo "v0.60.0" + git -C "${REPO}" tag v0.60.0 + run_prepare rc 0.61 + assert_version "v0.61.0-rc.1" || return + assert_yq_value "${REPO}/versions.yaml" '.supported[0].version' \ + 'v0.61.0-rc.1' || return + assert_file_contains "${REPO}/CHANGELOG.md" \ + '## [0.61.0-rc.1] - 2026-08-24' || return + assert_file_contains "${REPO}/CHANGELOG.md" \ + '[Unreleased]: https://github.com/radius-project/radius/compare/v0.61.0-rc.1...HEAD' || return + assert_file_contains \ + "${REPO}/docs/release-notes/v0.61.0-rc.1.md" \ + '@first made their first contribution' || return + assert_file_contains "${REPO}/out/release-plan.yaml" \ + 'version: v0.61.0-rc.1' || return + assert_yq_value "${REPO}/out/release-plan.yaml" \ + '.expectedOutputs.cliAssets[0].name' 'rad_linux_amd64' || return + assert_yq_value "${REPO}/out/release-plan.yaml" \ + '.source.releaseCommit' 'null' || return + assert_yq_value "${REPO}/out/release-plan.yaml" \ + '.source.releaseCommitResolution' \ + 'release PR squash commit on main' || return + ((++PASS)) +} + +test_first_rc_preserves_support_window() { + setup_repo "v0.60.0" + yq -i '.supported += [{"channel": "0.59", "version": "v0.59.1"}]' \ + "${REPO}/versions.yaml" + git -C "${REPO}" tag v0.60.0 + run_prepare rc 0.61 + assert_version "v0.61.0-rc.1" || return + assert_yq_value "${REPO}/versions.yaml" '.supported | length' '2' || return + assert_yq_value "${REPO}/versions.yaml" '.supported[1].channel' \ + '0.60' || return + assert_yq_value "${REPO}/versions.yaml" '.deprecated[0].channel' \ + '0.59' || return + ((++PASS)) +} + +test_first_rc_requires_latest_stable() { + setup_repo "v0.60.0-rc.3" + run_prepare rc 0.61 + if ((LAST_STATUS == 0)); then + fail_test "expected a new channel to reject an unfinished current channel" + return + fi + if [[ "${LAST_OUTPUT}" != *"must be stable"* ]]; then + fail_test "new-channel failure did not explain stable requirement" + return + fi + ((++PASS)) +} + +test_subsequent_rc() { + setup_repo "v0.60.0-rc.2" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.0-rc.1 + git -C "${REPO}" tag v0.60.0-rc.2 + run_prepare rc 0.60 + assert_version "v0.60.0-rc.3" || return + ((++PASS)) +} + +test_subsequent_rc_rejects_stale_metadata() { + setup_repo "v0.60.0-rc.1" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.0-rc.1 + git -C "${REPO}" tag v0.60.0-rc.2 + run_prepare rc 0.60 + if ((LAST_STATUS == 0)); then + fail_test "expected stale RC metadata to fail" + return + fi + if [[ "${LAST_OUTPUT}" != *"does not match the highest tag"* ]]; then + fail_test "stale metadata failure was unclear: ${LAST_OUTPUT}" + return + fi + ((++PASS)) +} + +test_subsequent_rc_rejects_divergent_branch() { + setup_repo "v0.60.0-rc.1" + git -C "${REPO}" branch release/0.60 v0.59.0 + git -C "${REPO}" tag v0.60.0-rc.1 + run_prepare rc 0.60 + if ((LAST_STATUS == 0)); then + fail_test "expected a branch missing the current RC to fail" + return + fi + if [[ "${LAST_OUTPUT}" != *"not reachable"* ]]; then + fail_test "divergent branch failure was unclear: ${LAST_OUTPUT}" + return + fi + ((++PASS)) +} + +test_final() { + setup_repo "v0.60.0-rc.3" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.0-rc.3 + run_prepare final 0.60 + assert_version "v0.60.0" || return + assert_file_contains "${REPO}/out/release-plan.yaml" \ + 'releaseType: final' || return + assert_file_contains "${REPO}/out/requires-backport.txt" 'true' || return + assert_file_contains "${REPO}/docs/release-notes/v0.60.0.md" \ + '## Upgrading to Radius v0.60.0' || return + ((++PASS)) +} + +test_final_rejects_unvalidated_branch_tip() { + setup_repo "v0.60.0-rc.3" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.0-rc.3 + git -C "${REPO}" checkout -q release/0.60 + commit "fix: unvalidated release branch change" + git -C "${REPO}" checkout -q main + run_prepare final 0.60 + if ((LAST_STATUS == 0)); then + fail_test "expected final preparation to reject an advanced branch" + return + fi + if [[ "${LAST_OUTPUT}" != *"validate another RC"* ]]; then + fail_test "final failure did not require another RC: ${LAST_OUTPUT}" + return + fi + ((++PASS)) +} + +test_final_rejects_out_of_policy_rc_number() { + setup_repo "v0.60.0-rc.0" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.0-rc.0 + run_prepare final 0.60 + if ((LAST_STATUS == 0)); then + fail_test "expected rc.0 to be rejected as a final release base" + return + fi + if [[ "${LAST_OUTPUT}" != *"has no RC"* ]]; then + fail_test "out-of-policy RC failure was unclear: ${LAST_OUTPUT}" + return + fi + ((++PASS)) +} + +test_version_only_does_not_mutate_files() { + local before + local output_dir="${TEST_ROOT}/version-only-output" + + setup_repo "v0.60.0" + git -C "${REPO}" tag v0.60.0 + before="$(git -C "${REPO}" status --porcelain)" + set +e + LAST_OUTPUT="$( + cd "${REPO}" && bash "${SCRIPT}" --release-type rc \ + --channel 0.61 --output-dir "${output_dir}" --version-only \ + --release-date 2026-08-24 2>&1 + )" + LAST_STATUS=$? + set -e + if ((LAST_STATUS != 0)); then + fail_test "expected version-only success, got: ${LAST_OUTPUT}" + return + fi + if [[ "$(< "${output_dir}/version.txt")" != "v0.61.0-rc.1" ]]; then + fail_test "version-only mode selected the wrong version" + return + fi + if [[ "$(git -C "${REPO}" status --porcelain)" != "${before}" ]]; then + fail_test "version-only mode changed repository files" + return + fi + ((++PASS)) +} + +test_patch() { + setup_repo "v0.60.2" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.2 + git -C "${REPO}" tag v0.61.0 + run_prepare patch 0.60 + assert_version "v0.60.3" || return + assert_file_contains "${REPO}/CHANGELOG.md" \ + 'compare/v0.60.2...v0.60.3' || return + ((++PASS)) +} + +test_unmerged_backport_fails() { + setup_repo "v0.60.0-rc.1" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.0-rc.1 + cat > "${REPO}/backports.json" << 'EOF' +[{"source_pr":123,"backport_merged":false}] +EOF + run_prepare rc 0.60 + if ((LAST_STATUS == 0)); then + fail_test "expected an unmerged selected backport to fail" + return + fi + if [[ "${LAST_OUTPUT}" != *"#123"* ]]; then + fail_test "failure did not identify the missing backport: ${LAST_OUTPUT}" + return + fi + ((++PASS)) +} + +test_publisher_uses_prepared_notes_for_every_release() { + local workflow="${SCRIPT_DIR}/../workflows/__publish-release.yaml" + local notes_count + + if grep -Fq -- '--generate-notes' "${workflow}"; then + fail_test "release publisher still bypasses canonical prepared notes" + return + fi + notes_count="$(grep -Fc -- '--notes-file "docs/release-notes/' "${workflow}")" + if [[ "${notes_count}" != "2" ]]; then + fail_test "both RC and stable publishers must use prepared notes" + return + fi + ((++PASS)) +} + +test_first_rc_rerun_preserves_curated_sections() { + local existing="${TEST_ROOT}/existing-notes.md" + local notes="${REPO}/docs/release-notes/v0.61.0-rc.1.md" + local first_commit second_commit + + setup_repo "v0.60.0" + git -C "${REPO}" tag v0.60.0 + run_prepare rc 0.61 + [[ "${LAST_STATUS}" == "0" ]] || { + fail_test "first preparation failed: ${LAST_OUTPUT}" + return + } + sed -i 's/<!-- CURATE HIGHLIGHTS -->/Curated highlight./' "${notes}" + sed -i 's/<!-- CURATE UPGRADING -->/Curated upgrade guidance./' "${notes}" + cp "${notes}" "${existing}" + first_commit="$(yq -r '.source.productCommit' "${REPO}/out/release-plan.yaml")" + + git -C "${REPO}" checkout -- versions.yaml CHANGELOG.md + rm -rf "${REPO}/out" "${notes}" + git -C "${REPO}" commit -q --allow-empty -m "fix: advance main" + run_prepare rc 0.61 + [[ "${LAST_STATUS}" == "0" ]] || { + fail_test "second preparation failed: ${LAST_OUTPUT}" + return + } + bash "${PRESERVE_SCRIPT}" "${notes}" "${existing}" + second_commit="$(yq -r '.source.productCommit' "${REPO}/out/release-plan.yaml")" + if [[ "${first_commit}" == "${second_commit}" ]]; then + fail_test "rerun did not update the planned product commit" + return + fi + assert_file_contains "${notes}" 'Curated highlight.' || return + assert_file_contains "${notes}" 'Curated upgrade guidance.' || return + assert_file_contains "${notes}" 'Fix release preparation' || return + ((++PASS)) +} + +test_patch_rerun_needs_no_curated_sections() { + local notes="${REPO}/docs/release-notes/v0.60.3.md" + + setup_repo "v0.60.2" + make_release_branch 0.60 + git -C "${REPO}" tag v0.60.2 + run_prepare patch 0.60 + [[ "${LAST_STATUS}" == "0" ]] || { + fail_test "first patch preparation failed: ${LAST_OUTPUT}" + return + } + git -C "${REPO}" checkout -- versions.yaml CHANGELOG.md + rm -rf "${REPO}/out" "${notes}" + run_prepare patch 0.60 + [[ "${LAST_STATUS}" == "0" ]] || { + fail_test "patch regeneration failed: ${LAST_OUTPUT}" + return + } + if grep -Eq '^## (Highlights|Upgrading to Radius )' "${notes}"; then + fail_test "patch notes unexpectedly require curated sections" + return + fi + assert_file_contains "${notes}" 'Fix release preparation' || return + ((++PASS)) +} + +main() { + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/prepare-release-test-XXXXXX")" + + test_first_rc + test_first_rc_preserves_support_window + test_first_rc_requires_latest_stable + test_subsequent_rc + test_subsequent_rc_rejects_stale_metadata + test_subsequent_rc_rejects_divergent_branch + test_final + test_final_rejects_unvalidated_branch_tip + test_final_rejects_out_of_policy_rc_number + test_version_only_does_not_mutate_files + test_patch + test_unmerged_backport_fails + test_publisher_uses_prepared_notes_for_every_release + test_first_rc_rerun_preserves_curated_sections + test_patch_rerun_needs_no_curated_sections + + if ((FAIL > 0)); then + echo "prepare release tests failed: ${PASS} passed, ${FAIL} failed" + exit 1 + fi + + echo "prepare release tests passed (${PASS} tests)" +} + +main "$@" diff --git a/.github/scripts/preserve-release-note-sections.sh b/.github/scripts/preserve-release-note-sections.sh new file mode 100644 index 0000000000..2ee106a324 --- /dev/null +++ b/.github/scripts/preserve-release-note-sections.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +TARGET_FILE="${1:-}" +EXISTING_FILE="${2:-}" +TEMP_DIR="" + +cleanup() { + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi +} +trap cleanup EXIT + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +preserve_section() { + local pattern="$1" + local name="$2" + local section_file="${TEMP_DIR}/${name}.md" + local output_file="${TEMP_DIR}/${name}-output.md" + local target_count existing_count + + target_count="$(grep -Ec "${pattern}" "${TARGET_FILE}")" + existing_count="$(grep -Ec "${pattern}" "${EXISTING_FILE}")" + if [[ "${target_count}" != "1" || "${existing_count}" != "1" ]]; then + fail "${name} heading must appear exactly once in both files" + fi + + awk -v pattern="${pattern}" ' + $0 ~ pattern { active = 1; next } + active && /^## / { exit } + active { print } + ' "${EXISTING_FILE}" > "${section_file}" + + awk -v pattern="${pattern}" -v section="${section_file}" ' + $0 ~ pattern { + print + while ((getline line < section) > 0) { print line } + close(section) + replacing = 1 + next + } + replacing && /^## / { replacing = 0 } + replacing { next } + { print } + ' "${TARGET_FILE}" > "${output_file}" + mv "${output_file}" "${TARGET_FILE}" +} + +main() { + [[ -f "${TARGET_FILE}" ]] || fail "generated release note not found" + [[ -f "${EXISTING_FILE}" ]] || fail "existing release note not found" + TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/release-note-sections-XXXXXX")" + + preserve_section '^## Highlights$' highlights + preserve_section '^## Upgrading to Radius ' upgrading +} + +main "$@" diff --git a/.github/scripts/select-release-backports.mjs b/.github/scripts/select-release-backports.mjs new file mode 100644 index 0000000000..edfe35ca2e --- /dev/null +++ b/.github/scripts/select-release-backports.mjs @@ -0,0 +1,101 @@ +// ------------------------------------------------------------ +// Copyright 2026 The Radius Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------ + +function expectedBase(pull) { + if (!pull.head.ref.startsWith("automation/prepare-release-")) { + return ""; + } + const body = pull.body ?? ""; + const start = "<!-- radius-release-plan:start -->"; + const end = "<!-- radius-release-plan:end -->"; + if (body.split(start).length !== 2 || body.split(end).length !== 2) { + throw new Error("Generated release PR has no unique release plan"); + } + const plan = body.split(start)[1].split(end)[0]; + const matches = [ + ...plan.matchAll(/^\s*productCommit:\s*([0-9a-f]{40})\s*$/gm) + ]; + if (matches.length !== 1) { + throw new Error("Release plan has no unique productCommit"); + } + return matches[0][1]; +} + +export function backportEntry(pull, channel) { + return { + channel, + source_pr: pull.number, + source_commit: pull.merge_commit_sha, + source_title: pull.title, + source_url: pull.html_url, + expected_base: expectedBase(pull) + }; +} + +export function entriesForMergedPull(pull) { + const channels = pull.labels + .map((label) => label.name.match(/^backport release\/(\d+\.\d+)$/)) + .filter(Boolean) + .map((match) => match[1]); + return [...new Set(channels)] + .sort() + .map((channel) => backportEntry(pull, channel)); +} + +export function selectNextBackport({ + channel, + sources, + openBackports, + historicalBackports +}) { + if ( + openBackports.some((pull) => + pull.head.ref.startsWith("automation/backport-") + ) + ) { + return []; + } + + const sourceByNumber = new Map(sources.map((pull) => [pull.number, pull])); + const completed = new Set(); + for (const backport of historicalBackports.filter((pull) => pull.merged_at)) { + const markers = [ + ...(backport.body ?? "").matchAll( + /<!-- radius-backport-source: #(\d+) -->/g + ) + ]; + if (markers.length !== 1) { + continue; + } + const sourceNumber = Number(markers[0][1]); + const source = sourceByNumber.get(sourceNumber); + if (!source) { + continue; + } + const trailer = `(cherry picked from commit ${source.merge_commit_sha})`; + const hasTrailer = (backport.commits ?? []).some((entry) => + (entry.commit?.message ?? "").split(/\r?\n/).includes(trailer) + ); + if (hasTrailer) { + completed.add(sourceNumber); + } + } + + const pending = sources + .filter((pull) => !completed.has(pull.number)) + .sort((left, right) => left.number - right.number); + return pending.length === 0 ? [] : [backportEntry(pending[0], channel)]; +} diff --git a/.github/scripts/select-release-backports_test.mjs b/.github/scripts/select-release-backports_test.mjs new file mode 100644 index 0000000000..df62ffc7e3 --- /dev/null +++ b/.github/scripts/select-release-backports_test.mjs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------ +// Copyright 2026 The Radius Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + entriesForMergedPull, + selectNextBackport +} from "./select-release-backports.mjs"; + +const source = (number) => ({ + number, + merge_commit_sha: String(number).padStart(40, "a"), + title: `fix: source ${number}`, + html_url: `https://example.test/pull/${number}`, + head: { ref: `feature-${number}` }, + labels: [{ name: "backport release/0.60" }] +}); + +test("serializes two backports through successive release pushes", () => { + const first = source(101); + const second = source(102); + const initial = selectNextBackport({ + channel: "0.60", + sources: [second, first], + openBackports: [], + historicalBackports: [] + }); + assert.equal(initial[0].source_pr, 101); + + const deferred = selectNextBackport({ + channel: "0.60", + sources: [first, second], + openBackports: [{ head: { ref: "automation/backport-101-to-0.60" } }], + historicalBackports: [] + }); + assert.deepEqual(deferred, []); + + const completedFirst = { + merged_at: "2026-08-24T00:00:00Z", + body: "<!-- radius-backport-source: #101 -->", + commits: [ + { + commit: { + message: `fix: source 101\n\n(cherry picked from commit ${first.merge_commit_sha})` + } + } + ] + }; + const next = selectNextBackport({ + channel: "0.60", + sources: [first, second], + openBackports: [], + historicalBackports: [completedFirst] + }); + assert.equal(next[0].source_pr, 102); +}); + +test("does not accept a merged marker without the exact trailer", () => { + const first = source(101); + const selected = selectNextBackport({ + channel: "0.60", + sources: [first], + openBackports: [], + historicalBackports: [ + { + merged_at: "2026-08-24T00:00:00Z", + body: "<!-- radius-backport-source: #101 -->", + commits: [{ commit: { message: "conflict handoff only" } }] + } + ] + }); + assert.equal(selected[0].source_pr, 101); +}); + +test("uses every current release label on a merged source PR", () => { + const pull = source(101); + pull.labels.push({ name: "backport release/0.59" }); + assert.deepEqual( + entriesForMergedPull(pull).map((entry) => entry.channel), + ["0.59", "0.60"] + ); +}); diff --git a/.github/scripts/validate-conventional-commits.mjs b/.github/scripts/validate-conventional-commits.mjs new file mode 100644 index 0000000000..b996cfa139 --- /dev/null +++ b/.github/scripts/validate-conventional-commits.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +// ------------------------------------------------------------ +// Copyright 2026 The Radius Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------ + +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const allowedTypes = [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test" +]; +const conventionalSubject = new RegExp( + `^(?:${allowedTypes.join("|")})(?:\\([^)]+\\))?!?: .+` +); +const conflictHandoffSubject = /^chore\(backport\): hand off #\d+ conflict$/; + +export function invalidCommits(commits) { + if (!Array.isArray(commits)) { + throw new TypeError("commit input must be a JSON array"); + } + + return commits.filter((entry) => { + const message = entry?.commit?.message; + const subject = + typeof message === "string" ? message.split("\n", 1)[0] : ""; + return ( + !conventionalSubject.test(subject) || conflictHandoffSubject.test(subject) + ); + }); +} + +export function validateBackportBase(body, baseSha) { + const markers = [ + ...(body ?? "").matchAll(/<!-- radius-backport-base: ([0-9a-f]{40}) -->/g) + ]; + if (markers.length === 0) { + return; + } + if (markers.length !== 1) { + throw new Error("backport PR must contain exactly one base marker"); + } + if (markers[0][1] !== baseSha) { + throw new Error( + `release branch advanced from ${markers[0][1]} to ${baseSha}` + ); + } +} + +export function validateGeneratedBackport(body, baseSha, headRef, commits) { + const branch = headRef.match(/^automation\/backport-(\d+)-to-\d+\.\d+$/); + if (!branch) { + return; + } + + const sources = [ + ...(body ?? "").matchAll(/<!-- radius-backport-source: #(\d+) -->/g) + ]; + const sourceCommits = [ + ...(body ?? "").matchAll(/<!-- radius-backport-commit: ([0-9a-f]{40}) -->/g) + ]; + if (sources.length !== 1 || sources[0][1] !== branch[1]) { + throw new Error( + "generated backport must contain one matching source marker" + ); + } + if (sourceCommits.length !== 1) { + throw new Error("generated backport must contain one source commit marker"); + } + const bases = [ + ...(body ?? "").matchAll(/<!-- radius-backport-base: ([0-9a-f]{40}) -->/g) + ]; + if (bases.length !== 1) { + throw new Error("generated backport must contain one base marker"); + } + + validateBackportBase(body, baseSha); + const trailer = `(cherry picked from commit ${sourceCommits[0][1]})`; + const hasTrailer = commits.some((entry) => { + const message = entry?.commit?.message; + return ( + typeof message === "string" && message.split(/\r?\n/).includes(trailer) + ); + }); + if (!hasTrailer) { + throw new Error(`generated backport is missing exact trailer: ${trailer}`); + } +} + +async function main() { + const inputPath = process.argv[2]; + if (!inputPath) { + throw new Error("usage: validate-conventional-commits.mjs <commits.json>"); + } + + const commits = JSON.parse(await readFile(inputPath, "utf8")); + const bodyPath = process.argv[3]; + const baseSha = process.argv[4]; + const headRef = process.argv[5]; + if (bodyPath || baseSha || headRef) { + if (!bodyPath || !baseSha || !headRef) { + throw new Error( + "body path, base SHA, and head ref must be supplied together" + ); + } + const body = await readFile(bodyPath, "utf8"); + validateBackportBase(body, baseSha); + validateGeneratedBackport(body, baseSha, headRef, commits); + } + const invalid = invalidCommits(commits); + if (invalid.length === 0) { + console.log(`Validated ${commits.length} Conventional Commit message(s).`); + return; + } + + console.error("The following release-branch commits are not conventional:"); + for (const entry of invalid) { + const subject = + entry?.commit?.message?.split("\n", 1)[0] ?? "<missing message>"; + console.error(`- ${(entry?.sha ?? "<unknown>").slice(0, 12)} ${subject}`); + } + process.exitCode = 1; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + await main(); +} diff --git a/.github/scripts/validate-conventional-commits_test.mjs b/.github/scripts/validate-conventional-commits_test.mjs new file mode 100644 index 0000000000..caa384fd81 --- /dev/null +++ b/.github/scripts/validate-conventional-commits_test.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node + +// ------------------------------------------------------------ +// Copyright 2026 The Radius Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------ + +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + invalidCommits, + validateBackportBase, + validateGeneratedBackport +} from "./validate-conventional-commits.mjs"; + +const commit = (sha, message) => ({ sha, commit: { message } }); + +test("accepts the repository Conventional Commit forms", () => { + const commits = [ + commit("a", "fix: repair release preparation"), + commit("b", "feat(cli)!: remove a legacy flag\n\nBREAKING CHANGE: removed"), + commit("c", "chore(backport): resolve #123 conflict"), + commit("d", "ci(deps): bump actions/checkout") + ]; + + assert.deepEqual(invalidCommits(commits), []); +}); + +test("rejects invalid and missing subjects", () => { + const invalid = invalidCommits([ + commit("bad-subject", "Fix release preparation"), + { sha: "missing-message", commit: {} } + ]); + + assert.deepEqual( + invalid.map(({ sha }) => sha), + ["bad-subject", "missing-message"] + ); +}); + +test("rejects a non-array payload", () => { + assert.throws(() => invalidCommits({}), /JSON array/); +}); + +test("CLI rejects an invalid commit file", () => { + const directory = mkdtempSync(join(tmpdir(), "conventional-commits-")); + const input = join(directory, "commits.json"); + const script = fileURLToPath( + new URL("./validate-conventional-commits.mjs", import.meta.url) + ); + writeFileSync(input, JSON.stringify([commit("bad", "Not conventional")])); + + const result = spawnSync(process.execPath, [script, input], { + encoding: "utf8" + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /bad Not conventional/); +}); + +test("CLI accepts a valid commit file", () => { + const directory = mkdtempSync(join(tmpdir(), "conventional-commits-")); + const input = join(directory, "commits.json"); + const script = fileURLToPath( + new URL("./validate-conventional-commits.mjs", import.meta.url) + ); + writeFileSync(input, JSON.stringify([commit("good", "fix: valid")])); + + const output = execFileSync(process.execPath, [script, input], { + encoding: "utf8" + }); + assert.match(output, /Validated 1 Conventional Commit message/); +}); + +test("rejects an unresolved conflict handoff commit", () => { + const invalid = invalidCommits([ + commit("handoff", "chore(backport): hand off #123 conflict") + ]); + + assert.deepEqual( + invalid.map(({ sha }) => sha), + ["handoff"] + ); +}); + +test("accepts a backport pinned to the current release base", () => { + const base = "a".repeat(40); + assert.doesNotThrow(() => + validateBackportBase(`<!-- radius-backport-base: ${base} -->`, base) + ); +}); + +test("rejects a backport after the release branch advances", () => { + const expected = "a".repeat(40); + const current = "b".repeat(40); + assert.throws( + () => + validateBackportBase( + `<!-- radius-backport-base: ${expected} -->`, + current + ), + /release branch advanced/ + ); +}); + +test("rejects duplicate backport base markers", () => { + const base = "a".repeat(40); + assert.throws( + () => + validateBackportBase( + `<!-- radius-backport-base: ${base} -->\n` + + `<!-- radius-backport-base: ${base} -->`, + base + ), + /exactly one base marker/ + ); +}); + +test("accepts complete generated backport metadata", () => { + const base = "a".repeat(40); + const source = "b".repeat(40); + const body = [ + "<!-- radius-backport-source: #123 -->", + `<!-- radius-backport-base: ${base} -->`, + `<!-- radius-backport-commit: ${source} -->` + ].join("\n"); + const commits = [ + commit("head", `fix: backport\n\n(cherry picked from commit ${source})`) + ]; + + assert.doesNotThrow(() => + validateGeneratedBackport( + body, + base, + "automation/backport-123-to-0.60", + commits + ) + ); +}); + +test("rejects generated backport with removed markers", () => { + const base = "a".repeat(40); + assert.throws( + () => + validateGeneratedBackport( + "", + base, + "automation/backport-123-to-0.60", + [] + ), + /source marker/ + ); +}); + +test("rejects generated backport without a base marker", () => { + const source = "b".repeat(40); + const body = [ + "<!-- radius-backport-source: #123 -->", + `<!-- radius-backport-commit: ${source} -->` + ].join("\n"); + assert.throws( + () => + validateGeneratedBackport( + body, + "a".repeat(40), + "automation/backport-123-to-0.60", + [ + commit( + "head", + `fix: backport\n\n(cherry picked from commit ${source})` + ) + ] + ), + /base marker/ + ); +}); + +test("rejects generated backport without exact source trailer", () => { + const base = "a".repeat(40); + const source = "b".repeat(40); + const body = [ + "<!-- radius-backport-source: #123 -->", + `<!-- radius-backport-base: ${base} -->`, + `<!-- radius-backport-commit: ${source} -->` + ].join("\n"); + assert.throws( + () => + validateGeneratedBackport(body, base, "automation/backport-123-to-0.60", [ + commit( + "head", + `fix: malformed\n\ntext (cherry picked from commit ${source})` + ) + ]), + /missing exact trailer/ + ); +}); diff --git a/.github/scripts/validate-release-merge-group.sh b/.github/scripts/validate-release-merge-group.sh new file mode 100644 index 0000000000..8144771d09 --- /dev/null +++ b/.github/scripts/validate-release-merge-group.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +CANDIDATES_FILE="" +MERGE_GROUP_SHA="" +BASE_SHA="" +OUTPUT_FILE="" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +tree_entry() { + local commit="$1" + local path="$2" + + git rev-parse "${commit}:${path}" 2> /dev/null \ + || printf '%s\n' '<missing>' +} + +main() { + local candidate_sha candidate_number candidate_blob merge_blob file + local candidates_tsv changed_files candidate_files + local matches_paths touches_release=false + local -a matches=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --candidates-file) + CANDIDATES_FILE="${2:-}" + shift 2 + ;; + --merge-group-sha) + MERGE_GROUP_SHA="${2:-}" + shift 2 + ;; + --base-sha) + BASE_SHA="${2:-}" + shift 2 + ;; + --output-file) + OUTPUT_FILE="${2:-}" + shift 2 + ;; + *) fail "unknown option: $1" ;; + esac + done + + [[ -f "${CANDIDATES_FILE}" ]] || fail "candidates file not found" + [[ -n "${OUTPUT_FILE}" ]] || fail "output file is required" + if ! git rev-parse --verify --quiet \ + "${MERGE_GROUP_SHA}^{commit}" > /dev/null; then + fail "merge group SHA is not a commit" + fi + if ! git rev-parse --verify --quiet \ + "${BASE_SHA}^{commit}" > /dev/null; then + fail "merge group base SHA is not a commit" + fi + jq -e 'type == "array" and all(.[]; + (.number | type == "number") and + (.head_sha | test("^[0-9a-f]{40}$")) and + (.files | type == "array") and + all(.files[]; type == "string"))' \ + "${CANDIDATES_FILE}" > /dev/null || fail "candidate input is invalid" + + changed_files="$(mktemp "${TMPDIR:-/tmp}/release-changes-XXXXXX")" + git diff --name-only "${BASE_SHA}" "${MERGE_GROUP_SHA}" \ + > "${changed_files}.raw" + sort "${changed_files}.raw" > "${changed_files}" + rm "${changed_files}.raw" + if grep -Eq \ + '^(CHANGELOG\.md|versions\.yaml|docs/release-notes/.+\.md)$' \ + "${changed_files}"; then + touches_release=true + fi + + candidates_tsv="$(mktemp "${TMPDIR:-/tmp}/release-candidates-XXXXXX")" + jq -r '.[] | [.number, .head_sha] | @tsv' "${CANDIDATES_FILE}" \ + > "${candidates_tsv}.raw" + tr -d '\r' < "${candidates_tsv}.raw" > "${candidates_tsv}" + rm "${candidates_tsv}.raw" + while IFS=$'\t' read -r candidate_number candidate_sha; do + if ! git rev-parse --verify --quiet \ + "${candidate_sha}^{commit}" > /dev/null; then + fail "release PR #${candidate_number} head is unavailable" + fi + matches_paths=true + candidate_files="${candidates_tsv}.${candidate_number}.files" + jq -r --argjson number "${candidate_number}" \ + '.[] | select(.number == $number) | .files[]' \ + "${CANDIDATES_FILE}" | tr -d '\r' | sort \ + > "${candidate_files}" + if ! diff -q "${changed_files}" "${candidate_files}" \ + > /dev/null; then + matches_paths=false + fi + while IFS= read -r file; do + [[ "${matches_paths}" == "true" ]] || break + candidate_blob="$(tree_entry "${candidate_sha}" "${file}")" + merge_blob="$(tree_entry "${MERGE_GROUP_SHA}" "${file}")" + if [[ "${candidate_blob}" != "${merge_blob}" ]]; then + matches_paths=false + break + fi + done < "${candidate_files}" + rm "${candidate_files}" + if [[ "${matches_paths}" == "true" ]]; then + matches+=("${candidate_number}:${candidate_sha}") + fi + done < "${candidates_tsv}" + rm "${candidates_tsv}" "${changed_files}" + + if ((${#matches[@]} == 0)); then + if [[ "${touches_release}" == "true" ]]; then + fail "release metadata changed without a matching release plan" + fi + : > "${OUTPUT_FILE}" + echo "Merge group contains no generated release pull request." + return + fi + if ((${#matches[@]} != 1)); then + fail "merge group contains multiple release pull requests" + fi + + candidate_number="${matches[0]%%:*}" + printf '%s\n' "${candidate_number}" > "${OUTPUT_FILE}" + echo "Merge group contains only release PR #${candidate_number}." +} + +main "$@" diff --git a/.github/scripts/validate-release-merge-group_test.sh b/.github/scripts/validate-release-merge-group_test.sh new file mode 100644 index 0000000000..d539cca79e --- /dev/null +++ b/.github/scripts/validate-release-merge-group_test.sh @@ -0,0 +1,193 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/validate-release-merge-group.sh" + +TEST_ROOT="" +REPO="" +BASE_SHA="" +RELEASE_SHA="" +GROUP_BASE_SHA="" +GROUP_SHA="" +PASS=0 +FAIL=0 + +cleanup() { + if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then + rm -rf "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail_test() { + echo " ASSERT FAILED: $1" + ((++FAIL)) +} + +setup_repo() { + REPO="${TEST_ROOT}/repo" + rm -rf "${REPO}" + mkdir -p "${REPO}" + git -C "${REPO}" init -q -b main + git -C "${REPO}" config user.name "Radius Test" + git -C "${REPO}" config user.email "test@example.com" + git -C "${REPO}" config commit.gpgsign false + printf 'base\n' > "${REPO}/base.txt" + git -C "${REPO}" add base.txt + git -C "${REPO}" commit -q -m "chore: initial" + BASE_SHA="$(git -C "${REPO}" rev-parse HEAD)" + + git -C "${REPO}" checkout -q -b release-pr + mkdir -p "${REPO}/docs/release-notes" + printf 'supported: []\n' > "${REPO}/versions.yaml" + printf '# Changelog\n' > "${REPO}/CHANGELOG.md" + printf '# Release notes\n' \ + > "${REPO}/docs/release-notes/v0.61.0-rc.1.md" + git -C "${REPO}" add versions.yaml CHANGELOG.md docs/release-notes + git -C "${REPO}" commit -q -m "chore(release): prepare v0.61.0-rc.1" + RELEASE_SHA="$(git -C "${REPO}" rev-parse HEAD)" + cat > "${REPO}/candidates.json" << EOF +[{"number":123,"head_sha":"${RELEASE_SHA}","files":["CHANGELOG.md","docs/release-notes/v0.61.0-rc.1.md","versions.yaml"]}] +EOF +} + +create_squash_group() { + local extra_change="$1" + local advance_base="${2:-false}" + + git -C "${REPO}" checkout -q main + git -C "${REPO}" reset -q --hard "${BASE_SHA}" + if [[ "${advance_base}" == "true" ]]; then + printf 'already on main\n' > "${REPO}/advanced-base.txt" + git -C "${REPO}" add advanced-base.txt + git -C "${REPO}" commit -q -m "fix: advance main before queueing" + fi + GROUP_BASE_SHA="$(git -C "${REPO}" rev-parse HEAD)" + git -C "${REPO}" diff --binary "${BASE_SHA}" "${RELEASE_SHA}" \ + | git -C "${REPO}" apply --index + if [[ "${extra_change}" == "true" ]]; then + printf 'other\n' > "${REPO}/other.txt" + git -C "${REPO}" add other.txt + fi + git -C "${REPO}" commit -q -m "squash merge group" + GROUP_SHA="$(git -C "${REPO}" rev-parse HEAD)" +} + +run_validator() { + local merge_group_sha="$1" + local status + + pushd "${REPO}" > /dev/null + set +e + bash "${SCRIPT}" --candidates-file candidates.json \ + --merge-group-sha "${merge_group_sha}" --base-sha "${GROUP_BASE_SHA}" \ + --output-file selected.txt + status=$? + set -e + popd > /dev/null + return "${status}" +} + +test_accepts_squash_release_only_group() { + local group_sha + create_squash_group false + group_sha="${GROUP_SHA}" + if git -C "${REPO}" merge-base --is-ancestor \ + "${RELEASE_SHA}" "${group_sha}"; then + fail_test "fixture must not make the PR head an ancestor" + return + fi + if ! run_validator "${group_sha}" > /dev/null; then + fail_test "expected a squash release-only group to pass" + return + fi + if [[ "$(< "${REPO}/selected.txt")" != "123" ]]; then + fail_test "selector did not identify release PR #123" + return + fi + ((++PASS)) +} + +test_rejects_group_with_extra_changes() { + local group_sha + create_squash_group true + group_sha="${GROUP_SHA}" + if run_validator "${group_sha}" > /dev/null 2>&1; then + fail_test "expected a batched merge group to fail" + return + fi + ((++PASS)) +} + +test_selects_release_pr_on_advanced_base() { + local group_sha + create_squash_group false true + group_sha="${GROUP_SHA}" + if [[ "$(git -C "${REPO}" rev-parse "${RELEASE_SHA}^{tree}")" == "$(git -C "${REPO}" rev-parse "${group_sha}^{tree}")" ]]; then + fail_test "fixture must produce different complete trees" + return + fi + if ! run_validator "${group_sha}" > /dev/null; then + fail_test "expected selector to identify the release PR on a newer base" + return + fi + ((++PASS)) +} + +test_accepts_group_without_release_pr() { + local group_sha + + git -C "${REPO}" checkout -q main + git -C "${REPO}" reset -q --hard "${BASE_SHA}" + printf 'other\n' > "${REPO}/other.txt" + git -C "${REPO}" add other.txt + git -C "${REPO}" commit -q -m "fix: unrelated change" + GROUP_BASE_SHA="${BASE_SHA}" + group_sha="$(git -C "${REPO}" rev-parse HEAD)" + if ! run_validator "${group_sha}" > /dev/null; then + fail_test "expected an unrelated merge group to pass" + return + fi + ((++PASS)) +} + +main() { + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/release-merge-group-XXXXXX")" + + setup_repo + test_accepts_squash_release_only_group + setup_repo + test_rejects_group_with_extra_changes + setup_repo + test_selects_release_pr_on_advanced_base + setup_repo + test_accepts_group_without_release_pr + + if ((FAIL > 0)); then + echo "release merge-group tests failed: ${PASS} passed, ${FAIL} failed" + exit 1 + fi + + echo "release merge-group tests passed (${PASS} tests)" +} + +main "$@" diff --git a/.github/scripts/validate-release-plan.sh b/.github/scripts/validate-release-plan.sh new file mode 100644 index 0000000000..ecd4bb11cb --- /dev/null +++ b/.github/scripts/validate-release-plan.sh @@ -0,0 +1,379 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR + +BODY_FILE="" +FILES_FILE="" +BASE_SHA="" +HEAD_DIR="" +REPOSITORY="" +TEMP_DIR="" +GENERATED_WORKTREE="" +PREPARE_RELEASE_SCRIPT="${PREPARE_RELEASE_SCRIPT:-}" +COLLECT_BACKPORTS_SCRIPT="${COLLECT_BACKPORTS_SCRIPT:-}" +if [[ -z "${PREPARE_RELEASE_SCRIPT}" ]]; then + PREPARE_RELEASE_SCRIPT="${SCRIPT_DIR}/prepare-release.sh" +fi +if [[ -z "${COLLECT_BACKPORTS_SCRIPT}" ]]; then + COLLECT_BACKPORTS_SCRIPT="${SCRIPT_DIR}/collect-release-backports.sh" +fi + +cleanup() { + if [[ -n "${GENERATED_WORKTREE}" && -d "${GENERATED_WORKTREE}" ]]; then + git worktree remove --force "${GENERATED_WORKTREE}" \ + > /dev/null 2>&1 || true + fi + if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then + rm -rf "${TEMP_DIR}" + fi +} +trap cleanup EXIT + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +usage() { + cat << 'EOF' +Usage: validate-release-plan.sh --body-file <path> --files-file <path> \ + --base-sha <commit> --head-dir <path> --repository <owner/repo> +EOF +} + +plan_value() { + yq -r "$1" "${TEMP_DIR}/release-plan.yaml" +} + +extract_plan() { + local start_count end_count + + start_count="$( + grep -Fc '<!-- radius-release-plan:start -->' "${BODY_FILE}" + )" + end_count="$(grep -Fc '<!-- radius-release-plan:end -->' "${BODY_FILE}")" + if [[ "${start_count}" != "1" || "${end_count}" != "1" ]]; then + fail "pull request body must contain exactly one release plan" + fi + + awk ' + /<!-- radius-release-plan:start -->/ { active = 1; next } + /<!-- radius-release-plan:end -->/ { active = 0; exit } + active && /^```(yaml)?$/ { next } + active { print } + ' "${BODY_FILE}" > "${TEMP_DIR}/release-plan.yaml" + if ! yq -e '.' "${TEMP_DIR}/release-plan.yaml" > /dev/null; then + fail "release plan is not valid YAML" + fi +} + +validate_files() { + local version="$1" + local expected="${TEMP_DIR}/expected-files.txt" + local actual="${TEMP_DIR}/actual-files.txt" + + { + echo 'CHANGELOG.md' + echo "docs/release-notes/${version}.md" + echo 'versions.yaml' + } | sort > "${expected}" + jq -e 'type == "array" and all(.[]; type == "string")' \ + "${FILES_FILE}" > /dev/null || fail "changed-files input is invalid" + jq -r '.[]' "${FILES_FILE}" | tr -d '\r' | sort > "${actual}" + if ! diff -u "${expected}" "${actual}"; then + fail "release PR changes files outside the generated contract" + fi +} + +canonical_json() { + local input="$1" + local expression="$2" + local output="$3" + + yq -o=json -I=0 "${expression}" "${input}" > "${output}.raw" + tr -d '\r' < "${output}.raw" | jq -S -c . > "${output}" + rm "${output}.raw" +} + +normalize_text() { + tr -d '\r' < "$1" > "$2" +} + +normalize_release_notes() { + local input="$1" + local output="$2" + + tr -d '\r' < "${input}" | awk ' + /^## Highlights$/ || /^## Upgrading to Radius / { + print + print "<!-- curated by maintainer -->" + curated = 1 + next + } + curated && /^## / { curated = 0 } + curated { next } + { print } + ' > "${output}" +} + +collect_expected_backports() { + local channel="$1" + local output="${TEMP_DIR}/expected-backports.json" + + if [[ -n "${EXPECTED_BACKPORTS_FILE:-}" ]]; then + cp "${EXPECTED_BACKPORTS_FILE}" "${output}" + else + bash "${COLLECT_BACKPORTS_SCRIPT}" --repository "${REPOSITORY}" \ + --channel "${channel}" --output "${output}" + fi + printf '%s\n' "${output}" +} + +regenerate_release() { + local release_type="$1" + local channel="$2" + local release_date="$3" + local backports_file="$4" + local output_dir="${TEMP_DIR}/generated-output" + + GENERATED_WORKTREE="${TEMP_DIR}/generated-worktree" + git worktree add --quiet --detach "${GENERATED_WORKTREE}" "${BASE_SHA}" + pushd "${GENERATED_WORKTREE}" > /dev/null + GITHUB_TOKEN="${GITHUB_TOKEN:-}" GITHUB_REPO="${REPOSITORY}" \ + bash "${PREPARE_RELEASE_SCRIPT}" \ + --release-type "${release_type}" --channel "${channel}" \ + --release-date "${release_date}" \ + --backports-file "${backports_file}" \ + --output-dir "${output_dir}" > /dev/null + popd > /dev/null +} + +validate_generated_contents() { + local version="$1" + local release_type="$2" + local channel="$3" + local release_date="$4" + local expected_backports expected_notes actual_notes + + expected_backports="$(collect_expected_backports "${channel}")" + canonical_json "${TEMP_DIR}/release-plan.yaml" '.includedBackports' \ + "${TEMP_DIR}/planned-backports.json" + jq -S -c . "${expected_backports}" > "${TEMP_DIR}/live-backports.json" + if ! diff -u "${TEMP_DIR}/live-backports.json" \ + "${TEMP_DIR}/planned-backports.json"; then + fail "included backports no longer match repository state" + fi + + regenerate_release "${release_type}" "${channel}" "${release_date}" \ + "${expected_backports}" + + canonical_json "${TEMP_DIR}/release-plan.yaml" '.' \ + "${TEMP_DIR}/actual-plan.json" + canonical_json "${TEMP_DIR}/generated-output/release-plan.yaml" '.' \ + "${TEMP_DIR}/expected-plan.json" + if ! diff -u "${TEMP_DIR}/expected-plan.json" \ + "${TEMP_DIR}/actual-plan.json"; then + fail "release plan differs from trusted regeneration" + fi + + canonical_json "${HEAD_DIR}/versions.yaml" '.' \ + "${TEMP_DIR}/actual-versions.json" + canonical_json "${GENERATED_WORKTREE}/versions.yaml" '.' \ + "${TEMP_DIR}/expected-versions.json" + if ! diff -u "${TEMP_DIR}/expected-versions.json" \ + "${TEMP_DIR}/actual-versions.json"; then + fail "versions.yaml differs from trusted regeneration" + fi + + normalize_text "${HEAD_DIR}/CHANGELOG.md" \ + "${TEMP_DIR}/actual-changelog.md" + normalize_text "${GENERATED_WORKTREE}/CHANGELOG.md" \ + "${TEMP_DIR}/expected-changelog.md" + if ! diff -u "${TEMP_DIR}/expected-changelog.md" \ + "${TEMP_DIR}/actual-changelog.md"; then + fail "CHANGELOG.md differs from trusted regeneration" + fi + + expected_notes="${GENERATED_WORKTREE}/docs/release-notes/${version}.md" + actual_notes="${HEAD_DIR}/docs/release-notes/${version}.md" + [[ -f "${actual_notes}" ]] || fail "generated release notes are missing" + normalize_release_notes "${expected_notes}" \ + "${TEMP_DIR}/expected-notes.md" + normalize_release_notes "${actual_notes}" "${TEMP_DIR}/actual-notes.md" + if ! diff -u "${TEMP_DIR}/expected-notes.md" \ + "${TEMP_DIR}/actual-notes.md"; then + fail "release notes differ outside Highlights or Upgrading" + fi +} + +validate_source() { + local channel="$1" + local product_ref product_commit release_commit expected_ref actual_commit + + product_ref="$(plan_value '.source.productRef')" + product_commit="$(plan_value '.source.productCommit')" + release_commit="$(plan_value '.source.releaseCommit')" + if [[ "${release_commit}" != "null" ]]; then + fail "releaseCommit must remain unresolved during preparation" + fi + if [[ "$(plan_value '.source.releaseCommitResolution')" == "null" ]]; then + fail "releaseCommitResolution is required" + fi + + if [[ "${product_ref}" == "HEAD" ]]; then + if [[ "${product_commit}" != "${BASE_SHA}" ]]; then + fail "main advanced; rerun release preparation" + fi + return + fi + + expected_ref="refs/remotes/origin/release/${channel}" + if [[ "${product_ref}" != "${expected_ref}" ]]; then + fail "productRef must be HEAD or ${expected_ref}" + fi + if ! actual_commit="$(git rev-parse "${expected_ref}^{commit}")"; then + fail "release/${channel} is not available" + fi + if [[ "${actual_commit}" != "${product_commit}" ]]; then + fail "release/${channel} advanced beyond the approved commit" + fi +} + +validate_policy() { + local release_type="$1" + local channel="$2" + local version="$3" + local policy_output="${TEMP_DIR}/policy" + local expected_version + + bash "${SCRIPT_DIR}/prepare-release.sh" \ + --release-type "${release_type}" --channel "${channel}" \ + --output-dir "${policy_output}" --version-only > /dev/null + expected_version="$(< "${policy_output}/version.txt")" + if [[ "${version}" != "${expected_version}" ]]; then + fail "planned version ${version} no longer matches ${expected_version}" + fi +} + +main() { + local version release_type channel release_date chart_version release_branch + local canonical_version_pattern expected_repository + + while [[ $# -gt 0 ]]; do + case "$1" in + --body-file) + BODY_FILE="${2:-}" + shift 2 + ;; + --files-file) + FILES_FILE="${2:-}" + shift 2 + ;; + --base-sha) + BASE_SHA="${2:-}" + shift 2 + ;; + --head-dir) + HEAD_DIR="${2:-}" + shift 2 + ;; + --repository) + REPOSITORY="${2:-}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; + esac + done + + [[ -f "${BODY_FILE}" ]] || fail "pull request body file not found" + [[ -f "${FILES_FILE}" ]] || fail "changed-files file not found" + [[ -d "${HEAD_DIR}" ]] || fail "pull request head directory not found" + if [[ ! "${REPOSITORY}" =~ ^[^/]+/[^/]+$ ]]; then + fail "repository must use owner/name format" + fi + if ! git rev-parse --verify --quiet \ + "${BASE_SHA}^{commit}" > /dev/null; then + fail "base SHA is not a commit" + fi + command -v jq > /dev/null || fail "required command not found: jq" + command -v yq > /dev/null || fail "required command not found: yq" + + TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/release-plan-test-XXXXXX")" + extract_plan + + if [[ "$(plan_value '.schemaVersion')" != "1" ]]; then + fail "unsupported release plan schema" + fi + version="$(plan_value '.version')" + release_type="$(plan_value '.releaseType')" + channel="$(plan_value '.channel')" + release_date="$(plan_value '.releaseDate')" + chart_version="$(plan_value '.chartVersion')" + release_branch="$(plan_value '.releaseBranch')" + # Numeric identifiers reject leading zeros, matching the semver policy in + # .github/scripts/release-version.sh. + canonical_version_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + canonical_version_pattern+='\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$' + if [[ ! "${version}" =~ ${canonical_version_pattern} ]]; then + fail "planned version is not canonical" + fi + if [[ ! "${release_type}" =~ ^(rc|final|patch)$ ]]; then + fail "planned release type is invalid" + fi + if [[ ! "${channel}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + fail "planned channel is invalid" + fi + if [[ ! "${release_date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + fail "planned release date is invalid" + fi + if [[ "${chart_version}" != "${version#v}" ]]; then + fail "chart version does not match release version" + fi + if [[ "${release_branch}" != "release/${channel}" ]]; then + fail "release branch does not match the channel" + fi + expected_repository="$(plan_value '.expectedOutputs.repository')" + if [[ "${expected_repository}" != "radius-project/radius" ]]; then + fail "expected output contract is invalid" + fi + if [[ "$(plan_value '.includedBackports | type')" != "!!seq" ]]; then + fail "includedBackports must be an array" + fi + if [[ "$( + plan_value '[.includedBackports[].backport_merged] | all' + )" != "true" ]]; then + fail "release plan contains an incomplete backport" + fi + + validate_policy "${release_type}" "${channel}" "${version}" + validate_source "${channel}" + validate_files "${version}" + validate_generated_contents "${version}" "${release_type}" "${channel}" \ + "${release_date}" + echo "Release plan ${version} is valid." +} + +main "$@" diff --git a/.github/scripts/validate-release-plan_test.sh b/.github/scripts/validate-release-plan_test.sh new file mode 100644 index 0000000000..29a0b46b9f --- /dev/null +++ b/.github/scripts/validate-release-plan_test.sh @@ -0,0 +1,319 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/validate-release-plan.sh" + +TEST_ROOT="" +REPO="" +BASE_SHA="" +HEAD_DIR="" +EXPECTED_DIR="" +EXPECTED_PLAN="" +EXPECTED_BACKPORTS="" +FAKE_PREPARE="" +PASS=0 +FAIL=0 + +cleanup() { + if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then + rm -rf "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail_test() { + echo " ASSERT FAILED: $1" + ((++FAIL)) +} + +write_plan() { + local product_commit="$1" + + cat > "${EXPECTED_PLAN}" << EOF +schemaVersion: 1 +version: v0.61.0-rc.1 +releaseType: rc +channel: "0.61" +releaseDate: 2026-08-24 +chartVersion: 0.61.0-rc.1 +source: + productRef: HEAD + productCommit: ${product_commit} + releaseCommit: null + releaseCommitResolution: release PR squash commit on main +releaseBranch: release/0.61 +previousVersion: v0.60.0 +expectedOutputs: + repository: radius-project/radius +includedBackports: [] +EOF +} + +wrap_body() { + { + echo '## Release plan' + echo + echo '<!-- radius-release-plan:start -->' + echo '```yaml' + cat "${EXPECTED_PLAN}" + echo '```' + echo '<!-- radius-release-plan:end -->' + } > "${REPO}/body.md" +} + +setup_repo() { + REPO="${TEST_ROOT}/repo" + HEAD_DIR="${TEST_ROOT}/head" + EXPECTED_DIR="${TEST_ROOT}/expected" + EXPECTED_PLAN="${TEST_ROOT}/expected-plan.yaml" + EXPECTED_BACKPORTS="${TEST_ROOT}/expected-backports.json" + FAKE_PREPARE="${TEST_ROOT}/fake-prepare.sh" + rm -rf "${REPO}" "${HEAD_DIR}" "${EXPECTED_DIR}" + mkdir -p "${REPO}" + git -C "${REPO}" init -q -b main + git -C "${REPO}" config user.name "Radius Test" + git -C "${REPO}" config user.email "test@example.com" + git -C "${REPO}" config commit.gpgsign false + cat > "${REPO}/versions.yaml" << 'EOF' +supported: + - channel: '0.60' + version: 'v0.60.0' +deprecated: [] +EOF + git -C "${REPO}" add versions.yaml + git -C "${REPO}" commit -q -m "chore: initial" + git -C "${REPO}" tag v0.60.0 + BASE_SHA="$(git -C "${REPO}" rev-parse HEAD)" + write_plan "${BASE_SHA}" + wrap_body + printf '%s\n' \ + '["CHANGELOG.md","docs/release-notes/v0.61.0-rc.1.md","versions.yaml"]' \ + > "${REPO}/files.json" + + mkdir -p "${EXPECTED_DIR}/docs/release-notes" + cat > "${EXPECTED_DIR}/versions.yaml" << 'EOF' +supported: + - channel: "0.61" + version: v0.61.0-rc.1 +deprecated: + - channel: '0.60' + version: 'v0.60.0' +EOF + cat > "${EXPECTED_DIR}/CHANGELOG.md" << 'EOF' +# Changelog + +## [Unreleased] + +## [0.61.0-rc.1] - 2026-08-24 + +### Fixed + +- Fix release preparation +EOF + cat > "${EXPECTED_DIR}/docs/release-notes/v0.61.0-rc.1.md" << 'EOF' +## Announcing Radius v0.61.0-rc.1 + +## Highlights + +<!-- CURATE HIGHLIGHTS --> + +## Upgrading to Radius v0.61.0-rc.1 + +<!-- CURATE UPGRADING --> + +## Full changelog + +### Fixed + +- Fix release preparation +EOF + cp -R "${EXPECTED_DIR}/." "${HEAD_DIR}/" + printf '[]\n' > "${EXPECTED_BACKPORTS}" + cat > "${FAKE_PREPARE}" << 'EOF' +#!/bin/bash +set -euo pipefail +output_dir="" +while [[ $# -gt 0 ]]; do + case "$1" in + --output-dir) output_dir="$2"; shift 2 ;; + *) shift ;; + esac +done +mkdir -p "${output_dir}" docs/release-notes +cp "${EXPECTED_RELEASE_DIR}/versions.yaml" versions.yaml +cp "${EXPECTED_RELEASE_DIR}/CHANGELOG.md" CHANGELOG.md +cp "${EXPECTED_RELEASE_DIR}/docs/release-notes/v0.61.0-rc.1.md" \ + docs/release-notes/v0.61.0-rc.1.md +cp "${EXPECTED_PLAN_FILE}" "${output_dir}/release-plan.yaml" +EOF + chmod +x "${FAKE_PREPARE}" +} + +run_validator() { + local status + + pushd "${REPO}" > /dev/null + set +e + PREPARE_RELEASE_SCRIPT="${FAKE_PREPARE}" \ + EXPECTED_BACKPORTS_FILE="${EXPECTED_BACKPORTS}" \ + EXPECTED_RELEASE_DIR="${EXPECTED_DIR}" \ + EXPECTED_PLAN_FILE="${EXPECTED_PLAN}" \ + bash "${SCRIPT}" --body-file body.md --files-file files.json \ + --base-sha "${BASE_SHA}" --head-dir "${HEAD_DIR}" \ + --repository radius-project/radius + status=$? + set -e + popd > /dev/null + return "${status}" +} + +test_accepts_generated_plan() { + if ! run_validator > /dev/null; then + fail_test "expected the generated plan to pass" + return + fi + ((++PASS)) +} + +test_accepts_curated_note_sections() { + sed -i 's/<!-- CURATE HIGHLIGHTS -->/A curated highlight./' \ + "${HEAD_DIR}/docs/release-notes/v0.61.0-rc.1.md" + sed -i 's/<!-- CURATE UPGRADING -->/Run the documented upgrade command./' \ + "${HEAD_DIR}/docs/release-notes/v0.61.0-rc.1.md" + if ! run_validator > /dev/null; then + fail_test "expected curated note sections to pass" + return + fi + ((++PASS)) +} + +test_rejects_product_commit_drift() { + sed -i "s/${BASE_SHA}/$(printf 'f%.0s' {1..40})/" "${REPO}/body.md" + if run_validator > /dev/null 2>&1; then + fail_test "expected product commit drift to fail" + return + fi + ((++PASS)) +} + +test_rejects_unexpected_file() { + jq '. + ["unrelated.txt"]' "${REPO}/files.json" \ + > "${REPO}/files.json.tmp" + mv "${REPO}/files.json.tmp" "${REPO}/files.json" + if run_validator > /dev/null 2>&1; then + fail_test "expected an unrelated changed file to fail" + return + fi + ((++PASS)) +} + +test_rejects_tampered_versions() { + yq -i '.supported[0].version = "v9.9.9"' "${HEAD_DIR}/versions.yaml" + if run_validator > /dev/null 2>&1; then + fail_test "expected tampered versions.yaml to fail" + return + fi + ((++PASS)) +} + +test_rejects_tampered_changelog() { + printf '\n- Unplanned entry\n' >> "${HEAD_DIR}/CHANGELOG.md" + if run_validator > /dev/null 2>&1; then + fail_test "expected a tampered changelog to fail" + return + fi + ((++PASS)) +} + +test_rejects_tampered_generated_notes() { + sed -i 's/Fix release preparation/Replace generated content/' \ + "${HEAD_DIR}/docs/release-notes/v0.61.0-rc.1.md" + if run_validator > /dev/null 2>&1; then + fail_test "expected generated note changes to fail" + return + fi + ((++PASS)) +} + +test_rejects_tampered_output_contract() { + sed -i 's|radius-project/radius|other/repository|' "${REPO}/body.md" + if run_validator > /dev/null 2>&1; then + fail_test "expected a tampered output contract to fail" + return + fi + ((++PASS)) +} + +test_rejects_tampered_backports() { + sed -i 's/includedBackports: \[\]/includedBackports: [{source_pr: 1, backport_merged: true}]/' \ + "${REPO}/body.md" + if run_validator > /dev/null 2>&1; then + fail_test "expected a tampered backport list to fail" + return + fi + ((++PASS)) +} + +test_rejects_live_backport_state_drift() { + cat > "${EXPECTED_BACKPORTS}" << 'EOF' +[{"source_pr":123,"backport_merged":true}] +EOF + if run_validator > /dev/null 2>&1; then + fail_test "expected live backport state drift to fail" + return + fi + ((++PASS)) +} + +main() { + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/release-plan-fixture-XXXXXX")" + + setup_repo + test_accepts_generated_plan + setup_repo + test_accepts_curated_note_sections + setup_repo + test_rejects_product_commit_drift + setup_repo + test_rejects_unexpected_file + setup_repo + test_rejects_tampered_versions + setup_repo + test_rejects_tampered_changelog + setup_repo + test_rejects_tampered_generated_notes + setup_repo + test_rejects_tampered_output_contract + setup_repo + test_rejects_tampered_backports + setup_repo + test_rejects_live_backport_state_drift + + if ((FAIL > 0)); then + echo "release plan tests failed: ${PASS} passed, ${FAIL} failed" + exit 1 + fi + + echo "release plan tests passed (${PASS} tests)" +} + +main "$@" diff --git a/.github/scripts/verify-deployment-engine-tag.sh b/.github/scripts/verify-deployment-engine-tag.sh new file mode 100644 index 0000000000..cf52bea404 --- /dev/null +++ b/.github/scripts/verify-deployment-engine-tag.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +GH="${GH:-gh}" +readonly REPOSITORY="azure-octo/deployment-engine" +TAG="${1:-}" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +main() { + local reference object_type object_sha verification recovery + local tag_pattern + + # Numeric identifiers reject leading zeros, matching the semver policy in + # .github/scripts/release-version.sh. + tag_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' + tag_pattern+='(-rc\.[1-9][0-9]*)?$' + if [[ ! "${TAG}" =~ ${tag_pattern} ]]; then + fail "tag must use vX.Y.Z or vX.Y.Z-rc.N format" + fi + command -v "${GH}" > /dev/null || fail "required command not found: ${GH}" + command -v jq > /dev/null || fail "required command not found: jq" + + recovery="git tag -s ${TAG} -m 'release tag ${TAG}'" + recovery+=" && git push origin ${TAG}" + if ! reference="$( + "${GH}" api "repos/${REPOSITORY}/git/ref/tags/${TAG}" + )"; then + fail "Create the signed tag with: ${recovery}" + fi + object_type="$(jq -r '.object.type' <<< "${reference}")" + object_sha="$(jq -r '.object.sha' <<< "${reference}")" + if [[ "${object_type}" != "tag" ]]; then + fail "Deployment Engine tag ${TAG} is lightweight, not signed" + fi + + verification="$( + "${GH}" api "repos/${REPOSITORY}/git/tags/${object_sha}" \ + --jq '.verification.verified' + )" + if [[ "${verification}" != "true" ]]; then + fail "Deployment Engine tag ${TAG} has no valid signature" + fi + + echo "Verified signed Deployment Engine tag ${TAG}." +} + +main "$@" diff --git a/.github/scripts/verify-deployment-engine-tag_test.sh b/.github/scripts/verify-deployment-engine-tag_test.sh new file mode 100644 index 0000000000..12a0c0d327 --- /dev/null +++ b/.github/scripts/verify-deployment-engine-tag_test.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SCRIPT="${SCRIPT_DIR}/verify-deployment-engine-tag.sh" + +TEST_ROOT="" +PASS=0 +FAIL=0 + +cleanup() { + if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then + rm -rf "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +fail_test() { + echo " ASSERT FAILED: $1" + ((++FAIL)) +} + +write_fake_gh() { + local type="$1" + local verified="$2" + + cat > "${TEST_ROOT}/gh" << EOF +#!/bin/bash +set -euo pipefail +if [[ "\$*" == *"git/ref/tags/"* ]]; then + printf '%s\n' '{"object":{"type":"${type}","sha":"tag-object"}}' +else + printf '%s\n' '${verified}' +fi +EOF + chmod +x "${TEST_ROOT}/gh" +} + +write_missing_fake_gh() { + cat > "${TEST_ROOT}/gh" << 'EOF' +#!/bin/bash +exit 1 +EOF + chmod +x "${TEST_ROOT}/gh" +} + +test_accepts_verified_annotated_tag() { + write_fake_gh tag true + if ! GH="${TEST_ROOT}/gh" bash "${SCRIPT}" v0.61.0-rc.1 \ + > /dev/null; then + fail_test "expected a verified annotated tag to pass" + return + fi + ((++PASS)) +} + +test_rejects_lightweight_tag() { + write_fake_gh commit true + if GH="${TEST_ROOT}/gh" bash "${SCRIPT}" v0.61.0 > /dev/null 2>&1; then + fail_test "expected a lightweight tag to fail" + return + fi + ((++PASS)) +} + +test_rejects_unverified_tag() { + write_fake_gh tag false + if GH="${TEST_ROOT}/gh" bash "${SCRIPT}" v0.61.0 > /dev/null 2>&1; then + fail_test "expected an unverified tag to fail" + return + fi + ((++PASS)) +} + +test_missing_tag_prints_recovery_command() { + local output + + write_missing_fake_gh + set +e + output="$(GH="${TEST_ROOT}/gh" bash "${SCRIPT}" v0.61.0-rc.1 2>&1)" + local status=$? + set -e + if ((status == 0)); then + fail_test "expected a missing tag to fail" + return + fi + if [[ "${output}" != *"git tag -s v0.61.0-rc.1"* || + "${output}" != *"git push origin v0.61.0-rc.1"* ]]; then + fail_test "missing-tag error did not include the recovery command" + return + fi + ((++PASS)) +} + +main() { + TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/de-tag-test-XXXXXX")" + + test_accepts_verified_annotated_tag + test_rejects_lightweight_tag + test_rejects_unverified_tag + test_missing_tag_prints_recovery_command + + if ((FAIL > 0)); then + echo "Deployment Engine tag tests failed: ${PASS} passed, ${FAIL} failed" + exit 1 + fi + + echo "Deployment Engine tag tests passed (${PASS} tests)" +} + +main "$@" diff --git a/.github/workflows/__publish-release.yaml b/.github/workflows/__publish-release.yaml index b2d1b0c444..f824b7e6e1 100644 --- a/.github/workflows/__publish-release.yaml +++ b/.github/workflows/__publish-release.yaml @@ -59,13 +59,13 @@ jobs: cd "${RELEASE_PATH}" && for i in *; do sha256sum -b "$i" > "$i.sha256"; done && cd - ls -l "${RELEASE_PATH}" - - name: Create GitHub RC Release (pre-release and auto-generate release notes) + - name: Create GitHub RC Release if: ${{ contains(env.REL_VERSION, 'rc') }} run: | gh release create "v${REL_VERSION}" \ "${RELEASE_PATH}"/* \ --title "Radius v${REL_VERSION}" \ - --generate-notes \ + --notes-file "docs/release-notes/v${REL_VERSION}.md" \ --verify-tag \ --prerelease env: diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml new file mode 100644 index 0000000000..2f1d951c10 --- /dev/null +++ b/.github/workflows/prepare-release.yaml @@ -0,0 +1,315 @@ +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +--- +name: prepare-release + +on: + workflow_dispatch: + inputs: + release-type: + description: Release type + required: true + type: choice + options: + - rc + - final + - patch + channel: + description: Release channel in X.Y format + required: true + type: string + backport-pr-numbers: + description: Optional comma-separated merged main PRs to include + required: false + type: string + +permissions: {} + +concurrency: + group: prepare-release-${{ inputs.channel }} + cancel-in-progress: false + +jobs: + prepare: + name: Prepare ${{ inputs.release-type }} for ${{ inputs.channel }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout main with release history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Fetch release branches + run: | + git fetch origin \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + + - name: Install release tools + run: make install-yq install-jq install-git-cliff + + - name: Create release App token + if: github.repository == 'radius-project/radius' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.RADIUS_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.RADIUS_RELEASE_BOT_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: radius + permission-contents: write + permission-issues: write + permission-pull-requests: write + + - name: Get release bot details + if: github.repository == 'radius-project/radius' + id: bot-details + uses: raven-actions/bot-details@ee8966a9ff6e7e42cbfc4a56b4ddb60a9d1b40a6 # v1.2.0 + with: + bot-slug-name: ${{ steps.app-token.outputs.app-slug }} + + - name: Validate release prerequisites + id: preflight + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + RELEASE_TYPE: ${{ inputs.release-type }} + CHANNEL: ${{ inputs.channel }} + run: | + output_dir="${RUNNER_TEMP}/release-preflight" + bash ./.github/scripts/prepare-release.sh \ + --release-type "${RELEASE_TYPE}" \ + --channel "${CHANNEL}" \ + --output-dir "${output_dir}" \ + --version-only + version="$(<"${output_dir}/version.txt")" + branch="$(<"${output_dir}/pr-branch.txt")" + + bash ./.github/scripts/verify-deployment-engine-tag.sh "${version}" + + existing="$( + gh pr list --repo "${GITHUB_REPOSITORY}" --state open \ + --head "${branch}" --base main --json number,url,headRefOid \ + --jq '.[0] // empty' + )" + if [[ -n "${existing}" ]]; then + echo "existing=true" >> "${GITHUB_OUTPUT}" + echo "existing-number=$(jq -r .number <<< "${existing}")" \ + >> "${GITHUB_OUTPUT}" + echo "existing-url=$(jq -r .url <<< "${existing}")" \ + >> "${GITHUB_OUTPUT}" + echo "existing-head=$(jq -r .headRefOid <<< "${existing}")" \ + >> "${GITHUB_OUTPUT}" + else + set +e + git ls-remote --exit-code origin "refs/heads/${branch}" \ + >/dev/null + branch_status=$? + set -e + case "${branch_status}" in + 2) ;; + 0) + echo "Branch ${branch} exists without an open PR." >&2 + exit 1 + ;; + *) + echo "Could not query branch ${branch}." >&2 + exit "${branch_status}" + ;; + esac + echo "existing=false" >> "${GITHUB_OUTPUT}" + fi + + { + echo "version=${version}" + echo "branch=${branch}" + } >> "${GITHUB_OUTPUT}" + + - name: Read curated notes from existing release pull request + if: >- + steps.preflight.outputs.existing == 'true' && + inputs.release-type != 'patch' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + VERSION: ${{ steps.preflight.outputs.version }} + HEAD_SHA: ${{ steps.preflight.outputs.existing-head }} + run: | + output="${RUNNER_TEMP}/existing-release-notes.md" + gh api \ + "repos/${GITHUB_REPOSITORY}/contents/docs/release-notes/${VERSION}.md?ref=${HEAD_SHA}" \ + --jq .content | tr -d '\n' | base64 --decode > "${output}" + + - name: Collect selected backports + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + CHANNEL: ${{ inputs.channel }} + EXPLICIT_PRS: ${{ inputs.backport-pr-numbers }} + run: | + bash ./.github/scripts/collect-release-backports.sh \ + --repository "${GITHUB_REPOSITORY}" \ + --channel "${CHANNEL}" \ + --explicit-prs "${EXPLICIT_PRS}" \ + --output "${RUNNER_TEMP}/release-backports.json" + + - name: Request incomplete explicit backports + if: >- + github.repository == 'radius-project/radius' && + inputs.backport-pr-numbers != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BACKPORT_PRS: ${{ inputs.backport-pr-numbers }} + CHANNEL: ${{ inputs.channel }} + run: | + release_branch="refs/remotes/origin/release/${CHANNEL}" + git show-ref --verify --quiet "${release_branch}" || { + echo "Explicit backports require release/${CHANNEL} to exist." >&2 + exit 1 + } + + label="backport release/${CHANNEL}" + gh label create "${label}" --repo "${GITHUB_REPOSITORY}" \ + --color 5319E7 --description "Backport to release/${CHANNEL}" \ + --force + IFS=',' read -r -a pull_requests <<< "${BACKPORT_PRS}" + for pull_request in "${pull_requests[@]}"; do + pull_request="${pull_request//[[:space:]]/}" + gh pr edit "${pull_request}" --repo "${GITHUB_REPOSITORY}" \ + --add-label "${label}" + done + + - name: Prepare release changes and plan + id: prepare + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + GITHUB_REPO: ${{ github.repository }} + RELEASE_TYPE: ${{ inputs.release-type }} + CHANNEL: ${{ inputs.channel }} + PREFLIGHT_VERSION: ${{ steps.preflight.outputs.version }} + run: | + output_dir="${RUNNER_TEMP}/release-preparation" + bash ./.github/scripts/prepare-release.sh \ + --release-type "${RELEASE_TYPE}" \ + --channel "${CHANNEL}" \ + --backports-file "${RUNNER_TEMP}/release-backports.json" \ + --output-dir "${output_dir}" + + version="$(<"${output_dir}/version.txt")" + requires_backport="$(<"${output_dir}/requires-backport.txt")" + backport_label="" + if [[ "${requires_backport}" == "true" ]]; then + backport_label="backport release/${CHANNEL}" + fi + { + echo "version=${version}" + echo "title=$(<"${output_dir}/pr-title.txt")" + echo "branch=$(<"${output_dir}/pr-branch.txt")" + echo "backport-label=${backport_label}" + } >> "${GITHUB_OUTPUT}" + + [[ "${version}" == "${PREFLIGHT_VERSION}" ]] || { + echo "Release state changed after preflight." >&2 + exit 1 + } + + - name: Preserve curated release note sections + if: >- + steps.preflight.outputs.existing == 'true' && + inputs.release-type != 'patch' + env: + VERSION: ${{ steps.prepare.outputs.version }} + run: | + bash ./.github/scripts/preserve-release-note-sections.sh \ + "docs/release-notes/${VERSION}.md" \ + "${RUNNER_TEMP}/existing-release-notes.md" + - name: Ensure generated backport label exists + if: >- + github.repository == 'radius-project/radius' && + steps.prepare.outputs.backport-label != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + LABEL: ${{ steps.prepare.outputs.backport-label }} + CHANNEL: ${{ inputs.channel }} + run: | + gh label create "${LABEL}" --repo "${GITHUB_REPOSITORY}" \ + --color 5319E7 --description "Backport to release/${CHANNEL}" \ + --force + + - name: Create or update release pull request + if: github.repository == 'radius-project/radius' + id: create-pr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + branch: ${{ steps.prepare.outputs.branch }} + base: main + commit-message: ${{ steps.prepare.outputs.title }} + committer: ${{ steps.bot-details.outputs.name-email }} + signoff: true + sign-commits: true + title: ${{ steps.prepare.outputs.title }} + body-path: ${{ runner.temp }}/release-preparation/release-pr-body.md + labels: ${{ steps.prepare.outputs.backport-label }} + draft: true + delete-branch: true + add-paths: | + versions.yaml + CHANGELOG.md + docs/release-notes/*.md + + - name: Verify release pull request commit signature + if: >- + steps.create-pr.outputs.pull-request-operation == 'created' || + steps.create-pr.outputs.pull-request-operation == 'updated' + env: + COMMITS_VERIFIED: >- + ${{ steps.create-pr.outputs.pull-request-commits-verified }} + run: | + [[ "${COMMITS_VERIFIED}" == "true" ]] || { + echo "The release App did not create a verified commit." >&2 + exit 1 + } + + - name: Upload release plan + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-plan-${{ steps.prepare.outputs.version }} + path: | + ${{ runner.temp }}/release-preparation/release-plan.yaml + ${{ runner.temp }}/release-preparation/changelog-section.md + ${{ runner.temp }}/release-backports.json + if-no-files-found: error + retention-days: 30 + + - name: Summarize preparation + env: + VERSION: ${{ steps.prepare.outputs.version }} + PR_URL: ${{ steps.create-pr.outputs.pull-request-url }} + run: | + { + echo "## Prepared ${VERSION}" + echo + if [[ -n "${PR_URL}" ]]; then + echo "Release pull request: ${PR_URL}" + else + echo "PR creation was skipped outside radius-project/radius." + fi + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/release-backport.yaml b/.github/workflows/release-backport.yaml new file mode 100644 index 0000000000..86e561ba70 --- /dev/null +++ b/.github/workflows/release-backport.yaml @@ -0,0 +1,368 @@ +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +--- +name: release-backport + +on: + pull_request_target: + branches: + - main + types: + - labeled + - closed + push: + branches: + - release/* + +permissions: {} + +concurrency: + group: >- + release-backport-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + select-channels: + name: Select release channels + if: >- + github.repository == 'radius-project/radius' && + (github.event_name == 'push' || github.event.pull_request.merged == true) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + issues: read + outputs: + backports: ${{ steps.channels.outputs.result }} + steps: + - name: Checkout trusted backport selector + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - name: Read backport labels + id: channels + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + result-encoding: string + script: | + const selector = await import( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/select-release-backports.mjs` + ) + + if (context.eventName !== 'push') { + return JSON.stringify( + selector.entriesForMergedPull(context.payload.pull_request), + ) + } + + const channelMatch = context.ref.match( + /^refs\/heads\/release\/(\d+\.\d+)$/, + ) + if (!channelMatch) { + return '[]' + } + const channel = channelMatch[1] + const releaseBranch = `release/${channel}` + const openBackports = await github.paginate( + github.rest.pulls.list, + { + owner: context.repo.owner, + repo: context.repo.repo, + base: releaseBranch, + state: 'open', + per_page: 100, + }, + ) + const issues = await github.paginate( + github.rest.issues.listForRepo, + { + owner: context.repo.owner, + repo: context.repo.repo, + labels: `backport release/${channel}`, + state: 'closed', + per_page: 100, + }, + ) + const sources = [] + for (const issue of issues.filter((item) => item.pull_request)) { + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number, + }) + if (pull.merged_at && pull.base.ref === 'main') { + sources.push(pull) + } + } + const historical = await github.paginate( + github.rest.pulls.list, + { + owner: context.repo.owner, + repo: context.repo.repo, + base: releaseBranch, + state: 'closed', + per_page: 100, + }, + ) + const historicalWithCommits = [] + for (const pull of historical) { + const commits = pull.merged_at + ? await github.paginate(github.rest.pulls.listCommits, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pull.number, + per_page: 100, + }) + : [] + historicalWithCommits.push({ ...pull, commits }) + } + return JSON.stringify( + selector.selectNextBackport({ + channel, + sources, + openBackports, + historicalBackports: historicalWithCommits, + }), + ) + + # korthout/backport-action was evaluated and rejected here: it commits and + # pushes with git, so it cannot produce the API-signed commits this job + # verifies, it opens every labelled backport at once instead of serialising + # one per channel, and its conflict mode commits conflict markers. Revisit if + # release/* ever requires strict up-to-date merges and signing moves to a key. + backport: + name: Backport to release/${{ matrix.channel }} + needs: select-channels + if: needs.select-channels.outputs.backports != '[]' + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.select-channels.outputs.backports) }} + concurrency: + group: release-backport-channel-${{ matrix.channel }} + cancel-in-progress: false + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + SOURCE_PR: ${{ matrix.source_pr }} + SOURCE_COMMIT: ${{ matrix.source_commit }} + SOURCE_TITLE: ${{ matrix.source_title }} + SOURCE_URL: ${{ matrix.source_url }} + CHANNEL: ${{ matrix.channel }} + EXPECTED_BASE: ${{ matrix.expected_base }} + steps: + - name: Create release App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.RADIUS_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.RADIUS_RELEASE_BOT_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: radius + permission-contents: write + permission-issues: write + permission-pull-requests: write + + - name: Checkout trusted backport tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + persist-credentials: true + + - name: Fetch source commit and release branch + run: | + git fetch origin "${SOURCE_COMMIT}" + git fetch origin \ + "refs/heads/release/${CHANNEL}:refs/remotes/origin/release/${CHANNEL}" + + - name: Find an existing backport pull request + id: existing + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + branch="automation/backport-${SOURCE_PR}-to-${CHANNEL}" + bash ./.github/scripts/collect-release-backports.sh \ + --repository "${GITHUB_REPOSITORY}" --channel "${CHANNEL}" \ + --explicit-prs "${SOURCE_PR}" \ + --output "${RUNNER_TEMP}/source-backport.json" + source_entry="$( + jq -c --argjson source_pr "${SOURCE_PR}" \ + '[.[] | select(.source_pr == $source_pr)]' \ + "${RUNNER_TEMP}/source-backport.json" + )" + [[ "$(jq 'length' <<< "${source_entry}")" == "1" ]] || { + echo "Expected exactly one backport record for #${SOURCE_PR}." >&2 + exit 1 + } + if [[ "$(jq -r '.[0].backport_merged' \ + <<< "${source_entry}")" == "true" ]]; then + { + echo "exists=true" + echo "url=$(jq -r '.[0].backport_url' <<< "${source_entry}")" + } >> "${GITHUB_OUTPUT}" + exit 0 + fi + + result="$( + gh pr list --repo "${GITHUB_REPOSITORY}" --state open \ + --base "release/${CHANNEL}" --json number,url,headRefName \ + --jq '[.[] | select(.headRefName | startswith("automation/backport-"))][0] // empty' + )" + if [[ -n "${result}" ]]; then + { + echo "exists=true" + echo "url=$(jq -r .url <<< "${result}")" + echo "deferred=$( + jq -r --arg branch "${branch}" '.headRefName != $branch' \ + <<< "${result}" + )" + } >> "${GITHUB_OUTPUT}" + exit 0 + fi + + remote_sha="$( + git ls-remote origin "refs/heads/${branch}" | awk '{print $1}' + )" + if [[ -n "${remote_sha}" ]]; then + echo "Branch ${branch} exists without an open pull request." >&2 + echo "Inspect it before deleting or recreating the backport." >&2 + exit 1 + fi + echo "exists=false" >> "${GITHUB_OUTPUT}" + echo "deferred=false" >> "${GITHUB_OUTPUT}" + + - name: Get release bot details + id: bot-details + uses: raven-actions/bot-details@ee8966a9ff6e7e42cbfc4a56b4ddb60a9d1b40a6 # v1.2.0 + with: + bot-slug-name: ${{ steps.app-token.outputs.app-slug }} + + - name: Build backport branch + if: steps.existing.outputs.exists != 'true' + id: build + run: | + output_dir="${RUNNER_TEMP}/release-backport" + args=( + --source-pr "${SOURCE_PR}" + --source-commit "${SOURCE_COMMIT}" + --source-title "${SOURCE_TITLE}" + --source-url "${SOURCE_URL}" + --channel "${CHANNEL}" + --output-dir "${output_dir}" + ) + if [[ -n "${EXPECTED_BASE}" ]]; then + args+=(--expected-base "${EXPECTED_BASE}") + fi + bash ./.github/scripts/create-release-backport.sh "${args[@]}" + { + echo "status=$(<"${output_dir}/status.txt")" + echo "branch=$(<"${output_dir}/branch.txt")" + echo "title=$(<"${output_dir}/title.txt")" + echo "author=$(<"${output_dir}/author.txt")" + } >> "${GITHUB_OUTPUT}" + delimiter="release_commit_$(openssl rand -hex 16)" + { + echo "commit-message<<${delimiter}" + cat "${output_dir}/commit-message.txt" + echo "${delimiter}" + } >> "${GITHUB_OUTPUT}" + + - name: Create backport pull request + if: steps.existing.outputs.exists != 'true' + id: pull-request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + branch: ${{ steps.build.outputs.branch }} + base: release/${{ matrix.channel }} + commit-message: ${{ steps.build.outputs.commit-message }} + # Bot signing requires an unmodified author, and rebase merges drop + # signatures anyway, so preserve attribution instead. + author: >- + ${{ steps.build.outputs.author || + steps.bot-details.outputs.name-email }} + committer: ${{ steps.bot-details.outputs.name-email }} + signoff: true + title: ${{ steps.build.outputs.title }} + body-path: ${{ runner.temp }}/release-backport/pull-request-body.md + draft: ${{ steps.build.outputs.status == 'conflict' }} + delete-branch: true + + - name: Verify backport commit authorship + if: >- + steps.pull-request.outputs.pull-request-operation == 'created' || + steps.pull-request.outputs.pull-request-operation == 'updated' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + HEAD_SHA: ${{ steps.pull-request.outputs.pull-request-head-sha }} + EXPECTED_AUTHOR: >- + ${{ steps.build.outputs.author || + steps.bot-details.outputs.name-email }} + run: | + actual="$( + gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}" \ + --jq '"\(.commit.author.name) <\(.commit.author.email)>"' + )" + [[ "${actual}" == "${EXPECTED_AUTHOR}" ]] || { + echo "Backport author ${actual} is not ${EXPECTED_AUTHOR}." >&2 + exit 1 + } + + - name: Comment with conflict handoff + if: >- + steps.existing.outputs.exists != 'true' && + steps.build.outputs.status == 'conflict' && + steps.pull-request.outputs.pull-request-operation == 'created' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + BACKPORT_URL: ${{ steps.pull-request.outputs.pull-request-url }} + run: | + comment="${RUNNER_TEMP}/release-backport-comment.md" + { + echo "The automated backport to \`release/${CHANNEL}\` conflicted." + echo + echo "Draft backport PR: ${BACKPORT_URL}" + echo + cat "${RUNNER_TEMP}/release-backport/conflict-handoff.md" + } > "${comment}" + gh pr comment "${SOURCE_PR}" --repo "${GITHUB_REPOSITORY}" \ + --body-file "${comment}" + + - name: Summarize backport + run: | + { + echo "## Backport #${SOURCE_PR} to release/${CHANNEL}" + echo + echo "Status: ${STATUS}" + if [[ "${DEFERRED}" == "true" ]]; then + echo "Deferred until the current channel backport merges." + fi + echo "Pull request: ${BACKPORT_URL}" + } >> "${GITHUB_STEP_SUMMARY}" + env: + STATUS: ${{ steps.build.outputs.status || 'existing' }} + DEFERRED: ${{ steps.existing.outputs.deferred || 'false' }} + BACKPORT_URL: >- + ${{ steps.pull-request.outputs.pull-request-url || steps.existing.outputs.url }} diff --git a/.github/workflows/release-branch-commits.yaml b/.github/workflows/release-branch-commits.yaml new file mode 100644 index 0000000000..f6e6d01719 --- /dev/null +++ b/.github/workflows/release-branch-commits.yaml @@ -0,0 +1,84 @@ +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +--- +name: release-branch-commits + +# This workflow runs the default branch's trusted validator and reads pull +# request commit messages through the API. It never checks out pull request code. +on: + pull_request_target: + branches: + - release/* + types: + - opened + - edited + - ready_for_review + - reopened + - synchronize + +permissions: {} + +concurrency: + group: release-branch-commits-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + name: Validate release branch commits + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout trusted validator + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + + - name: Read pull request commits + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + COMMITS_FILE: ${{ runner.temp }}/release-branch-commits.json + BODY_FILE: ${{ runner.temp }}/release-branch-body.md + with: + script: | + const fs = require('node:fs') + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }) + fs.writeFileSync(process.env.COMMITS_FILE, JSON.stringify(commits)) + fs.writeFileSync( + process.env.BODY_FILE, + context.payload.pull_request.body ?? '', + ) + + - name: Validate Conventional Commit messages + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + node ./.github/scripts/validate-conventional-commits.mjs \ + "${RUNNER_TEMP}/release-branch-commits.json" \ + "${RUNNER_TEMP}/release-branch-body.md" \ + "${BASE_SHA}" \ + "${HEAD_REF}" diff --git a/.github/workflows/release-plan.yaml b/.github/workflows/release-plan.yaml new file mode 100644 index 0000000000..d90f3da426 --- /dev/null +++ b/.github/workflows/release-plan.yaml @@ -0,0 +1,301 @@ +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +# yaml-language-server: $schema=https://www.schemastore.org/github-workflow.json +--- +name: release-plan + +# Use the base branch's trusted validator and read PR metadata through the API. +# Pull request code is never checked out or executed. +on: + pull_request_target: + branches: + - main + types: + - opened + - edited + - ready_for_review + - reopened + - synchronize + merge_group: + branches: + - main + +permissions: {} + +concurrency: + group: release-plan-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} + +jobs: + validate: + name: Validate release plan + if: >- + github.event_name == 'pull_request_target' && + startsWith(github.event.pull_request.head.ref, 'automation/prepare-release-') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout trusted validator at the PR base + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Fetch release branches + run: | + git fetch origin \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + + - name: Read pull request body and changed files + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BODY_FILE: ${{ runner.temp }}/release-pr-body.md + FILES_FILE: ${{ runner.temp }}/release-pr-files.json + HEAD_DIR: ${{ runner.temp }}/release-pr-head + with: + script: | + const fs = require('node:fs') + const path = require('node:path') + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + per_page: 100, + }) + fs.writeFileSync( + process.env.BODY_FILE, + context.payload.pull_request.body ?? '', + ) + fs.writeFileSync( + process.env.FILES_FILE, + JSON.stringify(files.map((file) => file.filename)), + ) + const root = path.resolve(process.env.HEAD_DIR) + for (const file of files) { + const target = path.resolve(root, file.filename) + if (!target.startsWith(`${root}${path.sep}`)) { + throw new Error(`Unsafe pull request path: ${file.filename}`) + } + const { data } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: file.filename, + ref: context.payload.pull_request.head.sha, + }) + if (Array.isArray(data) || data.type !== 'file' || !data.content) { + throw new Error(`Expected a file at ${file.filename}`) + } + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, Buffer.from(data.content, 'base64')) + } + + - name: Install plan validation tools + run: make install-yq install-jq install-git-cliff + + - name: Validate release plan + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPO: ${{ github.repository }} + run: | + bash ./.github/scripts/validate-release-plan.sh \ + --body-file "${RUNNER_TEMP}/release-pr-body.md" \ + --files-file "${RUNNER_TEMP}/release-pr-files.json" \ + --base-sha "${BASE_SHA}" \ + --head-dir "${RUNNER_TEMP}/release-pr-head" \ + --repository "${GITHUB_REPOSITORY}" + + validate-merge-group: + name: Validate release plan + if: github.event_name == 'merge_group' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout trusted validator at the merge-group base + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.merge_group.base_sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Find open generated release pull requests + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CANDIDATES_FILE: ${{ runner.temp }}/release-pr-candidates.json + with: + script: | + const fs = require('node:fs') + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + base: 'main', + state: 'open', + per_page: 100, + }) + const candidates = [] + for (const pull of pulls.filter((item) => item.head.ref.startsWith( + 'automation/prepare-release-', + ))) { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pull.number, + per_page: 100, + }) + candidates.push({ + number: pull.number, + head_sha: pull.head.sha, + body: pull.body ?? '', + files: files.map((file) => file.filename).sort(), + }) + } + fs.writeFileSync( + process.env.CANDIDATES_FILE, + JSON.stringify(candidates), + ) + + - name: Fetch merge group and generated release PR heads + run: | + git fetch --no-tags origin "${GITHUB_SHA}" + [[ "$(git rev-parse FETCH_HEAD)" == "${GITHUB_SHA}" ]] || { + echo "Merge-group SHA changed while validating." >&2 + exit 1 + } + while IFS=$'\t' read -r number expected_sha; do + git fetch --no-tags origin "refs/pull/${number}/head" + actual_sha="$(git rev-parse FETCH_HEAD)" + [[ "${actual_sha}" == "${expected_sha}" ]] || { + echo "PR #${number} changed while validating the merge group." >&2 + exit 1 + } + done < <( + jq -r '.[] | [.number, .head_sha] | @tsv' \ + "${RUNNER_TEMP}/release-pr-candidates.json" | tr -d '\r' + ) + + - name: Install merge-group validation tools + run: make install-yq install-jq install-git-cliff + + - name: Validate merge group contents + id: select + env: + BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + selected_file="${RUNNER_TEMP}/selected-release-pr.txt" + bash ./.github/scripts/validate-release-merge-group.sh \ + --candidates-file "${RUNNER_TEMP}/release-pr-candidates.json" \ + --merge-group-sha "${GITHUB_SHA}" \ + --base-sha "${BASE_SHA}" \ + --output-file "${selected_file}" + echo "number=$(<"${selected_file}")" >> "${GITHUB_OUTPUT}" + + - name: Read queued release pull request data + if: steps.select.outputs.number != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PULL_NUMBER: ${{ steps.select.outputs.number }} + BODY_FILE: ${{ runner.temp }}/release-pr-body.md + FILES_FILE: ${{ runner.temp }}/release-pr-files.json + HEAD_DIR: ${{ runner.temp }}/release-pr-head + CANDIDATES_FILE: ${{ runner.temp }}/release-pr-candidates.json + with: + script: | + const fs = require('node:fs') + const path = require('node:path') + const pull_number = Number(process.env.PULL_NUMBER) + const candidates = JSON.parse( + fs.readFileSync(process.env.CANDIDATES_FILE, 'utf8'), + ) + const snapshot = candidates.find( + (candidate) => candidate.number === pull_number, + ) + if (!snapshot) { + throw new Error(`No snapshot for release PR #${pull_number}`) + } + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number, + }) + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number, + per_page: 100, + }) + const liveFiles = files.map((file) => file.filename).sort() + if ( + pull.head.sha !== snapshot.head_sha || + (pull.body ?? '') !== snapshot.body || + JSON.stringify(liveFiles) !== JSON.stringify(snapshot.files) + ) { + throw new Error( + `Release PR #${pull_number} changed during merge-group validation`, + ) + } + fs.writeFileSync(process.env.BODY_FILE, snapshot.body) + fs.writeFileSync( + process.env.FILES_FILE, + JSON.stringify(snapshot.files), + ) + const root = path.resolve(process.env.HEAD_DIR) + for (const filename of snapshot.files) { + const target = path.resolve(root, filename) + if (!target.startsWith(`${root}${path.sep}`)) { + throw new Error(`Unsafe pull request path: ${filename}`) + } + const { data } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: filename, + ref: snapshot.head_sha, + }) + if (Array.isArray(data) || data.type !== 'file' || !data.content) { + throw new Error(`Expected a file at ${filename}`) + } + fs.mkdirSync(path.dirname(target), { recursive: true }) + fs.writeFileSync(target, Buffer.from(data.content, 'base64')) + } + + - name: Fetch release branches + if: steps.select.outputs.number != '' + run: | + git fetch origin \ + '+refs/heads/release/*:refs/remotes/origin/release/*' + + - name: Revalidate queued release plan and live state + if: steps.select.outputs.number != '' + env: + BASE_SHA: ${{ github.event.merge_group.base_sha }} + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPO: ${{ github.repository }} + run: | + bash ./.github/scripts/validate-release-plan.sh \ + --body-file "${RUNNER_TEMP}/release-pr-body.md" \ + --files-file "${RUNNER_TEMP}/release-pr-files.json" \ + --base-sha "${BASE_SHA}" \ + --head-dir "${RUNNER_TEMP}/release-pr-head" \ + --repository "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 06e91c827d..3e8fbede69 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,125 +25,10 @@ on: - release/* paths: - versions.yaml - pull_request: - branches: - - main - - release/* - paths: - - versions.yaml permissions: {} jobs: - generate_release_note: - name: Generate release note from template - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - pull-requests: write # Required for marocchino/sticky-pull-request-comment - contents: write # Required for actions/checkout and github.rest.repos.generateReleaseNotes - # We should only create the release note if this is a pull request against main - if: github.repository == 'radius-project/radius' && github.event_name == 'pull_request' && github.base_ref == 'main' - env: - RELNOTE_FOUND: false - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Install yq - run: make install-yq - - - name: Get supported versions from versions.yaml - id: get-supported-versions - run: echo "result=$(yq '.supported[].version' versions.yaml | tr '\n' ',' | sed 's/,$//')" >> "$GITHUB_OUTPUT" - - - name: Determine desired release version - id: get-version - run: | - ./.github/scripts/release-get-version.sh "${SUPPORTED_VERSIONS}" "." - env: - SUPPORTED_VERSIONS: ${{ steps.get-supported-versions.outputs.result }} - - - name: Find the previous tag - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - id: latest-release-tag - with: - # Reads a release in the current repository; uses the job's GITHUB_TOKEN permissions. - github-token: ${{ github.token }} - result-encoding: string - script: | - const { data } = await github.rest.repos.getLatestRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - }) - return data.tag_name - - - name: Generate the release notes - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - id: generate-notes - with: - # Generates notes for the current repository; requires contents: write (granted at job level). - github-token: ${{ github.token }} - result-encoding: string - script: | - const { data } = await github.rest.repos.generateReleaseNotes({ - owner: context.repo.owner, - repo: context.repo.repo, - tag_name: process.env.INPUT_RELEASE_VERSION, - target_commitish: 'main', - previous_tag_name: process.env.INPUT_PREVIOUS_TAG, - }) - return data.body - env: - INPUT_RELEASE_VERSION: ${{ steps.get-version.outputs.release-version }} - INPUT_PREVIOUS_TAG: ${{ steps.latest-release-tag.outputs.result }} - - - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 - with: - header: relnote-${{ github.run_id }} - number: ${{ github.event.pull_request.number }} - hide: true - hide_classify: OUTDATED - message: | - ## Release Information - * Previous version: ${{ steps.latest-release-tag.outputs.result }} - * New version: ${{ steps.get-version.outputs.release-version }} - - ## Change logs - ``` - ${{ steps.generate-notes.outputs.result }} - ``` - - - name: Find release note - shell: bash - run: | - if [ -f "./docs/release-notes/${RELEASE_VERSION}.md" ]; then - echo "RELNOTE_FOUND=true" >> "${GITHUB_ENV}" - fi - env: - RELEASE_VERSION: ${{ steps.get-version.outputs.release-version }} - - - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 - continue-on-error: true - if: ${{ !contains(steps.get-version.outputs.release-version, '-rc') && env.RELNOTE_FOUND == 'false' }} - with: - header: relnote-${{ github.run_id }} - number: ${{ github.event.pull_request.number }} - append: true - message: | - ## :warning: Missing release note :warning: - - This is the official Radius release. Create the release note by following instruction: - 1. Create ./docs/release-notes/${{ steps.get-version.outputs.release-version }}.md from [release note template](https://github.com/radius-project/radius/blob/main/docs/release-notes/template.md) - 2. Update the each section and add the above Change logs to the end of release note. - 3. Push release note changes to the PR branch. - - - name: Stop the workflow if release is the official and its release note is not found. - if: ${{ !contains(steps.get-version.outputs.release-version, '-rc') && env.RELNOTE_FOUND == 'false' }} - run: exit 1 - release: name: Create a new Radius release if: github.repository == 'radius-project/radius' && github.event_name == 'push' diff --git a/build/test.mk b/build/test.mk index 45c41fdbc1..f7b65113a4 100644 --- a/build/test.mk +++ b/build/test.mk @@ -53,7 +53,7 @@ GOTEST_OPTS ?= GOTEST_TOOL ?= go tool gotestsum $(GOTESTSUM_OPTS) -- .PHONY: test -test: test-get-envtools test-helm test-manage-radius-installation test-release-parity-manifest test-changelog-range test-build-summary test-goreleaser-shadow test-capture-release-image-digests test-release-version-format test-release-get-version test-release-tag-and-branch test-monitor-remote-workflow ## Runs unit tests, excluding kubernetes controller tests +test: test-get-envtools test-helm test-manage-radius-installation test-release-parity-manifest test-changelog-range test-prepare-release test-release-plan test-release-backport test-release-branch-commits test-build-summary test-goreleaser-shadow test-capture-release-image-digests test-release-version-format test-release-get-version test-release-tag-and-branch test-monitor-remote-workflow ## Runs unit tests, excluding kubernetes controller tests KUBEBUILDER_ASSETS="$(shell $(ENV_SETUP) use -p path ${K8S_VERSION} --arch amd64)" CGO_ENABLED=1 $(GOTEST_TOOL) ./pkg/... ./test/validation/... $(GOTEST_OPTS) .PHONY: test-manage-radius-installation @@ -68,6 +68,26 @@ test-release-parity-manifest: ## Tests release parity manifest collection test-changelog-range: ## Tests changelog channel boundary resolution @bash ./.github/scripts/changelog-range_test.sh +.PHONY: test-prepare-release +test-prepare-release: ## Tests release version, changelog, notes, and plan preparation + @bash ./.github/scripts/prepare-release_test.sh + @bash ./.github/scripts/verify-deployment-engine-tag_test.sh + +.PHONY: test-release-plan +test-release-plan: ## Tests release plan schema, policy, source, and file validation + @bash ./.github/scripts/validate-release-plan_test.sh + @bash ./.github/scripts/validate-release-merge-group_test.sh + +.PHONY: test-release-backport +test-release-backport: ## Tests release backport selection and branch construction + @bash ./.github/scripts/collect-release-backports_test.sh + @bash ./.github/scripts/create-release-backport_test.sh + @node --test ./.github/scripts/select-release-backports_test.mjs + +.PHONY: test-release-branch-commits +test-release-branch-commits: ## Tests Conventional Commit validation for release branches + @node --test ./.github/scripts/validate-conventional-commits_test.mjs + .PHONY: test-build-summary test-build-summary: ## Tests the build job summary rendering shared by the build workflows @bash ./.github/scripts/build-summary_test.sh diff --git a/cliff.toml b/cliff.toml index a0d4797d59..d7f929562a 100644 --- a/cliff.toml +++ b/cliff.toml @@ -78,9 +78,7 @@ https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} """ trim = true render_always = true -postprocessors = [ - { pattern = '\n{3,}', replace = "\n\n" }, -] +postprocessors = [{ pattern = '\n{3,}', replace = "\n\n" }] [git] conventional_commits = true diff --git a/docs/contributing/contributing-releases/README.md b/docs/contributing/contributing-releases/README.md index ec4c7a400c..4493f51331 100644 --- a/docs/contributing/contributing-releases/README.md +++ b/docs/contributing/contributing-releases/README.md @@ -10,7 +10,7 @@ This document is the maintainers' reference for cutting and publishing a Radius Before starting a release, ensure you have: -- **Release version number**: Determine the version in the form `<major>.<minor>.<patch>` (e.g., `0.56.0`). +- **Release type and channel**: Choose `rc`, `final`, or `patch` and the target `<major>.<minor>` channel. Prepare Release computes and validates the version. - **Repository access**: Write access to `radius-project/radius`, `radius-project/docs`, `radius-project/samples`, and `azure-octo/deployment-engine`. - **GPG signing configured**: The `azure-octo` org requires [verified tags](https://docs.github.com/en/authentication/managing-commit-signature-verification/displaying-verification-statuses-for-all-of-your-commits). [Set up GPG signing locally](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) before starting. - **Local clone of `radius-project/radius`**: Clone directly from the organization repo, not a personal fork. CI workflows require access to organization secrets that are not available in forks. @@ -19,6 +19,8 @@ Before starting a release, ensure you have: git clone git@github.com:radius-project/radius.git ``` +- **Required release checks configured**: The `Validate release plan` check is required for generated release pull requests to `main`. The `release/*` ruleset requires `Validate release branch commits` with **Require branches to be up to date before merging** enabled; this makes the backport's recorded base SHA fail closed if the release branch advances. Backport pull requests use rebase merge; ordinary `main` pull requests continue to use squash merge. + > **Important**: For the entire release process, create branches directly in repositories under the `radius-project` organization. Do not use personal forks. ## Terminology @@ -47,9 +49,11 @@ Radius follows a monthly release cadence. All contributions merged to `main` thr ### Release automation -Two GitHub Actions workflows drive the release process. **No one manually creates tags in `radius-project` repos.** Tags for repos in the `radius-project` organization are created automatically by the `release.yaml` workflow. (The [Deployment Engine repo](https://github.com/azure-octo/deployment-engine) in the `azure-octo` organization still requires manual tagging — see the release steps below.) +Four GitHub Actions workflows drive release preparation, backports, and publication. **No one manually creates tags in `radius-project` repos.** Tags for repos in the `radius-project` organization are created automatically by the `release.yaml` workflow. (The [Deployment Engine repo](https://github.com/azure-octo/deployment-engine) in the `azure-octo` organization still requires manual tagging — see the release steps below.) -1. **[Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml)** (`release.yaml`): Triggered whenever `versions.yaml` is pushed to `main` or a `release/*` branch. This workflow: +1. **[Prepare Release](https://github.com/radius-project/radius/actions/workflows/prepare-release.yaml)** (`prepare-release.yaml`): Manually dispatched with a release type and channel. It applies the fixed version policy, validates selected backports, renders `CHANGELOG.md` and the release notes with git-cliff, updates `versions.yaml`, records a structured release plan, and opens a signed draft pull request against `main`. +2. **[Release backport](https://github.com/radius-project/radius/actions/workflows/release-backport.yaml)** (`release-backport.yaml`): Runs when a merged `main` pull request has a `backport release/<channel>` label. It cherry-picks the squash commit with `-x` and opens a pull request against the release branch. A conflict produces a draft pull request with an exact resolution handoff instead of committing conflict markers. +3. **[Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml)** (`release.yaml`): Triggered whenever `versions.yaml` is pushed to `main` or a `release/*` branch. This workflow: - Scans `versions.yaml` in `.supported[]` order and selects the first `.version` whose tag is missing from any of `radius`, `recipes`, `dashboard`, or `bicep-types-aws` - **Automatically creates and pushes the version tag** (e.g., `v0.56.0-rc.1`) for `radius`, `recipes`, `dashboard`, and `bicep-types-aws` - Creates the release branch (`release/<channel>`) if it does not already exist @@ -57,53 +61,66 @@ Two GitHub Actions workflows drive the release process. **No one manually create - Reconciles matching existing branches and tags as successful state, rejects conflicting tag targets, and resumes any repositories left incomplete by a failed run - Waits on a `main` push only when the release branch exists but does not yet contain the triggering commit (this prevents duplicate work before the `versions.yaml` change is cherry-picked) - > **Note**: The workflow always checks out and reads `versions.yaml` from `main`, even when triggered by a push to a `release/*` branch. This means the version must be merged into `main` before the cherry-pick to the release branch triggers tag creation. + > **Note**: The workflow always checks out and reads `versions.yaml` from `main`, even when triggered by a push to a `release/*` branch. This means the version must be merged into `main` before the generated release backport triggers tag creation. > > **Important**: > > - Add the new release version at the top of the `supported` list in `versions.yaml`. > - Change only one version per PR. > - If more than one incomplete version is present in `supported`, `release.yaml` fails rather than guessing which one to release. -2. **[Release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml)** (`build-release.yaml`): Triggered by `v*` tag pushes (created by `release.yaml` above). This workflow: +4. **[Release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml)** (`build-release.yaml`): Triggered by `v*` tag pushes (created by `release.yaml` above). This workflow: - Builds CLI binaries and container images - Dispatches Bicep types publishing - - Creates the GitHub Release (auto-generated notes for RCs, or from `docs/release-notes/` for final and patch releases) + - Creates the GitHub Release from the prepared file in `docs/release-notes/`, marking RCs as prereleases During the GoReleaser migration, this workflow also runs advisory shadow jobs. They publish full-version production-image candidates only under `ghcr.io/radius-project/dev`, upload candidate CLI binaries and checksums as workflow artifacts, and compare them with the production outputs from the same tag. The parity report is attached as `goreleaser-shadow-parity-<commit>`. A shadow failure does not block or alter the current production release; inspect and resolve unexplained differences before the GoReleaser cutover. -The automated flow after merging a `versions.yaml` change: +The automated flow after dispatching Prepare Release: ```text -Merge versions.yaml change (to main or release/* branch) - → release.yaml detects new version in versions.yaml - → release.yaml creates git tag + release branch (if needed) - → tag push triggers build-release.yaml - → build-release.yaml publishes artifacts + creates GitHub Release +Prepare Release opens a draft release PR against main + → maintainer curates Highlights and Upgrading, then merges the PR + → first RC: release.yaml creates the release branch and tag + → existing channel: release-backport opens a PR against release/X.Y + → rebase-merge the backport PR + → release.yaml creates the tag + → build-release.yaml publishes artifacts + creates GitHub Release ``` #### When does tag creation happen? -| Scenario | Trigger | What happens | -|-------------------|----------------------------------------------|-----------------------------------------------------------------------------| -| **First RC** | `versions.yaml` merged to `main` | `release.yaml` creates the release branch from `main` and pushes the RC tag | -| **Subsequent RC** | `versions.yaml` cherry-picked to `release/*` | `release.yaml` runs on the release branch and pushes the new RC tag | -| **Final release** | Version bump cherry-picked to `release/*` | `release.yaml` runs on the release branch and pushes the final tag | -| **Patch release** | `versions.yaml` cherry-picked to `release/*` | `release.yaml` runs on the release branch and pushes the patch tag | +| Scenario | Trigger | What happens | +|-------------------|--------------------------------------------------|-----------------------------------------------------------------------------| +| **First RC** | `versions.yaml` merged to `main` | `release.yaml` creates the release branch from `main` and pushes the RC tag | +| **Subsequent RC** | Generated release backport merged to `release/*` | `release.yaml` runs on the release branch and pushes the new RC tag | +| **Final release** | Generated release backport merged to `release/*` | `release.yaml` runs on the release branch and pushes the final tag | +| **Patch release** | Generated release backport merged to `release/*` | `release.yaml` runs on the release branch and pushes the patch tag | + +### Preparing release changes + +Run the [Prepare Release](https://github.com/radius-project/radius/actions/workflows/prepare-release.yaml) workflow from `main` with these inputs: + +| Input | Value | +|-----------------------|-----------------------------------------------------------------| +| `release-type` | `rc`, `final`, or `patch` | +| `channel` | The target `X.Y` channel, such as `0.61` | +| `backport-pr-numbers` | Optional comma-separated merged `main` pull requests to include | + +The workflow computes the version from the repository state; release engineers do not type or edit it. The generated draft pull request contains the `versions.yaml` and `CHANGELOG.md` updates, generated release notes, and a structured release plan with the approved product commit, expected outputs, included backports, and the rule for resolving the later metadata-bearing release commit. Review the plan, curate only Highlights and Upgrading in the generated release notes, then mark the pull request ready and squash-merge it to `main`. -### Cherry-pick workflow +If `main` advances while the generated release pull request is open, rerun Prepare Release with the same inputs. The workflow regenerates the plan and generated sections from the new base while preserving the existing Highlights and Upgrading text. Patch notes contain no curated sections and are regenerated completely. -All release types follow the same pattern: changes merge to `main` first, then cherry-pick to the release branch (`release/<channel>`). The release branch is what gets tagged and built. +If an explicit pull request still needs a backport, the workflow adds the channel label and fails with the pending pull request number. Merge the generated backport pull request, then rerun Prepare Release. It never silently excludes a selected backport. -| Release type | What to cherry-pick to the release branch | -|-------------------|-------------------------------------------------------------------| -| **First RC** | Nothing — the release branch is created automatically from `main` | -| **Subsequent RC** | `versions.yaml` update + any additional bug fixes | -| **Final release** | A single commit with the version bump and release notes | -| **Patch release** | Bug-fix commits + `versions.yaml` update + patch release notes | +### Backporting changes to a release branch -> Always use `git cherry-pick -x` to preserve traceability. -> -> **Key concept:** The RC release is built from the **release branch** (`release/x.y`), not directly from `main`. After the initial RC is created, the release branch is used for subsequent RCs and for the final release. Changes for RC-2 and all subsequent RCs are first merged to `main` and then cherry-picked to the release branch. This applies to the `versions.yaml` update as well as any optional commits (bug fixes, late features) that must be included in the RC. +After a pull request is squash-merged to `main`, add the `backport release/<channel>` label to include it in a subsequent RC or patch. The release-backport workflow opens one pull request at a time from `automation/backport-<source-pr>-to-<channel>` to `release/<channel>` and records the source pull request and squash commit in its body. When that pull request merges, the release-branch push selects the next pending labeled change. This serial ordering keeps every backport pinned to the current release-branch base. Release preparation stops until every selected backport is merged. + +If the cherry-pick conflicts, the workflow opens a draft pull request containing a conflict-handoff file and posts the exact recovery commands on the source pull request. Follow those commands, force-push the resolved branch with `--force-with-lease`, delete the handoff commit by resetting to the release branch as instructed, and mark the pull request ready. + +Always **rebase-merge** backport pull requests. Rebase merging is enabled for this repository so the original Conventional Commit title remains on the release branch; the `release-branch-commits` check validates every commit message. Do not squash-merge a backport pull request. + +The generated release pull request receives the same backport label automatically whenever the release branch already exists. A first RC needs no backport because `release.yaml` creates the new release branch from the merged `main` commit. ## Creating an RC release @@ -130,51 +147,28 @@ Run the following in a local clone of the [Deployment Engine repo](https://githu ```bash git checkout main git pull origin main -git tag vX.Y.Z-rc.N +git tag -s vX.Y.Z-rc.N -m "release tag vX.Y.Z-rc.N" git push origin vX.Y.Z-rc.N ``` > **Note**: This manual tagging step is a temporary workaround. Ideally the [Deployment Engine Release Workflow](https://github.com/azure-octo/deployment-engine/actions/workflows/release.yaml) would handle this, but GPG signing is not yet configured there. See [azure-octo/deployment-engine#456](https://github.com/azure-octo/deployment-engine/issues/456). -### Step 3: Update versions.yaml +### Step 3: Prepare the RC -Create a branch from `main` in the `radius-project/radius` repo: +Run the [Prepare Release](https://github.com/radius-project/radius/actions/workflows/prepare-release.yaml) workflow from `main` with `release-type` set to `rc` and `channel` set to the target `X.Y` channel. For a subsequent RC, first label and merge every fix that must be included as described in [Backporting changes to a release branch](#backporting-changes-to-a-release-branch). -```bash -git checkout main -git pull origin main -git checkout -b <USERNAME>/release-X.Y.0-rc.N -``` +The workflow computes `rc.1` for a new channel or increments the highest existing RC number. It opens a signed draft release pull request containing `versions.yaml`, `CHANGELOG.md`, generated release notes, and the structured release plan. -Edit `versions.yaml` to add the new RC as a supported version. Move the oldest supported version to the `deprecated` list if needed ([example PR](https://github.com/radius-project/radius/pull/6077/files)). - -```yaml -supported: - - channel: '0.56' - version: 'v0.56.0-rc.1' - - channel: '0.55' - version: 'v0.55.0' -deprecated: - - channel: '0.54' - version: 'v0.54.0' -``` +### Step 4: Review and merge the release pull request -### Step 4: Merge to main - -Push the branch and create a PR against `main`: - -```bash -git push origin <USERNAME>/release-X.Y.0-rc.N -``` - -After approval, merge the PR to `main`. +Verify the generated version, product commit, included backports, and expected outputs in the release plan. Curate Highlights and Upgrading in the generated release notes. Mark the pull request ready, obtain approval, and squash-merge it to `main`. ### Step 5: Verify the automated release After merging, the [Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml) workflow automatically runs because `versions.yaml` changed on `main`. - **First RC**: The workflow creates the `release/X.Y` branch from `main` and pushes the `vX.Y.Z-rc.N` tag. The tag push then triggers the [release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml) workflow. No manual tag creation is needed. Verify the release using the checklist below. -- **Subsequent RCs**: The workflow detects that the release branch already exists and **skips tag creation**. This is expected — the tag will be created when the cherry-pick lands on the release branch in [Step 6](#step-6-cherry-pick-additional-changes-subsequent-rcs-only). Skip ahead to Step 6 for now and return to verify after completing it. +- **Subsequent RCs**: The workflow detects that the release branch already exists and **skips tag creation**. This is expected — the generated release backport will create the tag after it lands on the release branch in [Step 6](#step-6-merge-the-generated-release-backport-subsequent-rcs-only). Skip ahead to Step 6 for now and return to verify after completing it. Monitor and verify: @@ -182,29 +176,11 @@ Monitor and verify: 2. The [release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml) workflow (triggered by the tag push) completes successfully. This workflow also dispatches Bicep types publishing automatically. 3. An RC release marked as pre-release appears on [GitHub Releases](https://github.com/radius-project/radius/releases). -### Step 6: Cherry-pick additional changes (subsequent RCs only) +### Step 6: Merge the generated release backport (subsequent RCs only) > **Skip this step for the first RC.** The release branch was just created from `main` and already contains all changes. -For subsequent RCs (`rc.2`, `rc.3`, etc.), cherry-pick the `versions.yaml` update and any bug fixes onto the release branch: - -```bash -git checkout release/X.Y -git pull origin release/X.Y -git checkout -b <USERNAME>/cherry-pick-rc.N-to-release-branch -git cherry-pick -x <VERSIONS_YAML_COMMIT_HASH> -git cherry-pick -x <OPTIONAL_FIX_COMMIT_HASH> -``` - -> Use `git log --oneline main` to find commit hashes. - -Push and create a PR targeting the release branch: - -```bash -git push origin <USERNAME>/cherry-pick-rc.N-to-release-branch -``` - -After approval, merge the PR. This triggers the release automation on the release branch, creating the new RC tag. Return to [Step 5](#step-5-verify-the-automated-release) to verify the release completed successfully. +The release pull request is automatically labeled for the channel. After it merges to `main`, the release-backport workflow opens a pull request against `release/X.Y`. Verify that it contains the release metadata commit and that the `release-branch-commits` check passes, then **rebase-merge** it. This triggers release automation on the release branch and creates the new RC tag. Return to [Step 5](#step-5-verify-the-automated-release) to verify the release completed successfully. ### Step 7: Run validation workflows @@ -230,7 +206,7 @@ If validation fails, fix the issues on `main`, then create a new RC (increment t ## Creating the final release -The final release is built from the **last validated RC** on the release branch. The only change needed is a single cherry-pick that bumps the version and adds release notes. This ensures the final release contains exactly the same code as the validated RC. +The final release is built from the **last validated RC** on the release branch. The generated release backport changes only the version, changelog, and release notes, so the final release contains exactly the same product code as the validated RC. ### Step 1: Update the Teams release thread @@ -243,64 +219,27 @@ Run the following in a local clone of the [Deployment Engine repo](https://githu ```bash git checkout main git pull origin main -git tag vX.Y.Z +git tag -s vX.Y.Z -m "release tag vX.Y.Z" git push origin vX.Y.Z ``` > **Note**: Same temporary workaround as for [RC releases](#step-2-tag-the-deployment-engine). See [azure-octo/deployment-engine#456](https://github.com/azure-octo/deployment-engine/issues/456). -### Step 3: Update versions.yaml and create release notes - -Create a branch from `main`: - -```bash -git checkout main -git pull origin main -git checkout -b <USERNAME>/final-release-X.Y.0 -``` - -1. **Update `versions.yaml`**: Change the RC version to the final version ([example PR](https://github.com/radius-project/radius/pull/6992/files#diff-1c4cd801df522f4a92edbfb0fea95364ed074a391ea47c284ddc078f512f7b6a)). - - ```yaml - supported: - - channel: '0.56' - version: 'v0.56.0' # was v0.56.0-rc.1 - ``` - -2. **Create a draft release notes file**: Add `docs/release-notes/vX.Y.Z.md` using the [release notes template](../../release-notes/template.md). See the [release notes README](../../release-notes/README.md) for instructions ([example PR](https://github.com/radius-project/radius/pull/6092/files)). +### Step 3: Prepare the final release -3. **Push and create a PR** against `main`. The PR will receive an auto-generated release notes comment — use it to fill in the changelog and contributor list in your release notes file. Push an update with the completed release notes. +Run the [Prepare Release](https://github.com/radius-project/radius/actions/workflows/prepare-release.yaml) workflow from `main` with `release-type` set to `final` and the validated RC's `X.Y` channel. The workflow removes the RC suffix according to policy, renders the canonical changelog section and contributor list, and opens a signed draft release pull request. -> The PR will be squash-merged into a single commit on `main`, which is the commit you will cherry-pick to the release branch. +### Step 4: Review and merge the release pull request -### Step 4: Merge to main +Verify that the plan references the last validated RC and contains no unmerged backports. Curate Highlights and Upgrading, mark the pull request ready, obtain approval, and squash-merge it to `main`. -After approval, squash-merge the PR. +### Step 5: Merge the generated release backport -### Step 5: Cherry-pick to the release branch - -Cherry-pick the squash-merged commit (version bump + release notes) onto the release branch. - -```bash -git checkout release/X.Y -git pull origin release/X.Y -git checkout -b <USERNAME>/final-release-X.Y.0-cherry-pick -git cherry-pick -x <COMMIT_HASH> -``` - -> Use `git log --oneline main` to find the commit hash. - -Push and create a PR targeting the release branch ([example PR](https://github.com/radius-project/radius/pull/6114/files)): - -```bash -git push origin <USERNAME>/final-release-X.Y.0-cherry-pick -``` - -After approval, merge the PR. +After the release pull request merges, the release-backport workflow opens a pull request against `release/X.Y`. Verify that it changes only release metadata and notes, then **rebase-merge** it. Do not squash-merge it. ### Step 6: Verify the automated release -After the cherry-pick PR is merged to the `release/X.Y` branch, the [Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml) workflow automatically runs because `versions.yaml` changed on a `release/*` branch. It reads the final version from `versions.yaml`, creates and pushes the `vX.Y.Z` tag, and the tag push triggers the [release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml) workflow. No manual tag creation is needed. +After the generated release backport is merged to the `release/X.Y` branch, the [Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml) workflow automatically runs because `versions.yaml` changed on a `release/*` branch. It reads the final version from `versions.yaml`, creates and pushes the `vX.Y.Z` tag, and the tag push triggers the [release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml) workflow. No manual tag creation is needed. Monitor and verify: @@ -328,7 +267,7 @@ If all workflows pass, the release is complete. Post a final update in the Teams Use this process to fix a bug in an already-released version. -> **Note**: If the patch includes a fix to the [Deployment Engine](https://github.com/azure-octo/deployment-engine), you must also tag the Deployment Engine with the patch version (e.g., `vX.Y.Z`) before proceeding, following the same process as in the [RC](#step-2-tag-the-deployment-engine) and [Final release](#step-2-tag-the-deployment-engine-1) sections. +Before preparing a patch, create and push the signed [Deployment Engine](https://github.com/azure-octo/deployment-engine) tag with the patch version, following the same process as the [final release](#step-2-tag-the-deployment-engine-1). This tag is required even when Deployment Engine source did not change because it identifies the downstream image used by the Radius release. Prepare Release verifies the signature before changing labels or files. ### Step 1: Start a Teams release thread @@ -336,54 +275,19 @@ Start a new thread in the team's Microsoft Teams release channel titled with the ### Step 2: Merge the fix to main -Open a PR with the bug fix targeting `main`. After approval, merge it. - -### Step 3: Update versions.yaml and create patch release notes - -Create a branch from `main`: - -```bash -git checkout main -git pull origin main -git checkout -b <USERNAME>/patch-X.Y.Z -``` - -1. Update `versions.yaml` to reflect the new patch version (e.g., `v0.56.1`). -2. Create patch release notes at `docs/release-notes/vX.Y.Z.md` using the [patch release notes template](../../release-notes/template_patch.md). +Open a PR with the bug fix targeting `main`. After approval, squash-merge it, add the `backport release/X.Y` label, and rebase-merge the generated backport pull request before preparing the patch. -Push and create a PR against `main`: +### Step 3: Prepare the patch release -```bash -git push origin <USERNAME>/patch-X.Y.Z -``` - -After maintainer approval, merge the PR. - -### Step 4: Cherry-pick to the release branch - -Cherry-pick the bug fix, the `versions.yaml` update, and the patch release notes onto the release branch: - -```bash -git checkout release/X.Y -git pull origin release/X.Y -git checkout -b <USERNAME>/patch-X.Y.Z-cherry-pick -git cherry-pick -x <BUGFIX_COMMIT_HASH> -git cherry-pick -x <VERSIONS_AND_RELNOTES_COMMIT_HASH> -``` - -> Use `git log --oneline main` to find commit hashes. +Run the [Prepare Release](https://github.com/radius-project/radius/actions/workflows/prepare-release.yaml) workflow from `main` with `release-type` set to `patch` and the target `X.Y` channel. The workflow verifies that every selected fix has a merged backport, increments the channel's patch version, generates patch notes, and opens a signed draft release pull request. -Push and create a PR targeting the release branch: +### Step 4: Review and merge the generated pull requests -```bash -git push origin <USERNAME>/patch-X.Y.Z-cherry-pick -``` - -After approval, merge the PR. +Review the version, included fixes, changelog, and expected outputs. Mark the release pull request ready and squash-merge it to `main`. Then verify the generated release backport and **rebase-merge** it to `release/X.Y`. ### Step 5: Verify the automated release -After the cherry-pick PR is merged to the `release/X.Y` branch, the [Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml) workflow automatically runs because `versions.yaml` changed on a `release/*` branch. It reads the patch version from `versions.yaml`, creates and pushes the `vX.Y.Z` tag, and the tag push triggers the [release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml) workflow. No manual tag creation is needed. +After the generated release backport is merged to the `release/X.Y` branch, the [Release Radius](https://github.com/radius-project/radius/actions/workflows/release.yaml) workflow automatically runs because `versions.yaml` changed on a `release/*` branch. It reads the patch version from `versions.yaml`, creates and pushes the `vX.Y.Z` tag, and the tag push triggers the [release build](https://github.com/radius-project/radius/actions/workflows/build-release.yaml) workflow. No manual tag creation is needed. Monitor and verify: @@ -401,6 +305,24 @@ Monitor and verify: If all workflows pass, the patch release is complete. Post a final update in the Teams release thread announcing the successful patch and summarizing the timeline. +## Break-glass manual preparation + +> **Temporary fallback:** Use this path only when Prepare Release or release-backport automation is unavailable. It remains until the final migration sweep. Record why break-glass was required in the Teams release thread and open an issue for the automation failure. + +1. Choose the version strictly from the [version policy](#terminology): first RC uses the next minor channel and `-rc.1`, a subsequent RC increments the RC number, final removes the RC suffix, and patch increments the patch number. +2. Create a branch from `main`, update `versions.yaml`, render and add the corresponding section to `CHANGELOG.md`, and create `docs/release-notes/vX.Y.Z[-rc.N].md` from the appropriate template. Open a Conventional Commit pull request against `main` and squash-merge it. +3. For a first RC, stop here; `release.yaml` creates the release branch from `main`. For an existing channel, manually backport each required squash commit and the release preparation commit: + + ```bash + git checkout release/X.Y + git pull origin release/X.Y + git checkout -b <USERNAME>/backport-X.Y + git cherry-pick -x <SOURCE_COMMIT> [<ADDITIONAL_SOURCE_COMMITS>...] + git push origin <USERNAME>/backport-X.Y + ``` + +4. Open the backport pull request against `release/X.Y`, verify every commit follows Conventional Commits, and **rebase-merge** it. Never move or replace an existing release tag. + ## After every release ### Review and improve this document diff --git a/docs/release-notes/README.md b/docs/release-notes/README.md index 56c24065c0..a019e01ce7 100644 --- a/docs/release-notes/README.md +++ b/docs/release-notes/README.md @@ -8,15 +8,15 @@ Refer to the [release process docs](../contributing/contributing-releases/README ## Release notes format -Each release note is a Markdown file named `vX.Y.Z.md` where `X.Y.Z` is the semantic version of the release (_e.g. v0.21.0.md_). +Each release note is a Markdown file named for the release tag, such as `v0.61.0-rc.1.md`, `v0.61.0.md`, or `v0.61.1.md`. Refer to [template.md](./template.md) for the template to use when creating a new release note. ## Versions -The template contains a few places where the placeholder version, `X.Y.Z`, needs to be updated. This version is determined by the release process, documented in the [release contribution docs](../contributing/contributing-releases/README.md). +The [Prepare Release workflow](https://github.com/radius-project/radius/actions/workflows/prepare-release.yaml) computes the version from the fixed policy, replaces the template placeholders, and includes the generated file in the release pull request. The workflow fails rather than accepting a manually chosen version that violates the policy. - Check for the following comment, placed directly under any reference to the placeholder version. After updating the version make sure to delete the comment. +The template retains the following marker so local break-glass preparation can find each version placeholder. Prepare Release removes the marker automatically. ```markdown <!-- REMINDER TO UPDATE THE VERSION ABOVE AND DELETE THIS COMMENT --> @@ -26,19 +26,19 @@ The template contains a few places where the placeholder version, `X.Y.Z`, needs While the full changelog and release notes contain every PR and commit that went into the release, the highlights section is a curated list of the most important changes in the release. This section should be written in a way that is easy for users to understand and digest. Talk to a PM if you need help determining what to include in the highlights section, and how to phrase it. -## Generating the full changelog (release notes) and new contributors +## Generated changelog and contributors -Within the template is the `## Full changelog` section, which is a complete list of commits merged since the last release. +Prepare Release uses the pinned git-cliff configuration to update the canonical `CHANGELOG.md` and populate the release note's Breaking changes, New contributors, and Full changelog sections. Do not copy GitHub-generated notes or contributor lists into the file. -To populate the release notes: +Review the generated release pull request: -1. Open a pull request against `main` containing the final version update in `versions.yaml` and the draft release notes file. -2. Wait for the [Release Radius workflow](https://github.com/radius-project/radius/actions/workflows/release.yaml) to post the **Release Information** comment on the pull request. -3. Copy the contents under `## What's Changed` and `## New Contributors` from the comment into the corresponding sections in the release notes file. Do not copy the headings because they already exist in the template. -4. Commit and push the completed release notes to the same pull request. +1. Verify the generated changelog groups and contributor list against the included commits. +2. Curate Highlights for user-facing importance. +3. Review and update Upgrading with any release-specific actions. +4. Mark the draft pull request ready only after the release plan and notes agree. ## Patch releases -Patch releases will be uploaded to Github Releases and require patch release notes for the release workflow to execute. The patch release notes follow the same formatting as the original release notes, but only contain info about the changelog. +Patch releases are uploaded to GitHub Releases and require patch release notes for the release workflow to execute. Prepare Release generates them from [template_patch.md](./template_patch.md) with the selected fixes and patch version. Refer to [template_patch.md](./template_patch.md) for the template to use when creating a new patch release note. diff --git a/docs/release-notes/template_patch.md b/docs/release-notes/template_patch.md index b90b40c62c..cd4de2d696 100644 --- a/docs/release-notes/template_patch.md +++ b/docs/release-notes/template_patch.md @@ -1,8 +1,8 @@ ## Radius vX.Y.Z <!-- REMINDER TO UPDATE THE VERSION ABOVE AND DELETE THIS COMMENT --> -<!-- ADD A SENTENCE ABOUT WHAT THIS PATCH RELEASE ADDRESSES --> Check out the [changelog](#changelog) for more details of what was addressed in this patch. +This patch release includes the fixes listed in the [changelog](#changelog). ## Changelog -<!-- PASTE THE OUTPUT OF THE GENERATED CHANGELOG HERE --> \ No newline at end of file +<!-- PASTE THE OUTPUT OF THE GENERATED CHANGELOG HERE --> diff --git a/eng/design-notes/tools/2026-03-goreleaser-release-lifecycle-implementation-plan.md b/eng/design-notes/tools/2026-03-goreleaser-release-lifecycle-implementation-plan.md index 0d0383546a..f67e99f151 100644 --- a/eng/design-notes/tools/2026-03-goreleaser-release-lifecycle-implementation-plan.md +++ b/eng/design-notes/tools/2026-03-goreleaser-release-lifecycle-implementation-plan.md @@ -167,10 +167,13 @@ Add the version-preparation workflow (manual dispatch with inputs: release type - A maintainer marks a merged `main` PR for inclusion by adding a `backport release/<channel>` label, at merge time or retroactively. - A backport workflow reacts to the label: it cherry-picks the squash commit with `-x` onto a branch cut from the release branch and opens a backport PR with the release App identity, preserving the original Conventional Commit title and linking the source PR. On a conflict it still opens the PR and comments with the exact local commands to resolve and push, so the engineer resolves the conflict but never re-derives what to pick. +- The backport commit keeps the source commit's author and records the release App as the committer, so credit stays with the contributor. GitHub's bot signature verification is deliberately not used for backports: it only applies when the request carries no custom author, and `release/*` allows only rebase merges, which add commits to the base branch without signature verification. The signature would therefore be discarded when the backport lands, while the author survives. The release PR opened by `Prepare Release` is the opposite case - it carries no human author and targets `main`, which requires signatures and squash-merges - so it keeps bot signing. - Backport PRs to `release/*` branches are rebase-merged (rebase merging is enabled repository-wide in this PR) so each cherry-picked commit keeps its own Conventional Commit title for the release-branch changelog; a validation job on release branches enforces commit-message conformance. - `Prepare Release` for a subsequent RC or patch verifies that every labeled PR has a merged backport before proposing the version, lists the included commits in the release PR body, and also accepts an explicit list of PR numbers as a dispatch input for one-off inclusions. - A new runbook section, "Backporting changes to a release branch", documenting the label flow, conflict resolution, and verification, lands in this PR. +[korthout/backport-action](https://github.com/korthout/backport-action) was evaluated as a replacement for the backport scripts and rejected. It cherry-picks with `-x`, resolves the merge method automatically, and preserves the source author while committing as a bot, all of which match this design. It was rejected on three other grounds: it opens every labelled backport simultaneously against the current branch head rather than serialising one per channel; its `draft_commit_conflicts` mode commits conflict markers, which the completion check deliberately rejects; and it has no equivalent of the approved-base pinning or the completion verification that most of the custom code implements. Adopting it would replace roughly the cherry-pick mechanics alone. Revisit if `release/*` gains a strict up-to-date merge requirement, which is what the one-open-backport-per-channel rule currently compensates for. + The existing `versions.yaml`-triggered automation still runs unchanged when the release PR merges, so this PR only automates what release engineers previously did by hand. - **Cleanup**: the `generate_release_note` job in `release.yaml` (sticky PR comments with GitHub-generated notes) is superseded by the git-cliff notes in the release PR and deleted; the runbook's manual `versions.yaml`-editing and cherry-pick sections are replaced by the new instructions, with the old manual path retained only as a clearly marked break-glass appendix until PR 18. @@ -230,3 +233,4 @@ Because every phase deletes what it supersedes, this PR handles only the code wh - **Cadence fit**: with a monthly release train, PRs 1-6 can land within one cycle, PR 7 must observe a full cycle, and PRs 13, 15, and 16 should each ship at least one release apart. Realistic end-to-end duration is four to five release cycles. - **Parity manifest is the contract**: every cutover PR (7, 13, 17) treats an unexplained difference from the PR 1 manifest as a failure, per the design's test plan. - **Runner duration budget**: the release summary records total release duration each cycle; the GoReleaser Pro split-and-merge evaluation reopens only if duration exceeds the agreed budget, per the decided runner topology. +- **Script language boundary**: work that is a GitHub API problem belongs in an ESM module executed by [`actions/github-script`](https://github.com/actions/github-script), which supplies a pre-authenticated paginating Octokit plus `context` and `core`; work that is local git, registry, or artifact inspection stays in shell, because `github-script` would only wrap the same commands in `exec` while losing pipeline ergonomics and the real-git test fixtures. `monitor-remote-workflow.mjs` and `select-release-backports.mjs` sit on the API side of that line; the tag, branch, changelog, and verification scripts sit on the git side. The boundary is not a signing decision: no GitHub API creates a verified annotated tag object, and signed commits come from the GraphQL `createCommitOnBranch` mutation that `peter-evans/create-pull-request` already performs, so moving git plumbing into `github-script` would gain neither.