Files
dingtalk-workspace-cli/scripts/release/dws-release.sh
T

388 lines
12 KiB
Bash
Executable File

#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CLOUD_RELEASE_URL="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml"
VERSION=""
FROM_BETA=""
REMOTE=""
MODE="check"
MODE_SET=0
WIZARD=0
usage() {
cat >&2 <<'EOF'
usage: dws-release [version] [options]
dws-release config [--remote <name>]
dws-release recover <version> [--failed-run <run-id>] [--failed-attempt <attempt>] [--remote <name>]
With no arguments, starts an interactive release guide. For a version that has
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
full guarded preflight. Official publication runs from GitHub Actions so beta
and stable use the repository's release authorization policy.
Options:
--from-beta <tag> Required stable promotion baseline
--remote <name> Override the configured release remote
--check Validate only (default)
--publish Retired for official releases; use Actions -> Release
-h, --help Show this help
Examples:
dws-release config --remote origin
dws-release v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release recover v1.2.3-beta.1
EOF
}
die_usage() {
printf '%s\n' "$1" >&2
usage
exit 2
}
is_interactive() {
[ -t 0 ] && [ -t 1 ]
}
repository_identity() {
identity_url="$1"
case "$identity_url" in
https://github.com/*) identity_path="${identity_url#https://github.com/}" ;;
http://github.com/*) identity_path="${identity_url#http://github.com/}" ;;
git://github.com/*) identity_path="${identity_url#git://github.com/}" ;;
git@github.com:*) identity_path="${identity_url#git@github.com:}" ;;
ssh://git@github.com/*) identity_path="${identity_url#ssh://git@github.com/}" ;;
*) printf '%s\n' "$identity_url"; return 0 ;;
esac
identity_path="${identity_path%/}"
identity_path="${identity_path%.git}"
printf 'github.com/%s\n' "$identity_path"
}
release_remote_identity() {
identity_remote="$1"
identity_fetch_url="$(git remote get-url "$identity_remote" 2>/dev/null)" || {
printf 'configured release remote does not exist: %s\n' "$identity_remote" >&2
return 1
}
identity_push_urls="$(git remote get-url --push --all "$identity_remote" 2>/dev/null)" || {
printf 'could not resolve push URL for release remote: %s\n' "$identity_remote" >&2
return 1
}
identity_push_count="$(printf '%s\n' "$identity_push_urls" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
[ "$identity_push_count" -eq 1 ] || {
printf 'release remote must have exactly one push URL: %s\n' "$identity_remote" >&2
return 1
}
identity_push_url="$(printf '%s\n' "$identity_push_urls" | sed -n '1p')"
identity_fetch="$(repository_identity "$identity_fetch_url")"
identity_push="$(repository_identity "$identity_push_url")"
[ "$identity_fetch" = "$identity_push" ] || {
printf 'release remote fetch and push URLs target different repositories:\n fetch: %s\n push: %s\n' \
"$identity_fetch_url" "$identity_push_url" >&2
return 1
}
printf '%s\n' "$identity_fetch"
}
set_mode() {
requested_mode="$1"
if [ "$MODE_SET" -eq 1 ] && [ "$MODE" != "$requested_mode" ]; then
die_usage '--check and --publish cannot be used together'
fi
MODE="$requested_mode"
MODE_SET=1
}
require_remote() {
if [ -z "$REMOTE" ]; then
REMOTE="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
fi
if [ -z "$REMOTE" ] && is_interactive; then
printf 'Configured remotes:\n' >&2
git remote -v >&2
printf 'Release remote: ' >&2
IFS= read -r REMOTE
fi
[ -n "$REMOTE" ] || {
printf '%s\n' 'release remote is not configured; run: dws-release config --remote <name>' >&2
exit 1
}
expected_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
[ -n "$expected_repository" ] || {
printf '%s\n' 'release repository identity is not configured; rerun: dws-release config --remote <name>' >&2
exit 1
}
actual_repository="$(release_remote_identity "$REMOTE")" || exit 1
[ "$actual_repository" = "$expected_repository" ] || {
printf 'release remote %s changed repository identity:\n configured: %s\n current: %s\n' \
"$REMOTE" "$expected_repository" "$actual_repository" >&2
printf '%s\n' 'Review the remote and run dws-release config again only if this change is intentional.' >&2
exit 1
}
}
reject_official_publish() {
normalized_repository="$(
printf '%s\n' "$actual_repository" | tr '[:upper:]' '[:lower:]'
)"
if [ "$MODE" = "publish" ] &&
[ "$normalized_repository" = "github.com/dingtalk-real-ai/dingtalk-workspace-cli" ]; then
printf '%s\n' 'Direct local publication is disabled for official releases.' >&2
printf 'Use Actions -> Release -> Run workflow: %s\n' "$CLOUD_RELEASE_URL" >&2
exit 1
fi
}
sync_main_if_safe() {
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release worktree must be clean before synchronization' >&2
exit 1
}
printf '==> Synchronizing %s/main\n' "$REMOTE"
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
remote_main="refs/remotes/$REMOTE/main"
head_commit="$(git rev-parse HEAD)"
remote_commit="$(git rev-parse "$remote_main^{commit}")"
if [ "$head_commit" = "$remote_commit" ]; then
return 0
fi
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ "$current_branch" != "main" ]; then
if git merge-base --is-ancestor HEAD "$remote_main"; then
return 0
fi
printf 'HEAD is not contained in %s/main history; merge it through a reviewed PR before release\n' "$REMOTE" >&2
exit 1
fi
if git merge-base --is-ancestor HEAD "$remote_main"; then
git merge --ff-only "$remote_main"
return 0
fi
if git merge-base --is-ancestor "$remote_main" HEAD; then
printf 'local main is ahead of %s/main; publish only after the commits are reviewed and pushed\n' "$REMOTE" >&2
else
printf 'local main has diverged from %s/main; resolve it explicitly before release\n' "$REMOTE" >&2
fi
exit 1
}
configure_remote() {
shift
config_remote=""
while [ "$#" -gt 0 ]; do
case "$1" in
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
config_remote="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*) die_usage "unknown config argument: $1" ;;
esac
done
cd "$ROOT"
if [ -z "$config_remote" ]; then
configured="$(git config --local --get dws.releaseRemote 2>/dev/null || true)"
configured_repository="$(git config --local --get dws.releaseRepository 2>/dev/null || true)"
printf 'Configured release remote: %s\n' "${configured:-none}"
printf 'Configured repository: %s\n' "${configured_repository:-none}"
git remote -v
exit 0
fi
configured_repository="$(release_remote_identity "$config_remote")" || exit 1
git config --local dws.releaseRemote "$config_remote"
git config --local dws.releaseRepository "$configured_repository"
printf 'Configured release remote: %s\n' "$config_remote"
printf 'Configured repository: %s\n' "$configured_repository"
exit 0
}
recover_release() {
shift
recovery_version="${1:-}"
[ -n "$recovery_version" ] || die_usage 'recover requires a release version'
case "$recovery_version" in -h|--help) usage; exit 0 ;; esac
shift
recovery_failed_run=""
recovery_failed_attempt=""
while [ "$#" -gt 0 ]; do
case "$1" in
--failed-run)
[ "$#" -ge 2 ] || die_usage '--failed-run requires a workflow run ID'
recovery_failed_run="$2"
shift 2
;;
--failed-attempt)
[ "$#" -ge 2 ] || die_usage '--failed-attempt requires a run attempt'
recovery_failed_attempt="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
shift 2
;;
-h|--help) usage; exit 0 ;;
*) die_usage "unknown recovery argument: $1" ;;
esac
done
cd "$ROOT"
require_remote
sync_main_if_safe
set -- "$recovery_version" --remote "$REMOTE"
if [ -n "$recovery_failed_run" ]; then
set -- "$@" --failed-run "$recovery_failed_run"
fi
if [ -n "$recovery_failed_attempt" ]; then
set -- "$@" --failed-attempt "$recovery_failed_attempt"
fi
exec "$SCRIPT_DIR/recover-release.sh" "$@"
}
if [ "${1:-}" = "config" ]; then
configure_remote "$@"
fi
if [ "${1:-}" = "recover" ]; then
recover_release "$@"
fi
[ "$#" -gt 0 ] || WIZARD=1
while [ "$#" -gt 0 ]; do
case "$1" in
--from-beta)
[ "$#" -ge 2 ] || die_usage '--from-beta requires a value'
FROM_BETA="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
shift 2
;;
--check) set_mode check; shift ;;
--publish) set_mode publish; shift ;;
-h|--help) usage; exit 0 ;;
-*) die_usage "unknown argument: $1" ;;
*)
[ -z "$VERSION" ] || die_usage "unexpected argument: $1"
VERSION="$1"
shift
;;
esac
done
if [ -z "$VERSION" ]; then
is_interactive || { usage; exit 2; }
printf 'Release version (vX.Y.Z-beta.N or vX.Y.Z): ' >&2
IFS= read -r VERSION
fi
CHANNEL="$(release_channel_for_version "$VERSION")" || exit 2
if [ "$CHANNEL" = "stable" ]; then
if [ -z "$FROM_BETA" ] && is_interactive; then
printf 'Validated beta tag for %s: ' "$VERSION" >&2
IFS= read -r FROM_BETA
fi
[ -n "$FROM_BETA" ] || die_usage 'stable release requires --from-beta <tag>'
release_validate_version_channel prerelease "$FROM_BETA" || exit 2
[ "$(release_core_tag "$FROM_BETA")" = "$VERSION" ] || {
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
exit 2
}
else
[ -z "$FROM_BETA" ] || die_usage '--from-beta is only valid for stable releases'
fi
if [ "$WIZARD" -eq 1 ]; then
printf 'Mode [check/publish] (default: check): ' >&2
IFS= read -r selected_mode
case "$selected_mode" in
''|check) MODE=check ;;
publish) MODE=publish ;;
*) die_usage "invalid mode: $selected_mode" ;;
esac
fi
[ -z "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-}" ] || {
printf '%s\n' 'DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE is test-only and cannot be set through dws-release' >&2
exit 1
}
[ -z "${DWS_RELEASE_OFFICIAL_TAGS_URL:-}" ] || {
printf '%s\n' 'DWS_RELEASE_OFFICIAL_TAGS_URL cannot override release authority through dws-release' >&2
exit 1
}
[ -z "${DWS_RELEASE_OFFICIAL_REPOSITORY:-}" ] || {
printf '%s\n' 'DWS_RELEASE_OFFICIAL_REPOSITORY cannot override release authority through dws-release' >&2
exit 1
}
cd "$ROOT"
CHANGELOG="$ROOT/CHANGELOG.md"
[ -f "$CHANGELOG" ] || { printf 'CHANGELOG not found: %s\n' "$CHANGELOG" >&2; exit 1; }
MAIN_SYNCED=0
current_branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
if [ "$current_branch" = "main" ]; then
require_remote
reject_official_publish
sync_main_if_safe
MAIN_SYNCED=1
fi
semver="$(release_semver "$VERSION")"
section_count="$(awk -v wanted="$semver" '
BEGIN { prefix = "## [" wanted "] - "; count = 0 }
index($0, prefix) == 1 { count++ }
END { print count }
' "$CHANGELOG")"
if [ "$section_count" -eq 0 ]; then
printf '==> Preparing the missing CHANGELOG section for %s\n' "$VERSION"
if [ "$CHANNEL" = "stable" ]; then
"$SCRIPT_DIR/prepare-changelog.sh" stable "$VERSION" --from-beta "$FROM_BETA"
else
"$SCRIPT_DIR/prepare-changelog.sh" prerelease "$VERSION"
fi
printf '\nCHANGELOG preparation is complete; publishing intentionally stopped.\n'
printf 'Replace TODO, review the diff, commit it, and merge it to main. Then run dws-release for %s again.\n' "$VERSION"
exit 0
fi
notes_tmp="$(mktemp "${TMPDIR:-/tmp}/dws-release-entry.XXXXXX")"
cleanup() { rm -f "$notes_tmp"; }
trap cleanup EXIT HUP INT TERM
release_extract_changelog "$CHANGELOG" "$semver" "$notes_tmp"
if [ "$MAIN_SYNCED" -ne 1 ]; then
require_remote
reject_official_publish
sync_main_if_safe
fi
printf '==> DWS release entry\n'
printf ' mode: %s\n' "$MODE"
printf ' channel: %s\n' "$CHANNEL"
printf ' version: %s\n' "$VERSION"
printf ' remote: %s\n' "$REMOTE"
[ -z "$FROM_BETA" ] || printf ' beta: %s\n' "$FROM_BETA"
set -- "$CHANNEL" "$VERSION" --remote "$REMOTE"
if [ -n "$FROM_BETA" ]; then
set -- "$@" --from-beta "$FROM_BETA"
fi
if [ "$MODE" = "publish" ]; then
set -- "$@" --publish
fi
cleanup
trap - EXIT HUP INT TERM
exec "$SCRIPT_DIR/release.sh" "$@"