| 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_homepage="${VERIFY_HOMEPAGE:-false}" |
| 13 | site_only="${VERIFY_PUBLIC_SITE_ONLY:-false}" |
| 14 | operation="${RELEASE_OPERATION:-publish}" |
| 15 | ledger_output="${RELEASE_LEDGER_OUTPUT:-}" |
| 16 | |
| 17 | if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then |
| 18 | echo "::error::RELEASE_VERSION must be stable semver, got: $version" >&2 |
| 19 | exit 1 |
| 20 | fi |
| 21 | if [ "$cli_tag" != "v$version" ] || [ "$desktop_tag" != "desktop-v$version" ]; then |
| 22 | echo "::error::release tags do not match version $version: cli=$cli_tag desktop=$desktop_tag" >&2 |
| 23 | exit 1 |
| 24 | fi |
| 25 | case "$operation" in publish | recover) ;; *) echo "::error::RELEASE_OPERATION must be publish or recover" >&2; exit 1 ;; esac |
| 26 | |
| 27 | release_git_url="https://github.com/${repository}.git" |
| 28 | cli_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/$cli_tag" | awk 'NR == 1 {print $1}')" |
| 29 | npm_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/npm-v$version" | awk 'NR == 1 {print $1}')" |
| 30 | desktop_sha="$(git ls-remote --tags --refs "$release_git_url" "refs/tags/$desktop_tag" | awk 'NR == 1 {print $1}')" |
| 31 | if [ -z "$cli_sha" ] || [ "$cli_sha" != "$npm_sha" ] || [ "$cli_sha" != "$desktop_sha" ]; then |
| 32 | echo "::error::release tags are missing or do not identify one immutable commit" >&2 |
| 33 | exit 1 |
| 34 | fi |
| 35 | |
| 36 | tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-release-postflight.XXXXXX")" |
| 37 | cleanup() { |
| 38 | case "$tmp_dir" in |
| 39 | */reasonix-release-postflight.*) rm -rf -- "$tmp_dir" ;; |
| 40 | *) echo "refusing to clean unexpected postflight directory: $tmp_dir" >&2 ;; |
| 41 | esac |
| 42 | } |
| 43 | trap cleanup EXIT |
| 44 | |
| 45 | verify_site() { |
| 46 | local manifest="$tmp_dir/desktop-pointer.json" |
| 47 | local homepage="$tmp_dir/homepage.html" |
| 48 | local changelog="$tmp_dir/changelog.html" |
| 49 | local cask="$tmp_dir/reasonix.rb" |
| 50 | curl -fsSL https://dl.reasonix.io/latest/latest.json >"$manifest" |
| 51 | jq -e --arg version "v$version" ' |
| 52 | .version == $version and |
| 53 | ([.platforms[], (.native_packages // {})[], (.downloads // {})[]] | |
| 54 | all(.url | type == "string" and startswith("https://dl.reasonix.io/desktop-" + $version + "/"))) |
| 55 | ' "$manifest" >/dev/null |
| 56 | curl -fsSL "https://reasonix.io/?download=desktop&release-postflight=v$version" >"$homepage" |
| 57 | ! grep -Eq 'href="[^"]*(tag|download)/desktop-v[0-9]+\.[0-9]+\.[0-9]+' "$homepage" |
| 58 | curl -fsSL "https://reasonix.io/changelog/v$version/" >"$changelog" |
| 59 | grep -Fq "v$version" "$changelog" |
| 60 | curl -fsSL https://raw.githubusercontent.com/esengine/homebrew-reasonix/main/Casks/reasonix.rb >"$cask" |
| 61 | grep -Eq "version ['\"]$version['\"]" "$cask" |
| 62 | local browser="${CHROME_BIN:-}" |
| 63 | if [ -z "$browser" ]; then |
| 64 | for candidate in google-chrome google-chrome-stable chromium chromium-browser; do |
| 65 | if command -v "$candidate" >/dev/null 2>&1; then browser="$candidate"; break; fi |
| 66 | done |
| 67 | fi |
| 68 | [ -n "$browser" ] || { echo "::error::a Chromium browser is required for hydrated homepage verification" >&2; return 1; } |
| 69 | "$browser" --headless=new --disable-gpu --no-sandbox --virtual-time-budget=10000 \ |
| 70 | --dump-dom "https://reasonix.io/?download=desktop&release-postflight=v$version" \ |
| 71 | >"$tmp_dir/homepage-hydrated.html" |
| 72 | grep -Fq "data-release-version=\"desktop\">v$version<" "$tmp_dir/homepage-hydrated.html" |
| 73 | while IFS= read -r asset; do |
| 74 | url="$(jq -er --arg asset "$asset" ' |
| 75 | [.platforms[], (.native_packages // {})[], (.downloads // {})[]] |
| 76 | | map(select(.url | endswith("/" + $asset))) |
| 77 | | if length == 1 then .[0].url else error("visible asset is missing or ambiguous") end |
| 78 | ' "$manifest")" |
| 79 | grep -Fq "$url" "$tmp_dir/homepage-hydrated.html" |
| 80 | done < <(grep -Eo 'data-desktop-asset="[^"]+"' "$homepage" | cut -d '"' -f 2 | sort -u) |
| 81 | } |
| 82 | |
| 83 | if [ "$site_only" = "true" ]; then |
| 84 | verify_site |
| 85 | node "$script_dir/release-publication-ledger.mjs" site "$version" "$cli_sha" "$operation" \ |
| 86 | "$tmp_dir/desktop-pointer.json" "${ledger_output:-$tmp_dir/site-ledger.json}" |
| 87 | echo "public site release inputs OK: v$version" |
| 88 | exit 0 |
| 89 | fi |
| 90 | |
| 91 | gh release view "$cli_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/cli.json" |
| 92 | jq -e ' |
| 93 | .isDraft == false and .isPrerelease == false and |
| 94 | ([.assets[].name] as $names | |
| 95 | ["SHA256SUMS", "reasonix-darwin-amd64.tar.gz", "reasonix-darwin-arm64.tar.gz", |
| 96 | "reasonix-linux-amd64.tar.gz", "reasonix-linux-arm64.tar.gz", |
| 97 | "reasonix-windows-amd64.zip", "reasonix-windows-arm64.zip"] | |
| 98 | all(. as $required | $names | index($required))) |
| 99 | ' "$tmp_dir/cli.json" >/dev/null |
| 100 | |
| 101 | gh release view "$desktop_tag" --repo "$repository" --json isDraft,isPrerelease,assets >"$tmp_dir/desktop.json" |
| 102 | jq -e ' |
| 103 | .isDraft == false and .isPrerelease == false and |
| 104 | ([.assets[].name] as $names | |
| 105 | ($names | index("latest.json")) and |
| 106 | (["Reasonix-darwin-arm64.dmg", "Reasonix-darwin-amd64.dmg", |
| 107 | "Reasonix-darwin-universal.dmg", "Reasonix-darwin-arm64.zip", |
| 108 | "Reasonix-darwin-amd64.zip", "Reasonix-linux-amd64.deb", |
| 109 | "Reasonix-linux-amd64.tar.gz", "Reasonix-windows-amd64-installer.exe", |
| 110 | "Reasonix-windows-amd64.zip", "Reasonix-windows-arm64-installer.exe"] | |
| 111 | all(. as $required | ($names | index($required)) and ($names | index($required + ".minisig"))))) |
| 112 | ' "$tmp_dir/desktop.json" >/dev/null |
| 113 | |
| 114 | if [ "${DESKTOP_MANUAL_ONLY:-false}" = "true" ]; then |
| 115 | bash "$script_dir/manual-desktop-exception.sh" validate "$desktop_tag" |
| 116 | # Neither updater entry point may serve the manual release: the exception |
| 117 | # publishes downloads without advancing automatic updates. Assert that |
| 118 | # invariant rather than one release's prior version. |
| 119 | gh_latest="$(gh api "repos/$repository/releases/latest" --jq .tag_name)" |
| 120 | if [ "$gh_latest" = "$desktop_tag" ]; then |
| 121 | echo "::error::GitHub latest advanced to the manual release $desktop_tag" >&2 |
| 122 | exit 1 |
| 123 | fi |
| 124 | curl -fsSL https://dl.reasonix.io/latest/latest.json > "$tmp_dir/desktop-pointer.json" |
| 125 | jq -e --arg v "v$version" '.version != $v' "$tmp_dir/desktop-pointer.json" >/dev/null |
| 126 | gh release view "$desktop_tag" --repo "$repository" --json body --jq .body | grep -F 'manual-download only' |
| 127 | fi |
| 128 | |
| 129 | npm_names=( |
| 130 | "reasonix" |
| 131 | "@reasonix/cli-darwin-arm64" |
| 132 | "@reasonix/cli-darwin-x64" |
| 133 | "@reasonix/cli-linux-arm64" |
| 134 | "@reasonix/cli-linux-x64" |
| 135 | "@reasonix/cli-win32-arm64" |
| 136 | "@reasonix/cli-win32-x64" |
| 137 | ) |
| 138 | for attempt in $(seq 1 "$attempts"); do |
| 139 | rm -rf "$tmp_dir/npm" |
| 140 | mkdir -p "$tmp_dir/npm" |
| 141 | visible=true |
| 142 | for index in "${!npm_names[@]}"; do |
| 143 | package="${npm_names[$index]}" |
| 144 | raw="$tmp_dir/npm/$index.raw.json" |
| 145 | if ! npm view "$package@$version" name version reasonixCandidateSha gitHead dist.integrity dist-tags.latest --json >"$raw" 2>/dev/null; then |
| 146 | visible=false |
| 147 | continue |
| 148 | fi |
| 149 | jq --arg name "$package" ' |
| 150 | { |
| 151 | name: (.name // $name), |
| 152 | version, |
| 153 | reasonixCandidateSha, |
| 154 | gitHead, |
| 155 | integrity: (."dist.integrity" // .dist.integrity), |
| 156 | latest: (."dist-tags.latest" // ."dist-tags".latest) |
| 157 | } |
| 158 | ' "$raw" >"$tmp_dir/npm/$index.json" |
| 159 | jq -e --arg name "$package" --arg version "$version" --arg sha "$cli_sha" ' |
| 160 | .name == $name and .version == $version and |
| 161 | ((.reasonixCandidateSha == null or .reasonixCandidateSha == $sha) and |
| 162 | (.gitHead == null or .gitHead == $sha) and |
| 163 | ((.reasonixCandidateSha // .gitHead) == $sha)) and |
| 164 | (.integrity | type == "string" and length > 0) and |
| 165 | (.latest | type == "string" and length > 0) |
| 166 | ' "$tmp_dir/npm/$index.json" >/dev/null || { |
| 167 | echo "::error::npm package identity differs from the release candidate: $package@$version" >&2 |
| 168 | exit 1 |
| 169 | } |
| 170 | done |
| 171 | if [ "$visible" = "true" ]; then |
| 172 | jq -s '.' "$tmp_dir"/npm/[0-9].json >"$tmp_dir/npm.json" |
| 173 | if node "$script_dir/release-publication-ledger.mjs" core "$version" "$cli_sha" "$operation" \ |
| 174 | "$tmp_dir/cli.json" "$tmp_dir/desktop.json" "$tmp_dir/npm.json" "$tmp_dir/core-ledger.json"; then |
| 175 | if [ "$verify_homepage" = "true" ]; then |
| 176 | owns_site="$(bash "$script_dir/observe-release-site.sh" "$version" "$operation")" |
| 177 | if [ "$owns_site" = true ]; then |
| 178 | verify_site |
| 179 | node "$script_dir/release-publication-ledger.mjs" site "$version" "$cli_sha" "$operation" \ |
| 180 | "$tmp_dir/desktop-pointer.json" "$tmp_dir/site-ledger.json" |
| 181 | node "$script_dir/release-publication-ledger.mjs" merge "$tmp_dir/core-ledger.json" \ |
| 182 | "$tmp_dir/site-ledger.json" "${ledger_output:-$tmp_dir/publication-ledger.json}" |
| 183 | else |
| 184 | echo "A verified newer Stable release owns the site; recovered immutable v$version files only." |
| 185 | [ -z "$ledger_output" ] || cp "$tmp_dir/core-ledger.json" "$ledger_output" |
| 186 | fi |
| 187 | elif [ -n "$ledger_output" ]; then |
| 188 | cp "$tmp_dir/core-ledger.json" "$ledger_output" |
| 189 | fi |
| 190 | echo "stable release postflight OK: cli=$cli_tag desktop=$desktop_tag npm-packages=${#npm_names[@]}" |
| 191 | exit 0 |
| 192 | fi |
| 193 | fi |
| 194 | echo "npm publication has not converged (attempt $attempt/$attempts)" |
| 195 | if [ "$attempt" -lt "$attempts" ]; then sleep "$delay"; fi |
| 196 | done |
| 197 | |
| 198 | echo "::error::npm package set or public pointers did not converge for $version" >&2 |
| 199 | exit 1 |
| 200 |