| 1 | #!/usr/bin/env bash |
| 2 | # Verify that a stable orchestration produced every public release channel. |
| 3 | set -euo pipefail |
| 4 | |
| 5 | repository="${RELEASE_REPOSITORY:?RELEASE_REPOSITORY is required}" |
| 6 | version="${RELEASE_VERSION:?RELEASE_VERSION is required}" |
| 7 | cli_tag="${CLI_TAG:?CLI_TAG is required}" |
| 8 | desktop_tag="${DESKTOP_TAG:?DESKTOP_TAG is required}" |
| 9 | script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" |
| 10 | attempts="${VERIFY_ATTEMPTS:-6}" |
| 11 | delay="${VERIFY_DELAY_SECONDS:-10}" |
| 12 | verify_pointers="${VERIFY_PUBLIC_POINTERS:-false}" |
| 13 | operation="${RELEASE_OPERATION:-publish}" |
| 14 | ledger_output="${RELEASE_LEDGER_OUTPUT:-}" |
| 15 | # A frozen 1.x line publishes npm under the legacy-v1 dist-tag and leaves the |
| 16 | # latest tag, the Homebrew cask and the CLI pointers to the line that owns them. |
| 17 | frozen=false |
| 18 | npm_tag=latest |
| 19 | if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then |
| 20 | frozen=true |
| 21 | npm_tag=legacy-v1 |
| 22 | fi |
| 23 | |
| 24 | if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then |
| 25 | echo "::error::RELEASE_VERSION must be stable semver, got: $version" >&2 |
| 26 | exit 1 |
| 27 | fi |
| 28 | if [ "$cli_tag" != "v$version" ] || [ "$desktop_tag" != "desktop-v$version" ]; then |
| 29 | echo "::error::release tags do not match version $version: cli=$cli_tag desktop=$desktop_tag" >&2 |
| 30 | exit 1 |
| 31 | fi |
| 32 | case "$operation" in publish | recover) ;; *) echo "::error::RELEASE_OPERATION must be publish or recover" >&2; exit 1 ;; esac |
| 33 | |
| 34 | release_git_url="https://github.com/${repository}.git" |
| 35 | cli_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/$cli_tag" | awk 'NR == 1 {print $1}')" |
| 36 | npm_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/npm-v$version" | awk 'NR == 1 {print $1}')" |
| 37 | desktop_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/$desktop_tag" | awk 'NR == 1 {print $1}')" |
| 38 | if [ -z "$cli_sha" ] || [ "$cli_sha" != "$npm_sha" ] || [ "$cli_sha" != "$desktop_sha" ]; then |
| 39 | echo "::error::release tags are missing or do not identify one immutable commit" >&2 |
| 40 | exit 1 |
| 41 | fi |
| 42 | if [ -n "${RELEASE_EXPECTED_SHA:-}" ] && [ "$cli_sha" != "$RELEASE_EXPECTED_SHA" ]; then |
| 43 | echo "::error::public release identity differs from the verified source SHA" >&2 |
| 44 | exit 1 |
| 45 | fi |
| 46 | |
| 47 | tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-release-postflight.XXXXXX")" |
| 48 | cleanup() { |
| 49 | case "$tmp_dir" in |
| 50 | */reasonix-release-postflight.*) rm -rf -- "$tmp_dir" ;; |
| 51 | *) echo "refusing to clean unexpected postflight directory: $tmp_dir" >&2 ;; |
| 52 | esac |
| 53 | } |
| 54 | trap cleanup EXIT |
| 55 | |
| 56 | # The Desktop updater pointer and the Homebrew cask are product channels. The |
| 57 | # contents API reads the tap's branch head; raw.githubusercontent.com is cached. |
| 58 | verify_pointers() { |
| 59 | local manifest="$1" |
| 60 | local cask="$tmp_dir/reasonix.rb" |
| 61 | local homebrew_version=frozen |
| 62 | jq -e --arg version "v$version" ' |
| 63 | .version == $version and |
| 64 | ([.platforms[], (.native_packages // {})[], (.downloads // {})[]] | |
| 65 | all(.url | type == "string" and startswith("https://dl.reasonix.io/desktop-" + $version + "/"))) |
| 66 | ' "$manifest" >/dev/null |
| 67 | if [ "$frozen" != "true" ]; then |
| 68 | gh api -H 'Accept: application/vnd.github.raw' \ |
| 69 | repos/esengine/homebrew-reasonix/contents/Casks/reasonix.rb >"$cask" |
| 70 | homebrew_version="$(sed -nE "s/^[[:space:]]*version ['\"]([^'\"]+)['\"].*/\1/p" "$cask" | head -n 1)" |
| 71 | fi |
| 72 | node "$script_dir/release-publication-ledger.mjs" pointers "$version" "$cli_sha" "$operation" \ |
| 73 | "$manifest" "$homebrew_version" "$tmp_dir/pointer-ledger.json" |
| 74 | } |
| 75 | |
| 76 | gh release view "$cli_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/cli.json" |
| 77 | jq -e ' |
| 78 | .isDraft == false and .isPrerelease == false and |
| 79 | ([.assets[].name] as $names | |
| 80 | ["SHA256SUMS", "reasonix-darwin-amd64.tar.gz", "reasonix-darwin-arm64.tar.gz", |
| 81 | "reasonix-linux-amd64.tar.gz", "reasonix-linux-arm64.tar.gz", |
| 82 | "reasonix-windows-amd64.zip", "reasonix-windows-arm64.zip"] | |
| 83 | all(. as $required | $names | index($required))) |
| 84 | ' "$tmp_dir/cli.json" >/dev/null |
| 85 | |
| 86 | gh release view "$desktop_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/desktop.json" |
| 87 | jq -e ' |
| 88 | .isDraft == false and .isPrerelease == false and |
| 89 | ([.assets[].name] as $names | |
| 90 | ($names | index("latest.json")) and |
| 91 | (["Reasonix-darwin-arm64.dmg", "Reasonix-darwin-amd64.dmg", |
| 92 | "Reasonix-darwin-universal.dmg", "Reasonix-darwin-arm64.zip", |
| 93 | "Reasonix-darwin-amd64.zip", "Reasonix-linux-amd64.deb", |
| 94 | "Reasonix-linux-amd64.tar.gz", "Reasonix-windows-amd64-installer.exe", |
| 95 | "Reasonix-windows-amd64.zip", "Reasonix-windows-arm64-installer.exe"] | |
| 96 | all(. as $required | ($names | index($required)) and ($names | index($required + ".minisig"))))) |
| 97 | ' "$tmp_dir/desktop.json" >/dev/null |
| 98 | |
| 99 | if [ "${DESKTOP_MANUAL_ONLY:-false}" = "true" ]; then |
| 100 | bash "$script_dir/manual-desktop-exception.sh" validate "$desktop_tag" |
| 101 | # Neither updater entry point may serve the manual release: the exception |
| 102 | # publishes downloads without advancing automatic updates. Assert that |
| 103 | # invariant rather than one release's prior version. |
| 104 | gh_latest="$(gh api "repos/$repository/releases/latest" --jq .tag_name)" |
| 105 | if [ "$gh_latest" = "$desktop_tag" ]; then |
| 106 | echo "::error::GitHub latest advanced to the manual release $desktop_tag" >&2 |
| 107 | exit 1 |
| 108 | fi |
| 109 | bash "$script_dir/fetch-stable-release-manifest.sh" "$version" "$tmp_dir/desktop-pointer.json" |
| 110 | jq -e --arg v "v$version" '.version != $v' "$tmp_dir/desktop-pointer.json" >/dev/null |
| 111 | gh release view "$desktop_tag" --repo "$repository" --json body --jq .body | grep -F 'manual-download only' |
| 112 | fi |
| 113 | |
| 114 | npm_names=( |
| 115 | "reasonix" |
| 116 | "@reasonix/cli-darwin-arm64" |
| 117 | "@reasonix/cli-darwin-x64" |
| 118 | "@reasonix/cli-linux-arm64" |
| 119 | "@reasonix/cli-linux-x64" |
| 120 | "@reasonix/cli-win32-arm64" |
| 121 | "@reasonix/cli-win32-x64" |
| 122 | ) |
| 123 | for attempt in $(seq 1 "$attempts"); do |
| 124 | rm -rf "$tmp_dir/npm" |
| 125 | mkdir -p "$tmp_dir/npm" |
| 126 | visible=true |
| 127 | for index in "${!npm_names[@]}"; do |
| 128 | package="${npm_names[$index]}" |
| 129 | raw="$tmp_dir/npm/$index.raw.json" |
| 130 | if ! npm view "$package@$version" name version reasonixCandidateSha gitHead dist.integrity "dist-tags.$npm_tag" --json >"$raw" 2>/dev/null; then |
| 131 | visible=false |
| 132 | continue |
| 133 | fi |
| 134 | jq --arg name "$package" --arg tag "$npm_tag" ' |
| 135 | (."dist-tags.\($tag)" // ."dist-tags"[$tag]) as $tagged | |
| 136 | { |
| 137 | name: (.name // $name), |
| 138 | version, |
| 139 | reasonixCandidateSha, |
| 140 | gitHead, |
| 141 | integrity: (."dist.integrity" // .dist.integrity) |
| 142 | } + (if $tag == "latest" then {latest: $tagged} else {distTag: $tag, distTagVersion: $tagged} end) |
| 143 | ' "$raw" >"$tmp_dir/npm/$index.json" |
| 144 | jq -e --arg name "$package" --arg version "$version" --arg sha "$cli_sha" ' |
| 145 | .name == $name and .version == $version and |
| 146 | ((.reasonixCandidateSha == null or .reasonixCandidateSha == $sha) and |
| 147 | (.gitHead == null or .gitHead == $sha) and |
| 148 | ((.reasonixCandidateSha // .gitHead) == $sha)) and |
| 149 | (.integrity | type == "string" and length > 0) and |
| 150 | ((.latest // .distTagVersion) | type == "string" and length > 0) |
| 151 | ' "$tmp_dir/npm/$index.json" >/dev/null || { |
| 152 | echo "::error::npm package identity differs from the release candidate: $package@$version" >&2 |
| 153 | exit 1 |
| 154 | } |
| 155 | done |
| 156 | if [ "$visible" = "true" ]; then |
| 157 | jq -s '.' "$tmp_dir"/npm/[0-9].json >"$tmp_dir/npm.json" |
| 158 | if node "$script_dir/release-publication-ledger.mjs" core "$version" "$cli_sha" "$operation" \ |
| 159 | "$tmp_dir/cli.json" "$tmp_dir/desktop.json" "$tmp_dir/npm.json" "$tmp_dir/core-ledger.json"; then |
| 160 | if [ "$verify_pointers" = "true" ]; then |
| 161 | manifest="$tmp_dir/desktop-pointer.json" |
| 162 | bash "$script_dir/fetch-stable-release-manifest.sh" "$version" "$manifest" |
| 163 | owns_pointer="$(node "$script_dir/release-publication-ledger.mjs" pointer-owner "$version" "$operation" "$manifest")" |
| 164 | if [ "$owns_pointer" = true ]; then |
| 165 | verify_pointers "$manifest" |
| 166 | node "$script_dir/release-publication-ledger.mjs" merge "$tmp_dir/core-ledger.json" \ |
| 167 | "$tmp_dir/pointer-ledger.json" "${ledger_output:-$tmp_dir/publication-ledger.json}" |
| 168 | else |
| 169 | echo "A verified newer Stable release owns the public pointers; recovered immutable v$version files only." |
| 170 | [ -z "$ledger_output" ] || cp "$tmp_dir/core-ledger.json" "$ledger_output" |
| 171 | fi |
| 172 | elif [ -n "$ledger_output" ]; then |
| 173 | cp "$tmp_dir/core-ledger.json" "$ledger_output" |
| 174 | fi |
| 175 | echo "stable release postflight OK: cli=$cli_tag desktop=$desktop_tag npm-packages=${#npm_names[@]}" |
| 176 | exit 0 |
| 177 | fi |
| 178 | fi |
| 179 | echo "npm publication has not converged (attempt $attempt/$attempts)" |
| 180 | if [ "$attempt" -lt "$attempts" ]; then sleep "$delay"; fi |
| 181 | done |
| 182 | |
| 183 | echo "::error::npm package set or public pointers did not converge for $version" >&2 |
| 184 | exit 1 |
| 185 |