Repository navigation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |