cargo-horizon-mod: prebuilt binaries for cargo-binstall

`cargo install --git ...` compiles the tool and its workspace, which for a
mod developer on a slow machine is a long wait to write one block (#2120).
`cargo binstall` skips the build when the release holds a binary for the
platform.

- crates/cargo-horizon-mod/Cargo.toml gains [package.metadata.binstall]:
  one `cargo-horizon-mod-v<version>` release, one tar.gz per Rust target,
  the binary at the archive's root; a `.sha256` beside each.
- .github/workflows/cargo-horizon-mod.yml builds those targets (Linux
  x86_64 and aarch64, Windows x86_64, macOS x86_64 and arm64), starts
  each binary it can run, and publishes them on the tag. A pull request
  builds them without publishing.
- tools/horizon_mod_binstall.py is the one list of targets: the workflow's
  matrix comes from it, and it checks the built archives and the tag
  against the manifest before anything is published.
- docs/mods.md shows the binstall command first, the source build after.

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
This commit is contained in:
CocofireHDandDeepSeek V4.1 Flash committed 2026-11-03 16:16:07 +01:00
1 parent fc02103818
commit ab49fe48b7
5 files changed
+268

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
name: cargo-horizon-mod
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120): binstall reads
# [package.metadata.binstall] from crates/cargo-horizon-mod/Cargo.toml and downloads the archive this workflow
# attaches to the tag `cargo-horizon-mod-v<version>`, whose version must be the manifest's. The targets come from
# tools/horizon_mod_binstall.py, the same file the manifest's template is checked against, so a platform added there
# is built here and one nobody publishes is caught before it ships. A pull request builds the archives and publishes
# nothing.
on:
push:
tags: ['cargo-horizon-mod-v*']
pull_request:
paths:
- 'crates/cargo-horizon-mod/**'
- 'tools/horizon_mod_binstall.py'
- '.github/workflows/cargo-horizon-mod.yml'
permissions:
contents: read
jobs:
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
platforms: ${{ steps.platforms.outputs.platforms }}
steps:
- uses: actions/checkout@v4
- id: platforms
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
build:
needs: plan
name: build ${{ matrix.target }}
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.plan.outputs.platforms) }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: The aarch64 Linux linker
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV"
- name: Build
run: cargo build --release --locked -p cargo-horizon-mod --target ${{ matrix.target }}
- name: It starts
if: matrix.run
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
out=$("$binary" 2>&1 || true)
case "$out" in
*"usage: cargo horizon-mod"*) ;;
*) echo "$out"; echo "::error::$binary does not start"; exit 1 ;;
esac
- name: Package
shell: bash
run: |
set -euo pipefail
binary="target/${{ matrix.target }}/release/cargo-horizon-mod"
[ -f "$binary.exe" ] && binary="$binary.exe"
mkdir -p dist
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
- uses: actions/upload-artifact@v4
with:
name: cargo-horizon-mod-${{ matrix.target }}
path: dist/*.tar.gz
if-no-files-found: error
retention-days: 3
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: cargo-horizon-mod-*
path: dist
merge-multiple: true
- name: The tag and the manifest name the same release and archives
run: python3 tools/horizon_mod_binstall.py dist "$GITHUB_REF_NAME"
- name: Checksums
run: |
set -euo pipefail
cd dist
for file in *.tar.gz; do sha256sum "$file" | cut -d' ' -f1 > "$file.sha256"; done
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
notes="\`cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod\`"
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/* --clobber
else
gh release create "$GITHUB_REF_NAME" dist/* --title "cargo-horizon-mod ${GITHUB_REF_NAME#cargo-horizon-mod-v}" --notes "$notes"
fi
+8
View File
@@ -6,6 +6,14 @@ license.workspace = true
rust-version = "1.86"
description = "cargo horizon-mod new/build/run/check: make Horizon content mods (docs/mods.md)"
# `cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod` (#2120) reads this: the archive
# `cargo-horizon-mod`'s workflow attaches to the `cargo-horizon-mod-v<version>` release for the runner's target,
# holding the binary at its root. tools/horizon_mod_binstall.py expands it for the workflow's targets.
[package.metadata.binstall]
pkg-url = "https://github.com/horizon-rust/horizon/releases/download/cargo-horizon-mod-v{ version }/{ name }-{ target }.tar.gz"
bin-dir = "{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[dependencies]
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
serde_json = "1.0.151"
+3
View File
@@ -32,6 +32,9 @@ Benchmarks (`--benchmark`) never load mods.
## Making a content mod
```sh
# A prebuilt binary for Linux x86_64/aarch64, macOS x86_64/arm64 or Windows x86_64 (#2120):
cargo binstall --git https://github.com/horizon-rust/horizon cargo-horizon-mod
# Or build it from source, without cargo-binstall:
cargo install --git https://github.com/horizon-rust/horizon cargo-horizon-mod
cargo horizon-mod new my-mod # a content mod: manifest, one block with its model, loot and recipe
cd my-mod
+59
View File
@@ -0,0 +1,59 @@
"""`cargo binstall` finds every cargo-horizon-mod archive the release ships (#2120).
`[package.metadata.binstall]` and `.github/workflows/cargo-horizon-mod.yml` are two halves of one contract: the
manifest names a file per target, the workflow uploads it. These check the halves still fit, without a network.
"""
import tempfile
import unittest
from pathlib import Path
import tools.horizon_mod_binstall as binstall
WORKFLOW = binstall.ROOT / ".github/workflows/cargo-horizon-mod.yml"
class BinstallTests(unittest.TestCase):
def test_the_manifest_names_one_archive_per_platform_in_this_release(self):
for platform in binstall.PLATFORMS:
with self.subTest(target=platform["target"]):
url = binstall.url(platform["target"])
self.assertIn(f"/releases/download/{binstall.tag()}/", url)
self.assertTrue(url.endswith("/" + binstall.asset(platform["target"])))
def test_a_placeholder_binstall_cannot_fill_fails_here(self):
with tempfile.TemporaryDirectory() as directory:
manifest = Path(directory, "Cargo.toml")
manifest.write_text(
'[package]\nname = "cargo-horizon-mod"\nversion = "1.2.3"\n'
'[package.metadata.binstall]\npkg-url = "https://example.invalid/{ name }-{ arch }.tar.gz"\n'
)
with self.assertRaises(KeyError):
binstall.asset("x86_64-unknown-linux-gnu", manifest)
def test_a_release_missing_an_archive_or_held_by_another_tag_is_reported(self):
names = [binstall.asset(platform["target"]) for platform in binstall.PLATFORMS]
self.assertEqual(len(names), len(set(names)), "two targets would overwrite one asset")
with tempfile.TemporaryDirectory() as directory:
for name in names:
Path(directory, name).touch()
self.assertEqual(binstall.check(directory, binstall.tag()), [])
self.assertEqual(binstall.check(directory), [])
renamed = Path(directory, names[0])
renamed.rename(renamed.with_name("cargo-horizon-mod-mystery.tar.gz"))
problems = binstall.check(directory)
self.assertTrue(any(names[0] in problem for problem in problems), problems)
self.assertTrue(any("mystery" in problem for problem in problems), problems)
self.assertTrue(any("not" in problem for problem in binstall.check(directory, "cargo-horizon-mod-v9.9.9")))
def test_the_workflow_builds_this_list_and_checks_it_before_publishing(self):
workflow = WORKFLOW.read_text()
self.assertIn("tools.horizon_mod_binstall", workflow)
self.assertIn("fromJSON(needs.plan.outputs.platforms)", workflow)
self.assertIn("tools/horizon_mod_binstall.py dist", workflow)
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
"""Where `cargo binstall` finds `cargo-horizon-mod`'s prebuilt archives (#2120).
`[package.metadata.binstall]` in `crates/cargo-horizon-mod/Cargo.toml` names one release asset per Rust
target, and `.github/workflows/cargo-horizon-mod.yml` builds [`PLATFORMS`] and attaches them to the tag
[`tag`]. Both sides read this file, so renaming an archive here cannot leave `cargo binstall` asking for a
file no release holds.
"""
import re
import sys
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "crates/cargo-horizon-mod/Cargo.toml"
# One entry per archive the release holds: the Rust target, the runner that builds it, and whether that
# runner can start the binary (a cross build cannot).
PLATFORMS = [
{"target": "x86_64-unknown-linux-gnu", "os": "ubuntu-22.04", "run": True},
{"target": "aarch64-unknown-linux-gnu", "os": "ubuntu-22.04", "run": False},
{"target": "x86_64-pc-windows-msvc", "os": "windows-latest", "run": True},
{"target": "x86_64-apple-darwin", "os": "macos-latest", "run": True},
{"target": "aarch64-apple-darwin", "os": "macos-latest", "run": True},
]
PLACEHOLDER = re.compile(r"\{\s*([a-z-]+)\s*\}")
def _package(manifest=MANIFEST):
with open(manifest, "rb") as handle:
return tomllib.load(handle)["package"]
def version(manifest=MANIFEST):
return _package(manifest)["version"]
def tag(manifest=MANIFEST):
"""The GitHub release holding the archives: `cargo-horizon-mod-v<version>`."""
return f"cargo-horizon-mod-v{version(manifest)}"
def url(target, manifest=MANIFEST):
"""The download URL `binstall` builds for `target`, from the manifest's `pkg-url`.
Raises `KeyError` for a placeholder `binstall` would not fill in, so a typo fails here instead of at
every mod developer's download.
"""
template = _package(manifest)["metadata"]["binstall"]["pkg-url"]
fields = {
"name": "cargo-horizon-mod",
"version": version(manifest),
"target": target,
"binary-ext": ".exe" if "windows" in target else "",
"archive-format": "tar.gz",
}
def fill(match):
return fields[match.group(1)]
return PLACEHOLDER.sub(fill, template)
def asset(target, manifest=MANIFEST):
"""The archive `binstall` downloads for `target`: the file name of its download URL."""
return url(target, manifest).rsplit("/", 1)[-1]
def check(directory, expected_tag=None):
"""What is wrong with the archives in `directory` for this manifest: `[]` when binstall finds them all."""
problems = []
if expected_tag is not None and expected_tag != tag():
problems.append(f"the tag is {expected_tag}, not {tag()}")
wanted = {asset(platform["target"]) for platform in PLATFORMS}
found = {path.name for path in Path(directory).glob("*.tar.gz")}
problems += [f"the release has no {name}" for name in sorted(wanted - found)]
problems += [f"{name} is not an archive binstall asks for" for name in sorted(found - wanted)]
return problems
def main(argv):
directory = argv[0] if argv else "dist"
problems = check(directory, argv[1] if len(argv) > 1 else None)
for problem in problems:
print(f"::error::{problem}")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))