From 73b69cb9ccaa26de0274b92f4984581691f67603 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:30:44 -0700 Subject: [PATCH 1/7] ci: automate release preparation and backports Apply the fixed Radius version policy, render canonical changelog and release notes, and open signed release pull requests with a validated structured plan. Create backports serially per release channel, preserve source commit metadata, and fail closed on conflicts, stale bases, merge-queue drift, or incomplete backports. Refs: #12814 Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/collect-release-backports.sh | 178 ++++++ .../scripts/collect-release-backports_test.sh | 154 +++++ .github/scripts/create-release-backport.sh | 244 +++++++ .../scripts/create-release-backport_test.sh | 231 +++++++ .github/scripts/prepare-release.sh | 604 ++++++++++++++++++ .github/scripts/prepare-release_test.sh | 518 +++++++++++++++ .../scripts/preserve-release-note-sections.sh | 80 +++ .github/scripts/select-release-backports.mjs | 99 +++ .../scripts/select-release-backports_test.mjs | 93 +++ .../scripts/validate-conventional-commits.mjs | 147 +++++ .../validate-conventional-commits_test.mjs | 205 ++++++ .../scripts/validate-release-merge-group.sh | 148 +++++ .../validate-release-merge-group_test.sh | 193 ++++++ .github/scripts/validate-release-plan.sh | 377 +++++++++++ .github/scripts/validate-release-plan_test.sh | 319 +++++++++ .../scripts/verify-deployment-engine-tag.sh | 63 ++ .../verify-deployment-engine-tag_test.sh | 129 ++++ .github/workflows/__publish-release.yaml | 4 +- .github/workflows/prepare-release.yaml | 315 +++++++++ .github/workflows/release-backport.yaml | 351 ++++++++++ .github/workflows/release-branch-commits.yaml | 84 +++ .github/workflows/release-plan.yaml | 301 +++++++++ .github/workflows/release.yaml | 115 ---- build/test.mk | 22 +- .../contributing-releases/README.md | 256 +++----- docs/release-notes/README.md | 22 +- docs/release-notes/template_patch.md | 4 +- 27 files changed, 4958 insertions(+), 298 deletions(-) create mode 100644 .github/scripts/collect-release-backports.sh create mode 100644 .github/scripts/collect-release-backports_test.sh create mode 100644 .github/scripts/create-release-backport.sh create mode 100644 .github/scripts/create-release-backport_test.sh create mode 100644 .github/scripts/prepare-release.sh create mode 100644 .github/scripts/prepare-release_test.sh create mode 100644 .github/scripts/preserve-release-note-sections.sh create mode 100644 .github/scripts/select-release-backports.mjs create mode 100644 .github/scripts/select-release-backports_test.mjs create mode 100644 .github/scripts/validate-conventional-commits.mjs create mode 100644 .github/scripts/validate-conventional-commits_test.mjs create mode 100644 .github/scripts/validate-release-merge-group.sh create mode 100644 .github/scripts/validate-release-merge-group_test.sh create mode 100644 .github/scripts/validate-release-plan.sh create mode 100644 .github/scripts/validate-release-plan_test.sh create mode 100644 .github/scripts/verify-deployment-engine-tag.sh create mode 100644 .github/scripts/verify-deployment-engine-tag_test.sh create mode 100644 .github/workflows/prepare-release.yaml create mode 100644 .github/workflows/release-backport.yaml create mode 100644 .github/workflows/release-branch-commits.yaml create mode 100644 .github/workflows/release-plan.yaml diff --git a/.github/scripts/collect-release-backports.sh b/.github/scripts/collect-release-backports.sh new file mode 100644 index 00000000000..d4f4dde72b9 --- /dev/null +++ b/.github/scripts/collect-release-backports.sh @@ -0,0 +1,178 @@ +#!/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( + (.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 00000000000..d123d510104 --- /dev/null +++ b/.github/scripts/collect-release-backports_test.sh @@ -0,0 +1,154 @@ +#!/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"}' + ;; + 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"}]}]' +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)) +} + +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 + + 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 00000000000..55f2c5bed33 --- /dev/null +++ b/.github/scripts/create-release-backport.sh @@ -0,0 +1,244 @@ +#!/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}" + else + { + git show -s --format=%B "${SOURCE_COMMIT}" + echo + echo "(cherry picked from commit ${SOURCE_COMMIT})" + } > "${commit_message_file}" + 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 00000000000..ae5b822c49d --- /dev/null +++ b/.github/scripts/create-release-backport_test.sh @@ -0,0 +1,231 @@ +#!/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" \ + -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 + ((++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 + ((++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 00000000000..9f890b195e1 --- /dev/null +++ b/.github/scripts/prepare-release.sh @@ -0,0 +1,604 @@ +#!/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 +} + +highest_rc_number() { + local tag + local highest=0 + local pattern="^v${CHANNEL//./\\.}\\.0-rc\\.?([1-9][0-9]*)$" + + 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 [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; 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 [[ ! "${latest}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; 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="^v${CHANNEL//./\.}\\.0-rc\\.?([1-9][0-9]*)$" + 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 + if [[ ! "${current}" =~ ^v${CHANNEL//./\.}\.0-rc\.?[0-9]+$ ]]; then + fail "versions.yaml has no RC for ${CHANNEL}" + fi + local rc_commit branch_commit + 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 + if [[ ! "${current}" =~ ^v${CHANNEL//./\.}\.([0-9]+)$ ]]; 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 00000000000..7123615687d --- /dev/null +++ b/.github/scripts/prepare-release_test.sh @@ -0,0 +1,518 @@ +#!/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_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_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 00000000000..2ee106a3240 --- /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 00000000000..4eeb686d35a --- /dev/null +++ b/.github/scripts/select-release-backports.mjs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------ +// 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)] +} \ No newline at end of file diff --git a/.github/scripts/select-release-backports_test.mjs b/.github/scripts/select-release-backports_test.mjs new file mode 100644 index 00000000000..1dc7cd055b3 --- /dev/null +++ b/.github/scripts/select-release-backports_test.mjs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------ +// 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'], + ) +}) \ No newline at end of file diff --git a/.github/scripts/validate-conventional-commits.mjs b/.github/scripts/validate-conventional-commits.mjs new file mode 100644 index 00000000000..fc6be272c62 --- /dev/null +++ b/.github/scripts/validate-conventional-commits.mjs @@ -0,0 +1,147 @@ +#!/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 00000000000..d2363fbc9d8 --- /dev/null +++ b/.github/scripts/validate-conventional-commits_test.mjs @@ -0,0 +1,205 @@ +#!/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 00000000000..8144771d097 --- /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 00000000000..d539cca79ea --- /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 00000000000..bcc54cc43a1 --- /dev/null +++ b/.github/scripts/validate-release-plan.sh @@ -0,0 +1,377 @@ +#!/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')" + canonical_version_pattern='^v[0-9]+\.[0-9]+\.[0-9]+' + canonical_version_pattern+='(-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-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 00000000000..29a0b46b9f1 --- /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 00000000000..f787fa75c34 --- /dev/null +++ b/.github/scripts/verify-deployment-engine-tag.sh @@ -0,0 +1,63 @@ +#!/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 + + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[1-9][0-9]*)?$ ]]; 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 00000000000..12a0c0d3278 --- /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 b2d1b0c444b..f824b7e6e15 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 00000000000..2f1d951c107 --- /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 00000000000..9e8fd89eb9f --- /dev/null +++ b/.github/workflows/release-backport.yaml @@ -0,0 +1,351 @@ +# ------------------------------------------------------------ +# 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, + }), + ) + + 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")" + } >> "${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 }} + committer: ${{ steps.bot-details.outputs.name-email }} + signoff: true + sign-commits: 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 signature + if: >- + steps.pull-request.outputs.pull-request-operation == 'created' || + steps.pull-request.outputs.pull-request-operation == 'updated' + env: + COMMITS_VERIFIED: >- + ${{ steps.pull-request.outputs.pull-request-commits-verified }} + run: | + [[ "${COMMITS_VERIFIED}" == "true" ]] || { + echo "The release App did not create a verified commit." >&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 00000000000..f6e6d017193 --- /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 00000000000..d90f3da426e --- /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 06e91c827d3..3e8fbede696 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 45c41fdbc17..f7b65113a45 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/docs/contributing/contributing-releases/README.md b/docs/contributing/contributing-releases/README.md index ec4c7a400c7..4493f513314 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 56c24065c05..a019e01ce75 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 b90b40c62cd..cd4de2d696e 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 --> From a881e85d82522febfd932eda6f6494d20cbbb8b5 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:08:50 -0700 Subject: [PATCH 2/7] fix: streamline postprocessors configuration in cliff.toml Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- cliff.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cliff.toml b/cliff.toml index a0d4797d598..d7f929562ad 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 From 8184402921b9042d6368f6499c45a9895bd588d0 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:06:59 -0700 Subject: [PATCH 3/7] ci: enforce one release version policy across release scripts prepare-release.sh sourced release-version.sh but never called it, and re-implemented the version policy inline five times. Two of those copies disagreed: the final-release check accepted -rc.0 while highest_rc_number only ever counted -rc.1 and above, so a versions.yaml pinned to an out-of-policy RC could be promoted to a final release. Build the channel-scoped RC and patch patterns from the shared RADIUS_RC_NUMBER and RADIUS_SEMVER_NUMBER constants, and route the stable tag checks through is_radius_release_version so the policy has one home. Align the canonical gates in validate-release-plan.sh and verify-deployment-engine-tag.sh with the same rule, which rejects the leading zeros that semver forbids. Add a regression test covering the -rc.0 promotion path. Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/prepare-release.sh | 34 +++++++++++++++---- .github/scripts/prepare-release_test.sh | 17 ++++++++++ .github/scripts/validate-release-plan.sh | 8 +++-- .../scripts/verify-deployment-engine-tag.sh | 7 +++- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.github/scripts/prepare-release.sh b/.github/scripts/prepare-release.sh index 9f890b195e1..c84c2cafa11 100644 --- a/.github/scripts/prepare-release.sh +++ b/.github/scripts/prepare-release.sh @@ -94,11 +94,28 @@ release_branch_ref() { 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="^v${CHANNEL//./\\.}\\.0-rc\\.?([1-9][0-9]*)$" + local pattern + pattern="$(channel_rc_pattern)" while IFS= read -r tag; do if [[ "${tag}" =~ ${pattern} ]]; then if ((10#${BASH_REMATCH[1]} > highest)); then @@ -114,7 +131,7 @@ newest_stable_tag() { local tag while IFS= read -r tag; do - if [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if is_stable_release_tag "${tag}"; then printf '%s\n' "${tag}" return 0 fi @@ -146,7 +163,7 @@ calculate_version() { if [[ -n "${branch_ref}" ]]; then fail "release/${CHANNEL} exists but has no RC tags" fi - if [[ ! "${latest}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if ! is_stable_release_tag "${latest}"; then fail "latest supported version must be stable" fi if [[ "${CHANNEL}" != "$(next_channel "${latest}")" ]]; then @@ -158,7 +175,7 @@ calculate_version() { if [[ -z "${branch_ref}" ]]; then fail "release/${CHANNEL} is required for another RC" fi - current_rc_pattern="^v${CHANNEL//./\.}\\.0-rc\\.?([1-9][0-9]*)$" + current_rc_pattern="$(channel_rc_pattern)" if [[ ! "${current}" =~ ${current_rc_pattern} ]]; then fail "versions.yaml has no current RC for ${CHANNEL}" fi @@ -175,10 +192,11 @@ calculate_version() { if [[ -z "${branch_ref}" ]]; then fail "release/${CHANNEL} is required for a final release" fi - if [[ ! "${current}" =~ ^v${CHANNEL//./\.}\.0-rc\.?[0-9]+$ ]]; then + 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 - local rc_commit branch_commit if ! rc_commit="$( git rev-parse --verify "refs/tags/${current}^{commit}" )"; then @@ -194,7 +212,9 @@ calculate_version() { if [[ -z "${branch_ref}" ]]; then fail "release/${CHANNEL} is required for a patch" fi - if [[ ! "${current}" =~ ^v${CHANNEL//./\.}\.([0-9]+)$ ]]; then + 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 \ diff --git a/.github/scripts/prepare-release_test.sh b/.github/scripts/prepare-release_test.sh index 7123615687d..59e7f6b948c 100644 --- a/.github/scripts/prepare-release_test.sh +++ b/.github/scripts/prepare-release_test.sh @@ -349,6 +349,22 @@ test_final_rejects_unvalidated_branch_tip() { ((++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" @@ -500,6 +516,7 @@ main() { 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 diff --git a/.github/scripts/validate-release-plan.sh b/.github/scripts/validate-release-plan.sh index bcc54cc43a1..ecd4bb11cbb 100644 --- a/.github/scripts/validate-release-plan.sh +++ b/.github/scripts/validate-release-plan.sh @@ -333,15 +333,17 @@ main() { release_date="$(plan_value '.releaseDate')" chart_version="$(plan_value '.chartVersion')" release_branch="$(plan_value '.releaseBranch')" - canonical_version_pattern='^v[0-9]+\.[0-9]+\.[0-9]+' - canonical_version_pattern+='(-rc\.[1-9][0-9]*)?$' + # 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-9]+\.[0-9]+$ ]]; then + 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 diff --git a/.github/scripts/verify-deployment-engine-tag.sh b/.github/scripts/verify-deployment-engine-tag.sh index f787fa75c34..cf52bea4042 100644 --- a/.github/scripts/verify-deployment-engine-tag.sh +++ b/.github/scripts/verify-deployment-engine-tag.sh @@ -29,8 +29,13 @@ fail() { main() { local reference object_type object_sha verification recovery + local tag_pattern - if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[1-9][0-9]*)?$ ]]; then + # 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}" From 7b71229f6a0caabdec390978dc3e526dd64e7bc4 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:07:02 -0700 Subject: [PATCH 4/7] docs: record why backport automation is not korthout/backport-action The action cherry-picks with -x and detects the merge method, which fits this design, but it commits and pushes with git. Backport commits could then only be signed by introducing a bot GPG key, and this job instead asserts that the release App produced a verified commit through the API. It also opens every labelled backport at once against the current branch head rather than serialising one per channel, and its conflict mode commits conflict markers that the completion check rejects. Record the evaluation next to the job and in the implementation plan so the question is not reopened without the constraints, and name the condition that would change the answer. Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/workflows/release-backport.yaml | 5 +++++ ...26-03-goreleaser-release-lifecycle-implementation-plan.md | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/release-backport.yaml b/.github/workflows/release-backport.yaml index 9e8fd89eb9f..c22a9225a16 100644 --- a/.github/workflows/release-backport.yaml +++ b/.github/workflows/release-backport.yaml @@ -143,6 +143,11 @@ jobs: }), ) + # 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 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 0d0383546ac..7734aee13f5 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 @@ -171,6 +171,8 @@ Add the version-preparation workflow (manual dispatch with inputs: release type - `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` and resolves the merge method automatically, which matches this design, but it commits locally and pushes with git, so backport commits could only be signed by introducing a bot GPG key - the same key-custody problem this plan avoids for Deployment Engine tags. It also opens every labelled backport simultaneously against the current branch head rather than serialising one per channel, and its `draft_commit_conflicts` mode commits conflict markers, which the completion check deliberately rejects. Adopting it would replace roughly the cherry-pick mechanics alone while giving up API-signed commits, approved-base pinning, and fail-closed conflict handling; the selection and completion-verification logic that most of the custom code implements has no equivalent in the action. 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. From 41f98fa55cec71df6cb276ff5086786f908e2a7e Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:07:06 -0700 Subject: [PATCH 5/7] ci: reject ambiguous backport markers in the collector collect-release-backports.sh matched the source marker with contains(), while select-release-backports.mjs requires exactly one marker and skips an ambiguous body. Both encode the same completion contract, so a body naming two sources was read differently depending on which consumer ran. Require a single marker in the collector too. The exact -x trailer check already prevented a wrong release decision, so this closes a divergence rather than a live bug, but it leaves one reading of the contract instead of two. Record the script language boundary in the implementation plan: GitHub API work belongs in actions/github-script ESM modules, local git and artifact work stays in shell, and neither choice affects signing. Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/collect-release-backports.sh | 15 ++++++---- .../scripts/collect-release-backports_test.sh | 29 ++++++++++++++++++- ...r-release-lifecycle-implementation-plan.md | 1 + 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.github/scripts/collect-release-backports.sh b/.github/scripts/collect-release-backports.sh index d4f4dde72b9..cecf872a66a 100644 --- a/.github/scripts/collect-release-backports.sh +++ b/.github/scripts/collect-release-backports.sh @@ -139,11 +139,16 @@ main() { . as $source | ($backports[0] | map(select( - (.body // "") | - contains( - "<!-- radius-backport-source: #" + - "\($source.number) -->" - ) + # A body naming more than one source cannot identify + # which backport it is, so it is never trusted. + ((.body // "") | + [scan("<!-- radius-backport-source: #[0-9]+ -->")] | + length) == 1 and + ((.body // "") | + contains( + "<!-- radius-backport-source: #" + + "\($source.number) -->" + )) )) | sort_by([(.mergedAt != null), .number]) | last diff --git a/.github/scripts/collect-release-backports_test.sh b/.github/scripts/collect-release-backports_test.sh index d123d510104..66e21b21ee3 100644 --- a/.github/scripts/collect-release-backports_test.sh +++ b/.github/scripts/collect-release-backports_test.sh @@ -58,6 +58,11 @@ if [[ "$1 $2" == "pr view" ]]; then 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 @@ -90,7 +95,13 @@ else '"body":"<!-- radius-backport-source: #104 -->",'\ '"state":"MERGED","mergedAt":"2026-08-21T00:00:00Z",'\ '"mergeCommit":{"oid":"placeholder-104"},'\ -'"commits":[{"messageBody":"conflict handoff only"}]}]' +'"commits":[{"messageBody":"conflict handoff only"}]},'\ +'{"number":205,"url":"https://example.test/205",'\ +'"body":"<!-- radius-backport-source: #105 -->\n'\ +'<!-- radius-backport-source: #999 -->",'\ +'"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" @@ -136,12 +147,28 @@ test_rejects_unmerged_explicit_pr() { ((++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" 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 7734aee13f5..c37199cc88d 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 @@ -232,3 +232,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. From 89acd5d932076cd1ee9da65203637f78e6a0f474 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:07:09 -0700 Subject: [PATCH 6/7] ci: keep the source author on backport commits The backport pull request set sign-commits, which makes the action ignore the committer and author inputs entirely. The committer line was already dead config, and the author fell back to github.actor - the person who labelled the PR or pushed to the release branch, not the contributor whose change is being backported. GitHub only verifies a bot signature when the request carries no custom author, so author preservation and bot signing cannot both apply. The release/* ruleset settles it: it requires no signatures and permits only rebase merges, which add commits to the base branch without signature verification. The signature was going to be discarded when the backport landed, while the author survives the rebase. Capture the source commit author and pass it through, keep the release App as committer, and drop sign-commits. The conflict placeholder is the bot's own commit, so it keeps the bot as author and its signoff matches. A real backport carries the source commit body, including the original Signed-off-by, so DCO still matches its author. Replace the signature assertion with an authorship assertion, since that is the property that now reaches the release branch. Prepare Release is the opposite case and is unchanged: its commits have no human author and target main, which requires signatures and squash merges, so bot signing both applies and survives. Correct the backport-action comparison, which credited API-signed commits that this path no longer relies on. Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/create-release-backport.sh | 5 ++++ .../scripts/create-release-backport_test.sh | 9 +++++++ .github/workflows/release-backport.yaml | 24 ++++++++++++++----- ...r-release-lifecycle-implementation-plan.md | 3 ++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/scripts/create-release-backport.sh b/.github/scripts/create-release-backport.sh index 55f2c5bed33..517c85a8bbf 100644 --- a/.github/scripts/create-release-backport.sh +++ b/.github/scripts/create-release-backport.sh @@ -48,12 +48,17 @@ write_outputs() { 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" diff --git a/.github/scripts/create-release-backport_test.sh b/.github/scripts/create-release-backport_test.sh index ae5b822c49d..cb55cc26ecd 100644 --- a/.github/scripts/create-release-backport_test.sh +++ b/.github/scripts/create-release-backport_test.sh @@ -65,6 +65,7 @@ setup_repo() { 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}" @@ -128,6 +129,10 @@ test_successful_backport() { 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)) } @@ -184,6 +189,10 @@ test_conflict_creates_safe_handoff() { 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)) } diff --git a/.github/workflows/release-backport.yaml b/.github/workflows/release-backport.yaml index c22a9225a16..86e561ba70e 100644 --- a/.github/workflows/release-backport.yaml +++ b/.github/workflows/release-backport.yaml @@ -280,6 +280,7 @@ jobs: 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)" { @@ -297,24 +298,35 @@ jobs: 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 - sign-commits: 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 signature + - name: Verify backport commit authorship if: >- steps.pull-request.outputs.pull-request-operation == 'created' || steps.pull-request.outputs.pull-request-operation == 'updated' env: - COMMITS_VERIFIED: >- - ${{ steps.pull-request.outputs.pull-request-commits-verified }} + 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: | - [[ "${COMMITS_VERIFIED}" == "true" ]] || { - echo "The release App did not create a verified commit." >&2 + 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 } 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 c37199cc88d..f67e99f151f 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,11 +167,12 @@ 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` and resolves the merge method automatically, which matches this design, but it commits locally and pushes with git, so backport commits could only be signed by introducing a bot GPG key - the same key-custody problem this plan avoids for Deployment Engine tags. It also opens every labelled backport simultaneously against the current branch head rather than serialising one per channel, and its `draft_commit_conflicts` mode commits conflict markers, which the completion check deliberately rejects. Adopting it would replace roughly the cherry-pick mechanics alone while giving up API-signed commits, approved-base pinning, and fail-closed conflict handling; the selection and completion-verification logic that most of the custom code implements has no equivalent in the action. Revisit if `release/*` gains a strict up-to-date merge requirement, which is what the one-open-backport-per-channel rule currently compensates for. +[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. From e5263ef69e9f0f327e2a2df52197fc1732cbe21a Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:07:11 -0700 Subject: [PATCH 7/7] fix: add missing newline at end of file in backport scripts Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .cspellignore | 2 + .github/scripts/select-release-backports.mjs | 76 +++--- .../scripts/select-release-backports_test.mjs | 102 +++---- .../scripts/validate-conventional-commits.mjs | 145 +++++----- .../validate-conventional-commits_test.mjs | 249 +++++++++--------- 5 files changed, 297 insertions(+), 277 deletions(-) diff --git a/.cspellignore b/.cspellignore index e113c4cd8d4..a55741d63fc 100644 --- a/.cspellignore +++ b/.cspellignore @@ -1343,3 +1343,5 @@ repointed canonicality syft repoints +korthout +serialising diff --git a/.github/scripts/select-release-backports.mjs b/.github/scripts/select-release-backports.mjs index 4eeb686d35a..edfe35ca2ed 100644 --- a/.github/scripts/select-release-backports.mjs +++ b/.github/scripts/select-release-backports.mjs @@ -15,23 +15,23 @@ // ------------------------------------------------------------ function expectedBase(pull) { - if (!pull.head.ref.startsWith('automation/prepare-release-')) { - return '' + 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 -->' + 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') + throw new Error("Generated release PR has no unique release plan"); } - const plan = body.split(start)[1].split(end)[0] + const plan = body.split(start)[1].split(end)[0]; const matches = [ - ...plan.matchAll(/^\s*productCommit:\s*([0-9a-f]{40})\s*$/gm), - ] + ...plan.matchAll(/^\s*productCommit:\s*([0-9a-f]{40})\s*$/gm) + ]; if (matches.length !== 1) { - throw new Error('Release plan has no unique productCommit') + throw new Error("Release plan has no unique productCommit"); } - return matches[0][1] + return matches[0][1]; } export function backportEntry(pull, channel) { @@ -41,59 +41,61 @@ export function backportEntry(pull, channel) { source_commit: pull.merge_commit_sha, source_title: pull.title, source_url: pull.html_url, - expected_base: expectedBase(pull), - } + 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]) + .map((match) => match[1]); return [...new Set(channels)] .sort() - .map((channel) => backportEntry(pull, channel)) + .map((channel) => backportEntry(pull, channel)); } export function selectNextBackport({ channel, sources, openBackports, - historicalBackports, + historicalBackports }) { - if (openBackports.some((pull) => - pull.head.ref.startsWith('automation/backport-') - )) { - return [] + 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() + 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, - ), - ] + ...(backport.body ?? "").matchAll( + /<!-- radius-backport-source: #(\d+) -->/g + ) + ]; if (markers.length !== 1) { - continue + continue; } - const sourceNumber = Number(markers[0][1]) - const source = sourceByNumber.get(sourceNumber) + const sourceNumber = Number(markers[0][1]); + const source = sourceByNumber.get(sourceNumber); if (!source) { - continue + continue; } - const trailer = `(cherry picked from commit ${source.merge_commit_sha})` + const trailer = `(cherry picked from commit ${source.merge_commit_sha})`; const hasTrailer = (backport.commits ?? []).some((entry) => - (entry.commit?.message ?? '').split(/\r?\n/).includes(trailer) - ) + (entry.commit?.message ?? "").split(/\r?\n/).includes(trailer) + ); if (hasTrailer) { - completed.add(sourceNumber) + 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)] -} \ No newline at end of file + .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 index 1dc7cd055b3..df62ffc7e3e 100644 --- a/.github/scripts/select-release-backports_test.mjs +++ b/.github/scripts/select-release-backports_test.mjs @@ -14,80 +14,84 @@ // limitations under the License. // ------------------------------------------------------------ -import assert from 'node:assert/strict' -import test from 'node:test' +import assert from "node:assert/strict"; +import test from "node:test"; import { entriesForMergedPull, - selectNextBackport, -} from './select-release-backports.mjs' + selectNextBackport +} from "./select-release-backports.mjs"; const source = (number) => ({ number, - merge_commit_sha: String(number).padStart(40, 'a'), + 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' }], -}) + labels: [{ name: "backport release/0.60" }] +}); -test('serializes two backports through successive release pushes', () => { - const first = source(101) - const second = source(102) +test("serializes two backports through successive release pushes", () => { + const first = source(101); + const second = source(102); const initial = selectNextBackport({ - channel: '0.60', + channel: "0.60", sources: [second, first], openBackports: [], - historicalBackports: [], - }) - assert.equal(initial[0].source_pr, 101) + historicalBackports: [] + }); + assert.equal(initial[0].source_pr, 101); const deferred = selectNextBackport({ - channel: '0.60', + channel: "0.60", sources: [first, second], - openBackports: [{ head: { ref: 'automation/backport-101-to-0.60' } }], - historicalBackports: [], - }) - assert.deepEqual(deferred, []) + 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})`, - }, - }], - } + 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', + channel: "0.60", sources: [first, second], openBackports: [], - historicalBackports: [completedFirst], - }) - assert.equal(next[0].source_pr, 102) -}) + historicalBackports: [completedFirst] + }); + assert.equal(next[0].source_pr, 102); +}); -test('does not accept a merged marker without the exact trailer', () => { - const first = source(101) +test("does not accept a merged marker without the exact trailer", () => { + const first = source(101); const selected = selectNextBackport({ - channel: '0.60', + 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) -}) + 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' }) +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'], - ) -}) \ No newline at end of file + ["0.59", "0.60"] + ); +}); diff --git a/.github/scripts/validate-conventional-commits.mjs b/.github/scripts/validate-conventional-commits.mjs index fc6be272c62..b996cfa1394 100644 --- a/.github/scripts/validate-conventional-commits.mjs +++ b/.github/scripts/validate-conventional-commits.mjs @@ -16,132 +16,137 @@ // limitations under the License. // ------------------------------------------------------------ -import { readFile } from 'node:fs/promises' -import { pathToFileURL } from 'node:url' +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; const allowedTypes = [ - 'build', - 'chore', - 'ci', - 'docs', - 'feat', - 'fix', - 'perf', - 'refactor', - 'revert', - 'style', - 'test', -] + "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$/ + `^(?:${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') + 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] : '' + 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, - ), - ] + ...(body ?? "").matchAll(/<!-- radius-backport-base: ([0-9a-f]{40}) -->/g) + ]; if (markers.length === 0) { - return + return; } if (markers.length !== 1) { - throw new Error('backport PR must contain exactly one base marker') + 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}`, - ) + `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+$/) + const branch = headRef.match(/^automation\/backport-(\d+)-to-\d+\.\d+$/); if (!branch) { - return + return; } const sources = [ - ...(body ?? '').matchAll(/<!-- radius-backport-source: #(\d+) -->/g), - ] + ...(body ?? "").matchAll(/<!-- radius-backport-source: #(\d+) -->/g) + ]; const sourceCommits = [ - ...(body ?? '').matchAll( - /<!-- radius-backport-commit: ([0-9a-f]{40}) -->/g, - ), - ] + ...(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') + 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') + throw new Error("generated backport must contain one source commit marker"); } const bases = [ - ...(body ?? '').matchAll( - /<!-- radius-backport-base: ([0-9a-f]{40}) -->/g, - ), - ] + ...(body ?? "").matchAll(/<!-- radius-backport-base: ([0-9a-f]{40}) -->/g) + ]; if (bases.length !== 1) { - throw new Error('generated backport must contain one base marker') + throw new Error("generated backport must contain one base marker"); } - validateBackportBase(body, baseSha) - const trailer = `(cherry picked from commit ${sourceCommits[0][1]})` + 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) - }) + 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}`) + throw new Error(`generated backport is missing exact trailer: ${trailer}`); } } async function main() { - const inputPath = process.argv[2] + const inputPath = process.argv[2]; if (!inputPath) { - throw new Error('usage: validate-conventional-commits.mjs <commits.json>') + 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] + 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') + 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 body = await readFile(bodyPath, "utf8"); + validateBackportBase(body, baseSha); + validateGeneratedBackport(body, baseSha, headRef, commits); } - const invalid = invalidCommits(commits) + const invalid = invalidCommits(commits); if (invalid.length === 0) { - console.log(`Validated ${commits.length} Conventional Commit message(s).`) - return + console.log(`Validated ${commits.length} Conventional Commit message(s).`); + return; } - console.error('The following release-branch commits are not conventional:') + 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}`) + const subject = + entry?.commit?.message?.split("\n", 1)[0] ?? "<missing message>"; + console.error(`- ${(entry?.sha ?? "<unknown>").slice(0, 12)} ${subject}`); } - process.exitCode = 1 + process.exitCode = 1; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main() +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 index d2363fbc9d8..caa384fd81f 100644 --- a/.github/scripts/validate-conventional-commits_test.mjs +++ b/.github/scripts/validate-conventional-commits_test.mjs @@ -16,190 +16,197 @@ // 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 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' + validateGeneratedBackport +} from "./validate-conventional-commits.mjs"; -const commit = (sha, message) => ({ sha, commit: { message } }) +const commit = (sha, message) => ({ sha, commit: { message } }); -test('accepts the repository Conventional Commit forms', () => { +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'), - ] + 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), []) -}) + assert.deepEqual(invalidCommits(commits), []); +}); -test('rejects invalid and missing subjects', () => { +test("rejects invalid and missing subjects", () => { const invalid = invalidCommits([ - commit('bad-subject', 'Fix release preparation'), - { sha: 'missing-message', commit: {} }, - ]) + commit("bad-subject", "Fix release preparation"), + { sha: "missing-message", commit: {} } + ]); assert.deepEqual( invalid.map(({ sha }) => sha), - ['bad-subject', 'missing-message'], - ) -}) + ["bad-subject", "missing-message"] + ); +}); -test('rejects a non-array payload', () => { - assert.throws(() => invalidCommits({}), /JSON array/) -}) +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') +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')])) + 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') + 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')])) + 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/) -}) + encoding: "utf8" + }); + assert.match(output, /Validated 1 Conventional Commit message/); +}); -test('rejects an unresolved conflict handoff commit', () => { +test("rejects an unresolved conflict handoff commit", () => { const invalid = invalidCommits([ - commit('handoff', 'chore(backport): hand off #123 conflict'), - ]) + commit("handoff", "chore(backport): hand off #123 conflict") + ]); assert.deepEqual( invalid.map(({ sha }) => sha), - ['handoff'], - ) -}) + ["handoff"] + ); +}); -test('accepts a backport pinned to the current release base', () => { - const base = 'a'.repeat(40) +test("accepts a backport pinned to the current release base", () => { + const base = "a".repeat(40); assert.doesNotThrow(() => - validateBackportBase(`<!-- radius-backport-base: ${base} -->`, base), - ) -}) + 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) +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, + current ), - /release branch advanced/, - ) -}) + /release branch advanced/ + ); +}); -test('rejects duplicate backport base markers', () => { - const base = 'a'.repeat(40) +test("rejects duplicate backport base markers", () => { + const base = "a".repeat(40); assert.throws( () => validateBackportBase( `<!-- radius-backport-base: ${base} -->\n` + `<!-- radius-backport-base: ${base} -->`, - base, + base ), - /exactly one base marker/, - ) -}) + /exactly one base marker/ + ); +}); -test('accepts complete generated backport metadata', () => { - const base = 'a'.repeat(40) - const source = 'b'.repeat(40) +test("accepts complete generated backport metadata", () => { + const base = "a".repeat(40); + const source = "b".repeat(40); const body = [ - '<!-- radius-backport-source: #123 -->', + "<!-- 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})`)] + `<!-- 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) + "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', - [], + "automation/backport-123-to-0.60", + [] ), - /source marker/, - ) -}) + /source marker/ + ); +}); -test('rejects generated backport without a base marker', () => { - const source = 'b'.repeat(40) +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') + "<!-- 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})`)], + "a".repeat(40), + "automation/backport-123-to-0.60", + [ + commit( + "head", + `fix: backport\n\n(cherry picked from commit ${source})` + ) + ] ), - /base marker/, - ) -}) + /base marker/ + ); +}); -test('rejects generated backport without exact source trailer', () => { - const base = 'a'.repeat(40) - const source = 'b'.repeat(40) +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-source: #123 -->", `<!-- radius-backport-base: ${base} -->`, - `<!-- radius-backport-commit: ${source} -->`, - ].join('\n') + `<!-- 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/, - ) -}) + validateGeneratedBackport(body, base, "automation/backport-123-to-0.60", [ + commit( + "head", + `fix: malformed\n\ntext (cherry picked from commit ${source})` + ) + ]), + /missing exact trailer/ + ); +});