返回 DeepSeek-Reasonix
verify-stable-release-artifacts.sh
根目录 / scripts / verify-stable-release-artifacts.sh
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
200 lines BASH