Skip to content

V+

V+ #26

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"
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:io';
Future<void> main(List<String> args) async {
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');
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
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: tar -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
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps:
- name: Download executables
uses: actions/download-artifact@v8
with:
pattern: multiplexor-*
path: release-assets
merge-multiple: true
- 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: |
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
release_tag="$GITHUB_REF_NAME"
target_args=(--verify-tag)
channel_args=(--latest)
else
release_tag="v$BUILD_VERSION"
target_args=(--target "$BUILD_SHA")
if [[ "$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)
else
channel_args=(--prerelease)
fi
else
channel_args=(--prerelease)
fi
fi
if gh release view "$release_tag" >/dev/null 2>&1; then
gh release upload "$release_tag" release-assets/* --clobber
else
gh release create "$release_tag" release-assets/* \
--title "Multiplexor v$BUILD_VERSION" \
--generate-notes \
"${target_args[@]}" \
"${channel_args[@]}"
fi