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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cspellignore
Original file line number Diff line number Diff line change
Expand Up @@ -1343,3 +1343,5 @@ repointed
canonicality
syft
repoints
korthout
serialising
183 changes: 183 additions & 0 deletions .github/scripts/collect-release-backports.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/bin/bash

# ------------------------------------------------------------
# Copyright 2026 The Radius Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ------------------------------------------------------------

set -euo pipefail

GH="${GH:-gh}"
REPOSITORY=""
CHANNEL=""
EXPLICIT_PRS=""
OUTPUT_FILE=""
TEMP_DIR=""

cleanup() {
if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
rm -rf "${TEMP_DIR}"
fi
}
trap cleanup EXIT

fail() {
echo "Error: $*" >&2
exit 1
}

usage() {
cat << 'EOF'
Usage: collect-release-backports.sh --repository <owner/repo> --channel <X.Y> \
--output <path> [--explicit-prs <csv>]
EOF
}

collect_explicit_prs() {
local output="$1"
local pr metadata
local -a numbers=()

printf '[]\n' > "${output}"
[[ -n "${EXPLICIT_PRS}" ]] || return

IFS=',' read -r -a numbers <<< "${EXPLICIT_PRS}"
for pr in "${numbers[@]}"; do
pr="${pr//[[:space:]]/}"
if [[ ! "${pr}" =~ ^[1-9][0-9]*$ ]]; then
fail "invalid explicit pull request number: ${pr}"
fi
metadata="$(
"${GH}" pr view "${pr}" --repo "${REPOSITORY}" \
--json number,title,url,mergeCommit,mergedAt,baseRefName
)"
if ! jq -e '.mergedAt != null and .baseRefName == "main"' \
<<< "${metadata}" > /dev/null; then
fail "explicit pull request #${pr} is not merged into main"
fi
jq --argjson item "${metadata}" '. + [$item]' "${output}" \
> "${output}.tmp"
mv "${output}.tmp" "${output}"
done
}

main() {
local label release_branch
local labeled_file explicit_file sources_file backports_file

while [[ $# -gt 0 ]]; do
case "$1" in
--repository)
REPOSITORY="${2:-}"
shift 2
;;
--channel)
CHANNEL="${2:-}"
shift 2
;;
--explicit-prs)
EXPLICIT_PRS="${2:-}"
shift 2
;;
--output)
OUTPUT_FILE="${2:-}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*) fail "unknown option: $1" ;;
esac
done

