Merge pull request #2 from emil28092005/codex/reliability-and-onboarding
Cross-platform build (disposable signatures) / CI test bundle / linux-x86_64 (push) Waiting to run
Cross-platform build (disposable signatures) / CI test bundle / darwin-aarch64 (push) Waiting to run
Cross-platform build (disposable signatures) / CI test bundle / darwin-x86_64 (push) Waiting to run
Cross-platform build (disposable signatures) / CI test bundle / windows-x86_64 (push) Waiting to run
Launcher checks / check (push) Waiting to run

Fix cross-platform CI for signed launcher updates
This commit is contained in:
Emil Shanaty
2026-09-09 21:47:53 +03:00
committed by GitHub
11 changed files with 117 additions and 23 deletions
+2
View File
@@ -0,0 +1,2 @@
# Signed updater fixtures must retain their exact committed bytes on every OS.
src-tauri/tests/fixtures/updater/* -text
+9 -3
View File
@@ -54,13 +54,19 @@ jobs:
- name: Install Linux desktop dependencies - name: Install Linux desktop dependencies
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
sudo apt-get update # Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf test -s /etc/apt/sources.list
apt_options=(
-o Dir::Etc::sourcelist=/etc/apt/sources.list
-o Dir::Etc::sourceparts=-
)
sudo apt-get "${apt_options[@]}" --error-on=any update
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci - run: npm ci
- run: npm test - run: npm test
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- run: python -m unittest discover -s scripts -p 'release_test.py' -v - run: python -m unittest discover -s scripts -p 'release_test.py' -v
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- name: Generate disposable CI key (never a production secret) - name: Generate disposable CI key (never a production secret)
run: | run: |
python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater" python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater"
+9 -3
View File
@@ -27,11 +27,17 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- name: Install Linux desktop dependencies - name: Install Linux desktop dependencies
run: | run: |
sudo apt-get update # Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf test -s /etc/apt/sources.list
apt_options=(
-o Dir::Etc::sourcelist=/etc/apt/sources.list
-o Dir::Etc::sourceparts=-
)
sudo apt-get "${apt_options[@]}" --error-on=any update
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci - run: npm ci
- run: npm test - run: npm test
- run: npm run build - run: npm run build
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
- run: python -m unittest discover -s scripts -p 'release_test.py' -v - run: python -m unittest discover -s scripts -p 'release_test.py' -v
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
+8 -2
View File
@@ -115,8 +115,14 @@ jobs:
- name: Install Linux desktop dependencies - name: Install Linux desktop dependencies
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
sudo apt-get update # Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf test -s /etc/apt/sources.list
apt_options=(
-o Dir::Etc::sourcelist=/etc/apt/sources.list
-o Dir::Etc::sourceparts=-
)
sudo apt-get "${apt_options[@]}" --error-on=any update
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- run: npm ci - run: npm ci
- run: npm test - run: npm test
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml - run: cargo test --locked --manifest-path src-tauri/Cargo.toml
+2
View File
@@ -109,6 +109,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
trust domains used to install and run the game itself. trust domains used to install and run the game itself.
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. - `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
Ubuntu 22.04 dependency steps use only the runner main Ubuntu source list;
keep APT signature/hash checks and fail on index errors. Vendor PPAs are not needed.
- `.github/workflows/build.yml` — four-platform CI packages with disposable test - `.github/workflows/build.yml` — four-platform CI packages with disposable test
signing keys, explicitly unusable as production releases. signing keys, explicitly unusable as production releases.
- Release workflows and `scripts/release.py` implement protected draft → publish - Release workflows and `scripts/release.py` implement protected draft → publish
+4 -1
View File
@@ -110,7 +110,10 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
`npm test` covers asynchronous helpers and state transitions; `npm test` covers asynchronous helpers and state transitions;
`npm run build` runs strict TypeScript before Vite. `cargo test --locked` `npm run build` runs strict TypeScript before Vite. `cargo test --locked`
covers native policy and storage. Push/PR CI repeats checks on Linux. covers native policy and storage. Push/PR CI repeats checks on Linux. Ubuntu 22.04
CI installs desktop dependencies from the runner main distro source list only,
without indexing unrelated vendor repositories. APT signature/hash checks remain
mandatory, and any distro index failure stops installation.
The package workflow builds and checks Windows x64, Linux x64 and both macOS The package workflow builds and checks Windows x64, Linux x64 and both macOS
architectures with disposable test signing keys. These artifacts cannot be architectures with disposable test signing keys. These artifacts cannot be
published as production updater releases. Separate protected workflows assemble published as production updater releases. Separate protected workflows assemble
+63 -6
View File
@@ -2,10 +2,12 @@
import os import os
import re import re
import shutil
import subprocess import subprocess
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1])) ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
@@ -13,6 +15,32 @@ WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "rele
GATE = " - name: Resolve tag using trusted workflow Git commands\n" GATE = " - name: Resolve tag using trusted workflow Git commands\n"
def gate_bash(windows=os.name == "nt"):
# Native CreateProcess can resolve bare bash to the System32 WSL launcher
# before Git Bash. Bind tests to the Bash installed with the Git we use.
if windows:
git = shutil.which("git")
if git:
directory = Path(git).resolve().parent
candidates = (
directory / "bash.exe", # Git/bin/git.exe
directory.parent / "bin/bash.exe", # Git/cmd/git.exe
directory.parent.parent / "bin/bash.exe", # Git/mingw64/bin/git.exe
)
for candidate in candidates:
if candidate.is_file():
return str(candidate.resolve())
raise RuntimeError("Git for Windows Bash was not found beside the installed Git")
bash = shutil.which("bash")
if not bash:
raise RuntimeError("Bash is required to execute the release workflow gate")
return str(Path(bash).resolve())
def gate_diagnostic(result):
return f"command={result.args!r}\nstdout={result.stdout!r}\nstderr={result.stderr!r}"
def workflow_gate(workflow): def workflow_gate(workflow):
after = workflow.read_text().split(GATE, 1)[1] after = workflow.read_text().split(GATE, 1)[1]
lines = after.split(" run: |\n", 1)[1].splitlines() lines = after.split(" run: |\n", 1)[1].splitlines()
@@ -60,23 +88,52 @@ class ReleaseGateTests(unittest.TestCase):
def run_gate(self, workflow, tag): def run_gate(self, workflow, tag):
output = self.directory / (workflow.stem + "-output") output = self.directory / (workflow.stem + "-output")
output.unlink(missing_ok=True) output.unlink(missing_ok=True)
result = subprocess.run(["bash", "-c", workflow_gate(workflow)], cwd=self.directory, result = subprocess.run([gate_bash(), "--noprofile", "--norc", "-c", workflow_gate(workflow)], cwd=self.directory,
env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)), env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)),
capture_output=True, text=True, check=False) capture_output=True, text=True, check=False)
return result, output.read_text() if output.exists() else "" return result, output.read_text() if output.exists() else ""
def test_windows_gate_uses_git_bash_even_when_path_contains_wsl_launcher(self):
git_root = self.directory / "Git with spaces"
bash = git_root / "bin/bash.exe"
bash.parent.mkdir(parents=True)
bash.touch()
wsl = self.directory / "System32/bash.exe"
wsl.parent.mkdir()
wsl.touch()
for layout in ("bin", "cmd", "mingw64/bin"):
with self.subTest(layout=layout):
git = git_root / layout / "git.exe"
git.parent.mkdir(parents=True, exist_ok=True)
git.touch()
with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup:
self.assertEqual(gate_bash(windows=True), str(bash.resolve()))
lookup.assert_called_once_with("git")
def test_windows_gate_never_falls_back_to_unrelated_bash(self):
git = self.directory / "Git/cmd/git.exe"
git.parent.mkdir(parents=True)
git.touch()
wsl = self.directory / "System32/bash.exe"
wsl.parent.mkdir()
wsl.touch()
with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup:
with self.assertRaisesRegex(RuntimeError, "Git for Windows Bash"):
gate_bash(windows=True)
lookup.assert_called_once_with("git")
def test_main_tag_emits_immutable_commit(self): def test_main_tag_emits_immutable_commit(self):
for workflow in WORKFLOWS: for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name): with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v0.2.0") result, output = self.run_gate(workflow, "v0.2.0")
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(output, f"commit={self.good}\n") self.assertEqual(output, f"commit={self.good}\n")
def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self): def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self):
for workflow in WORKFLOWS: for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name): with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v9.9.9") result, output = self.run_gate(workflow, "v9.9.9")
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(output, "") self.assertEqual(output, "")
self.assertFalse((self.directory / "untrusted-code-ran").exists()) self.assertFalse((self.directory / "untrusted-code-ran").exists())
@@ -84,15 +141,15 @@ class ReleaseGateTests(unittest.TestCase):
for workflow in WORKFLOWS: for workflow in WORKFLOWS:
with self.subTest(workflow=workflow.name): with self.subTest(workflow=workflow.name):
result, output = self.run_gate(workflow, "v8.8.8") result, output = self.run_gate(workflow, "v8.8.8")
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(output, "") self.assertEqual(output, "")
self.git("tag", "-f", "v0.2.0", self.good) self.git("tag", "-f", "v0.2.0", self.good)
result, output = self.run_gate(workflow, "v0.2.0") result, output = self.run_gate(workflow, "v0.2.0")
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, gate_diagnostic(result))
self.git("tag", "-f", "v0.2.0", self.bad) self.git("tag", "-f", "v0.2.0", self.bad)
self.assertEqual(output, f"commit={self.good}\n") self.assertEqual(output, f"commit={self.good}\n")
result, fresh_output = self.run_gate(workflow, "v0.2.0") result, fresh_output = self.run_gate(workflow, "v0.2.0")
self.assertNotEqual(result.returncode, 0) self.assertNotEqual(result.returncode, 0, gate_diagnostic(result))
self.assertEqual(fresh_output, "") self.assertEqual(fresh_output, "")
def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self): def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self):
+1 -1
View File
@@ -272,7 +272,7 @@ mod tests {
#[test] #[test]
#[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"] #[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"]
fn live_cold_install_and_corruption_repair() { fn live_cold_install_and_corruption_repair() {
let directory = std::env::temp_dir().join(format!( let directory = std::env::temp_dir().canonicalize().unwrap().join(format!(
"shacraft-cold-install-{}", "shacraft-cold-install-{}",
SystemTime::now() SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
+4 -2
View File
@@ -459,8 +459,10 @@ mod tests {
use crate::{java, mojang}; use crate::{java, mojang};
let client = Client::builder().build().unwrap(); let client = Client::builder().build().unwrap();
let root = let root = std::env::temp_dir()
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id())); .canonicalize()
.unwrap()
.join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let game_dir = root.join("game"); let game_dir = root.join("game");
let cache_dir = root.join("cache"); let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).unwrap(); fs::create_dir_all(&cache_dir).unwrap();
+5 -1
View File
@@ -494,7 +494,11 @@ mod tests {
impl Fixture { impl Fixture {
fn new() -> Self { fn new() -> Self {
let root = Stage::new(&std::env::temp_dir()).unwrap(); // macOS temporary directories may use the system /var -> /private/var
// alias. Canonicalize only this trusted fixture anchor; production
// safe_path must continue rejecting root/descendant substitutions.
let temporary_root = std::env::temp_dir().canonicalize().unwrap();
let root = Stage::new(&temporary_root).unwrap();
let game = root.0.join("game"); let game = root.0.join("game");
let cache = root.0.join("cache"); let cache = root.0.join("cache");
let input = jar_bytes(b"verified vanilla"); let input = jar_bytes(b"verified vanilla");
+10 -4
View File
@@ -506,6 +506,12 @@ mod tests {
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
fn trusted_temporary_root() -> std::path::PathBuf {
// Resolve only the OS-provided fixture anchor. The profile root, its
// parent and payload descendants retain their production link checks.
std::env::temp_dir().canonicalize().unwrap()
}
fn manifest(hash: String, size: u64) -> Manifest { fn manifest(hash: String, size: u64) -> Manifest {
Manifest { Manifest {
schema_version: 1, schema_version: 1,
@@ -531,7 +537,7 @@ mod tests {
#[test] #[test]
fn reports_missing_and_matching_files() { fn reports_missing_and_matching_files() {
let root = std::env::temp_dir().join(format!( let root = trusted_temporary_root().join(format!(
"shacraft-launcher-test-{}-{}", "shacraft-launcher-test-{}-{}",
process::id(), process::id(),
SystemTime::now() SystemTime::now()
@@ -559,7 +565,7 @@ mod tests {
#[test] #[test]
fn edited_seed_files_remain_up_to_date() { fn edited_seed_files_remain_up_to_date() {
let root = std::env::temp_dir().join(format!( let root = trusted_temporary_root().join(format!(
"shacraft-seed-test-{}-{}", "shacraft-seed-test-{}-{}",
process::id(), process::id(),
SystemTime::now() SystemTime::now()
@@ -577,7 +583,7 @@ mod tests {
#[test] #[test]
fn preserves_changed_seed_files_as_current() { fn preserves_changed_seed_files_as_current() {
let root = std::env::temp_dir().join(format!( let root = trusted_temporary_root().join(format!(
"shacraft-launcher-seed-test-{}-{}", "shacraft-launcher-seed-test-{}-{}",
process::id(), process::id(),
SystemTime::now() SystemTime::now()
@@ -600,7 +606,7 @@ mod tests {
#[test] #[test]
fn refuses_linked_profile_directories() { fn refuses_linked_profile_directories() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!( let root = trusted_temporary_root().join(format!(
"shacraft-link-test-{}-{}", "shacraft-link-test-{}-{}",
process::id(), process::id(),
SystemTime::now() SystemTime::now()