Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/cross-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
# Copyright: (c) 2025-2026 HYPERI PTY LIMITED
#
# Trigger: - workflow_run after CI succeeds on main in this repository, never
# for a pull request (most common path - fires when
# hyperi-ci's publish step tags vX.Y.Z; the [skip ci] suffix on
# the chore commit blocks the tag-push trigger so we use the
# ci-completion event instead).
# - on tag push (vX.Y.Z) -> fallback for tags pushed without the
# [skip ci] suffix (manual tagging, foreign release tools, etc.).
# for a pull request. The Package workflow follows this run only.
# - on tag push (vX.Y.Z). hyperi-ci tags the released commit, which
# carries no [skip ci], so every release starts this run as well as
# the workflow_run one. Builds are queued per tag, and the second
# finds the first one's upload and writes nothing. The [skip ci] is
# on the untagged chore(release) commit that follows the tag.
# - workflow_dispatch with `tag` input -> retroactive / out-of-band.
#
# Status: Windows builds are EXPERIMENTAL - best effort, not in the formal
Expand Down
7 changes: 4 additions & 3 deletions .hyperi-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
# Reference: config/defaults.yaml in hyperi-ci.

# Category 4, general OSS: public, Apache-2.0, publishes, neutral branding
# (no HyperI brand furniture). This records what the repo already does -- the
# LICENSE, the public visibility and `publish.target: oss` below were all
# already in place; only the declaration was missing.
# (no HyperI brand furniture).
classification: oss

language: rust
Expand All @@ -29,6 +27,9 @@ build:
rust:
features: all
targets: []
optimize:
# A short-lived CLI that declares no jemalloc feature, so it builds on the system allocator.
allocator: system
release:
enabled: true
channel: release
25 changes: 14 additions & 11 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,20 @@ checksums, `.deb` and `.rpm` via nfpm, the rendered Homebrew formula and
Scoop manifest pushed into the sibling tap and bucket repos, and the static
install scripts.

## Why the chain uses workflow_run, not tag pushes

The obvious wiring -- trigger Cross-build on `push: tags: v*` -- does not
fire. semantic-release commits the version bump with a `[skip ci]` suffix,
and GitHub suppresses the tag-push event for those commits. Cross-build
therefore listens for CI COMPLETION instead, and derives the tag itself by
reading the most recent `v*` tag.

The tag-push trigger is still declared as a fallback for tags created outside
semantic-release (manual tagging, a foreign release tool). Both paths land in
the same `resolve-tag` job.
## Why Cross-build has two triggers

A release starts Cross-build twice. hyperi-ci tags the released commit, which
carries no `[skip ci]`, so the tag push starts a `push` run. It then commits
`VERSION` and `CHANGELOG.md` back as an untagged
`chore(release)` commit marked `[skip ci]`, and that mark only stops the commit
starting another CI run. The completion of the CI run starts the `workflow_run`
build, which derives the tag by reading the most recent `v*` tag.

Both land in the same `resolve-tag` job. Builds are queued per tag and platform,
so the second one finds the first one's upload and writes nothing. Package
follows only the `workflow_run` build, so a release packages once. A tag pushed
outside hyperi-ci (manual tagging, a foreign release tool) starts the `push`
run alone.

Because Package runs off Cross-build's completion rather than a shared
artefact handoff, its linux tarball jobs POLL R2 for the raw binary before
Expand Down
15 changes: 8 additions & 7 deletions packaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ the rendered files.
| Platform | Status |
|---|---|
| Linux (amd64, arm64) | Supported - built and tested in CI |
| macOS (amd64, arm64) | Templates ready; binary builds pending hyperi-ci darwin support |
| **Windows (amd64)** | **EXPERIMENTAL - best effort.** Not in the formal CI test path. install.ps1 template ships, but Windows binaries are not yet produced by hyperi-ci. Planned phase 2 via `cargo-zigbuild` cross-compilation |
| macOS (amd64, arm64) | Built by the Cross-build workflow and published with each release. Not tested in CI |
| **Windows (amd64)** | **EXPERIMENTAL - best effort.** Not in the formal CI test path. Cross-build compiles it with `cargo-zigbuild` and publishes it beside the other platforms. There is no `windows-arm64` build |

The Windows channel is provided so consumers can iterate, but it has
no CI gate behind it - expect rough edges until the formal test path
covers it.
The Windows channel is provided so consumers can iterate. Nothing runs
the Windows binary in CI, and `tests/install_ps1.rs` drives install.ps1
under `pwsh` only on a host that has it - expect rough edges until the
formal test path covers it.

