| 1 | #!/usr/bin/env bash |
| 2 | # Validate one stable release tag set and emit the values shared by all release |
| 3 | # workflows. Stable releases are deliberately all-or-nothing: the CLI, npm, and |
| 4 | # desktop tags must resolve to one commit. Normal publication accepts the Notes |
| 5 | # candidate after main-v2 advances beyond it; the protected workflow separately |
| 6 | # revalidates that candidate's Notes change and exact push CI. Recovery may also |
| 7 | # target an older commit while the control plane stays on current main-v2. |
| 8 | set -euo pipefail |
| 9 | |
| 10 | release_tag="${RELEASE_TAG:?RELEASE_TAG is required}" |
| 11 | release_remote="${RELEASE_REMOTE:-origin}" |
| 12 | allow_recovery="${ALLOW_STABLE_RECOVERY:-false}" |
| 13 | |
| 14 | case "$allow_recovery" in |
| 15 | true | false) ;; |
| 16 | *) |
| 17 | echo "::error::ALLOW_STABLE_RECOVERY must be true or false, got: $allow_recovery" >&2 |
| 18 | exit 1 |
| 19 | ;; |
| 20 | esac |
| 21 | |
| 22 | if [[ ! "$release_tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then |
| 23 | echo "::error::stable release tag must be vMAJOR.MINOR.PATCH, got: $release_tag" >&2 |
| 24 | exit 1 |
| 25 | fi |
| 26 | |
| 27 | version="${release_tag#v}" |
| 28 | cli_tag="$release_tag" |
| 29 | npm_tag="npm-v${version}" |
| 30 | desktop_tag="desktop-v${version}" |
| 31 | |
| 32 | checkout_sha="$(git rev-parse HEAD^{commit})" |
| 33 | main_sha="$(git ls-remote "$release_remote" refs/heads/main-v2 | awk 'NR == 1 { print $1 }')" |
| 34 | if [ -z "$main_sha" ]; then |
| 35 | echo "::error::cannot resolve $release_remote/main-v2" >&2 |
| 36 | exit 1 |
| 37 | fi |
| 38 | |
| 39 | git fetch --quiet --no-tags "$release_remote" refs/heads/main-v2 |
| 40 | if ! git merge-base --is-ancestor "$checkout_sha" "$main_sha"; then |
| 41 | echo "::error::release control checkout $checkout_sha is not on $release_remote/main-v2 history ($main_sha)" >&2 |
| 42 | exit 1 |
| 43 | fi |
| 44 | |
| 45 | release_sha="$( |
| 46 | git ls-remote --tags "$release_remote" "refs/tags/$cli_tag" "refs/tags/$cli_tag^{}" | |
| 47 | awk '/\^\{\}$/ { print $1; found = 1; exit } NR == 1 { first = $1 } END { if (!found) print first }' |
| 48 | )" |
| 49 | if [ -z "$release_sha" ]; then |
| 50 | echo "::error::required stable release tag is missing: $cli_tag" >&2 |
| 51 | exit 1 |
| 52 | fi |
| 53 | git fetch --quiet --no-tags "$release_remote" "refs/tags/$cli_tag" |
| 54 | if ! git merge-base --is-ancestor "$release_sha" "$main_sha"; then |
| 55 | echo "::error::stable tag $cli_tag points to $release_sha, which is not an ancestor of $release_remote/main-v2 ($main_sha)" >&2 |
| 56 | exit 1 |
| 57 | fi |
| 58 | if [ "$release_sha" != "$main_sha" ]; then |
| 59 | mode="candidate" |
| 60 | [ "$allow_recovery" = "true" ] && mode="recovery" |
| 61 | echo "stable $mode: $cli_tag remains on main-v2 history at $release_sha; current main-v2 is $main_sha" |
| 62 | fi |
| 63 | |
| 64 | for tag in "$cli_tag" "$npm_tag" "$desktop_tag"; do |
| 65 | # Prefer the peeled commit for annotated tags; lightweight tags only return |
| 66 | # the first line. Both forms are valid release refs. |
| 67 | tag_sha="$( |
| 68 | git ls-remote --tags "$release_remote" "refs/tags/$tag" "refs/tags/$tag^{}" | |
| 69 | awk '/\^\{\}$/ { print $1; found = 1; exit } NR == 1 { first = $1 } END { if (!found) print first }' |
| 70 | )" |
| 71 | if [ -z "$tag_sha" ]; then |
| 72 | echo "::error::required stable release tag is missing: $tag" >&2 |
| 73 | exit 1 |
| 74 | fi |
| 75 | if [ "$tag_sha" != "$release_sha" ]; then |
| 76 | echo "::error::$tag points to $tag_sha, expected $release_sha" >&2 |
| 77 | exit 1 |
| 78 | fi |
| 79 | done |
| 80 | |
| 81 | output_file="${GITHUB_OUTPUT:-/dev/stdout}" |
| 82 | { |
| 83 | echo "version=$version" |
| 84 | echo "cli_tag=$cli_tag" |
| 85 | echo "npm_tag=$npm_tag" |
| 86 | echo "desktop_tag=$desktop_tag" |
| 87 | echo "sha=$release_sha" |
| 88 | } >>"$output_file" |
| 89 | |
| 90 | echo "stable release resolved: version=$version sha=$release_sha" |
| 91 |