mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f65cd2faa | ||
|
|
01abc78c0f |
No files matched your search
@@ -4,3 +4,9 @@
|
||||
* @ludovic111 @horizon-rust/horizon-team
|
||||
/.github/ @ludovic111
|
||||
/revoked.toml @ludovic111
|
||||
# What decides which builds the native-builds environment signs, and under which key (native-build.yml,
|
||||
# horizon#11859): the signing key's owner reviews it.
|
||||
/scripts/native_build.py @ludovic111
|
||||
/scripts/test_native_build.py @ludovic111
|
||||
/prebuilt.toml @ludovic111
|
||||
/.github/workflows/native-build.yml @ludovic111
|
||||
@@ -29,13 +29,9 @@ jobs:
|
||||
with:
|
||||
path: head
|
||||
persist-credentials: false
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin
|
||||
~/.cargo/horizon-rev
|
||||
key: cargo-horizon-mod-${{ env.HORIZON_REV }}
|
||||
# main's scripts, not the pull request's.
|
||||
# main's scripts, not the pull request's. No actions/cache: this job decides whether a submission passes,
|
||||
# and main's cache entries can be written by runs that build other people's code (native-build.yml), so
|
||||
# the tool is built fresh and the files are checked against their SHA-256 (fetch.sh).
|
||||
- run: base/scripts/install-tool.sh
|
||||
- name: Who submitted it
|
||||
id: author
|
||||
@@ -60,13 +56,8 @@ jobs:
|
||||
| tee -a "$GITHUB_STEP_SUMMARY" || status=1
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
exit $status
|
||||
# main's files, kept by publish: each download from a release counts as a
|
||||
# player's.
|
||||
- uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: files
|
||||
key: files-${{ hashFiles('head/mods/*.toml') }}
|
||||
restore-keys: files-
|
||||
# Published files come from the registry-files release publish keeps: downloads from a mod's own
|
||||
# release would count as a player's.
|
||||
- name: Download its files
|
||||
run: base/scripts/fetch.sh head
|
||||
- name: Check each new file
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# Prebuilt native builds (horizon#11859): the game compiled for one exact set of registry native mods, signed, so
|
||||
# players' launchers download it instead of compiling for 30 to 60 minutes.
|
||||
# - Every day after the Nightly, `dispatch` starts one run per set of prebuilt.toml on the newest Nightly. A run
|
||||
# builds ONE set, and only a set prebuilt.toml lists (`plan` and `sign` both refuse any other).
|
||||
# - On GitHub's runners only:
|
||||
# plan (no secrets): resolves the Nightly and each mod against the published index (newest unrevoked version,
|
||||
# not broken_on that commit), computes each target's request key as the launcher does, leaves out what is
|
||||
# published, downloads the mods' files and the launcher release named by vars.NATIVE_BUILD_LAUNCHER and
|
||||
# checks that release's signed SHA256SUMS against Horizon's release keys (pinned below, by SHA-256).
|
||||
# build (no secrets, a token that can only read, no cache): runs that launcher headless
|
||||
# (`native-build --prebuilt-out`): its pinned Rust, the Nightly's source, the release profile, the same
|
||||
# pins, hook points and left-out mods as a player's build. A mod crate's build script runs here, so this
|
||||
# job is UNTRUSTED: with the runner's own token it can replace any artifact of the run (`plan` included)
|
||||
# and write cache entries on main.
|
||||
# sign (environment `native-builds`, main only): trusts nothing a build job could touch. What it signs comes
|
||||
# from the plan job's OUTPUTS only (no later job can change them); it recomputes each key from the base,
|
||||
# the mods and the target, derives every file name from key and target, downloads `build-<target>` by
|
||||
# its exact name and refuses anything in it but that one archive. It runs only Horizon's
|
||||
# tools/ci/sign_native_build.sh (sha256sum and OpenSSL) at a pinned commit, checked by SHA-256 before
|
||||
# the key is in reach. A build can only ever be signed as its own set's.
|
||||
# Caches: no job here restores one, and no job of horizon-mods that holds a write token may restore one either
|
||||
# (publish.yml, native-compat.yml): the build job above can write cache entries on main, and restoring an entry
|
||||
# unpacks a tar anywhere on the runner.
|
||||
name: native-build
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 19 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mods:
|
||||
description: "One set of prebuilt.toml, exactly as listed (`id` or `id@version`, space-separated). Empty: every set (one run each)"
|
||||
default: ""
|
||||
base:
|
||||
description: "The Nightly: `latest` or nightly-<date>-<short>"
|
||||
default: latest
|
||||
targets:
|
||||
description: "`all`, or target triples separated by commas"
|
||||
default: all
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: native-build-${{ inputs.mods || 'dispatch' }}-${{ inputs.base || 'latest' }}
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
# A launcher release with `native-build --prebuilt-out` (Horizon #11859), e.g. [email protected].
|
||||
LAUNCHER: ${{ vars.NATIVE_BUILD_LAUNCHER }}
|
||||
# The Horizon commit this workflow takes its signer and keys from: its own pin, never vars.HORIZON_REV (shared with
|
||||
# publish and the checks) and never a branch. Each file is checked against its SHA-256 here before it is used.
|
||||
# Moving the pin means reviewing the files' diff and updating the three hashes (`sha256sum`).
|
||||
SIGN_REV: c794b87841b58f72ade9842b46757d843246054c
|
||||
SIGN_SCRIPT_SHA256: 95ebefe4b0beeb1b4282a09352260c48f1793b13e370414e91175aa789fc2278 # tools/ci/sign_native_build.sh
|
||||
SIGN_KEYS_SHA256: f75dc6fe23343a4e97fe2453853e67f20ddcce1bc1f2c240a56b8b6d6681f611 # crates/horizon-release-sign/native-build-keys.txt
|
||||
RELEASE_KEYS_SHA256: bb1f43a3eb14a2c0d89d7d823a3352dd4b7ac01f50d55451e282ea178fb61f6f # crates/horizon-release-sign/release-keys.txt
|
||||
# The largest archive signed: the launcher refuses bigger ones before checking (launcher/src/native_prebuilt.rs).
|
||||
MAX_ARCHIVE: "536870912"
|
||||
jobs:
|
||||
dispatch:
|
||||
# Idle until a launcher with native-build --prebuilt-out is released and named.
|
||||
if: (github.event_name == 'schedule' || inputs.mods == '') && vars.NATIVE_BUILD_LAUNCHER != ''
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: One run per set
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE: ${{ inputs.base || 'latest' }}
|
||||
run: |
|
||||
python3 scripts/native_build.py sets | while read -r set; do
|
||||
echo "native-build: $set"
|
||||
gh workflow run native-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f mods="$set" -f base="$BASE"
|
||||
done
|
||||
plan:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.mods != ''
|
||||
runs-on: ubuntu-latest
|
||||
# The sign job reads these, never the `plan` artifact: build jobs can replace an artifact, not a job's outputs.
|
||||
outputs:
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
any: ${{ steps.plan.outputs.any }}
|
||||
tag: ${{ steps.plan.outputs.tag }}
|
||||
base: ${{ steps.plan.outputs.base }}
|
||||
mods: ${{ steps.plan.outputs.mods }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: python3 -m unittest scripts/test_native_build.py
|
||||
- id: plan
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MODS: ${{ inputs.mods }}
|
||||
BASE: ${{ inputs.base }}
|
||||
TARGETS: ${{ inputs.targets }}
|
||||
run: python3 scripts/native_build.py plan --mods "$MODS" --base "$BASE" --targets "$TARGETS" --out plan
|
||||
- name: The launcher, checked against Horizon's release keys
|
||||
if: steps.plan.outputs.any == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -n "$LAUNCHER" ] || { echo "::error::vars.NATIVE_BUILD_LAUNCHER names no launcher release"; exit 1; }
|
||||
mkdir -p plan/launcher && cd plan/launcher
|
||||
base="https://github.com/horizon-rust/horizon/releases/download/$LAUNCHER"
|
||||
curl -sSfLO "$base/SHA256SUMS" && curl -sSfLO "$base/SHA256SUMS.sig"
|
||||
for asset in $(jq -r '.builds[].launcher' ../plan.json | sort -u); do curl -sSfLO "$base/$asset"; done
|
||||
curl -sSfL -o release-keys.txt \
|
||||
"https://raw.githubusercontent.com/horizon-rust/horizon/$SIGN_REV/crates/horizon-release-sign/release-keys.txt"
|
||||
echo "$RELEASE_KEYS_SHA256 release-keys.txt" | sha256sum -c
|
||||
grep -v '^#' release-keys.txt | grep . > keys
|
||||
{ printf 'horizon release %s\n' "$LAUNCHER"; cat SHA256SUMS; } > message
|
||||
ok=
|
||||
while read -r key; do
|
||||
{ printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00'; echo "$key" | base64 -d; } > pub.der
|
||||
while read -r sig; do
|
||||
echo "$sig" | base64 -d > sig.bin
|
||||
openssl pkeyutl -verify -rawin -pubin -keyform DER -inkey pub.der -in message -sigfile sig.bin >/dev/null 2>&1 && ok=1
|
||||
done < SHA256SUMS.sig
|
||||
done < keys
|
||||
[ -n "$ok" ] || { echo "::error::$LAUNCHER: SHA256SUMS isn't signed by a Horizon release key"; exit 1; }
|
||||
sha256sum --ignore-missing -c SHA256SUMS
|
||||
rm -f message pub.der sig.bin keys release-keys.txt SHA256SUMS SHA256SUMS.sig
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plan
|
||||
path: plan
|
||||
build:
|
||||
needs: plan
|
||||
if: needs.plan.outputs.any == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.plan.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 180
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: plan
|
||||
path: plan
|
||||
- if: runner.os == 'Linux'
|
||||
# The game's ALSA and udev headers; the launcher links xkbcommon and xcb even headless.
|
||||
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config libxkbcommon-x11-0 libxcb1
|
||||
- name: Build the set as a player's launcher would
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
KEY: ${{ matrix.key }}
|
||||
LAUNCHER_ASSET: ${{ matrix.launcher }}
|
||||
ARCHIVE: ${{ matrix.archive }}
|
||||
TAG: ${{ needs.plan.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scratch="$RUNNER_TEMP/native"
|
||||
mkdir -p "$scratch/launcher" "$scratch/out"
|
||||
case "$LAUNCHER_ASSET" in
|
||||
*.tar.gz) tar xzf "plan/launcher/$LAUNCHER_ASSET" -C "$scratch/launcher"; launcher="$scratch/launcher/horizon-launcher" ;;
|
||||
*macos*) unzip -q "plan/launcher/$LAUNCHER_ASSET" -d "$scratch/launcher"; launcher="$scratch/launcher/Horizon.app/Contents/MacOS/horizon-launcher" ;;
|
||||
*) unzip -q "plan/launcher/$LAUNCHER_ASSET" -d "$scratch/launcher"; launcher="$scratch/launcher/horizon-launcher.exe" ;;
|
||||
esac
|
||||
export HORIZON_LAUNCHER_HOME="$scratch/home" HORIZON_HOME="$scratch/game" XDG_DATA_HOME="$scratch/data" \
|
||||
XDG_CONFIG_HOME="$scratch/config" HORIZON_LAUNCHER_NO_SELF_UPDATE=1 HORIZON_NO_AUDIO=1
|
||||
py=$(command -v python3 || command -v python) # Windows runners name it python
|
||||
"$launcher" install "$TAG"
|
||||
"$launcher" native-build --prebuilt-out "$scratch/out/build" --version "$TAG" --profile release \
|
||||
--trust-native-code plan/hnmods
|
||||
got=$("$py" -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$scratch/out/build/request.txt")
|
||||
[ "$got" = "$KEY" ] || { echo "::error::the launcher built request $got, the plan asked for $KEY"; exit 1; }
|
||||
mkdir -p dist
|
||||
tar czf "dist/$ARCHIVE" -C "$scratch/out/build" .
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.target }}
|
||||
path: dist/${{ matrix.archive }}
|
||||
if-no-files-found: error
|
||||
sign:
|
||||
needs: [plan, build]
|
||||
if: always() && needs.plan.outputs.any == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: native-builds
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
env:
|
||||
# Job outputs of plan, the only input of this job besides the archives themselves.
|
||||
BASE: ${{ needs.plan.outputs.base }}
|
||||
TAG: ${{ needs.plan.outputs.tag }}
|
||||
MODS: ${{ needs.plan.outputs.mods }}
|
||||
MATRIX: ${{ needs.plan.outputs.matrix }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts/native_build.py
|
||||
prebuilt.toml
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
path: registry
|
||||
- name: The signer, at its pinned commit and hashes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/signer" && cd "$RUNNER_TEMP/signer"
|
||||
raw="https://raw.githubusercontent.com/horizon-rust/horizon/$SIGN_REV"
|
||||
curl -sSfL -o sign_native_build.sh "$raw/tools/ci/sign_native_build.sh"
|
||||
curl -sSfL -o native-build-keys.txt "$raw/crates/horizon-release-sign/native-build-keys.txt"
|
||||
printf '%s %s\n' "$SIGN_SCRIPT_SHA256" sign_native_build.sh "$SIGN_KEYS_SHA256" native-build-keys.txt | sha256sum -c
|
||||
- name: What to sign, recomputed from the plan job's outputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Checks the mods are a prebuilt.toml set and every row's key is that set's for its target; prints
|
||||
# "<key> <target>". Fails the job on any mismatch.
|
||||
python3 -I registry/scripts/native_build.py signable > "$RUNNER_TEMP/rows"
|
||||
cat "$RUNNER_TEMP/rows"
|
||||
- name: Each target's build, by its exact artifact name
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
while read -r key target <&3; do
|
||||
name="${key:0:16}-$target.tar.gz"
|
||||
dir="$RUNNER_TEMP/builds/$target"
|
||||
mkdir -p "$dir"
|
||||
if ! gh run download "$GITHUB_RUN_ID" --name "build-$target" --dir "$dir"; then
|
||||
echo "::warning::$target: no build"
|
||||
continue
|
||||
fi
|
||||
# Exactly one regular file, named from the key and target; anything else in the artifact is refused.
|
||||
listing=$(cd "$dir" && find . -mindepth 1 -printf '%y %P\n')
|
||||
if [ "$listing" != "f $name" ]; then
|
||||
echo "::error::$target: build-$target holds something other than $name alone:"; echo "$listing"; exit 1
|
||||
fi
|
||||
size=$(stat -c %s "$dir/$name")
|
||||
[ "$size" -le "$MAX_ARCHIVE" ] || { echo "::error::$target: $name is $size bytes, over $MAX_ARCHIVE"; exit 1; }
|
||||
echo "$target: $name, $size bytes, sha256 $(sha256sum < "$dir/$name" | cut -d' ' -f1)"
|
||||
done 3< "$RUNNER_TEMP/rows"
|
||||
- name: Sign, then publish
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
NATIVE_BUILD_SIGNING_KEY: ${{ secrets.NATIVE_BUILD_SIGNING_KEY }}
|
||||
NATIVE_BUILD_SIGNING_KEY_NEXT: ${{ secrets.NATIVE_BUILD_SIGNING_KEY_NEXT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
signer="$RUNNER_TEMP/signer"
|
||||
# Checked again in the step that holds the key: nothing between the steps may have changed them.
|
||||
(cd "$signer" && printf '%s %s\n' "$SIGN_SCRIPT_SHA256" sign_native_build.sh "$SIGN_KEYS_SHA256" native-build-keys.txt \
|
||||
| sha256sum -c --quiet)
|
||||
release="native-builds-${BASE:0:12}"
|
||||
if ! gh release view "$release" >/dev/null 2>&1; then
|
||||
gh release create "$release" --prerelease --latest=false --title "Prebuilt native builds on $TAG" \
|
||||
--notes "The game built with registry native mods on Horizon $BASE, signed by native-build.yml (horizon#11859). The Horizon launcher downloads and checks these; nothing here is for downloading by hand."
|
||||
fi
|
||||
missing=0
|
||||
while read -r key target <&3; do
|
||||
dir="$RUNNER_TEMP/builds/$target"
|
||||
name="${key:0:16}-$target.tar.gz"
|
||||
sig="${key:0:16}-$target.sig"
|
||||
if [ ! -f "$dir/$name" ]; then missing=1; continue; fi
|
||||
NATIVE_BUILD_KEYS="$signer/native-build-keys.txt" bash "$signer/sign_native_build.sh" "$key" "$target" "$dir/$name"
|
||||
# The signature last: the launcher looks for it first, so a half-uploaded build is never offered.
|
||||
gh release upload "$release" "$dir/$name"
|
||||
gh release upload "$release" "$dir/$sig"
|
||||
done 3< "$RUNNER_TEMP/rows"
|
||||
exit $missing
|
||||
@@ -5,8 +5,9 @@
|
||||
# (`cargo check`): the ones horizon-mixins refuses alone and the ones whose own code doesn't compile go
|
||||
# to the `native-health` release, which publish adds to the index (`broken_on`) so the launcher warns
|
||||
# before an install.
|
||||
# The check compiles the submission, whose crates may have build scripts: it runs with a token that can
|
||||
# only read, and main's script, never the pull request's.
|
||||
# Both compile other people's crates, whose build scripts run: those jobs hold a token that can only read and
|
||||
# run main's script, never the pull request's. The daily result goes to the release from a separate job that
|
||||
# runs nothing of theirs and restores no cache (a build can write cache entries on main).
|
||||
name: native-compat
|
||||
on:
|
||||
pull_request:
|
||||
@@ -66,17 +67,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Each mod horizon-mixins refuses costs one more apply of the whole set.
|
||||
timeout-minutes: 300
|
||||
# Read only: every registry native mod's build script runs in this job.
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||
- run: rustup toolchain install $RUST --profile minimal && rustup default $RUST
|
||||
- run: sudo apt-get update && sudo apt-get install -y libasound2-dev libudev-dev pkg-config
|
||||
# Yesterday's dependencies, checked, and horizon-mixins: only Horizon's own crates and the mods compile again.
|
||||
# Safe only because this job holds nothing: it builds untrusted code anyway.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
@@ -94,10 +96,35 @@ jobs:
|
||||
run: |
|
||||
set -o pipefail
|
||||
python3 scripts/native_compat.py health --horizon "$HORIZON" --out native-health.json | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-health
|
||||
path: native-health.json
|
||||
if-no-files-found: error
|
||||
health-publish:
|
||||
needs: health
|
||||
if: inputs.upload != 'false'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: native-health
|
||||
path: health
|
||||
- name: Keep it for publish
|
||||
if: inputs.upload != 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Data from a job that ran other people's code: only the one file, only the shape publish reads.
|
||||
[ "$(cd health && find . -mindepth 1 -printf '%y %P\n')" = "f native-health.json" ]
|
||||
jq -e '(.horizon | type == "string" and test("^[0-9a-f]{40}$"))
|
||||
and (.broken | type == "object")
|
||||
and ([.broken | to_entries[] | (.key | test("^[a-z0-9_-]+$")) and (.value.version | type == "string")] | all)' \
|
||||
health/native-health.json > /dev/null
|
||||
gh release view native-health >/dev/null 2>&1 \
|
||||
|| gh release create native-health --prerelease --title "Native mods' health" \
|
||||
--notes "native-health.json: the native mods that don't apply on the newest Nightly (native-compat.yml). publish adds them to the index as broken_on."
|
||||
gh release upload native-health native-health.json --clobber
|
||||
gh release upload native-health health/native-health.json --clobber
|
||||
@@ -30,22 +30,17 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
HORIZON_REV: ${{ vars.HORIZON_REV || 'main' }}
|
||||
steps:
|
||||
# No actions/cache in this job, ever: it holds a write token (releases, Pages), and any run on main can
|
||||
# write cache entries, including native-build.yml's and native-compat.yml's builds of other people's
|
||||
# code. Restoring an entry unpacks a tar anywhere on the runner, so the tool is built fresh (about two
|
||||
# minutes) and the files come from the registry-files release, each checked against its SHA-256.
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin
|
||||
~/.cargo/horizon-rev
|
||||
key: cargo-horizon-mod-${{ env.HORIZON_REV }}
|
||||
persist-credentials: false
|
||||
- run: scripts/install-tool.sh
|
||||
# Files kept from the last run: GitHub counts every download of a release
|
||||
# asset, so fetching them all again each hour would count as players.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: files
|
||||
key: files-${{ hashFiles('mods/*.toml') }}
|
||||
restore-keys: files-
|
||||
- run: scripts/fetch.sh .
|
||||
# FILES_MIRROR_UPLOAD: what isn't in registry-files yet is fetched from its own release once, then kept
|
||||
# there, so these hourly runs don't count as players' downloads.
|
||||
- run: FILES_MIRROR_UPLOAD=1 scripts/fetch.sh .
|
||||
- name: Build the index
|
||||
run: |
|
||||
mkdir -p site
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# The native mod sets the registry prebuilds on each new Nightly (.github/workflows/native-build.yml, horizon#11859):
|
||||
# a player whose turned-on native mods are exactly one of these sets downloads the game instead of compiling it.
|
||||
# Each set is built for every target (Linux, Windows x64 and ARM64, macOS ARM and Intel) on GitHub's runners,
|
||||
# about 20-30 minutes per target. A mod is named by its id (its newest version) or `id@version`.
|
||||
# Keep the list short: every set costs five builds per Nightly.
|
||||
|
||||
[[set]]
|
||||
name = "Horizon's own client mods"
|
||||
mods = ["zoom", "fps_overlay", "minimap", "coords_hud", "keystrokes", "recipes"]
|
||||
|
||||
[[set]]
|
||||
name = "Zoom"
|
||||
mods = ["zoom"]
|
||||
|
||||
[[set]]
|
||||
name = "FPS overlay"
|
||||
mods = ["fps_overlay"]
|
||||
|
||||
[[set]]
|
||||
name = "Minimap"
|
||||
mods = ["minimap"]
|
||||
+42
-13
@@ -1,26 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Downloads every file the entries list into files/<sha256>: from its own release
|
||||
# (<id>-<version>) once published, else from the `staging` prerelease, where the
|
||||
# submission form uploads <sha256>.<extension>. Usage: scripts/fetch.sh [checkout]
|
||||
# Needs `gh` (GH_TOKEN) and cargo-horizon-mod. FILES_FROM=<dir> reads <dir>/<file>
|
||||
# instead, for a local trial.
|
||||
# Downloads every file the entries list into files/<sha256>, each checked against its SHA-256. Sources, in order:
|
||||
# - the `registry-files` prerelease: a copy of every published file named <sha256>, kept by publish, so the
|
||||
# registry's own runs don't count as players' downloads (it reads it by URL, not through the API);
|
||||
# - its own release (<id>-<version>) once published;
|
||||
# - the `staging` prerelease, where the submission form uploads <sha256>.<extension>.
|
||||
# A file already in files/ stays only if its SHA-256 is right.
|
||||
# Usage: scripts/fetch.sh [checkout]. Needs `gh` (GH_TOKEN, GH_REPO), curl and cargo-horizon-mod.
|
||||
# FILES_FROM=<dir> reads <dir>/<file> instead, for a local trial. FILES_MIRROR_UPLOAD=1 (publish, which can
|
||||
# write releases) adds each file it had to take from elsewhere to registry-files.
|
||||
set -euo pipefail
|
||||
checkout="${1:-.}"
|
||||
mirror=registry-files
|
||||
mkdir -p files
|
||||
sha_of() { sha256sum < "$1" | cut -d' ' -f1; }
|
||||
cargo horizon-mod registry list "$checkout" | while read -r sha id version file; do
|
||||
[ -f "files/$sha" ] && continue
|
||||
[[ "$sha" =~ ^[0-9a-f]{64}$ ]] || { echo "::error::$id $version: $sha is not a SHA-256"; exit 1; }
|
||||
if [ -f "files/$sha" ] && [ ! -L "files/$sha" ] && [ "$(sha_of "files/$sha")" = "$sha" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "files/$sha"
|
||||
ext="${file##*.}"
|
||||
tmp="$(mktemp -d)"
|
||||
mkdir "$tmp/dl" # gh downloads land here, apart from $tmp/file
|
||||
if [ -n "${FILES_FROM:-}" ]; then
|
||||
cp "$FILES_FROM/$file" "$tmp/$file"
|
||||
elif gh release download "$id-$version" --pattern "$file" --dir "$tmp" 2>/dev/null; then
|
||||
:
|
||||
elif gh release download staging --pattern "$sha.$ext" --dir "$tmp"; then
|
||||
mv "$tmp/$sha.$ext" "$tmp/$file"
|
||||
cp "$FILES_FROM/$file" "$tmp/file"
|
||||
from=folder
|
||||
elif [ -n "${GH_REPO:-}" ] \
|
||||
&& curl -sfL --max-filesize 1073741824 -o "$tmp/file" "https://github.com/$GH_REPO/releases/download/$mirror/$sha" < /dev/null \
|
||||
&& [ "$(sha_of "$tmp/file")" = "$sha" ]; then
|
||||
from=mirror
|
||||
elif gh release download "$id-$version" --pattern "$file" --dir "$tmp/dl" < /dev/null 2>/dev/null; then
|
||||
mv "$tmp/dl/$file" "$tmp/file"
|
||||
from="release $id-$version"
|
||||
elif gh release download staging --pattern "$sha.$ext" --dir "$tmp/dl" < /dev/null; then
|
||||
mv "$tmp/dl/$sha.$ext" "$tmp/file"
|
||||
from=staging
|
||||
else
|
||||
echo "::error::$id $version: $file is neither in release $id-$version nor in staging as $sha.$ext"
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp/$file" "files/$sha"
|
||||
rmdir "$tmp"
|
||||
if [ "$(sha_of "$tmp/file")" != "$sha" ]; then
|
||||
echo "::error::$id $version: $file from $from doesn't have the SHA-256 its entry lists ($sha)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${FILES_MIRROR_UPLOAD:-}" = 1 ] && [ "$from" != mirror ] && [ "$from" != folder ]; then
|
||||
gh release view "$mirror" > /dev/null 2>&1 < /dev/null \
|
||||
|| gh release create "$mirror" --prerelease --latest=false --title "Registry files (for the registry's own runs)" \
|
||||
--notes "Every published mod file, named by its SHA-256, for publish and the checks to read without counting as a player's download (scripts/fetch.sh). Players download from each version's own release." < /dev/null
|
||||
cp "$tmp/file" "$tmp/dl/$sha"
|
||||
gh release upload "$mirror" "$tmp/dl/$sha" --clobber < /dev/null
|
||||
fi
|
||||
mv "$tmp/file" "files/$sha"
|
||||
rm -rf "$tmp"
|
||||
done
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prebuilt native builds (horizon#11859): which builds native-build.yml makes, named as the launcher names them.
|
||||
|
||||
python3 scripts/native_build.py sets [--sets prebuilt.toml] # one line per set: its mods
|
||||
python3 scripts/native_build.py plan --mods "zoom [email protected]" [--base latest|nightly-<date>-<short>]
|
||||
[--targets all|<triple>,...] --out plan/
|
||||
BASE=<40 hex> MODS=<json> MATRIX=<json> python3 scripts/native_build.py signable # sign job: "key target" lines
|
||||
|
||||
`plan` resolves one set for one base: the Nightly (its tag for the launcher, its 40-hex commit for the key),
|
||||
each mod against the published index (its newest version unless `@version` names one; refused when unknown,
|
||||
revoked, or `broken_on` that commit), and each target's request key (horizon_release_sign::native_build:
|
||||
the same text, so the launcher looks up what CI built). Targets already published are left out. It writes
|
||||
plan/plan.json and downloads each mod's file into plan/hnmods/, checked against the index's SHA-256.
|
||||
|
||||
A run builds ONE set, and only a set prebuilt.toml lists (exactly its items): nobody can dispatch a signed build
|
||||
of any other mix of registry mods. Every build job of a run belongs to that set, so a mod's build script, which
|
||||
runs in that job, can only ever replace a build of its own set.
|
||||
|
||||
`signable` is what the sign job (which holds the key) signs: it reads only the plan job's outputs (job outputs,
|
||||
which no later job can rewrite; never the `plan` artifact, which a build job can), checks again that the mods are a
|
||||
prebuilt.toml set, recomputes every row's key from the base, the mods and the row's target, and prints
|
||||
`<key> <target>` per row. The sign job derives every file name from those two.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
INDEX = "https://horizon-rust.github.io/horizon-mods/index.json"
|
||||
REGISTRY = "horizon-rust/horizon-mods"
|
||||
HORIZON = "horizon-rust/horizon"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# The launcher's toolchain host for each target (Horizon's launcher/src/rust_toolchain.rs), the GitHub-hosted
|
||||
# runner that builds it, and the launcher release asset that runs there.
|
||||
TARGETS = {
|
||||
"x86_64-unknown-linux-gnu": ("ubuntu-22.04", "horizon-launcher-linux-x86_64.tar.gz"),
|
||||
"x86_64-pc-windows-gnu": ("windows-latest", "horizon-launcher-windows-x64.zip"),
|
||||
"aarch64-pc-windows-gnullvm": ("windows-11-arm", "horizon-launcher-windows-arm64.zip"),
|
||||
"aarch64-apple-darwin": ("macos-15", "horizon-launcher-macos-arm64.zip"),
|
||||
"x86_64-apple-darwin": ("macos-15-intel", "horizon-launcher-macos-x64.zip"),
|
||||
}
|
||||
MAX_MODS = 64
|
||||
SETS = ROOT / "prebuilt.toml"
|
||||
# horizon_release_sign::native_build's field rules: what the launcher accepts in a request.
|
||||
ID = re.compile(r"[a-z0-9_-]+")
|
||||
VERSION = re.compile(r"[!-~]+")
|
||||
HEX40 = re.compile(r"[0-9a-f]{40}")
|
||||
HEX64 = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
def request_text(target, base, mods):
|
||||
"""horizon_release_sign::native_build::Request::text: mods are (id, version, sha256), sorted by id."""
|
||||
lines = ["horizon native build request 1", f"target {target}", f"base {base.lower()}"]
|
||||
lines += [f"mod {i} {v} {s.lower()}" for i, v, s in sorted(mods)]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def request_key(target, base, mods):
|
||||
return hashlib.sha256(request_text(target, base, mods).encode()).hexdigest()
|
||||
|
||||
|
||||
def release_tag(base):
|
||||
return f"native-builds-{base[:12]}"
|
||||
|
||||
|
||||
def archive(key, target):
|
||||
return f"{key[:16]}-{target}.tar.gz"
|
||||
|
||||
|
||||
def signature(key, target):
|
||||
return f"{key[:16]}-{target}.sig"
|
||||
|
||||
|
||||
def get(url, token=True):
|
||||
headers = {"User-Agent": "horizon-mods native-build"}
|
||||
if token and os.environ.get("GITHUB_TOKEN") and url.startswith("https://api.github.com/"):
|
||||
headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}"
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=120) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def exists(url):
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, method="HEAD"), timeout=60):
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def nightly(base):
|
||||
"""(launcher tag, 40-hex commit) of the Nightly `base` names: `latest`, or `nightly-<date>-<short>`. A Nightly
|
||||
stays buildable after it is pruned: its permanent `nightly-source/<date>-<short>` tag keeps the commit."""
|
||||
refs = json.loads(get(f"https://api.github.com/repos/{HORIZON}/git/matching-refs/tags/nightly-source/"))
|
||||
names = {r["ref"].removeprefix("refs/tags/nightly-source/"): r["object"]["sha"] for r in refs}
|
||||
if base == "latest":
|
||||
if not names:
|
||||
sys.exit("no Nightly has a nightly-source/ tag")
|
||||
name = max(names)
|
||||
else:
|
||||
name = base.removeprefix("nightly-")
|
||||
if name not in names:
|
||||
sys.exit(f"{base}: no such Nightly (nightly-<date>-<short>)")
|
||||
commit = json.loads(get(f"https://api.github.com/repos/{HORIZON}/commits/nightly-source/{name}"))["sha"]
|
||||
return f"nightly-{name}", commit
|
||||
|
||||
|
||||
def pick(index, wanted, commit):
|
||||
"""The index version each `id[@version]` of `wanted` names, refusing what the launcher wouldn't build."""
|
||||
revoked = {r.get("sha256") for r in index.get("revoked", [])}
|
||||
projects = {p["id"]: p for p in index["projects"]}
|
||||
picked = []
|
||||
for item in wanted:
|
||||
mod_id, _, version = item.partition("@")
|
||||
project = projects.get(mod_id)
|
||||
if not project or project.get("kind") != "native-mod":
|
||||
raise ValueError(f"{mod_id}: no native mod of that id in the registry")
|
||||
versions = [v for v in project["versions"] if v["file"]["sha256"] not in revoked]
|
||||
found = next((v for v in versions if v["version"] == version), None) if version else (versions or [None])[0]
|
||||
if not found:
|
||||
raise ValueError(f"{item}: no such published, unrevoked version")
|
||||
if any(b.get("horizon") == commit for b in found.get("broken_on", [])):
|
||||
raise ValueError(f"{mod_id} {found['version']} doesn't build on {commit[:7]} (broken_on)")
|
||||
picked.append(found | {"id": mod_id})
|
||||
ids = [p["id"] for p in picked]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise ValueError("a mod is listed twice")
|
||||
if not 1 <= len(picked) <= MAX_MODS:
|
||||
raise ValueError(f"a set has 1 to {MAX_MODS} mods")
|
||||
return picked
|
||||
|
||||
|
||||
def listed(wanted, sets_path=SETS):
|
||||
"""`wanted` (its `id[@version]` items) if it is exactly one set of prebuilt.toml, else ValueError."""
|
||||
if sorted(wanted) not in [sorted(line.split()) for line in sets(sets_path)]:
|
||||
raise ValueError(f"{' '.join(wanted) or '(nothing)'}: not a set of prebuilt.toml; only those are built")
|
||||
return wanted
|
||||
|
||||
|
||||
def plan(mods, base, targets, out):
|
||||
wanted = listed(mods.split())
|
||||
tag, commit = nightly(base)
|
||||
index = json.loads(get(INDEX, token=False))
|
||||
picked = pick(index, wanted, commit)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
hnmods = out / "hnmods"
|
||||
hnmods.mkdir(exist_ok=True)
|
||||
for v in picked:
|
||||
data = get(v["file"]["url"], token=False)
|
||||
if hashlib.sha256(data).hexdigest() != v["file"]["sha256"]:
|
||||
sys.exit(f"{v['file']['url']}: its SHA-256 isn't the index's")
|
||||
(hnmods / f"{v['id']}-{v['version']}.hnmod").write_bytes(data)
|
||||
triples = list(TARGETS) if targets == "all" else targets.split(",")
|
||||
request = [(v["id"], v["version"], v["file"]["sha256"]) for v in picked]
|
||||
builds = []
|
||||
for target in triples:
|
||||
if target not in TARGETS:
|
||||
sys.exit(f"{target}: not a target the launcher builds")
|
||||
key = request_key(target, commit, request)
|
||||
url = f"https://github.com/{REGISTRY}/releases/download/{release_tag(commit)}/{signature(key, target)}"
|
||||
if exists(url):
|
||||
print(f"{target}: already published ({key[:16]})")
|
||||
continue
|
||||
runner, launcher = TARGETS[target]
|
||||
builds.append({"target": target, "key": key, "runner": runner, "launcher": launcher,
|
||||
"archive": archive(key, target), "signature": signature(key, target)})
|
||||
result = {"tag": tag, "base": commit, "release": release_tag(commit),
|
||||
"mods": [{"id": i, "version": v, "sha256": s} for i, v, s in sorted(request)], "builds": builds}
|
||||
(out / "plan.json").write_text(json.dumps(result, indent=1) + "\n")
|
||||
print(json.dumps({k: result[k] for k in ("tag", "base", "mods")}), f"{len(builds)} to build")
|
||||
return result
|
||||
|
||||
|
||||
def signable(base, mods, matrix, sets_path=SETS):
|
||||
"""[(key, target)] the sign job may sign, from the plan job's outputs only (see the module's doc)."""
|
||||
if not isinstance(base, str) or not HEX40.fullmatch(base):
|
||||
raise ValueError(f"{base!r}: not a 40-hex commit")
|
||||
if not isinstance(mods, list) or not mods:
|
||||
raise ValueError("no mods")
|
||||
request = []
|
||||
for m in mods:
|
||||
i, v, s = (m.get(k) if isinstance(m, dict) else None for k in ("id", "version", "sha256"))
|
||||
if not (isinstance(i, str) and ID.fullmatch(i) and isinstance(v, str) and VERSION.fullmatch(v)
|
||||
and isinstance(s, str) and HEX64.fullmatch(s)):
|
||||
raise ValueError(f"{m!r}: not a mod (id, version, sha256)")
|
||||
request.append((i, v, s))
|
||||
ids = [i for i, _, _ in request]
|
||||
if len(set(ids)) != len(ids) or len(ids) > MAX_MODS:
|
||||
raise ValueError("a mod is listed twice, or too many mods")
|
||||
versions = dict((i, v) for i, v, _ in request)
|
||||
for line in sets(sets_path):
|
||||
items = [item.partition("@") for item in line.split()]
|
||||
if sorted(i for i, _, _ in items) == sorted(ids) and all(not v or versions[i] == v for i, _, v in items):
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"{' '.join(sorted(ids))}: not a set of prebuilt.toml")
|
||||
if not isinstance(matrix, list):
|
||||
raise ValueError("the matrix is not a list")
|
||||
rows, seen = [], set()
|
||||
for row in matrix:
|
||||
target, key = (row.get(k) if isinstance(row, dict) else None for k in ("target", "key"))
|
||||
if target not in TARGETS or target in seen:
|
||||
raise ValueError(f"{target!r}: not a target, or twice")
|
||||
seen.add(target)
|
||||
if key != request_key(target, base, request):
|
||||
raise ValueError(f"{target}: the matrix's key {key!r} isn't this set's")
|
||||
rows.append((key, target))
|
||||
return rows
|
||||
|
||||
|
||||
def sets(path):
|
||||
"""Each set of prebuilt.toml as one line of space-separated `id[@version]`."""
|
||||
data = tomllib.loads(path.read_text())
|
||||
lines = []
|
||||
for s in data.get("set", []):
|
||||
mods = s.get("mods", [])
|
||||
if not mods or not all(isinstance(m, str) and m and " " not in m for m in mods):
|
||||
sys.exit(f"{path}: set {s.get('name', '?')!r} needs a list of mod ids")
|
||||
lines.append(" ".join(mods))
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
s = sub.add_parser("sets")
|
||||
s.add_argument("--sets", type=Path, default=ROOT / "prebuilt.toml")
|
||||
p = sub.add_parser("plan")
|
||||
p.add_argument("--mods", required=True)
|
||||
p.add_argument("--base", default="latest")
|
||||
p.add_argument("--targets", default="all")
|
||||
p.add_argument("--out", type=Path, required=True)
|
||||
sub.add_parser("signable")
|
||||
args = parser.parse_args()
|
||||
if args.command == "sets":
|
||||
print("\n".join(sets(args.sets)))
|
||||
return
|
||||
if args.command == "signable":
|
||||
try:
|
||||
env = {k: json.loads(os.environ[k]) for k in ("MODS", "MATRIX")}
|
||||
rows = signable(os.environ["BASE"], env["MODS"], env["MATRIX"])
|
||||
except (KeyError, ValueError) as e:
|
||||
sys.exit(f"signable: {e}")
|
||||
print("\n".join(f"{key} {target}" for key, target in rows))
|
||||
return
|
||||
try:
|
||||
result = plan(args.mods, args.base, args.targets, args.out)
|
||||
except ValueError as e:
|
||||
sys.exit(str(e))
|
||||
if os.environ.get("GITHUB_OUTPUT"):
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
# The sign job reads these job outputs, never the plan artifact (a build job can replace that).
|
||||
f.write(f"matrix={json.dumps(result['builds'])}\n")
|
||||
f.write(f"any={'true' if result['builds'] else 'false'}\n")
|
||||
f.write(f"tag={result['tag']}\n")
|
||||
f.write(f"base={result['base']}\n")
|
||||
f.write(f"mods={json.dumps(result['mods'])}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import native_build # noqa: E402
|
||||
|
||||
BASE = "656064105c0e1fbd0a8f1d6e5f5d3a8b3c2e1f00"
|
||||
|
||||
|
||||
def version(v, sha, broken=()):
|
||||
return {"version": v, "file": {"url": f"https://x/{v}", "sha256": sha},
|
||||
"broken_on": [{"horizon": b, "code": "E"} for b in broken]}
|
||||
|
||||
|
||||
INDEX = {
|
||||
"projects": [
|
||||
{"id": "zoom", "kind": "native-mod", "versions": [version("2.1.0", "c" * 64), version("2.0.0", "b" * 64)]},
|
||||
{"id": "minimap", "kind": "native-mod", "versions": [version("1.4.0", "a" * 64)]},
|
||||
{"id": "broken", "kind": "native-mod", "versions": [version("1.0.0", "d" * 64, [BASE])]},
|
||||
{"id": "pack", "kind": "hmod", "versions": [version("1.0.0", "e" * 64)]},
|
||||
],
|
||||
"revoked": [{"id": "zoom", "sha256": "c" * 64}],
|
||||
}
|
||||
|
||||
|
||||
class Key(unittest.TestCase):
|
||||
def test_the_key_is_the_launchers(self):
|
||||
# The same vector as horizon_release_sign::native_build's test: CI and the launcher name a build alike.
|
||||
mods = [("zoom", "2.0.0", "b" * 64), ("minimap", "1.4.0", "a" * 64)]
|
||||
self.assertEqual(native_build.request_key("x86_64-unknown-linux-gnu", BASE, mods),
|
||||
"c07fffc296cf14c5c496a016a930f84b0d0ac028a63c667c2bdf47657788445d")
|
||||
|
||||
def test_names(self):
|
||||
key = "0123456789abcdef" + "0" * 48
|
||||
self.assertEqual(native_build.release_tag(BASE), "native-builds-656064105c0e")
|
||||
self.assertEqual(native_build.archive(key, "aarch64-apple-darwin"),
|
||||
"0123456789abcdef-aarch64-apple-darwin.tar.gz")
|
||||
self.assertEqual(native_build.signature(key, "aarch64-apple-darwin"),
|
||||
"0123456789abcdef-aarch64-apple-darwin.sig")
|
||||
|
||||
|
||||
class Pick(unittest.TestCase):
|
||||
def test_newest_unrevoked_version_unless_one_is_named(self):
|
||||
picked = native_build.pick(INDEX, ["zoom", "[email protected]"], BASE)
|
||||
self.assertEqual([(p["id"], p["version"]) for p in picked], [("zoom", "2.0.0"), ("minimap", "1.4.0")])
|
||||
|
||||
def test_what_the_launcher_would_not_build_is_refused(self):
|
||||
for wanted in (["nothing"], ["pack"], ["[email protected]"], ["zoom@9"], ["broken"], ["zoom", "[email protected]"], []):
|
||||
with self.assertRaises(ValueError, msg=wanted):
|
||||
native_build.pick(INDEX, wanted, BASE)
|
||||
|
||||
def test_broken_on_another_commit_still_builds(self):
|
||||
picked = native_build.pick(INDEX, ["broken"], "1" * 40)
|
||||
self.assertEqual(picked[0]["version"], "1.0.0")
|
||||
|
||||
|
||||
SETS = """
|
||||
[[set]]
|
||||
name = "Two"
|
||||
mods = ["zoom", "[email protected]"]
|
||||
|
||||
[[set]]
|
||||
name = "One"
|
||||
mods = ["zoom"]
|
||||
"""
|
||||
LINUX = "x86_64-unknown-linux-gnu"
|
||||
MAC = "aarch64-apple-darwin"
|
||||
ZOOM = {"id": "zoom", "version": "2.0.0", "sha256": "b" * 64}
|
||||
MINIMAP = {"id": "minimap", "version": "1.4.0", "sha256": "a" * 64}
|
||||
|
||||
|
||||
def key(target, *mods):
|
||||
return native_build.request_key(target, BASE, [(m["id"], m["version"], m["sha256"]) for m in mods])
|
||||
|
||||
|
||||
class OnlyListedSets(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.sets = Path(tmp.name) / "prebuilt.toml"
|
||||
self.sets.write_text(SETS)
|
||||
|
||||
def test_plan_takes_a_listed_set_in_any_order(self):
|
||||
self.assertEqual(native_build.listed(["[email protected]", "zoom"], self.sets), ["[email protected]", "zoom"])
|
||||
|
||||
def test_plan_refuses_any_other_set(self):
|
||||
for wanted in (["minimap"], ["zoom", "minimap"], ["zoom", "[email protected]", "keystrokes"], ["[email protected]"], []):
|
||||
with self.assertRaises(ValueError, msg=wanted):
|
||||
native_build.listed(wanted, self.sets)
|
||||
|
||||
def test_sign_recomputes_each_rows_key(self):
|
||||
matrix = [{"target": LINUX, "key": key(LINUX, ZOOM, MINIMAP), "archive": "ignored"},
|
||||
{"target": MAC, "key": key(MAC, ZOOM, MINIMAP)}]
|
||||
self.assertEqual(native_build.signable(BASE, [ZOOM, MINIMAP], matrix, self.sets),
|
||||
[(key(LINUX, ZOOM, MINIMAP), LINUX), (key(MAC, ZOOM, MINIMAP), MAC)])
|
||||
|
||||
def test_sign_refuses_another_sets_key(self):
|
||||
# A row naming the key of `zoom` alone, in a run of the two-mod set: H1 of the review.
|
||||
with self.assertRaises(ValueError):
|
||||
native_build.signable(BASE, [ZOOM, MINIMAP], [{"target": LINUX, "key": key(LINUX, ZOOM)}], self.sets)
|
||||
# Another target's key on this row.
|
||||
with self.assertRaises(ValueError):
|
||||
native_build.signable(BASE, [ZOOM], [{"target": LINUX, "key": key(MAC, ZOOM)}], self.sets)
|
||||
|
||||
def test_sign_refuses_mods_or_rows_outside_the_rules(self):
|
||||
good = [{"target": LINUX, "key": key(LINUX, ZOOM)}]
|
||||
cases = [
|
||||
("1" * 39, [ZOOM], good), # not a commit
|
||||
(BASE, [], good), # no mods
|
||||
(BASE, [dict(ZOOM, id="zoom\nmod x")], good), # a line break in an id
|
||||
(BASE, [dict(ZOOM, version="2.0 0")], good), # a space in a version
|
||||
(BASE, [dict(ZOOM, sha256="B" * 64)], good), # not lowercase hex
|
||||
(BASE, [MINIMAP], [{"target": LINUX, "key": key(LINUX, MINIMAP)}]), # minimap alone isn't listed
|
||||
(BASE, [dict(MINIMAP, version="1.5.0"), ZOOM], # the set pins minimap 1.4.0
|
||||
[{"target": LINUX, "key": key(LINUX, dict(MINIMAP, version="1.5.0"), ZOOM)}]),
|
||||
(BASE, [ZOOM], good + good), # a target twice
|
||||
(BASE, [ZOOM], [{"target": "x86_64-unknown-freebsd", "key": key("x86_64-unknown-freebsd", ZOOM)}]),
|
||||
(BASE, [ZOOM], {"target": LINUX}), # not a list
|
||||
]
|
||||
for base, mods, matrix in cases:
|
||||
with self.assertRaises(ValueError, msg=(base, mods, matrix)):
|
||||
native_build.signable(base, mods, matrix, self.sets)
|
||||
|
||||
|
||||
class Sets(unittest.TestCase):
|
||||
def test_the_registrys_sets_are_native_mods_it_lists(self):
|
||||
lines = native_build.sets(native_build.ROOT / "prebuilt.toml")
|
||||
self.assertTrue(lines)
|
||||
official = {p.name for p in (native_build.ROOT / "native").iterdir() if (p / "compatibility.json").is_file()}
|
||||
for line in lines:
|
||||
for item in line.split():
|
||||
self.assertIn(item.partition("@")[0].replace("_", "-"), official, line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user