## What it produces

Expand All @@ -39,7 +40,7 @@ sibling repos on the same org:

These are populated per release by copying the rendered manifests in.

Both detect OS/arch, download the matching binary from the configured R2 download base (or any HTTP host), and install to a system or user directory. `install.sh` refuses to install unless the binary matches a published SHA-256 for that exact asset: a missing sums file, a sums file without the asset, a mismatch or a download error all exit non-zero.
Both detect OS/arch, download the matching binary from the configured R2 download base (or any HTTP host), and install to a system or user directory. Each refuses to install unless the binary matches a published SHA-256 for that exact asset: a missing sums file, a sums file without the asset, a mismatch or a download error all stop the install. `install.sh` exits non-zero. `install.ps1` throws, so run as a file it exits 1, and under `irm | iex` it leaves the caller's session open.

Adding a new channel: drop a file into `packaging/templates/` or
`packaging/templates-release/`, reference any `${PKG_*}` variable,
Expand Down Expand Up @@ -84,7 +85,7 @@ ${PKG_DOWNLOAD_BASE}/${VERSION}/${PKG_BINARY}-${os}-${arch}.sha256 # checked fi
${PKG_DOWNLOAD_BASE}/${VERSION}/checksums.sha256 # fallback
```

`install.sh` needs one of the two sums files to carry a `<sha256> <asset>` line for the exact asset name. The first file that names the asset decides, so a mismatch there never falls through to the other.
Each installer needs one of the two sums files to carry a `<sha256> <asset>` line for the exact asset name, case included. On Windows the asset is the `.exe`, and the per-platform sums file drops that extension: `<binary>-windows-<arch>.sha256` lists the `.exe` and the `.zip`. The first file that names the asset decides, so a mismatch there never falls through to the other.

Where `${VERSION}` is either `latest` or a `vX.Y.Z` tag, and `os` is
one of `linux`, `darwin`, `windows`, and `arch` is `amd64` or `arm64`.
Expand Down
78 changes: 55 additions & 23 deletions packaging/templates/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,36 @@ $binary = '${PKG_BINARY}'
$downloadBase = '${PKG_DOWNLOAD_BASE}'
$defaultDir = "${PKG_WINDOWS_DEFAULT_DIR}"

# Every failure throws: `exit` would close the caller's session under `irm | iex`.
function Stop-Install([string]$Message) {
throw "install.ps1: $Message"
}

# True once $From is saved to $To. Any HTTP or network error returns false.
function Save-Download([string]$From, [string]$To) {
try {
Invoke-WebRequest -UseBasicParsing -Uri $From -OutFile $To
return $true
} catch {
return $false
}
}

# The SHA-256 that sums file $Path records for exactly the file named $Name, or nothing
# when that entry is missing, malformed or listed more than once.
function Get-RecordedSha256([string]$Path, [string]$Name) {
$hashes = @()
foreach ($line in @(Get-Content -LiteralPath $Path)) {
$fields = @($line.Trim() -split '\s+')
if ($fields.Count -eq 2 -and ($fields[1] -replace '^\*', '') -ceq $Name) {
$hashes += $fields[0]
}
}
if ($hashes.Count -eq 1 -and $hashes[0] -match '^[0-9a-fA-F]{64}$') {
return $hashes[0].ToLowerInvariant()
}
}

Write-Host "[experimental] Windows install is not yet in the formal CI test path." -ForegroundColor Yellow

if ($Dir) {
Expand All @@ -52,38 +82,40 @@ if ($Version -eq 'latest') {
$versionPath = "v$Version"
}

$asset = "$binary-windows-$arch.exe"
$url = "$downloadBase/$versionPath/$asset"
$sumsUrl = "$downloadBase/$versionPath/checksums.sha256"
$asset = "$binary-windows-$arch.exe"
$releaseUrl = "$downloadBase/$versionPath"
$platformSums = "$binary-windows-$arch.sha256"

$tmpDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("$binary-install-" + [Guid]::NewGuid().Guid))
try {
$tmpBinary = Join-Path $tmpDir "$binary.exe"
Write-Host "Downloading $binary $versionPath (windows-$arch)..."
try {
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $tmpBinary
} catch {
Write-Error "Download failed: $url`n$_"
exit 1
if (-not (Save-Download "$releaseUrl/$asset" $tmpBinary)) {
Stop-Install "download failed: $releaseUrl/$asset"
}

