Skip to content

Commit 31e73d0

Browse files
mdesmetclauderalphstodomingo
authored
feat(install): verify release archive checksums in both installers (#942)
* feat(install): verify release archive checksums (both installers) Raises the integrity bar for the standalone installers (follow-up to #930). - release.yml: generate a checksums.txt (sha256sum format) over the release archives and publish it as a release asset. - install (bash) + install.ps1: fetch checksums.txt and verify the downloaded archive's SHA256 before extracting. Hard-fail on mismatch; soft-skip with a notice when checksums.txt is absent (older pinned releases) or unreachable, so existing version-pinned installs keep working. - Cross-platform sha in bash (sha256sum or shasum -a 256); Get-FileHash on Windows. Verification runs before extraction in both. - Tests: checksum-verification.test.ts asserts release.yml publishes the file and both installers fetch + compare + hard-fail on mismatch. Verified: bash -n clean; install.ps1 parses clean and the Pester suite (6/6) still passes on PowerShell 7.6.2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(install): address checksum-verification review - install.ps1: decode a Byte[] checksums.txt body so verification works on Windows PowerShell 5.1. GitHub serves release assets as octet-stream, so on PS 5.1 Invoke-WebRequest returns .Content as Byte[]; it coerced to a decimal string and every check silently soft-skipped (sahrizvi, P1). - install.ps1: pin the archive and checksums.txt to the resolved release tag instead of the mutable latest/ URL, so a release published mid-install can't hand back mismatched assets and trigger a spurious hard-fail (cubic, P2). Falls back to latest/ only when the version can't be resolved. - install: in verify_checksum, clean up via $(dirname "$file") rather than the caller's dynamically-scoped $tmp_dir local — self-contained (cubic, P2). - tests: Pester coverage for Test-Checksum (String + Byte[] + mismatch paths, verified to fail without the decode) and TS guards for the decode and the PowerShell same-release pinning. Note: the bash installer is intentionally left on the latest/download path here to keep this PR disjoint from #946 (which owns the bash latest-version block); the two PRs then merge in either order with no conflict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M7GkS3bYZaFhEbBhVTecG * fix(install.ps1): ASCII-only so it parses on Windows PowerShell 5.1 install.ps1 had no BOM and used a few non-ASCII characters (em dash, ellipsis, right arrow) in comments and messages. Windows PowerShell 5.1 — the default shell on Windows 10 and preinstalled on Windows 11 — reads a BOM-less file as the system ANSI codepage, not UTF-8, so those multi-byte characters corrupt the token stream and the whole script fails to parse (verified on real PS 5.1: "The '<' operator is reserved", cascading to "Missing closing '}'"). This is a pre-existing issue (the characters predate this PR) that CI doesn't catch because the Pester job runs under pwsh (PowerShell 7, UTF-8 by default). Replacing the three characters with ASCII equivalents (-, ..., ->) makes the installer parse and run on PS 5.1 while keeping pwsh behavior identical. Verified end-to-end on real Windows PowerShell 5.1: resolve version -> download -> extract -> place the binary all succeed. Same transliteration is applied verbatim in #946 so the two PRs merge cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M7GkS3bYZaFhEbBhVTecG * fix(install): guard the verify_checksum cleanup against a pathological path Defensive depth (coderabbit): only `rm -rf` the cleanup dir when dirname resolves to a real subdirectory, never "." or "/", so an unexpectedly empty or root-level $file can't wipe the cwd or worse. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M7GkS3bYZaFhEbBhVTecG * test: update #930 release-validation for the checksum URL refactor The #952 release-validation suite asserted the exact #930 URL literals. This PR builds the archive and checksums.txt from a shared $base (so they always come from the same release), so update those assertions to the $base/$url form, and convert the now-obsolete "verification deferred" test.todo into a real assertion that Test-Checksum verifies SHA256 before extraction. (This test never ran on this PR until it was retargeted from the merged feat/windows-powershell-installer branch to main.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M7GkS3bYZaFhEbBhVTecG --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: ralphstodomingo <ralphstodomingo@users.noreply.github.com>
1 parent f8b3454 commit 31e73d0

6 files changed

Lines changed: 284 additions & 23 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,13 @@ jobs:
357357
path: packages/opencode/dist/
358358
merge-multiple: true
359359

360+
- name: Generate checksums
361+
# Single checksums.txt (sha256sum format: "<hash> <bare-filename>") shipped
362+
# as a release asset. The curl and PowerShell installers fetch it and verify
363+
# the downloaded archive before extracting.
364+
working-directory: packages/opencode/dist
365+
run: sha256sum *.tar.gz *.zip > checksums.txt
366+
360367
- name: Create GitHub Release
361368
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
362369
with:
@@ -366,5 +373,6 @@ jobs:
366373
files: |
367374
packages/opencode/dist/*.tar.gz
368375
packages/opencode/dist/*.zip
376+
packages/opencode/dist/checksums.txt
369377
env:
370378
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

‎install‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,59 @@ download_with_progress() {
356356
return $ret
357357
}
358358

359+
# Verify the downloaded archive against the release's checksums.txt.
360+
# Hard-fails on a real mismatch; soft-skips when checksums.txt can't be fetched
361+
# (older release, network blip) or has no entry, so pinned installs of
362+
# pre-checksums releases keep working.
363+
verify_checksum() {
364+
local file="$1"
365+
local name="$2"
366+
# $url ends in /$filename — strip it to get the release base, append checksums.txt.
367+
local checksums_url="${url%/*}/checksums.txt"
368+
369+
local sums
370+
if ! sums=$(curl --fail -sL "$checksums_url" 2>/dev/null); then
371+
print_message info "${MUTED}Skipping integrity check — checksums.txt not published for this release${NC}"
372+
return 0
373+
fi
374+
375+
# checksums.txt is sha256sum format: "<hash> <filename>" (sha256sum may
376+
# prefix the name with '*' in binary mode — tolerate it).
377+
local expected
378+
expected=$(printf '%s\n' "$sums" | awk -v f="$name" '{ n=$2; sub(/^\*/,"",n); if (n==f) { print $1; exit } }')
379+
if [ -z "$expected" ]; then
380+
print_message info "${MUTED}Skipping integrity check — no checksum entry for $name${NC}"
381+
return 0
382+
fi
383+
384+
local actual
385+
if command -v sha256sum >/dev/null 2>&1; then
386+
actual=$(sha256sum "$file" | cut -d' ' -f1)
387+
elif command -v shasum >/dev/null 2>&1; then
388+
actual=$(shasum -a 256 "$file" | cut -d' ' -f1)
389+
else
390+
print_message info "${MUTED}Skipping integrity check — no sha256 tool available${NC}"
391+
return 0
392+
fi
393+
394+
if [ "$actual" != "$expected" ]; then
395+
print_message error "Checksum mismatch for $name"
396+
print_message error " expected: $expected"
397+
print_message error " actual: $actual"
398+
# Clean up via the file's own directory rather than the caller's $tmp_dir,
399+
# so this stays self-contained and doesn't depend on a dynamically-scoped
400+
# local from download_and_install. Guard against a pathological $file
401+
# (empty or root-level) that would make dirname resolve to "." or "/".
402+
local cleanup_dir
403+
cleanup_dir=$(dirname "$file")
404+
if [ -n "$cleanup_dir" ] && [ "$cleanup_dir" != "." ] && [ "$cleanup_dir" != "/" ]; then
405+
rm -rf "$cleanup_dir"
406+
fi
407+
exit 1
408+
fi
409+
print_message info "${MUTED}Verified ${NC}$name${MUTED} (sha256)${NC}"
410+
}
411+
359412
download_and_install() {
360413
print_message info "\n${MUTED}Installing ${NC}altimate ${MUTED}version: ${NC}$specific_version"
361414
local tmp_dir="${TMPDIR:-/tmp}/altimate_install_$$"
@@ -367,6 +420,8 @@ download_and_install() {
367420
curl --fail -# -L -o "$tmp_dir/$filename" "$url"
368421
fi
369422

423+
verify_checksum "$tmp_dir/$filename" "$filename"
424+
370425
# Extract only the expected binary member rather than the whole archive.
371426
# The current build only puts a single file in each archive, but listing
372427
# the member explicitly makes a future "tars a whole directory" mistake

‎install.ps1‎

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
# Mirrors ./install (the bash installer for macOS/Linux): it downloads the
55
# Bun-compiled standalone executable (altimate.exe) from GitHub releases and
6-
# drops it in %USERPROFILE%\.altimate\bin — it does NOT depend on npm/Node.
6+
# drops it in %USERPROFILE%\.altimate\bin - it does NOT depend on npm/Node.
77
#
88
# Usage:
99
# powershell -c "irm https://www.altimate.sh/install.ps1 | iex"
@@ -64,8 +64,8 @@ if ($Help) {
6464
exit 0
6565
}
6666

67-
# A single P/Invoke type carries both native calls we need — the AVX2 CPU probe
68-
# (kernel32) and the PATH-change broadcast (user32) — so we Add-Type once instead
67+
# A single P/Invoke type carries both native calls we need - the AVX2 CPU probe
68+
# (kernel32) and the PATH-change broadcast (user32) - so we Add-Type once instead
6969
# of compiling a throwaway type per call site.
7070
function Initialize-Native {
7171
if (-not ("Win32.AltimateNative" -as [type])) {
@@ -77,6 +77,46 @@ public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wP
7777
}
7878
}
7979

80+
# Verify a downloaded archive against the release's checksums.txt.
81+
# Hard-fails (throws) on a real mismatch. Soft-skips when checksums.txt can't be
82+
# fetched (older release, network blip) or has no entry for this file, so pinned
83+
# installs of pre-checksums releases keep working.
84+
function Test-Checksum {
85+
param([string]$Path, [string]$Name, [string]$ChecksumsUrl)
86+
87+
$sums = $null
88+
try {
89+
$resp = Invoke-WebRequest -Uri $ChecksumsUrl -UseBasicParsing
90+
# On Windows PowerShell 5.1, .Content is a Byte[] (not a String) whenever the
91+
# response isn't a text-recognized content-type - and GitHub serves release
92+
# assets as application/octet-stream. A raw Byte[] coerces to a "49 50 51 ..."
93+
# decimal string when split, so verification would silently soft-skip on the
94+
# default Windows shell. Decode the bytes explicitly to recover real text.
95+
if ($resp.Content -is [byte[]]) {
96+
$sums = [System.Text.Encoding]::UTF8.GetString($resp.Content)
97+
} else {
98+
$sums = $resp.Content
99+
}
100+
} catch {
101+
Write-Muted "Skipping integrity check - checksums.txt not published for this release"
102+
return
103+
}
104+
105+
# checksums.txt is sha256sum format: "<hash> <filename>" (one entry per line).
106+
$line = ($sums -split "`n") | Where-Object { $_ -match "\s\*?$([regex]::Escape($Name))\s*$" } | Select-Object -First 1
107+
if (-not $line) {
108+
Write-Muted "Skipping integrity check - no checksum entry for $Name"
109+
return
110+
}
111+
112+
$expected = (($line -split '\s+')[0]).ToLower()
113+
$actual = (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLower()
114+
if ($actual -ne $expected) {
115+
throw "Checksum mismatch for $Name (expected $expected, got $actual)"
116+
}
117+
Write-Muted "Verified $Name (sha256)"
118+
}
119+
80120
# ---------------------------------------------------------------------------
81121
# Architecture / baseline detection
82122
# ---------------------------------------------------------------------------
@@ -101,14 +141,14 @@ function Test-Avx2 {
101141
Initialize-Native
102142
return [bool][Win32.AltimateNative]::IsProcessorFeaturePresent(40)
103143
} catch {
104-
# If detection fails, assume no AVX2 and fall back to the baseline build —
144+
# If detection fails, assume no AVX2 and fall back to the baseline build -
105145
# the baseline binary runs everywhere, an AVX2 binary on a non-AVX2 CPU crashes.
106146
return $false
107147
}
108148
}
109149

110150
# ---------------------------------------------------------------------------
111-
# Resolve version (once) — latest tag or a pinned release
151+
# Resolve version (once) - latest tag or a pinned release
112152
# ---------------------------------------------------------------------------
113153
if ([string]::IsNullOrWhiteSpace($Version)) {
114154
$useLatest = $true
@@ -170,11 +210,19 @@ function Install-Target {
170210
if ($Baseline) { $target = "$target-baseline" }
171211
$filename = "$App-$target.zip"
172212

173-
if ($useLatest) {
174-
$url = "https://github.com/AltimateAI/altimate-code/releases/latest/download/$filename"
213+
# Pin BOTH the archive and checksums.txt to the same resolved release. The
214+
# mutable releases/latest/download URL would fetch the two assets in separate
215+
# requests, so a release published mid-install could hand back an archive from
216+
# one release and checksums from another -> a spurious hard-fail. We resolve
217+
# the concrete tag up front ($specificVersion), so pin to it. Only fall back
218+
# to the mutable latest/ URL when the version genuinely couldn't be resolved.
219+
if ($useLatest -and -not $specificVersion) {
220+
$base = "https://github.com/AltimateAI/altimate-code/releases/latest/download"
175221
} else {
176-
$url = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion/$filename"
222+
$base = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion"
177223
}
224+
$url = "$base/$filename"
225+
$checksumsUrl = "$base/checksums.txt"
178226

179227
Write-Host ""
180228
Write-Host "Installing $App version: $specificVersion"
@@ -184,12 +232,6 @@ function Install-Target {
184232
$zipPath = Join-Path $tmpDir $filename
185233

186234
try {
187-
# NOTE: integrity verification (SHA256/signature) of the archive is
188-
# intentionally deferred to match the bash installer's posture — both rely
189-
# on HTTPS from github.com release assets. Releases do not currently publish
190-
# a checksums file; adding one + verifying it in both installers is tracked
191-
# as a follow-up. See PR #930 discussion.
192-
#
193235
# Prefer curl.exe (ships with Windows 10 1803+) for a fast download with
194236
# --fail so HTTP errors don't write an error page to disk; fall back to
195237
# Invoke-WebRequest where curl.exe is unavailable.
@@ -201,6 +243,10 @@ function Install-Target {
201243
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
202244
}
203245

246+
# Integrity check: hard-fail on mismatch; skip (with notice) when the release
247+
# predates checksums.txt or the fetch fails, so older pinned installs still work.
248+
Test-Checksum -Path $zipPath -Name $filename -ChecksumsUrl $checksumsUrl
249+
204250
Expand-Archive -Path $zipPath -DestinationPath $tmpDir -Force
205251
$extracted = Join-Path $tmpDir $BinaryName
206252
if (-not (Test-Path $extracted)) {
@@ -210,7 +256,7 @@ function Install-Target {
210256

211257
# Windows locks a running .exe, so `altimate upgrade` (which re-runs this
212258
# installer) can't overwrite the binary that is currently executing. Windows
213-
# *does* allow renaming a running exe — move the old one aside first, then
259+
# *does* allow renaming a running exe - move the old one aside first, then
214260
# drop the new one in. Best-effort cleanup of the stale copy afterward.
215261
if (Test-Path $InstalledBinary) {
216262
$stale = "$InstalledBinary.old"
@@ -237,7 +283,7 @@ if (-not $needsBaseline) {
237283
& $InstalledBinary --version *> $null
238284
$code = $LASTEXITCODE
239285
if ($code -eq 3221225501 -or $code -eq 1073741795 -or $code -eq -1073741795) {
240-
Write-Muted "CPU lacks AVX2 — reinstalling the baseline build"
286+
Write-Muted "CPU lacks AVX2 - reinstalling the baseline build"
241287
Install-Target -Baseline:$true
242288
}
243289
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Release-archive integrity verification across the install surface.
3+
*
4+
* The release publishes a checksums.txt asset; both installers fetch it and
5+
* verify the downloaded archive (sha256) before extracting — hard-fail on
6+
* mismatch, soft-skip when the file is absent (older pinned releases).
7+
*/
8+
import { describe, test, expect } from "bun:test"
9+
import { readFileSync } from "node:fs"
10+
import { join } from "node:path"
11+
12+
const REPO_ROOT = join(import.meta.dir, "../../../..")
13+
const BASH_INSTALL = readFileSync(join(REPO_ROOT, "install"), "utf-8")
14+
const PS1 = readFileSync(join(REPO_ROOT, "install.ps1"), "utf-8")
15+
const RELEASE_YML = readFileSync(join(REPO_ROOT, ".github/workflows/release.yml"), "utf-8")
16+
17+
describe("release publishes checksums", () => {
18+
test("release.yml generates checksums.txt and uploads it", () => {
19+
expect(RELEASE_YML).toContain("sha256sum *.tar.gz *.zip > checksums.txt")
20+
expect(RELEASE_YML).toContain("packages/opencode/dist/checksums.txt")
21+
})
22+
})
23+
24+
describe("bash installer verifies checksums", () => {
25+
test("fetches checksums.txt and compares sha256", () => {
26+
expect(BASH_INSTALL).toContain("checksums.txt")
27+
expect(BASH_INSTALL).toMatch(/sha256sum|shasum -a 256/)
28+
})
29+
30+
test("hard-fails on mismatch", () => {
31+
expect(BASH_INSTALL).toContain("Checksum mismatch")
32+
expect(BASH_INSTALL).toContain("verify_checksum")
33+
})
34+
})
35+
36+
describe("PowerShell installer verifies checksums", () => {
37+
test("fetches checksums.txt and compares sha256", () => {
38+
expect(PS1).toContain("checksums.txt")
39+
expect(PS1).toContain("Get-FileHash")
40+
expect(PS1).toContain("Test-Checksum")
41+
})
42+
43+
test("hard-fails on mismatch before extracting", () => {
44+
expect(PS1).toContain("Checksum mismatch")
45+
// The verify call must precede the actual extraction call (not the
46+
// Expand-Archive mention in the top-of-file ProgressPreference comment).
47+
expect(PS1.indexOf("Test-Checksum -Path")).toBeLessThan(PS1.indexOf("Expand-Archive -Path"))
48+
})
49+
50+
test("decodes a Byte[] checksums.txt body (Windows PowerShell 5.1)", () => {
51+
// GitHub serves release assets as octet-stream, so PS 5.1 returns .Content
52+
// as Byte[]; without an explicit decode it coerces to decimal text and the
53+
// check silently soft-skips. See test/windows/install.Tests.ps1 for the
54+
// behavioral guard.
55+
expect(PS1).toContain("-is [byte[]]")
56+
expect(PS1).toContain("[System.Text.Encoding]::UTF8.GetString")
57+
})
58+
})
59+
60+
describe("archive and checksums come from the same release (no latest/ race)", () => {
61+
test("bash derives the checksums URL from the same base as the archive", () => {
62+
// verify_checksum builds checksums_url from the archive's own URL (${url%/*}),
63+
// so the two are always fetched from the same release path.
64+
expect(BASH_INSTALL).toContain('checksums_url="${url%/*}/checksums.txt"')
65+
})
66+
67+
test("PowerShell pins both URLs to the resolved release tag (cubic P2)", () => {
68+
// The archive and checksums.txt share one $base; that base is the resolved
69+
// tag, so a release published mid-install can't hand back mismatched assets.
70+
// Falls back to latest/ only when the version couldn't be resolved.
71+
expect(PS1).toContain('$url = "$base/$filename"')
72+
expect(PS1).toContain('$checksumsUrl = "$base/checksums.txt"')
73+
expect(PS1).toContain('$base = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion"')
74+
})
75+
})

‎packages/opencode/test/release-validation/windows-installer-930-codex.test.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ function upgradePowershellBlock() {
2424

2525
describe("PR #930 install.ps1 release URL construction", () => {
2626
test("uses only HTTPS GitHub release URLs for Windows zip assets", () => {
27-
expect(INSTALL_PS1).toContain('"https://github.com/AltimateAI/altimate-code/releases/latest/download/$filename"')
28-
expect(INSTALL_PS1).toContain('"https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion/$filename"')
27+
// The archive and checksums.txt share one $base so they always resolve to the
28+
// same release (see verify_checksum / Test-Checksum). $base is the latest
29+
// download path or the pinned release tag; $url and $checksumsUrl derive from it.
30+
expect(INSTALL_PS1).toContain('$base = "https://github.com/AltimateAI/altimate-code/releases/latest/download"')
31+
expect(INSTALL_PS1).toContain('$base = "https://github.com/AltimateAI/altimate-code/releases/download/v$specificVersion"')
32+
expect(INSTALL_PS1).toContain('$url = "$base/$filename"')
2933
expect(INSTALL_PS1).toContain('"https://api.github.com/repos/AltimateAI/altimate-code/releases/latest"')
3034
expect(INSTALL_PS1).not.toMatch(/http:\/\/(?:github\.com|api\.github\.com|www\.altimate\.sh)/)
3135
})
@@ -62,9 +66,13 @@ describe("PR #930 install.ps1 release URL construction", () => {
6266
})
6367

6468
describe("PR #930 install.ps1 download and archive safety", () => {
65-
// BUG: install.ps1 currently documents that SHA256/signature verification is deferred
66-
// and relies only on HTTPS. Release assets should be verified before extraction.
67-
test.todo("verifies downloaded archive integrity with SHA256 or a signature before extraction", () => {})
69+
test("verifies downloaded archive integrity with SHA256 before extraction", () => {
70+
// Closed by the checksum-verification work: Test-Checksum fetches checksums.txt
71+
// and compares SHA256, and the verify call precedes the actual extraction.
72+
expect(INSTALL_PS1).toContain("Test-Checksum -Path $zipPath")
73+
expect(INSTALL_PS1).toContain("Get-FileHash -Path $Path -Algorithm SHA256")
74+
expect(INSTALL_PS1.indexOf("Test-Checksum -Path")).toBeLessThan(INSTALL_PS1.indexOf("Expand-Archive -Path"))
75+
})
6876

6977
test("fails curl.exe downloads on HTTP errors and checks curl exit status", () => {
7078
const installTarget = scriptBlock("function Install-Target", "$needsBaseline")

0 commit comments

Comments
 (0)