mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-16 06:02:27 +02:00
966 lines
32 KiB
Bash
Executable File
966 lines
32 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -eo pipefail
|
||
|
||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||
WORKING_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)"
|
||
if ! cd "${WORKING_DIR}" >/dev/null 2>&1; then
|
||
echo "🧨 Error: Unable to change directory to repo root: ${WORKING_DIR}" >&2
|
||
exit 1
|
||
fi
|
||
|
||
TMPDIR_ORIG=$TMPDIR
|
||
RESOURCES_DIR="$WORKING_DIR/resources"
|
||
RELEASE_DIR="$WORKING_DIR/release"
|
||
FRONTEND_DIR="${WORKING_DIR}/frontend"
|
||
FRONTEND_BUILD_DIR="${FRONTEND_DIR}/dist"
|
||
BACKEND_DIR="${WORKING_DIR}/backend/app"
|
||
TARGET_DIR="${WORKING_DIR}/target"
|
||
BRANCH=$(git branch --show-current)
|
||
START_BRANCH="${BRANCH}"
|
||
START_HEAD="$(git rev-parse HEAD)"
|
||
RUN_KEY=""
|
||
DOCKER_BUILD_RUN_ID=""
|
||
ORIGIN_MASTER_AFTER_BUMP_SHA=""
|
||
ORIGIN_DEVELOP_BEFORE_SHA=""
|
||
ORIGIN_DEVELOP_AFTER_FF_SHA=""
|
||
GITHUB_MASTER_AFTER_BUMP_SHA=""
|
||
GITHUB_DEVELOP_BEFORE_SHA=""
|
||
GITHUB_DEVELOP_AFTER_FF_SHA=""
|
||
DEVELOP_BRANCH_FROZEN="false"
|
||
DEVELOP_BRANCH_PROTECTION_CREATED="false"
|
||
|
||
die() {
|
||
echo "🧨 Error: $*" >&2
|
||
exit 1
|
||
}
|
||
|
||
log_sha() {
|
||
local label="$1"
|
||
local value="${2:-}"
|
||
if [ -n "${value}" ]; then
|
||
echo "🔎 ${label}: ${value}"
|
||
else
|
||
echo "🔎 ${label}: <empty>"
|
||
fi
|
||
}
|
||
|
||
csv_contains() {
|
||
local csv="${1:-}"
|
||
local needle="${2:-}"
|
||
[[ ",${csv}," == *",${needle},"* ]]
|
||
}
|
||
|
||
csv_contains_any() {
|
||
local csv="${1:-}"
|
||
shift || true
|
||
local needle
|
||
for needle in "$@"; do
|
||
if csv_contains "${csv}" "${needle}"; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
gh_token_scopes_csv() {
|
||
local scopes
|
||
|
||
scopes="$(
|
||
gh auth status 2>/dev/null \
|
||
| awk -F'Token scopes: ' '/Token scopes:/{print $2; exit}' \
|
||
|| true
|
||
)"
|
||
scopes="$(echo "${scopes}" | tr -d "'" | tr -d '"' | tr -d ' ' || true)"
|
||
if [ -n "${scopes}" ]; then
|
||
echo "${scopes}"
|
||
return 0
|
||
fi
|
||
|
||
scopes="$(
|
||
gh api -i user 2>/dev/null \
|
||
| awk -F': ' 'tolower($1)=="x-oauth-scopes"{print $2; exit}' \
|
||
|| true
|
||
)"
|
||
scopes="$(echo "${scopes}" | tr -d ' ' || true)"
|
||
echo "${scopes}"
|
||
}
|
||
|
||
gh_guard_permissions() {
|
||
local scopes_csv
|
||
local -a missing_required
|
||
scopes_csv="$(gh_token_scopes_csv || true)"
|
||
|
||
if [ -z "${scopes_csv}" ]; then
|
||
echo "⚠️ Could not determine GitHub token scopes; skipping scope validation." >&2
|
||
return 0
|
||
fi
|
||
|
||
missing_required=()
|
||
|
||
# Required for workflow dispatch + repo administration.
|
||
if ! csv_contains_any "${scopes_csv}" repo public_repo; then
|
||
missing_required+=("repo (or public_repo for public repos)")
|
||
fi
|
||
if ! csv_contains "${scopes_csv}" workflow; then
|
||
missing_required+=("workflow")
|
||
fi
|
||
|
||
if [ "${#missing_required[@]}" -gt 0 ]; then
|
||
echo "🧨 Missing required GitHub token scopes: ${missing_required[*]}" >&2
|
||
echo " Fix: gh auth refresh -h github.com -s repo -s workflow" >&2
|
||
echo " (or re-auth with a PAT that has these scopes via 'gh auth login --with-token')" >&2
|
||
die "Insufficient GitHub token scopes."
|
||
fi
|
||
|
||
if [ "${FREEZE_DEVELOP_BRANCH:-1}" != "0" ]; then
|
||
local is_admin
|
||
is_admin="$(gh api "repos/{owner}/{repo}" --jq '.permissions.admin' 2>/dev/null || true)"
|
||
if [ "${is_admin}" != "true" ]; then
|
||
die "GitHub account lacks admin permission for {owner}/{repo} (required to lock/unlock 'develop'). Set FREEZE_DEVELOP_BRANCH=0 to skip."
|
||
fi
|
||
fi
|
||
|
||
gh_guard_ghcr_cleanup_permissions() {
|
||
local owner owner_type image list_endpoint
|
||
owner="$(gh repo view --json owner -q .owner.login 2>/dev/null || true)"
|
||
if [ -z "${owner}" ]; then
|
||
echo "🧨 Could not resolve repo owner for GHCR cleanup permission check." >&2
|
||
return 1
|
||
fi
|
||
|
||
owner_type="$(gh api "users/${owner}" --jq .type 2>/dev/null || true)"
|
||
if [ -z "${owner_type}" ]; then
|
||
owner_type="User"
|
||
fi
|
||
|
||
for image in tuliprox tuliprox-alpine; do
|
||
if [ "${owner_type}" = "Organization" ]; then
|
||
list_endpoint="orgs/${owner}/packages/container/${image}/versions?per_page=1"
|
||
else
|
||
list_endpoint="users/${owner}/packages/container/${image}/versions?per_page=1"
|
||
fi
|
||
|
||
if ! gh api "${list_endpoint}" >/dev/null 2>&1; then
|
||
echo "🧨 Missing GitHub permissions for GHCR cleanup: cannot list container package versions for '${owner}/${image}'." >&2
|
||
echo " Fix: gh auth refresh -h github.com -s read:packages -s write:packages -s delete:packages" >&2
|
||
echo " Or skip cleanup: CLEANUP_DOCKER_IMAGES_ON_FAILURE=0" >&2
|
||
return 1
|
||
fi
|
||
done
|
||
|
||
return 0
|
||
}
|
||
|
||
if [ "${CLEANUP_DOCKER_IMAGES_ON_FAILURE:-1}" != "0" ]; then
|
||
gh_guard_ghcr_cleanup_permissions || die "Insufficient GitHub permissions for GHCR cleanup."
|
||
fi
|
||
}
|
||
|
||
extract_release_notes_from_changelog() {
|
||
local version="${1#v}"
|
||
local changelog_file="${2:-${WORKING_DIR}/CHANGELOG.md}"
|
||
|
||
if [ -z "${version}" ]; then
|
||
return 1
|
||
fi
|
||
if [ ! -f "${changelog_file}" ]; then
|
||
return 1
|
||
fi
|
||
|
||
local notes
|
||
notes="$(
|
||
awk -v ver="${version}" '
|
||
function is_version_heading(line) {
|
||
return (line ~ "^#{1,6}[[:space:]]+v?[0-9]+\\.[0-9]+\\.[0-9]+")
|
||
}
|
||
BEGIN { in_section = 0 }
|
||
{
|
||
if ($0 ~ ("^#{1,6}[[:space:]]+v?" ver "([[:space:]]|$)")) {
|
||
in_section = 1
|
||
next
|
||
}
|
||
if (in_section && is_version_heading($0)) {
|
||
exit
|
||
}
|
||
if (in_section) {
|
||
print
|
||
}
|
||
}
|
||
' "${changelog_file}"
|
||
)"
|
||
|
||
if [ -z "$(echo "${notes}" | tr -d '[:space:]')" ]; then
|
||
return 1
|
||
fi
|
||
|
||
printf "%s\n" "${notes}"
|
||
}
|
||
|
||
gh_cleanup_docker_images_on_failure() {
|
||
local run_id="${1:-}"
|
||
|
||
if [ "${CLEANUP_DOCKER_IMAGES_ON_FAILURE:-1}" = "0" ]; then
|
||
echo "🧹 Skipping GHCR cleanup (CLEANUP_DOCKER_IMAGES_ON_FAILURE=0)" >&2
|
||
return 0
|
||
fi
|
||
|
||
local bump_version="${BUMP_VERSION:-}"
|
||
bump_version="${bump_version#v}"
|
||
if [ -z "${bump_version}" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if ! command -v gh >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
if ! gh auth status >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
if [ -n "${run_id}" ]; then
|
||
local status conclusion
|
||
for _ in {1..30}; do
|
||
status="$(gh run view "${run_id}" --json status --jq .status 2>/dev/null || true)"
|
||
conclusion="$(gh run view "${run_id}" --json conclusion --jq .conclusion 2>/dev/null || true)"
|
||
if [ "$status" = "completed" ] && [ "$conclusion" = "cancelled" ]; then
|
||
break
|
||
fi
|
||
sleep 2
|
||
done
|
||
echo "🔎 docker-build run ${run_id}: status=${status:-unknown} conclusion=${conclusion:-unknown}" >&2
|
||
fi
|
||
|
||
local owner
|
||
owner="$(gh repo view --json owner -q .owner.login 2>/dev/null || true)"
|
||
if [ -z "${owner}" ]; then
|
||
echo "⚠️ Could not resolve repo owner for GHCR cleanup; skipping." >&2
|
||
return 0
|
||
fi
|
||
|
||
local owner_type
|
||
owner_type="$(gh api "users/${owner}" --jq .type 2>/dev/null || true)"
|
||
if [ -z "${owner_type}" ]; then
|
||
owner_type="User"
|
||
fi
|
||
|
||
local previous_version="${VERSION:-}"
|
||
previous_version="${previous_version#v}"
|
||
|
||
declare -a images=("tuliprox" "tuliprox-alpine")
|
||
for image in "${images[@]}"; do
|
||
local restored_latest="false"
|
||
|
||
# Best-effort: restore ':latest' to the previously released version before deleting the failed release tag.
|
||
if [ -n "${previous_version}" ] && [ "${previous_version}" != "${bump_version}" ]; then
|
||
if command -v docker >/dev/null 2>&1 && docker buildx version >/dev/null 2>&1; then
|
||
local gh_user
|
||
gh_user="$(gh api user --jq .login 2>/dev/null || true)"
|
||
if [ -n "${gh_user}" ]; then
|
||
echo "⏪ Restoring ghcr.io/${owner}/${image}:latest -> ${previous_version}" >&2
|
||
if gh auth token 2>/dev/null | docker login ghcr.io --username "${gh_user}" --password-stdin >/dev/null 2>&1; then
|
||
if docker buildx imagetools create \
|
||
-t "ghcr.io/${owner}/${image}:latest" \
|
||
"ghcr.io/${owner}/${image}:${previous_version}" \
|
||
>/dev/null 2>&1; then
|
||
restored_latest="true"
|
||
else
|
||
echo "⚠️ Failed to restore ':latest' for ${image} -> ${previous_version}. 'latest' may disappear if we delete the failed version." >&2
|
||
restored_latest="false"
|
||
fi
|
||
else
|
||
echo "⚠️ Failed to login to ghcr.io; cannot restore ':latest' for ${image}." >&2
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
echo "🗑 Deleting GHCR images for failed release tag '${bump_version}' (${image})" >&2
|
||
|
||
local list_endpoint delete_prefix
|
||
if [ "${owner_type}" = "Organization" ]; then
|
||
list_endpoint="orgs/${owner}/packages/container/${image}/versions?per_page=100"
|
||
delete_prefix="orgs/${owner}/packages/container/${image}/versions"
|
||
else
|
||
list_endpoint="users/${owner}/packages/container/${image}/versions?per_page=100"
|
||
delete_prefix="users/${owner}/packages/container/${image}/versions"
|
||
fi
|
||
|
||
local version_ids gh_err
|
||
if ! version_ids="$(
|
||
gh api "${list_endpoint}" \
|
||
--jq ".[] | select((.metadata.container.tags // []) | index(\"${bump_version}\")) | [.id, (((.metadata.container.tags // []) | index(\"latest\")) != null)] | @tsv" \
|
||
2>&1
|
||
)"; then
|
||
gh_err="${version_ids}"
|
||
echo "⚠️ Failed to list GHCR package versions for ${image}. Skipping delete. (${gh_err})" >&2
|
||
continue
|
||
fi
|
||
|
||
# Defensive: avoid treating API error JSON as an ID.
|
||
version_ids="$(echo "${version_ids}" | awk -F'\t' '$1 ~ /^[0-9]+$/ { print }' || true)"
|
||
|
||
if [ -z "${version_ids}" ]; then
|
||
echo "ℹ️ No GHCR package versions found for ${image}:${bump_version}" >&2
|
||
continue
|
||
fi
|
||
|
||
while IFS=$'\t' read -r vid has_latest; do
|
||
[ -z "${vid}" ] && continue
|
||
if [ "${has_latest}" = "true" ] && [ "${restored_latest}" != "true" ]; then
|
||
echo "⚠️ Skipping delete of GHCR package version id ${vid} for ${image}:${bump_version} because it contains the 'latest' tag and we could not restore it." >&2
|
||
continue
|
||
fi
|
||
echo "🗑 Deleting package version id ${vid} (${image}:${bump_version})" >&2
|
||
gh api -X DELETE "${delete_prefix}/${vid}" >/dev/null 2>&1 || true
|
||
done <<< "${version_ids}"
|
||
done
|
||
}
|
||
|
||
gh_freeze_develop_branch() {
|
||
if [ "${FREEZE_DEVELOP_BRANCH:-1}" = "0" ]; then
|
||
echo "🧊 Skipping develop branch freeze (FREEZE_DEVELOP_BRANCH=0)"
|
||
return 0
|
||
fi
|
||
|
||
if ! command -v gh >/dev/null 2>&1; then
|
||
die "gh is required to freeze develop branch (install GitHub CLI)."
|
||
fi
|
||
gh auth status >/dev/null 2>&1 || die "Not logged into GitHub CLI. Run 'gh auth login' first."
|
||
|
||
echo "🔒 Freezing pushes to 'develop' (branch lock)"
|
||
|
||
if gh api "repos/{owner}/{repo}/branches/develop/protection" >/dev/null 2>&1; then
|
||
DEVELOP_BRANCH_PROTECTION_CREATED="false"
|
||
else
|
||
DEVELOP_BRANCH_PROTECTION_CREATED="true"
|
||
fi
|
||
|
||
if ! gh api -X PUT "repos/{owner}/{repo}/branches/develop/protection" --input - >/dev/null 2>&1 <<'JSON'
|
||
{
|
||
"required_status_checks": null,
|
||
"enforce_admins": true,
|
||
"required_pull_request_reviews": null,
|
||
"restrictions": null,
|
||
"lock_branch": true
|
||
}
|
||
JSON
|
||
then
|
||
die "Failed to enable/lock branch protection for 'develop'."
|
||
fi
|
||
|
||
DEVELOP_BRANCH_FROZEN="true"
|
||
}
|
||
|
||
gh_stop_actions_develop_branch() {
|
||
if [ "${STOP_DEVELOP_ACTIONS_ON_RELEASE:-1}" = "0" ]; then
|
||
echo "🛑 Skipping develop workflow cancellation (STOP_DEVELOP_ACTIONS_ON_RELEASE=0)"
|
||
return 0
|
||
fi
|
||
|
||
if ! command -v gh >/dev/null 2>&1; then
|
||
die "gh is required to stop workflows on develop (install GitHub CLI)."
|
||
fi
|
||
gh auth status >/dev/null 2>&1 || die "Not logged into GitHub CLI. Run 'gh auth login' first."
|
||
|
||
echo "🛑 Stopping GitHub Actions workflows on 'develop' (queued/in_progress)"
|
||
|
||
local runs
|
||
runs="$(
|
||
gh run list \
|
||
-b develop \
|
||
-L 50 \
|
||
--json databaseId,status,workflowName,displayTitle \
|
||
--jq '.[] | select(.status=="queued" or .status=="in_progress") | "\(.databaseId)\t\(.status)\t\(.workflowName)\t\(.displayTitle)"' \
|
||
2>/dev/null || true
|
||
)"
|
||
|
||
if [ -z "${runs}" ]; then
|
||
echo "✅ No queued/in_progress workflow runs found on 'develop'."
|
||
return 0
|
||
fi
|
||
|
||
local fail_count=0
|
||
local total_count=0
|
||
local id status workflow_name title
|
||
while IFS=$'\t' read -r id status workflow_name title; do
|
||
[ -z "${id}" ] && continue
|
||
total_count=$((total_count + 1))
|
||
echo "🛑 Cancelling run ${id} (${status}) - ${workflow_name}: ${title}" >&2
|
||
if ! gh run cancel "${id}" >/dev/null 2>&1; then
|
||
echo "⚠️ Failed to cancel run ${id}" >&2
|
||
fail_count=$((fail_count + 1))
|
||
fi
|
||
done <<< "${runs}"
|
||
|
||
if [ "${fail_count}" -gt 0 ]; then
|
||
die "Failed to cancel ${fail_count}/${total_count} workflow runs on 'develop'. Check your permissions (scopes: repo, workflow)."
|
||
fi
|
||
|
||
# Best-effort: wait until the queue is drained so the release doesn't race ongoing develop jobs.
|
||
local remaining
|
||
for _ in {1..20}; do
|
||
remaining="$(
|
||
gh run list \
|
||
-b develop \
|
||
-L 50 \
|
||
--json status \
|
||
--jq '[.[] | select(.status=="queued" or .status=="in_progress")] | length' \
|
||
2>/dev/null || echo "0"
|
||
)"
|
||
if [ "${remaining}" = "0" ]; then
|
||
echo "✅ All develop workflows are stopped."
|
||
return 0
|
||
fi
|
||
sleep 3
|
||
done
|
||
|
||
echo "⚠️ Some develop workflows are still not stopped (remaining=${remaining:-unknown}). Continuing anyway." >&2
|
||
}
|
||
|
||
gh_unfreeze_develop_branch() {
|
||
if [ "${DEVELOP_BRANCH_FROZEN}" != "true" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if ! command -v gh >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
if ! gh auth status >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
|
||
echo "🔓 Unfreezing pushes to 'develop'"
|
||
|
||
if [ "${DEVELOP_BRANCH_PROTECTION_CREATED}" = "true" ]; then
|
||
gh api -X DELETE "repos/{owner}/{repo}/branches/develop/protection" >/dev/null 2>&1 || true
|
||
else
|
||
# Best-effort unlock (keep existing protection settings)
|
||
gh api -X PUT "repos/{owner}/{repo}/branches/develop/protection" --input - >/dev/null 2>&1 <<'JSON' || true
|
||
{
|
||
"required_status_checks": null,
|
||
"enforce_admins": true,
|
||
"required_pull_request_reviews": null,
|
||
"restrictions": null,
|
||
"lock_branch": false
|
||
}
|
||
JSON
|
||
fi
|
||
DEVELOP_BRANCH_FROZEN="false"
|
||
}
|
||
|
||
cleanup() {
|
||
exit_code=$?
|
||
|
||
echo "🧰 Restoring TMPDIR to original value"
|
||
if [ -n "${TMPDIR_ORIG:-}" ]; then
|
||
export TMPDIR="$TMPDIR_ORIG"
|
||
else
|
||
unset TMPDIR
|
||
fi
|
||
|
||
# Some tools (rustc/gh) fail when the current directory was removed (e.g., 'cargo clean'
|
||
# executed while being inside ./target). Re-anchor in the repo root for best-effort cleanup.
|
||
cd "${WORKING_DIR}" >/dev/null 2>&1 || true
|
||
|
||
gh_unfreeze_develop_branch || true
|
||
if [ "${exit_code}" -eq 0 ]; then
|
||
return
|
||
fi
|
||
|
||
echo "🧹 Cleanup after failure (exit ${exit_code})" >&2
|
||
log_sha "START_BRANCH" "${START_BRANCH}" >&2
|
||
log_sha "START_HEAD" "${START_HEAD}" >&2
|
||
log_sha "ORIGIN_MASTER_AFTER_BUMP_SHA" "${ORIGIN_MASTER_AFTER_BUMP_SHA}" >&2
|
||
log_sha "ORIGIN_DEVELOP_BEFORE_SHA" "${ORIGIN_DEVELOP_BEFORE_SHA}" >&2
|
||
log_sha "ORIGIN_DEVELOP_AFTER_FF_SHA" "${ORIGIN_DEVELOP_AFTER_FF_SHA}" >&2
|
||
log_sha "GITHUB_MASTER_AFTER_BUMP_SHA" "${GITHUB_MASTER_AFTER_BUMP_SHA}" >&2
|
||
log_sha "GITHUB_DEVELOP_BEFORE_SHA" "${GITHUB_DEVELOP_BEFORE_SHA}" >&2
|
||
log_sha "GITHUB_DEVELOP_AFTER_FF_SHA" "${GITHUB_DEVELOP_AFTER_FF_SHA}" >&2
|
||
log_sha "RUN_KEY" "${RUN_KEY}" >&2
|
||
log_sha "DOCKER_BUILD_RUN_ID" "${DOCKER_BUILD_RUN_ID}" >&2
|
||
|
||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||
run_id="${DOCKER_BUILD_RUN_ID:-}"
|
||
if [ -z "${run_id}" ] && [ -n "${RUN_KEY:-}" ]; then
|
||
run_id="$(
|
||
gh run list \
|
||
-w docker-build.yml \
|
||
-e workflow_dispatch \
|
||
-b master \
|
||
-L 20 \
|
||
--json databaseId,displayTitle \
|
||
--jq ".[] | select(.displayTitle | contains(\"${RUN_KEY}\")) | .databaseId" \
|
||
| head -n 1
|
||
)"
|
||
fi
|
||
|
||
if [ -n "${run_id}" ]; then
|
||
status="$(gh run view "${run_id}" --json status --jq .status 2>/dev/null || true)"
|
||
if [ -n "${status}" ] && [ "${status}" != "completed" ]; then
|
||
echo "🛑 Cancelling docker-build workflow run ${run_id}" >&2
|
||
gh run cancel "${run_id}" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
gh_cleanup_docker_images_on_failure "${run_id}"
|
||
fi
|
||
fi
|
||
|
||
echo "↩️ Resetting local git state to ${START_BRANCH}@${START_HEAD}" >&2
|
||
git reset --hard "${START_HEAD}" >/dev/null 2>&1 || true
|
||
git checkout -f "${START_BRANCH}" >/dev/null 2>&1 || true
|
||
|
||
if [ -n "${ORIGIN_MASTER_AFTER_BUMP_SHA}" ]; then
|
||
echo "⏪ Reverting remote 'origin/master' (force-with-lease)" >&2
|
||
git push --force-with-lease=refs/heads/master:"${ORIGIN_MASTER_AFTER_BUMP_SHA}" origin "${START_HEAD}:refs/heads/master" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
if [ -n "${ORIGIN_DEVELOP_AFTER_FF_SHA}" ] && [ -n "${ORIGIN_DEVELOP_BEFORE_SHA}" ]; then
|
||
echo "⏪ Reverting remote 'origin/develop' (force-with-lease)" >&2
|
||
git push --force-with-lease=refs/heads/develop:"${ORIGIN_DEVELOP_AFTER_FF_SHA}" origin "${ORIGIN_DEVELOP_BEFORE_SHA}:refs/heads/develop" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
if [ -n "${GITHUB_MASTER_AFTER_BUMP_SHA}" ]; then
|
||
echo "⏪ Reverting remote 'github/master' (force-with-lease)" >&2
|
||
git push --force-with-lease=refs/heads/master:"${GITHUB_MASTER_AFTER_BUMP_SHA}" github "${START_HEAD}:refs/heads/master" >/dev/null 2>&1 || true
|
||
fi
|
||
|
||
if [ -n "${GITHUB_DEVELOP_AFTER_FF_SHA}" ] && [ -n "${GITHUB_DEVELOP_BEFORE_SHA}" ]; then
|
||
echo "⏪ Reverting remote 'github/develop' (force-with-lease)" >&2
|
||
git push --force-with-lease=refs/heads/develop:"${GITHUB_DEVELOP_AFTER_FF_SHA}" github "${GITHUB_DEVELOP_BEFORE_SHA}:refs/heads/develop" >/dev/null 2>&1 || true
|
||
fi
|
||
}
|
||
|
||
trap cleanup EXIT INT TERM
|
||
|
||
# Validate release strategy
|
||
if [ $# -ne 1 ]; then
|
||
die "Release strategy required (major|minor)"
|
||
fi
|
||
|
||
# Validate we're creating the release from master branch
|
||
if [ "$BRANCH" != "master" ]; then
|
||
die "Creating the release from your current branch '${BRANCH}' is prohibited!"
|
||
fi
|
||
|
||
# Guards: ensure we're releasing the right state
|
||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||
die "Working tree is not clean. Commit/stash your changes first."
|
||
fi
|
||
|
||
echo "🧭 Release starting on '${START_BRANCH}'"
|
||
log_sha "START_HEAD" "${START_HEAD}"
|
||
|
||
git fetch --quiet --tags origin master develop || die "Failed to fetch from 'origin'."
|
||
|
||
ORIGIN_MASTER_SHA="$(git rev-parse origin/master)"
|
||
ORIGIN_DEVELOP_SHA="$(git rev-parse origin/develop)"
|
||
log_sha "origin/master" "${ORIGIN_MASTER_SHA}"
|
||
log_sha "origin/develop" "${ORIGIN_DEVELOP_SHA}"
|
||
|
||
if [ "$(git rev-parse HEAD)" != "${ORIGIN_MASTER_SHA}" ]; then
|
||
die "Local 'master' is not at 'origin/master'. Please pull/push before releasing."
|
||
fi
|
||
|
||
if ! git merge-base --is-ancestor origin/develop HEAD; then
|
||
die "'master' does not contain 'origin/develop'. Merge develop into master before releasing."
|
||
fi
|
||
|
||
ORIGIN_DEVELOP_BEFORE_SHA="$(git rev-parse origin/develop)"
|
||
log_sha "ORIGIN_DEVELOP_BEFORE_SHA (saved)" "${ORIGIN_DEVELOP_BEFORE_SHA}"
|
||
if git remote get-url github >/dev/null 2>&1; then
|
||
git fetch --quiet github develop || die "Failed to fetch from 'github'."
|
||
GITHUB_DEVELOP_BEFORE_SHA="$(git rev-parse github/develop)"
|
||
log_sha "GITHUB_DEVELOP_BEFORE_SHA (saved)" "${GITHUB_DEVELOP_BEFORE_SHA}"
|
||
fi
|
||
|
||
LAST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || true)"
|
||
if [ -n "$LAST_TAG" ]; then
|
||
if git diff --quiet "${LAST_TAG}..HEAD" -- backend frontend shared resources docker config Cargo.toml Cargo.lock; then
|
||
die "No relevant changes since last release tag '${LAST_TAG}'."
|
||
fi
|
||
fi
|
||
|
||
if ! command -v gh &> /dev/null; then
|
||
die "GitHub CLI could not be found. Please install gh toolset: https://cli.github.com"
|
||
fi
|
||
|
||
gh auth status >/dev/null 2>&1 || die "Not logged into GitHub CLI. Run 'gh auth login' first."
|
||
gh_guard_permissions
|
||
|
||
# Read current tag on HEAD
|
||
VERSION="$(git describe --tags --exact-match 2>/dev/null || true)"
|
||
if [ -z "${VERSION}" ]; then
|
||
VERSION="$(git describe --tags --abbrev=0 2>/dev/null || true)"
|
||
fi
|
||
|
||
if [ -z "${VERSION}" ]; then
|
||
die "Failed to read current tag."
|
||
fi
|
||
|
||
case "$1" in
|
||
major) ./bin/inc_version.sh m ;;
|
||
minor) ./bin/inc_version.sh p ;;
|
||
*) die "Unknown option '$1' (expected: major|minor)" ;;
|
||
esac
|
||
|
||
# Marker: version bump push + trigger docker-build workflow (master)
|
||
echo "📦 Committing version bump"
|
||
echo
|
||
FILES=(Cargo.toml Cargo.lock backend/*/Cargo.toml frontend/Cargo.toml shared/Cargo.toml)
|
||
for f in "${FILES[@]}"; do
|
||
if [ -f "$f" ]; then
|
||
git add "$f"
|
||
fi
|
||
done
|
||
|
||
if git diff --cached --quiet; then
|
||
die "Version bump produced no changes to commit."
|
||
fi
|
||
|
||
BUMP_VERSION="$(grep -Po '^version\s*=\s*"\K[0-9]+\.[0-9]+\.[0-9]+' "${WORKING_DIR}/Cargo.toml" | head -n1)"
|
||
if [ -z "${BUMP_VERSION}" ]; then
|
||
die "Failed to read workspace version from '${WORKING_DIR}/Cargo.toml' after bump."
|
||
fi
|
||
|
||
RUN_KEY="$(uuidgen 2>/dev/null || true)"
|
||
if [ -z "${RUN_KEY}" ]; then
|
||
RUN_KEY="run-$(date +%s)-$$"
|
||
fi
|
||
|
||
RUN_KEY="${1}-${RUN_KEY}"
|
||
|
||
log_sha "VERSION (current)" "${VERSION}"
|
||
log_sha "VERSION (release)" "v${BUMP_VERSION}"
|
||
log_sha "RUN_KEY (saved)" "${RUN_KEY}"
|
||
|
||
if ! extract_release_notes_from_changelog "v${BUMP_VERSION}" "${WORKING_DIR}/CHANGELOG.md" >/dev/null; then
|
||
die "CHANGELOG.md does not contain release notes for version 'v${BUMP_VERSION}'."
|
||
fi
|
||
|
||
read -rp "Releasing version: '${BUMP_VERSION}', please confirm? [y/N] " answer
|
||
|
||
# Default 'N', cancel, if not 'y' or 'Y'
|
||
if [[ ! "$answer" =~ ^[Yy]$ ]]; then
|
||
die "Canceled."
|
||
fi
|
||
|
||
gh_freeze_develop_branch
|
||
|
||
gh_stop_actions_develop_branch
|
||
|
||
echo "🧰 Setting TMPDIR to '$PWD/dev/tmp' for isolated builds"
|
||
TMPDIR_WORK="${WORKING_DIR}/dev/tmp"
|
||
mkdir -p "${TMPDIR_WORK}" || die "Failed to create TMPDIR: ${TMPDIR_WORK}"
|
||
if [ ! -d "${TMPDIR_WORK}" ] || [ ! -w "${TMPDIR_WORK}" ]; then
|
||
die "TMPDIR is not writable: ${TMPDIR_WORK}"
|
||
fi
|
||
export TMPDIR="${TMPDIR_WORK}"
|
||
|
||
git commit -m "ci: bump version v${BUMP_VERSION}"
|
||
git push origin HEAD:master
|
||
ORIGIN_MASTER_AFTER_BUMP_SHA="$(git rev-parse HEAD)"
|
||
log_sha "ORIGIN_MASTER_AFTER_BUMP_SHA (saved)" "${ORIGIN_MASTER_AFTER_BUMP_SHA}"
|
||
ORIGIN_MASTER_REMOTE_SHA="$(git ls-remote origin refs/heads/master | awk '{print $1}' | head -n 1)"
|
||
log_sha "origin/master (remote)" "${ORIGIN_MASTER_REMOTE_SHA}"
|
||
if git remote get-url github >/dev/null 2>&1; then
|
||
git push github HEAD:master
|
||
GITHUB_MASTER_AFTER_BUMP_SHA="${ORIGIN_MASTER_AFTER_BUMP_SHA}"
|
||
log_sha "GITHUB_MASTER_AFTER_BUMP_SHA (saved)" "${GITHUB_MASTER_AFTER_BUMP_SHA}"
|
||
GITHUB_MASTER_REMOTE_SHA="$(git ls-remote github refs/heads/master | awk '{print $1}' | head -n 1)"
|
||
log_sha "github/master (remote)" "${GITHUB_MASTER_REMOTE_SHA}"
|
||
fi
|
||
|
||
echo "🚀 Triggering docker-build workflow (master, key: ${RUN_KEY})"
|
||
gh workflow run docker-build.yml --ref master -f branch=master -f choice=none -f run_key="${RUN_KEY}"
|
||
|
||
echo "🔎 Resolving docker-build workflow run id..."
|
||
for _ in {1..30}; do
|
||
DOCKER_BUILD_RUN_ID="$(
|
||
gh run list \
|
||
-w docker-build.yml \
|
||
-e workflow_dispatch \
|
||
-b master \
|
||
-L 20 \
|
||
--json databaseId,displayTitle \
|
||
--jq ".[] | select(.displayTitle | contains(\"${RUN_KEY}\")) | .databaseId" \
|
||
| head -n 1
|
||
)"
|
||
if [ -n "${DOCKER_BUILD_RUN_ID}" ]; then
|
||
break
|
||
fi
|
||
sleep 2
|
||
done
|
||
|
||
if [ -z "${DOCKER_BUILD_RUN_ID}" ]; then
|
||
die "Timed out waiting for docker-build workflow run id (run_key=${RUN_KEY})."
|
||
fi
|
||
|
||
echo "🧩 docker-build run id: ${DOCKER_BUILD_RUN_ID}"
|
||
|
||
./bin/build_resources.sh
|
||
|
||
cd "$FRONTEND_DIR" || die "Can't find frontend directory"
|
||
|
||
echo "🛠️ Building version $BUMP_VERSION"
|
||
|
||
declare -A ARCHITECTURES=(
|
||
[LINUX]=x86_64-unknown-linux-musl
|
||
[WINDOWS]=x86_64-pc-windows-gnu
|
||
[ARM7]=armv7-unknown-linux-musleabihf
|
||
[AARCH64]=aarch64-unknown-linux-musl
|
||
# [DARWIN64]=aarch64-apple-darwin
|
||
# [DARWIN86]=x86_64-apple-darwin
|
||
)
|
||
|
||
declare -A DIRS=(
|
||
[LINUX]=tuliprox_${BUMP_VERSION}_linux_x86_64
|
||
[WINDOWS]=tuliprox_${BUMP_VERSION}_windows_x86_64
|
||
[ARM7]=tuliprox_${BUMP_VERSION}_armv7
|
||
[AARCH64]=tuliprox_${BUMP_VERSION}_aarch64
|
||
# [DARWIN86]=tuliprox_${BUMP_VERSION}_apple-darwin_x86_64
|
||
# [DARWIN64]=tuliprox_${BUMP_VERSION}_apple-darwin_aarch64
|
||
)
|
||
|
||
# Special case mapping for binary extensions (e.g., Windows needs .exe)
|
||
declare -A BIN_EXTENSIONS=(
|
||
[WINDOWS]=.exe
|
||
)
|
||
|
||
if ! command -v cross >/dev/null 2>&1; then
|
||
die "'cross' is required to install Rust targets."
|
||
fi
|
||
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
die "'docker' is required for 'cross' builds. Install/start Docker Desktop (macOS) or Docker Engine (Linux)."
|
||
fi
|
||
|
||
if ! docker info >/dev/null 2>&1; then
|
||
die "Docker daemon is not available. Start Docker so 'cross' can run builds in containers (otherwise it may fall back to host builds)."
|
||
fi
|
||
|
||
# On Apple Silicon, many cross images are linux/amd64-only. Force linux/amd64 containers so Docker
|
||
# doesn't fail with 'no matching manifest' and cross doesn't fall back to host builds.
|
||
HOST_OS="$(uname -s)"
|
||
HOST_ARCH="$(uname -m)"
|
||
if [ "${HOST_OS}" = "Darwin" ] && [ "${HOST_ARCH}" = "arm64" ]; then
|
||
CROSS_PLATFORM_OPT="--platform=linux/amd64"
|
||
if [ -z "${CROSS_CONTAINER_OPTS:-}" ]; then
|
||
export CROSS_CONTAINER_OPTS="${CROSS_PLATFORM_OPT}"
|
||
echo "🧰 Using CROSS_CONTAINER_OPTS='${CROSS_CONTAINER_OPTS}' (Apple Silicon compatibility)"
|
||
elif [[ " ${CROSS_CONTAINER_OPTS} " == *" ${CROSS_PLATFORM_OPT} "* ]]; then
|
||
echo "🧰 Keeping existing CROSS_CONTAINER_OPTS='${CROSS_CONTAINER_OPTS}' (Apple Silicon compatibility)"
|
||
else
|
||
export CROSS_CONTAINER_OPTS="${CROSS_CONTAINER_OPTS} ${CROSS_PLATFORM_OPT}"
|
||
echo "🧰 Extending CROSS_CONTAINER_OPTS='${CROSS_CONTAINER_OPTS}' (Apple Silicon compatibility)"
|
||
fi
|
||
fi
|
||
|
||
DARWIN_CROSS_ENABLED="false"
|
||
for TARGET in "${ARCHITECTURES[@]}"; do
|
||
if [[ "${TARGET}" == *"-apple-darwin" ]]; then
|
||
DARWIN_CROSS_ENABLED="true"
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [ "${DARWIN_CROSS_ENABLED}" = "true" ]; then
|
||
if [ "${BUILD_DARWIN_CROSS_IMAGES:-1}" = "0" ]; then
|
||
echo "🍎 Skipping Darwin cross image build (BUILD_DARWIN_CROSS_IMAGES=0)"
|
||
else
|
||
echo "🍎 Ensuring local cross toolchain images for Darwin targets"
|
||
darwin_targets=()
|
||
for darwin_target in "${ARCHITECTURES[@]}"; do
|
||
if [[ "${darwin_target}" == *"-apple-darwin" ]]; then
|
||
darwin_targets+=("${darwin_target}")
|
||
fi
|
||
done
|
||
DARWIN_TARGET_TRIPLES="${darwin_targets[*]}" "${WORKING_DIR}/bin/build_cross_toolchains_darwin_images.sh"
|
||
fi
|
||
fi
|
||
|
||
if ! command -v rustup >/dev/null 2>&1; then
|
||
die "'rustup' is required to install Rust targets."
|
||
fi
|
||
|
||
if command -v rustc >/dev/null 2>&1; then
|
||
RUSTC_HOST_TRIPLE="$(rustc -vV 2>/dev/null | awk -F': ' '/^host:/{print $2}' | head -n 1)"
|
||
if [ -n "${RUSTC_HOST_TRIPLE}" ]; then
|
||
echo "🧾 rustc host triple: ${RUSTC_HOST_TRIPLE}"
|
||
fi
|
||
fi
|
||
|
||
echo "🧰 Ensuring rustup targets are installed"
|
||
RUSTUP_TOOLCHAIN_FOR_TARGETS="${RUSTUP_TOOLCHAIN_FOR_TARGETS:-stable}"
|
||
echo "🧰 Using rustup toolchain for target installs: ${RUSTUP_TOOLCHAIN_FOR_TARGETS}"
|
||
|
||
# cross expects a Linux toolchain sysroot on non-Linux hosts (mounted into the container).
|
||
# rustup refuses to install non-host toolchains unless explicitly forced.
|
||
if [ "$(uname -s)" != "Linux" ]; then
|
||
CROSS_SYSROOT_TOOLCHAIN="${CROSS_SYSROOT_TOOLCHAIN:-${RUSTUP_TOOLCHAIN_FOR_TARGETS}-x86_64-unknown-linux-gnu}"
|
||
if rustup toolchain list | cut -d' ' -f1 | grep -Fxq "${CROSS_SYSROOT_TOOLCHAIN}"; then
|
||
echo "✅ cross sysroot toolchain already installed: ${CROSS_SYSROOT_TOOLCHAIN}"
|
||
else
|
||
echo "➕ Installing cross sysroot toolchain: ${CROSS_SYSROOT_TOOLCHAIN}"
|
||
rustup toolchain install "${CROSS_SYSROOT_TOOLCHAIN}" --profile minimal --force-non-host
|
||
fi
|
||
fi
|
||
|
||
RUSTUP_REQUIRED_TARGETS=(
|
||
wasm32-unknown-unknown
|
||
)
|
||
|
||
for TARGET in "${ARCHITECTURES[@]}"; do
|
||
RUSTUP_REQUIRED_TARGETS+=("${TARGET}")
|
||
done
|
||
|
||
for TARGET in "${RUSTUP_REQUIRED_TARGETS[@]}"; do
|
||
if rustup +"${RUSTUP_TOOLCHAIN_FOR_TARGETS}" target list --installed | grep -Fxq "${TARGET}"; then
|
||
echo "✅ rust target already installed: ${TARGET}"
|
||
else
|
||
echo "➕ Installing rust target: ${TARGET}"
|
||
rustup +"${RUSTUP_TOOLCHAIN_FOR_TARGETS}" target add "${TARGET}"
|
||
fi
|
||
done
|
||
|
||
cd "$WORKING_DIR"
|
||
mkdir -p "$RELEASE_DIR"
|
||
|
||
# Clean previous builds
|
||
cargo clean || true
|
||
|
||
"${SCRIPT_DIR}/build_fe.sh" release
|
||
# Check if the frontend build directory exists
|
||
if [ ! -d "${FRONTEND_BUILD_DIR}" ]; then
|
||
die "🧨 Error: Web directory '${FRONTEND_BUILD_DIR}' does not exist."
|
||
fi
|
||
|
||
cd "$WORKING_DIR"
|
||
|
||
# Prepare release package folder (assets to be uploaded to GitHub Release later)
|
||
RELEASE_PKG="$RELEASE_DIR/release_${BUMP_VERSION}"
|
||
rm -rf "${RELEASE_PKG}"
|
||
mkdir -p "${RELEASE_PKG}"
|
||
|
||
# Build binaries
|
||
for PLATFORM in "${!ARCHITECTURES[@]}"; do
|
||
ARCHITECTURE="${ARCHITECTURES[$PLATFORM]}"
|
||
DIR="${DIRS[$PLATFORM]}"
|
||
|
||
BIN_NAME="tuliprox${BIN_EXTENSIONS[$PLATFORM]:-}"
|
||
BIN_REL="${ARCHITECTURE}/release/${BIN_NAME}"
|
||
BIN_PATH="${TARGET_DIR}/${BIN_REL}"
|
||
|
||
# Ensure target is installed (guarded above, keep here as a safety net)
|
||
rustup +"${RUSTUP_TOOLCHAIN_FOR_TARGETS}" target add "${ARCHITECTURE}"
|
||
|
||
# Build for each platform
|
||
cd "${WORKING_DIR}"
|
||
cargo clean || true # Clean before each build to avoid conflicts
|
||
cd "${WORKING_DIR}" >/dev/null 2>&1 || true
|
||
env RUSTFLAGS="--remap-path-prefix $HOME=~" cross build -p tuliprox --release --target "${ARCHITECTURE}"
|
||
|
||
if [ ! -f "${BIN_PATH}" ]; then
|
||
die "Expected binary not found: ${BIN_PATH}"
|
||
fi
|
||
|
||
# Create staging directory and copy binaries plus runtime assets
|
||
STAGING_DIR="${TARGET_DIR}/${DIR}"
|
||
rm -rf "${STAGING_DIR}"
|
||
mkdir -p "${STAGING_DIR}"
|
||
cp "${BIN_PATH}" "${STAGING_DIR}/"
|
||
cp -rf "${FRONTEND_BUILD_DIR}" "${STAGING_DIR}/web"
|
||
cp -rf "${RESOURCES_DIR}"/*.ts "${STAGING_DIR}/"
|
||
|
||
# Create archive for the platform
|
||
if [[ "${PLATFORM}" == "WINDOWS" ]]; then
|
||
if ! command -v zip >/dev/null 2>&1; then
|
||
die "'zip' is required to package Windows artifacts."
|
||
fi
|
||
ARC="${RELEASE_PKG}/${DIR}.zip"
|
||
(cd "${TARGET_DIR}" && zip -r "${ARC}" "${DIR}") >/dev/null
|
||
else
|
||
ARC="${RELEASE_PKG}/${DIR}.tgz"
|
||
tar -C "${TARGET_DIR}" -czf "${ARC}" "${DIR}"
|
||
fi
|
||
|
||
CHECKSUM_FILE="${RELEASE_PKG}/checksum_$(basename "${ARC}").txt"
|
||
shasum -a 256 "${ARC}" > "${CHECKSUM_FILE}"
|
||
done
|
||
|
||
# Marker: wait for docker-build workflow completion
|
||
echo "⏳ Waiting for docker-build workflow to finish (run id: ${DOCKER_BUILD_RUN_ID})"
|
||
gh run watch "${DOCKER_BUILD_RUN_ID}" --exit-status
|
||
|
||
echo "🔀 Back-merging master into develop (fast-forward)"
|
||
git fetch --quiet origin develop || die "Failed to fetch 'origin/develop'."
|
||
if ! git merge-base --is-ancestor origin/develop HEAD; then
|
||
die "Refusing to update develop: 'origin/develop' is not an ancestor of current master."
|
||
fi
|
||
|
||
gh_unfreeze_develop_branch
|
||
|
||
git push origin HEAD:develop
|
||
ORIGIN_DEVELOP_AFTER_FF_SHA="$(git rev-parse HEAD)"
|
||
log_sha "ORIGIN_DEVELOP_AFTER_FF_SHA (saved)" "${ORIGIN_DEVELOP_AFTER_FF_SHA}"
|
||
ORIGIN_DEVELOP_REMOTE_SHA="$(git ls-remote origin refs/heads/develop | awk '{print $1}' | head -n 1)"
|
||
log_sha "origin/develop (remote)" "${ORIGIN_DEVELOP_REMOTE_SHA}"
|
||
if git remote get-url github >/dev/null 2>&1; then
|
||
git push github HEAD:develop
|
||
GITHUB_DEVELOP_AFTER_FF_SHA="${ORIGIN_DEVELOP_AFTER_FF_SHA}"
|
||
log_sha "GITHUB_DEVELOP_AFTER_FF_SHA (saved)" "${GITHUB_DEVELOP_AFTER_FF_SHA}"
|
||
GITHUB_DEVELOP_REMOTE_SHA="$(git ls-remote github refs/heads/develop | awk '{print $1}' | head -n 1)"
|
||
log_sha "github/develop (remote)" "${GITHUB_DEVELOP_REMOTE_SHA}"
|
||
fi
|
||
|
||
echo "🗑 Cleaning up build artifacts"
|
||
# Clean up the build directories
|
||
cd "$WORKING_DIR"
|
||
cargo clean
|
||
|
||
RELEASE_TAG="v${BUMP_VERSION#v}"
|
||
echo "🏷️ Tagging release: ${RELEASE_TAG}"
|
||
|
||
if git rev-parse -q --verify "refs/tags/${RELEASE_TAG}" >/dev/null; then
|
||
die "Tag '${RELEASE_TAG}' already exists."
|
||
fi
|
||
|
||
RELEASE_PKG="$RELEASE_DIR/release_${BUMP_VERSION}"
|
||
if [ ! -d "${RELEASE_PKG}" ]; then
|
||
die "Release package directory not found: ${RELEASE_PKG}"
|
||
fi
|
||
|
||
echo "📝 Extracting release notes from CHANGELOG.md (version: ${BUMP_VERSION})"
|
||
RELEASE_NOTES_FILE="${RELEASE_PKG}/release-notes-${RELEASE_TAG}.md"
|
||
if ! extract_release_notes_from_changelog "${BUMP_VERSION}" "${WORKING_DIR}/CHANGELOG.md" >"${RELEASE_NOTES_FILE}"; then
|
||
die "No release notes found in CHANGELOG.md for version '${BUMP_VERSION}'."
|
||
fi
|
||
|
||
shopt -s nullglob
|
||
candidate_assets=("${RELEASE_PKG}"/*)
|
||
shopt -u nullglob
|
||
assets=()
|
||
for f in "${candidate_assets[@]}"; do
|
||
if [ -f "${f}" ] && [ "${f}" != "${RELEASE_NOTES_FILE}" ]; then
|
||
assets+=("${f}")
|
||
fi
|
||
done
|
||
if [ "${#assets[@]}" -eq 0 ]; then
|
||
die "No release assets found in ${RELEASE_PKG}."
|
||
fi
|
||
|
||
git tag -a "${RELEASE_TAG}" -m "${RELEASE_TAG}"
|
||
|
||
echo "📦 Pushing release tag: ${RELEASE_TAG}"
|
||
git push origin "${RELEASE_TAG}"
|
||
if git remote get-url github >/dev/null 2>&1; then
|
||
git push github "${RELEASE_TAG}"
|
||
fi
|
||
|
||
echo "🚢 Creating GitHub release '${RELEASE_TAG}' (uploading ${#assets[@]} assets)"
|
||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||
die "GitHub release '${RELEASE_TAG}' already exists."
|
||
fi
|
||
gh release create "${RELEASE_TAG}" --verify-tag -t "${RELEASE_TAG}" -F "${RELEASE_NOTES_FILE}" "${assets[@]}"
|
||
|
||
echo "🎉 Done!"
|