# Checksum verify if available
$sumsFile = Join-Path $tmpDir 'checksums.sha256'
try {
Invoke-WebRequest -UseBasicParsing -Uri $sumsUrl -OutFile $sumsFile -ErrorAction Stop
$line = Select-String -Path $sumsFile -Pattern ([regex]::Escape($asset)) | Select-Object -First 1
if ($line) {
$expected = ($line.Line -split '\s+')[0]
$actual = (Get-FileHash -Algorithm SHA256 -Path $tmpBinary).Hash.ToLower()
if ($expected.ToLower() -ne $actual) {
Write-Error "Checksum mismatch for $asset`n expected: $expected`n actual: $actual"
exit 1
}
Write-Host "Checksum verified."
# Every release publishes <binary>-windows-<arch>.sha256 (the .exe and the .zip), and
# checksums.sha256 is the older combined file. The first one that names this asset
# decides, so a mismatch never falls through.
$expected = $null
foreach ($sums in @($platformSums, 'checksums.sha256')) {
$sumsFile = Join-Path $tmpDir $sums
if (Save-Download "$releaseUrl/$sums" $sumsFile) {
$expected = Get-RecordedSha256 $sumsFile $asset
}
} catch {
# checksums file is optional
if ($expected) {
break
}
}
if (-not $expected) {
Stop-Install "no SHA-256 for $asset in $releaseUrl/$platformSums or $releaseUrl/checksums.sha256, refusing to install an unverified binary"
}

$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $tmpBinary).Hash.ToLowerInvariant()
if ($expected -cne $actual) {
Stop-Install "checksum mismatch for $asset, refusing to install (expected: $expected, actual: $actual)"
}
Write-Host "Checksum verified: sha256 $actual"

if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
Expand Down
85 changes: 84 additions & 1 deletion tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Project: macbash
// File: tests/common/mod.rs
// Purpose: Shared helpers for the end-to-end CLI integration tests
// Purpose: Shared helpers for the end-to-end CLI and installer integration tests
// Language: Rust
//
// License: Apache-2.0
Expand All @@ -10,11 +10,22 @@
// used by only one of them would otherwise be reported as dead code.
#![allow(dead_code)]

use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

use assert_cmd::Command;
use tempfile::TempDir;

/// The release tag the loopback download host serves its files under.
pub const VERSION: &str = "v9.9.9";

/// A SHA-256 that no fixture binary has.
pub const OTHER_SHA256: &str = "14b1f81dd672550d0055c981ed42e01aa0c33590e09460468f890cb4cfa734b9";

/// A script with no findings at any severity -- the "already portable" case.
pub const PORTABLE_SCRIPT: &str =
"#!/usr/bin/env bash\nset -euo pipefail\n\nprintf '%s\\n' \"hello\"\n";
Expand Down Expand Up @@ -125,3 +136,75 @@ pub fn json_severity_count(json: &str, severity: &str) -> usize {
json.matches(&format!("\"severity\": \"{severity}\""))
.count()
}

/// What the download host serves under the version directory, keyed by file name.
#[derive(Default)]
pub struct Release {
files: HashMap<String, String>,
}

impl Release {
pub fn file(mut self, name: &str, body: impl Into<String>) -> Self {
self.files.insert(name.to_string(), body.into());
self
}
}

/// Serve `release` over HTTP on a loopback port and return the download base URL.
pub fn serve(release: Release) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("binding a loopback port");
let base = format!(
"http://{}",
listener.local_addr().expect("reading the bound port")
);
let files: HashMap<String, String> = release
.files
.into_iter()
.map(|(name, body)| (format!("/{VERSION}/{name}"), body))
.collect();
thread::spawn(move || {
for stream in listener.incoming().flatten() {
respond(stream, &files);
}
});
base
}

fn respond(mut stream: TcpStream, files: &HashMap<String, String>) {
// A client that connects and never sends a request must not wedge the server.
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let Ok(clone) = stream.try_clone() else {
return;
};
let mut reader = BufReader::new(clone);
let mut request_line = String::new();
if reader.read_line(&mut request_line).is_err() {
return;
}
loop {
let mut header = String::new();
match reader.read_line(&mut header) {
Ok(0) | Err(_) => break,
Ok(_) if header.trim_end().is_empty() => break,
Ok(_) => {}
}
}
let path = request_line.split_whitespace().nth(1).unwrap_or_default();
let (status, body) = match files.get(path) {
Some(body) => ("200 OK", body.as_str()),
None => ("404 Not Found", "not found\n"),
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
}

/// The first `tool` on PATH, if any.
pub fn which(tool: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(tool))
.find(|candidate| candidate.is_file())
}
Loading
Loading