if [[ ! "${REPOSITORY}" =~ ^[^/]+/[^/]+$ ]]; then
fail "repository must use owner/name format"
fi
if [[ ! "${CHANNEL}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
fail "channel must use X.Y format"
fi
[[ -n "${OUTPUT_FILE}" ]] || fail "output path is required"
command -v "${GH}" > /dev/null || fail "required command not found: ${GH}"
command -v jq > /dev/null || fail "required command not found: jq"

TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/release-backports-XXXXXX")"
labeled_file="${TEMP_DIR}/labeled.json"
explicit_file="${TEMP_DIR}/explicit.json"
sources_file="${TEMP_DIR}/sources.json"
backports_file="${TEMP_DIR}/backports.json"
label="backport release/${CHANNEL}"
release_branch="release/${CHANNEL}"

"${GH}" pr list --repo "${REPOSITORY}" --state merged --base main \
--label "${label}" --limit 1000 \
--json number,title,url,mergeCommit,mergedAt,baseRefName \
> "${labeled_file}"
collect_explicit_prs "${explicit_file}"
jq -s 'add | unique_by(.number) | sort_by(.number)' \
"${labeled_file}" "${explicit_file}" > "${sources_file}"

"${GH}" pr list --repo "${REPOSITORY}" --state all \
--base "${release_branch}" --limit 1000 \
--json number,url,body,state,mergedAt,mergeCommit,commits \
> "${backports_file}"

mkdir -p "$(dirname "${OUTPUT_FILE}")"
jq --slurpfile backports "${backports_file}" '
map(
. as $source |
($backports[0] |
map(select(
# A body naming more than one source cannot identify
# which backport it is, so it is never trusted.
((.body // "") |
[scan("<!-- radius-backport-source: #[0-9]+ -->")] |
length) == 1 and
((.body // "") |
contains(
"<!-- radius-backport-source: #" +
"\($source.number) -->"
))
)) |
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 "$@"
181 changes: 181 additions & 0 deletions .github/scripts/collect-release-backports_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/bin/bash

# ------------------------------------------------------------
# Copyright 2026 The Radius Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ------------------------------------------------------------

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly SCRIPT="${SCRIPT_DIR}/collect-release-backports.sh"

TEST_ROOT=""
PASS=0
FAIL=0

cleanup() {
if [[ -n "${TEST_ROOT}" && -d "${TEST_ROOT}" ]]; then
rm -rf "${TEST_ROOT}"
fi
}
trap cleanup EXIT

fail_test() {
echo " ASSERT FAILED: $1"
((++FAIL))
}

setup_fake_gh() {
cat > "${TEST_ROOT}/gh" << 'EOF'
#!/bin/bash
set -euo pipefail

if [[ "$1 $2" == "pr view" ]]; then
case "$3" in
102)
printf '%s\n' '{"number":102,"title":"feat: explicit",'\
'"url":"https://example.test/102","mergeCommit":{"oid":"source-102"},'\
'"mergedAt":"2026-08-20T00:00:00Z","baseRefName":"main"}'
;;
103)
printf '%s\n' '{"number":103,"title":"fix: open",'\
'"url":"https://example.test/103","mergeCommit":null,'\
'"mergedAt":null,"baseRefName":"main"}'
;;
104)
printf '%s\n' '{"number":104,"title":"fix: placeholder",'\
'"url":"https://example.test/104","mergeCommit":{"oid":"source-104"},'\
'"mergedAt":"2026-08-20T00:00:00Z","baseRefName":"main"}'
;;
105)
printf '%s\n' '{"number":105,"title":"fix: ambiguous",'\
'"url":"https://example.test/105","mergeCommit":{"oid":"source-105"},'\
'"mergedAt":"2026-08-20T00:00:00Z","baseRefName":"main"}'
;;
esac
exit 0
fi

base=""
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--base" ]]; then
base="$2"
break
fi
shift
done

if [[ "${base}" == "main" ]]; then
printf '%s\n' '[{"number":101,"title":"fix: labeled",'\
'"url":"https://example.test/101","mergeCommit":{"oid":"source-101"},'\
'"mergedAt":"2026-08-19T00:00:00Z","baseRefName":"main"}]'
else
printf '%s\n' '[{"number":201,"url":"https://example.test/201",'\
'"body":"<!-- radius-backport-source: #101 -->",'\
'"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":"<!-- radius-backport-source: #102 -->",'\
'"state":"OPEN","mergedAt":null,"mergeCommit":null,"commits":[]},'\
'{"number":204,"url":"https://example.test/204",'\
'"body":"<!-- radius-backport-source: #104 -->",'\
'"state":"MERGED","mergedAt":"2026-08-21T00:00:00Z",'\
'"mergeCommit":{"oid":"placeholder-104"},'\
'"commits":[{"messageBody":"conflict handoff only"}]},'\
'{"number":205,"url":"https://example.test/205",'\
'"body":"<!-- 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"
}

test_collects_labeled_and_explicit_prs() {
local output="${TEST_ROOT}/backports.json"

GH="${TEST_ROOT}/gh" bash "${SCRIPT}" \
--repository radius-project/radius --channel 0.60 \
--explicit-prs '102,104' --output "${output}"

if [[ "$(jq 'length' "${output}")" != "3" ]]; then
fail_test "expected three selected pull requests"
return
fi
if [[ "$(jq -r '.[] | select(.source_pr == 101) | .backport_merged' \
"${output}")" != "true" ]]; then
fail_test "labeled pull request should have a merged backport"
return
fi
if [[ "$(jq -r '.[] | select(.source_pr == 102) | .backport_merged' \
"${output}")" != "false" ]]; then
fail_test "explicit pull request should report its open backport"
return
fi
if [[ "$(jq -r '.[] | select(.source_pr == 104) | .backport_merged' \
"${output}")" != "false" ]]; then
fail_test "merged conflict placeholder must not satisfy the backport"
return
fi
((++PASS))
}

test_rejects_unmerged_explicit_pr() {
if GH="${TEST_ROOT}/gh" bash "${SCRIPT}" \
--repository radius-project/radius --channel 0.60 \
--explicit-prs '103' --output "${TEST_ROOT}/invalid.json" \
> /dev/null 2>&1; then
fail_test "expected an unmerged explicit pull request to fail"
return
fi
((++PASS))
}

test_ambiguous_backport_body_is_not_trusted() {
local output="${TEST_ROOT}/ambiguous.json"

GH="${TEST_ROOT}/gh" bash "${SCRIPT}" \
--repository radius-project/radius --channel 0.60 \
--explicit-prs '105' --output "${output}"

if [[ "$(jq -r '.[] | select(.source_pr == 105) | .backport_merged' \
"${output}")" != "false" ]]; then
fail_test "a body naming two sources must not satisfy the backport"
return
fi
((++PASS))
}

main() {
TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/collect-backports-test-XXXXXX")"
setup_fake_gh

test_collects_labeled_and_explicit_prs
test_rejects_unmerged_explicit_pr
test_ambiguous_backport_body_is_not_trusted

if ((FAIL > 0)); then
echo "collect release backports tests failed: ${PASS} passed, ${FAIL} failed"
exit 1
fi

echo "collect release backports tests passed (${PASS} tests)"
}

main "$@"
Loading
Loading