You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
nextcloud-docker/.github/scripts/generate-release-draft.sh

730 lines
19 KiB

#!/usr/bin/env bash
set -Eeuo pipefail
: "${GH_TOKEN:?GH_TOKEN is required}"
: "${REPO:?REPO is required}"
official_repo="docker-library/official-images"
tmp_notes="${RUNNER_TEMP}/release-notes.md"
export SKIP_RELEASE=false
export SKIP_REASON=""
export OFFICIAL_IMAGES_PR=""
export OFFICIAL_IMAGES_PR_URL=""
export PREVIOUS_OFFICIAL_IMAGES_PR=""
export PREVIOUS_OFFICIAL_IMAGES_PR_URL=""
export PREVIOUS_TAG=""
export RELEASE_TAG=""
export TARGET_SHA=""
export CHANGED_GIT_COMMITS=""
get_official_images_pr_number() {
if [[ -n "${INPUT_OFFICIAL_IMAGES_PR:-}" ]]; then
printf '%s\n' "$INPUT_OFFICIAL_IMAGES_PR"
return 0
fi
local prs_json
prs_json="$(
gh pr list \
--repo "$official_repo" \
--state merged \
--search 'label:"library/nextcloud"' \
--limit 20 \
--json number,mergedAt,url,title
)"
jq -r 'sort_by(.mergedAt) | reverse | .[0].number // empty' <<<"$prs_json"
}
extract_previous_official_images_pr_from_release_body() {
local body="$1"
local pr=""
pr="$(
{ grep -oE 'official_images_pr=[0-9]+' <<<"$body" | head -1 | cut -d= -f2; } || true
)"
if [[ -z "$pr" ]]; then
pr="$(
{ grep -oE 'docker-library/official-images/pull/[0-9]+' <<<"$body" | head -1 | grep -oE '[0-9]+$'; } || true
)"
fi
printf '%s\n' "$pr"
}
get_library_nextcloud_patch() {
local pr_number="$1"
gh api \
-H "Accept: application/vnd.github+json" \
"/repos/${official_repo}/pulls/${pr_number}/files?per_page=100" \
| jq -r '.[] | select(.filename == "library/nextcloud") | .patch // empty'
}
get_library_nextcloud_file_at_ref() {
local ref="$1"
gh api \
-H "Accept: application/vnd.github.raw+json" \
"/repos/${official_repo}/contents/library/nextcloud?ref=${ref}"
}
extract_added_gitcommits_from_patch() {
local patch="$1"
grep '^\+GitCommit:' <<<"$patch" \
| sed -n 's/^\+GitCommit: //p' \
| sort -u || true
}
join_by() {
local delimiter="$1"
shift || true
local first=1
for item in "$@"; do
if [[ $first -eq 1 ]]; then
printf '%s' "$item"
first=0
else
printf '%s%s' "$delimiter" "$item"
fi
done
}
human_join() {
local items=("$@")
local count="${#items[@]}"
if [[ "$count" -eq 0 ]]; then
return 0
elif [[ "$count" -eq 1 ]]; then
printf '%s' "${items[0]}"
elif [[ "$count" -eq 2 ]]; then
printf '%s and %s' "${items[0]}" "${items[1]}"
else
local last_index=$((count - 1))
local last="${items[$last_index]}"
unset 'items[$last_index]'
printf '%s, and %s' "$(join_by ', ' "${items[@]}")" "$last"
fi
}
extract_base_version_from_tags_csv() {
local csv="$1"
grep -oE '\b[0-9]+\.[0-9]+\.[0-9]+\b' <<<"$csv" | head -1 || true
}
csv_has_tag() {
local csv="$1"
local needle="$2"
tr ',' '\n' <<<"$csv" | sed 's/^ *//; s/ *$//' | grep -Fxq "$needle"
}
flavor_from_directory() {
local directory="$1"
case "$directory" in
*/apache) printf '%s\n' "apache" ;;
*/fpm) printf '%s\n' "fpm" ;;
*/fpm-alpine) printf '%s\n' "fpm-alpine" ;;
*) printf '%s\n' "$directory" ;;
esac
}
parse_library_file_to_state() {
local file="$1"
awk '
BEGIN {
tags = ""
gitcommit = ""
dir = ""
}
/^Tags:/ {
tags = $0
sub(/^Tags:[[:space:]]*/, "", tags)
next
}
/^GitCommit:/ {
gitcommit = $0
sub(/^GitCommit:[[:space:]]*/, "", gitcommit)
next
}
/^Directory:/ {
dir = $0
sub(/^Directory:[[:space:]]*/, "", dir)
print dir "\t" tags "\t" gitcommit
tags = ""
gitcommit = ""
dir = ""
next
}
' <<<"$file"
}
find_state_line_for_flavor() {
local state="$1"
local flavor="$2"
awk -F'\t' -v f="$flavor" '
{
dir = $1
if ((dir ~ /\/apache$/ && f=="apache") ||
(dir ~ /\/fpm$/ && f=="fpm") ||
(dir ~ /\/fpm-alpine$/ && f=="fpm-alpine")) {
print $0
}
}
' <<<"$state" | tail -1 || true
}
role_flavor_phrase() {
local role="$1"
local flavor="$2"
printf '%s `%s` tag' "$flavor" "$role"
}
summarize_role_movements_from_files() {
local role="$1"
local old_state="$2"
local new_state="$3"
local flavors=(apache fpm fpm-alpine)
local moved_flavors=()
local common_from="" common_to=""
local common_from_set=1
local common_to_set=1
for flavor in "${flavors[@]}"; do
local old_line new_line old_tags new_tags old_version new_version
old_line="$(find_state_line_for_flavor "$old_state" "$flavor")"
new_line="$(find_state_line_for_flavor "$new_state" "$flavor")"
old_tags="$(cut -f2 <<<"$old_line" || true)"
new_tags="$(cut -f2 <<<"$new_line" || true)"
old_version="$(extract_base_version_from_tags_csv "${old_tags:-}")"
new_version="$(extract_base_version_from_tags_csv "${new_tags:-}")"
if [[ -n "${old_tags:-}" && -n "${new_tags:-}" ]] \
&& csv_has_tag "$old_tags" "$role" \
&& csv_has_tag "$new_tags" "$role" \
&& [[ -n "$old_version" && -n "$new_version" && "$old_version" != "$new_version" ]]; then
moved_flavors+=("$flavor")
if [[ -z "$common_from" ]]; then
common_from="$old_version"
elif [[ "$common_from" != "$old_version" ]]; then
common_from_set=0
fi
if [[ -z "$common_to" ]]; then
common_to="$new_version"
elif [[ "$common_to" != "$new_version" ]]; then
common_to_set=0
fi
fi
done
if [[ "${#moved_flavors[@]}" -eq 3 && "$common_from_set" -eq 1 && "$common_to_set" -eq 1 ]]; then
case "$role" in
latest)
printf '%s\n' "Move \`latest\` tag from ${common_from} to ${common_to} across apache, fpm, and fpm-alpine variants"
;;
stable|production)
printf '%s\n' "Bump \`$role\` tag to ${common_to} across apache, fpm, and fpm-alpine variants"
;;
esac
return 0
fi
for flavor in "${moved_flavors[@]}"; do
local old_line new_line old_tags new_tags old_version new_version
old_line="$(find_state_line_for_flavor "$old_state" "$flavor")"
new_line="$(find_state_line_for_flavor "$new_state" "$flavor")"
old_tags="$(cut -f2 <<<"$old_line" || true)"
new_tags="$(cut -f2 <<<"$new_line" || true)"
old_version="$(extract_base_version_from_tags_csv "${old_tags:-}")"
new_version="$(extract_base_version_from_tags_csv "${new_tags:-}")"
[[ -n "$old_tags" && -n "$new_tags" ]] || continue
csv_has_tag "$old_tags" "$role" || continue
csv_has_tag "$new_tags" "$role" || continue
[[ -n "$old_version" && -n "$new_version" && "$old_version" != "$new_version" ]] || continue
case "$role" in
latest)
printf '%s\n' "Move $(role_flavor_phrase "$role" "$flavor") from ${old_version} to ${new_version}"
;;
stable|production)
printf '%s\n' "Bump $(role_flavor_phrase "$role" "$flavor") to ${new_version}"
;;
esac
done
}
summarize_new_variants_from_files() {
local old_state="$1"
local new_state="$2"
local new_pairs_file="${RUNNER_TEMP}/nextcloud-new-variants.$$"
: > "$new_pairs_file"
while IFS=$'\t' read -r new_dir new_tags new_git; do
[[ -z "${new_dir:-}" || -z "${new_tags:-}" ]] && continue
local old_line old_tags old_version new_version flavor
old_line="$(awk -F'\t' -v d="$new_dir" '$1==d {print $0}' <<<"$old_state" | tail -1 || true)"
old_tags="$(cut -f2 <<<"$old_line" || true)"
old_version="$(extract_base_version_from_tags_csv "${old_tags:-}")"
new_version="$(extract_base_version_from_tags_csv "${new_tags:-}")"
flavor="$(flavor_from_directory "$new_dir")"
if [[ -n "$new_version" && ( -z "$old_version" || "$old_version" != "$new_version" ) ]]; then
printf '%s\t%s\n' "$new_version" "$flavor" >> "$new_pairs_file"
fi
done <<<"$new_state"
sort -u -o "$new_pairs_file" "$new_pairs_file"
if [[ ! -s "$new_pairs_file" ]]; then
rm -f "$new_pairs_file"
return 0
fi
local versions
versions="$(awk -F'\t' '{print $1}' "$new_pairs_file" | sort -Vu || true)"
while read -r version; do
[[ -z "$version" ]] && continue
local flavors=()
while read -r flavor; do
[[ -z "$flavor" ]] && continue
flavors+=("$flavor")
done < <(awk -F'\t' -v v="$version" '$1==v {print $2}' "$new_pairs_file" | sort -u)
if [[ "${#flavors[@]}" -gt 0 ]]; then
printf '%s\n' "Add Nextcloud ${version} $(human_join "${flavors[@]}") variants"
fi
done <<<"$versions"
rm -f "$new_pairs_file"
}
emit_tag_change_bullets_from_files() {
local old_file="$1"
local new_file="$2"
local old_state new_state
old_state="$(parse_library_file_to_state "$old_file")"
new_state="$(parse_library_file_to_state "$new_file")"
summarize_role_movements_from_files "latest" "$old_state" "$new_state"
summarize_role_movements_from_files "stable" "$old_state" "$new_state"
summarize_role_movements_from_files "production" "$old_state" "$new_state"
summarize_new_variants_from_files "$old_state" "$new_state"
}
extract_semver_changes_from_patch() {
local patch="$1"
local new_versions
new_versions="$(
grep '^\+' <<<"$patch" \
| grep -oE '\b[0-9]+\.[0-9]+\.[0-9]+\b' \
| sort -Vu || true
)"
if [[ -n "$new_versions" ]]; then
local versions=()
while read -r v; do
[[ -z "$v" ]] && continue
versions+=("$v")
done <<<"$new_versions"
if [[ "${#versions[@]}" -gt 0 ]]; then
printf '%s\n' "Bump Nextcloud Server to $(join_by ' / ' "${versions[@]}")"
return 0
fi
fi
return 1
}
extract_dependency_bumps_from_patch() {
local patch="$1"
local emitted=0
if grep -qi 'alpine' <<<"$patch"; then
local alpine_version
alpine_version="$(
grep -oE 'Alpine[[:space:]]+[0-9]+\.[0-9]+' <<<"$patch" | tail -1 || true
)"
if [[ -n "$alpine_version" ]]; then
printf '%s\n' "Bump alpine images to ${alpine_version}"
emitted=1
fi
fi
for dep in APCu apcu imagick redis smbclient; do
local version
version="$(
grep -iE "${dep}[^0-9]*[0-9]+\.[0-9]+(\.[0-9]+)?" <<<"$patch" \
| grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' \
| tail -1 || true
)"
if [[ -n "$version" ]]; then
printf '%s\n' "Bump PHP ${dep} to ${version}"
emitted=1
fi
done
if [[ "$emitted" -eq 1 ]]; then
return 0
fi
return 1
}
render_generator_commit_bullets() {
local sha="$1"
local subject="$2"
local patch="$3"
local emitted=0
if extract_semver_changes_from_patch "$patch"; then
emitted=1
fi
if extract_dependency_bumps_from_patch "$patch"; then
emitted=1
fi
if [[ "$emitted" -eq 0 ]]; then
printf '%s\n' "${subject} (${sha:0:7})"
fi
}
render_merge_pr_bullet() {
local sha="$1"
local subject="$2"
if [[ "$subject" =~ ^Merge[[:space:]]pull[[:space:]]request[[:space:]]#([0-9]+) ]]; then
local pr_number="${BASH_REMATCH[1]}"
local pr_json
pr_json="$(
gh pr view "$pr_number" \
--repo "$REPO" \
--json number,title,url,author \
2>/dev/null || true
)"
if [[ -n "$pr_json" && "$pr_json" != "null" ]]; then
local title url author
title="$(jq -r '.title // empty' <<<"$pr_json")"
url="$(jq -r '.url // empty' <<<"$pr_json")"
author="$(jq -r '.author.login // empty' <<<"$pr_json")"
if [[ -n "$title" && -n "$url" && -n "$author" ]]; then
printf '%s\n' "${title} by @${author} in [#${pr_number}](${url})"
return 0
fi
fi
fi
return 1
}
render_subject_pr_bullet() {
local subject="$1"
local author="$2"
if [[ "$subject" =~ ^(.+)[[:space:]]\(#([0-9]+)\)$ ]]; then
local title="${BASH_REMATCH[1]}"
local pr_number="${BASH_REMATCH[2]}"
local pr_url="https://github.com/${REPO}/pull/${pr_number}"
printf '%s\n' "${title} by @${author} in [#${pr_number}](${pr_url})"
return 0
fi
return 1
}
render_regular_commit_bullet() {
local sha="$1"
local subject="$2"
local author="$3"
if render_subject_pr_bullet "$subject" "$author"; then
return 0
fi
printf '%s\n' "${subject} (${sha:0:7}) by ${author}"
}
render_commit_bullets() {
local sha="$1"
local subject="$2"
local author="$3"
local patch
patch="$(git show --format= --unified=0 "$sha")"
if render_merge_pr_bullet "$sha" "$subject"; then
return 0
fi
case "$subject" in
"Runs update.sh"*|"Run update.sh"*|"Run update.sh script"*|"Runs update.sh script"*)
render_generator_commit_bullets "$sha" "$subject" "$patch"
return 0
;;
esac
render_regular_commit_bullet "$sha" "$subject" "$author"
}
OFFICIAL_IMAGES_PR="$(get_official_images_pr_number)"
if [[ -z "$OFFICIAL_IMAGES_PR" || "$OFFICIAL_IMAGES_PR" == "null" ]]; then
SKIP_RELEASE=true
SKIP_REASON="No merged docker-library/official-images pull request with label library/nextcloud was found."
{
echo "SKIP_RELEASE=true"
echo "SKIP_REASON=$SKIP_REASON"
} >> "$GITHUB_ENV"
exit 0
fi
official_pr_json="$(
gh pr view "$OFFICIAL_IMAGES_PR" \
--repo "$official_repo" \
--json number,title,mergedAt,url,labels,author,baseRefOid,headRefOid
)"
if ! jq -e '.labels[]? | select(.name == "library/nextcloud")' <<<"$official_pr_json" >/dev/null; then
echo "Selected PR #$OFFICIAL_IMAGES_PR does not have label library/nextcloud" >&2
exit 1
fi
OFFICIAL_IMAGES_PR_URL="$(jq -r '.url' <<<"$official_pr_json")"
official_pr_merged_at="$(jq -r '.mergedAt' <<<"$official_pr_json")"
official_pr_base_oid="$(jq -r '.baseRefOid // empty' <<<"$official_pr_json")"
official_pr_head_oid="$(jq -r '.headRefOid // empty' <<<"$official_pr_json")"
if [[ -z "$official_pr_base_oid" || -z "$official_pr_head_oid" ]]; then
echo "Could not determine base/head OIDs for official-images PR #${OFFICIAL_IMAGES_PR}." >&2
exit 1
fi
existing_tags="$(
gh release list \
--repo "$REPO" \
--limit 100 \
--json tagName \
--jq '.[].tagName'
)"
if [[ -n "$existing_tags" ]]; then
while read -r tag; do
[[ -z "$tag" ]] && continue
body="$(
gh release view "$tag" \
--repo "$REPO" \
--json body \
--jq '.body // ""'
)"
if grep -qF "docker-library/official-images/pull/${OFFICIAL_IMAGES_PR}" <<<"$body"; then
SKIP_RELEASE=true
SKIP_REASON="A release already references docker-library/official-images PR #${OFFICIAL_IMAGES_PR}."
{
echo "SKIP_RELEASE=true"
echo "SKIP_REASON=$SKIP_REASON"
} >> "$GITHUB_ENV"
exit 0
fi
done <<<"$existing_tags"
fi
PREVIOUS_TAG="$(
gh release list \
--repo "$REPO" \
--exclude-drafts \
--limit 20 \
--json tagName,publishedAt \
--jq 'sort_by(.publishedAt) | reverse | .[0].tagName'
)"
if [[ -z "$PREVIOUS_TAG" || "$PREVIOUS_TAG" == "null" ]]; then
echo "Could not determine the previous published release tag." >&2
exit 1
fi
previous_release_body="$(
gh release view "$PREVIOUS_TAG" \
--repo "$REPO" \
--json body \
--jq '.body // ""'
)"
PREVIOUS_OFFICIAL_IMAGES_PR="$(
extract_previous_official_images_pr_from_release_body "$previous_release_body"
)"
if [[ -z "$PREVIOUS_OFFICIAL_IMAGES_PR" ]]; then
echo "Could not determine the previous official-images PR from release ${PREVIOUS_TAG}." >&2
exit 1
fi
previous_official_pr_json="$(
gh pr view "$PREVIOUS_OFFICIAL_IMAGES_PR" \
--repo "$official_repo" \
--json number,title,mergedAt,url
)"
PREVIOUS_OFFICIAL_IMAGES_PR_URL="$(jq -r '.url' <<<"$previous_official_pr_json")"
current_patch="$(get_library_nextcloud_patch "$OFFICIAL_IMAGES_PR")"
if [[ -z "$current_patch" ]]; then
echo "Could not find library/nextcloud patch in official-images PR #${OFFICIAL_IMAGES_PR}." >&2
exit 1
fi
old_library_file="$(get_library_nextcloud_file_at_ref "$official_pr_base_oid")"
new_library_file="$(get_library_nextcloud_file_at_ref "$official_pr_head_oid")"
if [[ -z "$old_library_file" || -z "$new_library_file" ]]; then
echo "Could not fetch library/nextcloud contents for base/head of official-images PR #${OFFICIAL_IMAGES_PR}." >&2
exit 1
fi
added_git_commits="$(
extract_added_gitcommits_from_patch "$current_patch"
)"
if [[ -n "$added_git_commits" ]]; then
while read -r sha; do
[[ -z "$sha" ]] && continue
if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
git fetch --quiet origin "$sha" || true
fi
if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
echo "GitCommit ${sha} is not available locally." >&2
exit 1
fi
done <<<"$added_git_commits"
fi
if [[ -n "${INPUT_RELEASE_TAG:-}" ]]; then
RELEASE_TAG="${INPUT_RELEASE_TAG}"
else
year_month="$(date -u +v%Y.%m)"
existing_month_tags="$(
gh release list \
--repo "$REPO" \
--limit 100 \
--json tagName \
--jq '.[].tagName' \
| grep "^${year_month}\." || true
)"
next_n=1
if [[ -n "$existing_month_tags" ]]; then
max_n="$(
sed -n "s/^${year_month//./\\.}\.\([0-9][0-9]*\)$/\1/p" <<<"$existing_month_tags" \
| sort -n \
| tail -1
)"
if [[ -n "$max_n" ]]; then
next_n=$((max_n + 1))
fi
fi
RELEASE_TAG="${year_month}.${next_n}"
fi
TARGET_SHA="$(git rev-parse refs/remotes/origin/master)"
tag_bullets="$(
emit_tag_change_bullets_from_files "$old_library_file" "$new_library_file" || true
)"
commit_bullets="$(
if [[ -n "$added_git_commits" ]]; then
while read -r sha; do
[[ -z "$sha" ]] && continue
subject="$(git log -1 --format=%s "$sha")"
author="$(git log -1 --format=%an "$sha")"
render_commit_bullets "$sha" "$subject" "$author"
done <<<"$added_git_commits"
fi
)"
bullet_lines="$(
{
printf '%s\n' "$tag_bullets"
printf '%s\n' "$commit_bullets"
} | sed '/^$/d' | awk '!seen[$0]++'
)"
change_count=0
if [[ -n "$bullet_lines" ]]; then
change_count="$(printf '%s\n' "$bullet_lines" | grep -c '^' | tr -d ' ')"
fi
current_git_range_display="$(
paste -sd',' <(printf '%s\n' "$added_git_commits") || true
)"
{
echo "## What's Changed"
echo
if [[ "$change_count" -eq 0 ]]; then
echo "* No relevant changes were inferred from official-images PR #${OFFICIAL_IMAGES_PR}"
else
while IFS= read -r line; do
[[ -z "$line" ]] && continue
echo "* $line"
done <<<"$bullet_lines"
fi
echo
echo "**Full Changelog**:"
echo "* Image: [${REPO} ${PREVIOUS_TAG}...${TARGET_SHA:0:7}](https://github.com/${REPO}/compare/${PREVIOUS_TAG}...${TARGET_SHA})"
echo "* Nextcloud Server: [Changelog](https://nextcloud.com/changelog/)"
echo "* Docker Official Image: [${official_repo}#${OFFICIAL_IMAGES_PR}](${OFFICIAL_IMAGES_PR_URL})"
echo
echo "<!-- release-meta:"
echo "official_images_pr=${OFFICIAL_IMAGES_PR}"
echo "official_images_merged_at=${official_pr_merged_at}"
echo "previous_official_images_pr=${PREVIOUS_OFFICIAL_IMAGES_PR}"
echo "previous_tag=${PREVIOUS_TAG}"
echo "changed_git_commits=${current_git_range_display}"
echo "target_sha=${TARGET_SHA}"
echo "generated_at=$(date -u +%FT%TZ)"
echo "-->"
} > "$tmp_notes"
{
echo "SKIP_RELEASE=false"
echo "OFFICIAL_IMAGES_PR=$OFFICIAL_IMAGES_PR"
echo "OFFICIAL_IMAGES_PR_URL=$OFFICIAL_IMAGES_PR_URL"
echo "PREVIOUS_OFFICIAL_IMAGES_PR=$PREVIOUS_OFFICIAL_IMAGES_PR"
echo "PREVIOUS_OFFICIAL_IMAGES_PR_URL=$PREVIOUS_OFFICIAL_IMAGES_PR_URL"
echo "PREVIOUS_TAG=$PREVIOUS_TAG"
echo "CHANGED_GIT_COMMITS<<EOF"
printf '%s\n' "$added_git_commits"
echo "EOF"
echo "RELEASE_TAG=$RELEASE_TAG"
echo "TARGET_SHA=$TARGET_SHA"
} >> "$GITHUB_ENV"
echo "Prepared release draft notes at $tmp_notes"