Skip to content

f

f #45

name: Build executables
on:
push:
branches:
- "**"
tags:
- "v*"
pull_request:
branches:
- master
workflow_dispatch:
permissions:
contents: read
jobs:
version:
name: Resolve build version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.metadata.outputs.version }}
sha: ${{ steps.metadata.outputs.sha }}
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: "3.10.0"
- name: Resolve build metadata
id: metadata
shell: bash
run: |
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
version="$(dart run MultiplexorApp/tool/resolve_build_version.dart --tag "$GITHUB_REF_NAME")"
else
version="$(dart run MultiplexorApp/tool/resolve_build_version.dart --run-number "$GITHUB_RUN_NUMBER")"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
gameplay:
name: Test Mineflayer (${{ matrix.runner }})
needs: version
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-latest
- windows-latest
- macos-15
- macos-15-intel
defaults:
run:
working-directory: MultiplexorApp/tool/mineflayer
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
ref: ${{ needs.version.outputs.sha }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: MultiplexorApp/tool/mineflayer/package-lock.json
- name: Install Mineflayer dependencies
run: npm ci --no-audit --no-fund
- name: Test macOS launcher
if: runner.os == 'macOS'
working-directory: .
shell: bash
run: /bin/bash MultiplexorApp/tool/test_launcher.sh
- name: Test Mineflayer harness
run: npm test
- name: Check Mineflayer dependencies
run: npm run doctor -- --json
build:
name: Build ${{ matrix.name }}
needs: version
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- name: macOS (Apple Silicon)
runner: macos-15
executable: multiplexor
archive_suffix: macos-arm64.tar.gz
- name: macOS (Intel)
runner: macos-15-intel
executable: multiplexor
archive_suffix: macos-x64.tar.gz
- name: Windows (x64)
runner: windows-latest
executable: multiplexor.exe
archive_suffix: windows-x64.zip
env:
EXECUTABLE_NAME: ${{ matrix.executable }}
ARCHIVE_NAME: multiplexor-v${{ needs.version.outputs.version }}-${{ matrix.archive_suffix }}
BUILD_VERSION: ${{ needs.version.outputs.version }}
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
ref: ${{ needs.version.outputs.sha }}
- name: Set up Dart
uses: dart-lang/setup-dart@v1
with:
sdk: "3.10.0"
- name: Install dependencies
working-directory: MultiplexorApp
run: dart pub get
- name: Analyze
working-directory: MultiplexorApp
run: dart analyze
- name: Test
working-directory: MultiplexorApp
run: dart test
- name: Compile macOS executable
if: runner.os == 'macOS'
shell: bash
run: dart run MultiplexorApp/tool/build_exe.dart --version "$BUILD_VERSION" --output "$RUNNER_TEMP/$EXECUTABLE_NAME"
- name: Compile Windows executable
if: runner.os == 'Windows'
shell: pwsh
run: |
$outputPath = Join-Path $env:RUNNER_TEMP $env:EXECUTABLE_NAME
dart run MultiplexorApp/tool/build_exe.dart --version $env:BUILD_VERSION --output $outputPath
- name: Smoke-test macOS executable
if: runner.os == 'macOS'
shell: bash
run: |
actual="$("$RUNNER_TEMP/$EXECUTABLE_NAME" version | head -n 1)"
test "$actual" = "Multiplexor CLI v$BUILD_VERSION"
- name: Smoke-test Windows executable
if: runner.os == 'Windows'
shell: pwsh
run: |
$outputPath = Join-Path $env:RUNNER_TEMP $env:EXECUTABLE_NAME
$actual = (& $outputPath version | Select-Object -First 1)
if ($actual -ne "Multiplexor CLI v$env:BUILD_VERSION") {
throw "Expected Multiplexor CLI v$env:BUILD_VERSION, got $actual"
}
- name: Smoke-test Windows create and runtime lifecycle
if: runner.os == 'Windows'
shell: pwsh
run: |
$outputPath = Join-Path $env:RUNNER_TEMP $env:EXECUTABLE_NAME
$smokeRoot = Join-Path $env:RUNNER_TEMP "multiplexor-windows-smoke"
$fakeTools = Join-Path $env:RUNNER_TEMP "multiplexor-fake-tools"
New-Item -ItemType Directory -Force -Path $fakeTools | Out-Null
$fakeJavaSource = Join-Path $fakeTools "fake_java.dart"
@'
import 'dart:async';
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
if (args.contains('-version')) {
stderr.writeln('openjdk version "21.0.1"');
return;
}
if (args.contains('--installServer')) {
final Directory argsDir = Directory('libraries\\fake')..createSync(recursive: true);
File('${argsDir.path}\\win_args.txt').writeAsStringSync('--launchTarget forge_server');
File('run.bat').writeAsStringSync('java @libraries\\fake\\win_args.txt %*');
return;
}
stdout.writeln('fake java started');
if (Directory.current.path.toLowerCase().endsWith('ci-network-proxy')) {
File('.ci-java-args.tmp')
..writeAsStringSync(jsonEncode(args))
..renameSync('.ci-java-args.json');
await for (final String command in stdin
.transform(utf8.decoder)
.transform(const LineSplitter())
.timeout(const Duration(seconds: 60))) {
if (command == 'end') {
File('.ci-proxy-ended').writeAsStringSync('end');
return;
}
}
throw StateError('Proxy input closed before end');
}
final File restartMarker = File('.ci-restart-triggered');
if (Directory.current.path.toLowerCase().endsWith('ci-purpur') &&
!restartMarker.existsSync()) {
final File restartRequest = File('.ci-request-restart');
for (var attempt = 0; attempt < 300; attempt++) {
if (restartRequest.existsSync()) {
restartMarker.writeAsStringSync('triggered');
return;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
await Future<void>.delayed(const Duration(seconds: 30));
}
'@ | Set-Content -LiteralPath $fakeJavaSource
dart compile exe $fakeJavaSource -o (Join-Path $fakeTools "java.exe")
$env:PATH = "$fakeTools;$env:PATH"
$env:MULTIPLEXOR_FORCE_WINDOWS_LINK_FALLBACK = "1"
$jarPath = Join-Path $env:RUNNER_TEMP "fake-server.jar"
Set-Content -LiteralPath $jarPath -Value "fake server"
$installerPath = Join-Path $env:RUNNER_TEMP "forge-installer.jar"
Set-Content -LiteralPath $installerPath -Value "fake installer"
function Get-ConsumerRoot([string] $Consumer) {
switch ($Consumer) {
"plugin" { return "plugin-consumers" }
"forge" { return "forge-mod-consumers" }
"fabric" { return "fabric-mod-consumers" }
"neoforge" { return "neoforge-mod-consumers" }
default { throw "Unknown smoke consumer: $Consumer" }
}
}
function Invoke-ServerLifecycle {
param(
[string] $Consumer,
[string] $Type,
[string] $Name,
[string] $Jar,
[switch] $ExpectRestart
)
& $outputPath --root $smokeRoot --consumer $Consumer server create $Name --type $Type --jar $Jar
if ($LASTEXITCODE -ne 0) { throw "Windows $Type server create failed" }
$consumerRoot = Get-ConsumerRoot $Consumer
$instanceRoot = Join-Path $smokeRoot "consumers\$consumerRoot\instances\$Name"
if ($Type -eq "forge" -or $Type -eq "neoforge") {
$metadata = Get-Content -Raw -LiteralPath (Join-Path $instanceRoot ".server-source")
if ($metadata -notmatch "args_file_rel=.*win_args.txt") {
throw "Windows $Type metadata did not select win_args.txt: $metadata"
}
}
& $outputPath --root $smokeRoot --consumer $Consumer instance activate $Name
if ($LASTEXITCODE -ne 0) { throw "Windows $Type activation failed" }
& $outputPath --root $smokeRoot --consumer $Consumer instance activate $Name
if ($LASTEXITCODE -ne 0) { throw "Windows $Type re-activation failed" }
& $outputPath --root $smokeRoot --consumer $Consumer runtime start $Name --no-console
if ($LASTEXITCODE -ne 0) { throw "Windows $Type runtime start failed" }
$status = (& $outputPath --root $smokeRoot --consumer $Consumer runtime status $Name) -join "`n"
if ($LASTEXITCODE -ne 0 -or $status -notmatch "Runtime running") {
throw "Windows $Type runtime status failed: $status"
}
if ($ExpectRestart) {
$pidFile = Join-Path $smokeRoot "consumers\$consumerRoot\state\runtime\$Name.server.pid"
$initialPid = (Get-Content -Raw -LiteralPath $pidFile).Trim()
$restartScript = Join-Path $instanceRoot "multiplexor-restart.cmd"
Start-Process -FilePath "cmd.exe" -ArgumentList @("/d", "/c", $restartScript) -WindowStyle Hidden
Set-Content -LiteralPath (Join-Path $instanceRoot ".ci-request-restart") -Value "requested"
$restarted = $false
for ($attempt = 0; $attempt -lt 45; $attempt++) {
Start-Sleep -Seconds 1
if (-not (Test-Path -LiteralPath $pidFile)) { continue }
$currentPid = (Get-Content -Raw -LiteralPath $pidFile).Trim()
$restartStatus = (& $outputPath --root $smokeRoot --consumer $Consumer runtime status $Name) -join "`n"
if ($LASTEXITCODE -eq 0 -and $currentPid -ne $initialPid -and $restartStatus -match "Runtime running") {
$restarted = $true
break
}
}
if (-not $restarted) {
$restartLog = Join-Path $smokeRoot "consumers\$consumerRoot\state\runtime\$Name.restart.log"
$runtimeLog = Join-Path $smokeRoot "consumers\$consumerRoot\state\runtime\$Name.log"
$restartDetails = if (Test-Path -LiteralPath $restartLog) {
Get-Content -Raw -LiteralPath $restartLog
} else {
"<missing restart log>"
}
$runtimeDetails = if (Test-Path -LiteralPath $runtimeLog) {
Get-Content -Raw -LiteralPath $runtimeLog
} else {
"<missing runtime log>"
}
throw "Windows $Type /restart lifecycle failed.`nRestart log:`n$restartDetails`nRuntime log:`n$runtimeDetails"
}
}
& $outputPath --root $smokeRoot --consumer $Consumer runtime stop $Name
if ($LASTEXITCODE -ne 0) { throw "Windows $Type runtime stop failed" }
$stoppedStatus = (& $outputPath --root $smokeRoot --consumer $Consumer runtime status $Name) -join "`n"
if ($LASTEXITCODE -ne 0 -or $stoppedStatus -notmatch "Runtime stopped") {
throw "Windows $Type stopped status failed: $stoppedStatus"
}
& $outputPath --root $smokeRoot --consumer $Consumer instance delete $Name
if ($LASTEXITCODE -ne 0) { throw "Windows $Type instance delete failed" }
}
Invoke-ServerLifecycle -Consumer plugin -Type paper -Name ci-paper -Jar $jarPath
$sharedIris = Join-Path $smokeRoot "consumers\plugin-consumers\shared-plugin-data\iris\packs"
$sharedSentinel = Join-Path $sharedIris "keep.txt"
Set-Content -LiteralPath $sharedSentinel -Value "preserve shared target"
Invoke-ServerLifecycle -Consumer plugin -Type purpur -Name ci-purpur -Jar $jarPath -ExpectRestart
foreach ($type in @("folia", "canvas", "leaf", "spigot", "custom")) {
Invoke-ServerLifecycle -Consumer plugin -Type $type -Name "ci-$type" -Jar $jarPath
}
Invoke-ServerLifecycle -Consumer forge -Type forge -Name ci-forge -Jar $installerPath
Invoke-ServerLifecycle -Consumer fabric -Type fabric -Name ci-fabric -Jar $jarPath
Invoke-ServerLifecycle -Consumer neoforge -Type neoforge -Name ci-neoforge -Jar $installerPath
& $outputPath --root $smokeRoot --consumer plugin server create ci-velocity-backend --type paper --jar $jarPath --mc 1.21.11 --isolated
if ($LASTEXITCODE -ne 0) { throw "Windows Velocity backend create failed" }
$portProbe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$portProbe.Start()
$proxyPort = $portProbe.LocalEndpoint.Port
$portProbe.Stop()
& $outputPath --root $smokeRoot --consumer plugin network create ci-network --members ci-velocity-backend --default ci-velocity-backend --jar $jarPath --proxy-version 3.4.0 --offline --port $proxyPort
if ($LASTEXITCODE -ne 0) { throw "Windows Velocity network create failed" }
$proxyRoot = Join-Path $smokeRoot "consumers\plugin-consumers\instances\ci-network-proxy"
try {
& $outputPath --root $smokeRoot --consumer plugin runtime start ci-network-proxy --no-console
if ($LASTEXITCODE -ne 0) { throw "Windows Velocity runtime start failed" }
$proxyStatus = (& $outputPath --root $smokeRoot --consumer plugin runtime status ci-network-proxy) -join "`n"
if ($LASTEXITCODE -ne 0 -or $proxyStatus -notmatch "Runtime running") {
throw "Windows Velocity runtime status failed: $proxyStatus"
}
$argumentsPath = Join-Path $proxyRoot ".ci-java-args.json"
$argumentsDeadline = [DateTime]::UtcNow.AddSeconds(10)
while (-not (Test-Path -LiteralPath $argumentsPath)) {
if ([DateTime]::UtcNow -ge $argumentsDeadline) {
throw "Windows Velocity did not record its Java arguments"
}
Start-Sleep -Milliseconds 100
}
$proxyArguments = @(Get-Content -Raw -LiteralPath $argumentsPath | ConvertFrom-Json)
if ($proxyArguments.Count -ne 5 -or $proxyArguments[0] -ne "-Xms1G" -or
$proxyArguments[1] -ne "-Xmx1G" -or $proxyArguments[2] -ne "-Dfile.encoding=UTF-8" -or
$proxyArguments[3] -ne "-jar") {
throw "Windows Velocity Java arguments were incorrect: $($proxyArguments -join ' ')"
}
$proxyJarPath = if ([System.IO.Path]::IsPathRooted($proxyArguments[4])) {
$proxyArguments[4]
} else {
Join-Path $proxyRoot $proxyArguments[4]
}
if (-not (Test-Path -LiteralPath $proxyJarPath -PathType Leaf)) {
throw "Windows Velocity Java target does not exist: $proxyJarPath"
}
foreach ($gameFile in @("server.properties", "eula.txt", "ops.json", "multiplexor-restart.sh", "multiplexor-restart.cmd", ".multiplexor-log4j2.xml", "plugins\iris")) {
if (Test-Path -LiteralPath (Join-Path $proxyRoot $gameFile)) {
throw "Windows Velocity unexpectedly created $gameFile"
}
}
} finally {
& $outputPath --root $smokeRoot --consumer plugin runtime stop ci-network-proxy
if ($LASTEXITCODE -ne 0) { throw "Windows Velocity runtime stop failed" }
}
if (-not (Test-Path -LiteralPath (Join-Path $proxyRoot ".ci-proxy-ended"))) {
throw "Windows Velocity did not receive the graceful end command"
}
$proxyStopped = (& $outputPath --root $smokeRoot --consumer plugin runtime status ci-network-proxy) -join "`n"
if ($LASTEXITCODE -ne 0 -or $proxyStopped -notmatch "Runtime stopped") {
throw "Windows Velocity stopped status failed: $proxyStopped"
}
& $outputPath --root $smokeRoot --consumer plugin network delete ci-network --confirm ci-network
if ($LASTEXITCODE -ne 0) { throw "Windows Velocity network cleanup failed" }
foreach ($instanceName in @("ci-network-proxy", "ci-velocity-backend")) {
& $outputPath --root $smokeRoot --consumer plugin instance delete $instanceName
if ($LASTEXITCODE -ne 0) { throw "Windows Velocity instance cleanup failed: $instanceName" }
}
if (-not (Test-Path -LiteralPath $sharedSentinel)) {
throw "Windows junction deletion traversed the shared Iris target"
}
& $outputPath --root $smokeRoot --consumer plugin plugins watch-stop
if ($LASTEXITCODE -ne 0) { throw "Windows plugin watcher stop failed" }
foreach ($consumer in @("forge", "fabric", "neoforge")) {
& $outputPath --root $smokeRoot --consumer $consumer mods watch-stop
if ($LASTEXITCODE -ne 0) { throw "Windows $consumer watcher stop failed" }
}
- name: Package macOS executable
if: runner.os == 'macOS'
shell: bash
run: COPYFILE_DISABLE=1 tar --format=ustar --no-xattrs -czf "$RUNNER_TEMP/$ARCHIVE_NAME" -C "$RUNNER_TEMP" "$EXECUTABLE_NAME"
- name: Package Windows executable
if: runner.os == 'Windows'
shell: pwsh
run: |
$outputPath = Join-Path $env:RUNNER_TEMP $env:EXECUTABLE_NAME
$archivePath = Join-Path $env:RUNNER_TEMP $env:ARCHIVE_NAME
Compress-Archive -LiteralPath $outputPath -DestinationPath $archivePath -Force
- name: Upload executable
uses: actions/upload-artifact@v7
with:
name: ${{ env.ARCHIVE_NAME }}
path: ${{ runner.temp }}/${{ env.ARCHIVE_NAME }}
if-no-files-found: error
retention-days: 30
release:
name: Publish GitHub Release
if: github.event_name == 'push'
needs:
- version
- build
- gameplay
runs-on: ubuntu-latest
concurrency:
group: executable-releases-${{ github.repository }}
cancel-in-progress: false
permissions:
actions: read
contents: write
steps:
- name: Download executables
uses: actions/download-artifact@v8
with:
pattern: multiplexor-*
path: release-assets
merge-multiple: true
- name: Check release archives and write checksums
shell: bash
working-directory: release-assets
env:
BUILD_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
shopt -s nullglob dotglob
archives=(
"multiplexor-v$BUILD_VERSION-macos-arm64.tar.gz"
"multiplexor-v$BUILD_VERSION-macos-x64.tar.gz"
"multiplexor-v$BUILD_VERSION-windows-x64.zip"
)
downloaded=(*)
if [[ ${#downloaded[@]} -ne ${#archives[@]} ]]; then
echo "Expected exactly three executable archives, got ${#downloaded[@]}." >&2
exit 1
fi
for archive in "${archives[@]}"; do
if [[ ! -f "$archive" || -L "$archive" || ! -s "$archive" ]]; then
echo "Missing or invalid release archive: $archive" >&2
exit 1
fi
done
sha256sum -- "${archives[@]}" > SHA256SUMS
sha256sum --check --strict SHA256SUMS
- name: Publish release
shell: bash
env:
BUILD_SHA: ${{ needs.version.outputs.sha }}
BUILD_VERSION: ${{ needs.version.outputs.version }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
release_tag="$GITHUB_REF_NAME"
target_args=(--target "$BUILD_SHA" --verify-tag)
else
release_tag="v$BUILD_VERSION"
target_args=(--target "$BUILD_SHA")
fi
verify_tag_target() {
local tag_count tag_sha
tag_count="$(gh api "repos/$GH_REPO/git/matching-refs/tags/$release_tag" \
--jq "map(select(.ref == \"refs/tags/$release_tag\")) | length")"
if [[ "$tag_count" -ne 0 ]]; then
tag_sha="$(gh api "repos/$GH_REPO/commits/$release_tag" --jq .sha)"
if [[ "$tag_sha" != "$BUILD_SHA" ]]; then
echo "Release tag $release_tag points to $tag_sha, expected $BUILD_SHA." >&2
exit 1
fi
elif [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
echo "Pushed release tag $release_tag no longer exists." >&2
exit 1
fi
}
verify_tag_target
already_published=false
if is_draft="$(gh release view "$release_tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
if [[ "$is_draft" == "true" ]]; then
gh release edit "$release_tag" --target "$BUILD_SHA"
gh release upload "$release_tag" release-assets/* --clobber
else
already_published=true
fi
else
gh release create "$release_tag" release-assets/* \
--title "Multiplexor v$BUILD_VERSION" \
--generate-notes --draft \
"${target_args[@]}"
fi
verification_dir="$(mktemp -d)"
trap 'rm -rf "$verification_dir"' EXIT
gh release download "$release_tag" --dir "$verification_dir"
diff --recursive --brief release-assets "$verification_dir"
(cd "$verification_dir" && sha256sum --check --strict SHA256SUMS)
if [[ "$already_published" == "true" ]]; then
echo "Release $release_tag is already published with these exact assets."
exit 0
fi
verify_tag_target
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
channel_args=(--latest --prerelease=false)
elif [[ "$GITHUB_REF_NAME" == "$DEFAULT_BRANCH" ]]; then
current_default_sha="$(gh api "repos/$GH_REPO/commits/$DEFAULT_BRANCH" --jq .sha)"
if [[ "$current_default_sha" == "$BUILD_SHA" ]]; then
channel_args=(--latest --prerelease=false)
else
channel_args=(--latest=false --prerelease)
fi
else
channel_args=(--latest=false --prerelease)
fi
gh release edit "$release_tag" --draft=false \
"${target_args[@]}" "${channel_args[@]}"