diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml deleted file mode 100644 index 7765576..0000000 --- a/.github/workflows/integration-test.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Update Integration Test - -on: - workflow_call: - workflow_dispatch: - -jobs: - update-cycle: - name: Update cycle (${{ matrix.scenario.name }}) - runs-on: windows-latest - strategy: - fail-fast: false - matrix: - scenario: - - { name: single, script: ./test-local-update.ps1 } - - { name: dual, script: ./test-local-update-dual.ps1 } - # Regression guard: server ships a byte-different external updater (same version), which - # is installed eagerly and so must be trusted from the signed manifest at launch time. - - { name: updater-mismatch, script: ./test-local-update-updater-mismatch.ps1 } - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - submodules: recursive - - - name: Setup .NET - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - - - name: Run ${{ matrix.scenario.name }}-channel update cycle - shell: pwsh - run: ${{ matrix.scenario.script }} - - - name: Upload diagnostics on failure - if: failure() - uses: actions/upload-artifact@v7 - with: - name: update-cycle-${{ matrix.scenario.name }}-logs - # Updater log: %TEMP%\extUpdateLog-*.txt; RawDevLauncher app log: %APPDATA%\RawDevLauncher\*.txt - path: | - ${{ runner.temp }}\extUpdateLog-*.txt - ${{ env.APPDATA }}\RawDevLauncher\*.txt - if-no-files-found: ignore - retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a55bdf0..ab19b84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,50 +10,36 @@ on: branch: description: "The branch to a release to" required: true - + env: - TOOL_PROJ_PATH: ./src/DevLauncher/DevLauncher.csproj - PUBLISH_SCRIPT: ./ModdingToolBase/scripts/Publish-Release.ps1 - TOOL_EXE: RaW-DevLauncher.exe - UPDATER_EXE: AnakinRaW.ExternalUpdater.exe - EMBEDDED_TRUST_CERT: src/DevLauncher/Resources/Certs/anakinraw-trust.cer - ORIGIN_BASE: https://republicatwar.com/downloads/RawDevLauncher/v2 - SFTP_BASE_PATH: downloads/RawDevLauncher/v2 + # Per-run release channel. Every other per-app value lives in update-tooling.jsonc and is loaded + # into the environment by the "Load app config" step in each job below. BRANCH_NAME: ${{ github.event.inputs.branch || 'stable' }} - # RESCUE (transitional). Publish THIS external-updater binary instead of the freshly built one. - # A client on an old version rejects any updater whose bytes differ from the copy it embeds, so it - # cannot self-update once the updater is rebuilt. Serving the exact binary it embeds lets its old - # integrity check pass, so it can pull the fixed app exe. The binary MUST be byte-identical to that - # client's embedded updater (verified for 3.0.2). This does not change the app, only which updater - # is published. CLEAR IT the release after the fleet has moved off the broken version (otherwise a - # patched client re-downloads it every launch). Unlike COMPAT_UPDATER below, this needs no next-gen - # channel — it simply substitutes the primary updater, so no NEXT_ORIGIN guard applies. - RESCUE_UPDATER: tools/AnakinRaW.ExternalUpdater.exe - - # Migration-release values. Leave empty for a normal release; populate to enable. - # - # Origin URL of the next-generation channel, written into the manifest's componentOriginInfo. - # NEXT_ORIGIN_BASE: https://republicatwar.com/downloads/RawDevLauncher/v2 - - # SFTP path the next-generation channel uploads to. Set together with NEXT_ORIGIN_BASE. - # NEXT_SFTP_BASE_PATH: downloads/RawDevLauncher/v2/ - - # Previously-deployed updater used in place of the build-output one for the primary deploy. - # Only the old-gen manifest lists this binary; the next-gen manifest still uses the build-output updater. - # Requires NEXT_ORIGIN_BASE + NEXT_SFTP_BASE_PATH to be set. - # COMPAT_UPDATER: tools/v1/AnakinRaW.ExternalUpdater.exe - jobs: # Builds and tests the solution. test: uses: ./.github/workflows/test.yml - # End-to-end self-update test + # End-to-end self-update test. integration-test: + name: Integration test needs: [test] - uses: ./.github/workflows/integration-test.yml + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Run self-update integration suite + shell: pwsh + run: | + $c = Get-Content -Raw ./update-tooling.jsonc | ConvertFrom-Json + & "$($c.scriptsDir)/Test-LocalUpdateSuite.ps1" -ConfigPath ./update-tooling.jsonc pack: name: Pack @@ -65,6 +51,11 @@ jobs: with: fetch-depth: 0 submodules: recursive + - name: Load app config + shell: pwsh + run: | + $c = Get-Content -Raw ./update-tooling.jsonc | ConvertFrom-Json + "TOOL_PROJ_PATH=$($c.toolProject)" >> $env:GITHUB_ENV - name: Setup .NET uses: actions/setup-dotnet@v5 - name: Create NetFramework Release @@ -103,26 +94,37 @@ jobs: with: dotnet-version: 10.0.x + - name: Load app config + shell: pwsh + # Per-app release values come from update-tooling.jsonc. + run: | + $c = Get-Content -Raw ./update-tooling.jsonc | ConvertFrom-Json + "PUBLISH_SCRIPT=$($c.scriptsDir)/Publish-Release.ps1" >> $env:GITHUB_ENV + "TOOL_EXE=$($c.appExe)" >> $env:GITHUB_ENV + "UPDATER_EXE=$($c.updaterExe)" >> $env:GITHUB_ENV + "EMBEDDED_TRUST_CERT=$($c.embeddedTrustCert)" >> $env:GITHUB_ENV + "ORIGIN_BASE=$($c.origin)" >> $env:GITHUB_ENV + "SFTP_BASE_PATH=$($c.sftpBasePath)" >> $env:GITHUB_ENV + "SFTP_HOST=$($c.sftpHost)" >> $env:GITHUB_ENV + "SFTP_PORT=$($c.sftpPort)" >> $env:GITHUB_ENV + "NEXT_ORIGIN_BASE=$($c.nextOrigin)" >> $env:GITHUB_ENV + "NEXT_SFTP_BASE_PATH=$($c.nextSftpBasePath)" >> $env:GITHUB_ENV + "COMPAT_UPDATER=$($c.compatUpdater)" >> $env:GITHUB_ENV - name: Publish self-update release shell: pwsh run: | - # RESCUE_UPDATER, when set, replaces the freshly built updater with a pinned binary so old - # clients can still self-update (see env comment). Falls back to the build output. - $updaterExe = if ($env:RESCUE_UPDATER) { $env:RESCUE_UPDATER } else { "./releases/net481/$env:UPDATER_EXE" } - if (-not (Test-Path $updaterExe)) { throw "Updater exe not found at '$updaterExe'." } - Write-Host "Publishing updater: $updaterExe" & $env:PUBLISH_SCRIPT ` -AppExePath "./releases/net481/$env:TOOL_EXE" ` - -UpdaterExePath "$updaterExe" ` + -UpdaterExePath "./releases/net481/$env:UPDATER_EXE" ` -EmbeddedTrustCertPath "$env:EMBEDDED_TRUST_CERT" ` -Origin "$env:ORIGIN_BASE" ` -SftpBasePath "$env:SFTP_BASE_PATH" ` -Branch "$env:BRANCH_NAME" ` -SigningPfxBase64 "${{ secrets.UPDATER_SIGNING_PFX_B64 }}" ` -SigningPfxPassword "${{ secrets.UPDATER_SIGNING_PFX_PASSWORD }}" ` - -SftpHost "republicatwar.com" ` - -SftpPort 1579 ` + -SftpHost "$env:SFTP_HOST" ` + -SftpPort $env:SFTP_PORT ` -SftpUser "${{ secrets.SFTP_USER }}" ` -SftpPassword "${{ secrets.SFTP_PASSWORD }}" ` -NextOrigin "$env:NEXT_ORIGIN_BASE" ` @@ -141,5 +143,5 @@ jobs: tag_name: v${{ steps.nbgv.outputs.SemVer2 }} token: ${{ secrets.GITHUB_TOKEN }} generate_release_notes: false - files: | - ./releases/net481/$env:TOOL_EXE \ No newline at end of file + files: | + ./releases/net481/${{ env.TOOL_EXE }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05f2035..ff97bd9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,4 +23,21 @@ jobs: dotnet-version: 10.0.x - name: Build in Release Mode - run: dotnet test --configuration Release --report-github \ No newline at end of file + run: dotnet test --configuration Release --report-github + + test-moddingtoolbase: + name: Test ModdingToolBase + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Test ModdingToolBase (submodule) + shell: pwsh + run: | + $c = Get-Content -Raw ./update-tooling.jsonc | ConvertFrom-Json + & "$($c.scriptsDir)/Test-ModdingToolBase.ps1" \ No newline at end of file diff --git a/ModVerify b/ModVerify index f7f3a24..0b66df6 160000 --- a/ModVerify +++ b/ModVerify @@ -1 +1 @@ -Subproject commit f7f3a248fb05dc579b425f42f2c659688568ba28 +Subproject commit 0b66df679003dc6b181d2a1433e68b44fb9d1e5a diff --git a/ModdingToolBase b/ModdingToolBase index d575a11..1fc635e 160000 --- a/ModdingToolBase +++ b/ModdingToolBase @@ -1 +1 @@ -Subproject commit d575a112196400d2710ed7d8b1d0759316c8b712 +Subproject commit 1fc635ed3050e77992fbc8fd3db1916669c00d33 diff --git a/deploy-local.ps1 b/deploy-local.ps1 deleted file mode 100644 index 7613e36..0000000 --- a/deploy-local.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -param( - [string]$InstalledVersion = "0.0.1-local", - [string]$ServerVersion = "99.99.99-local", - [switch]$DualPublish, - [string]$CompatibilityUpdater, - - # Make the server's updater byte-different from the installed app's embedded copy (same version, - # still runnable) so the cycle exercises the launch-time integrity check. See block below. - [switch]$PerturbServerUpdater -) - -$ErrorActionPreference = "Stop" - -$root = $PSScriptRoot -if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } - -. (Join-Path $root "ModdingToolBase\scripts\NbgvVersion.ps1") - -$deployRoot = Join-Path $root ".local_deploy" -$installBuildDir = Join-Path $deployRoot "bin\install" -$serverBuildDir = Join-Path $deployRoot "bin\tool" - -$toolProj = Join-Path $root "src\DevLauncher\DevLauncher.csproj" -$baseScript = Join-Path $root "ModdingToolBase\scripts\Publish-LocalRelease.ps1" - -if (Test-Path $deployRoot) { Remove-Item -Recurse -Force $deployRoot } -New-Item -ItemType Directory -Path $deployRoot | Out-Null - -$nbgv = Backup-NbgvVersion -RepoRoot $root -try { - Write-Host "--- Building DevLauncher (net481) @ installed v$InstalledVersion ---" -ForegroundColor Cyan - Set-NbgvVersion -Snapshot $nbgv -Version $InstalledVersion - dotnet build $toolProj --configuration Release -f net481 --output $installBuildDir /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true - - Write-Host "--- Building DevLauncher (net481) @ server v$ServerVersion ---" -ForegroundColor Cyan - Set-NbgvVersion -Snapshot $nbgv -Version $ServerVersion - dotnet build $toolProj --configuration Release -f net481 --output $serverBuildDir /p:DebugType=None /p:DebugSymbols=false /p:LocalDeploy=true - - if ($PerturbServerUpdater) { - # Make the server's updater differ byte-for-byte from the installed app's embedded copy. - # Deterministic builds otherwise produce identical updaters, so the cycle never exercises an - # updater whose bytes changed. Appended trailing bytes change the SHA-256 but are ignored by - # the PE loader (the exe still runs) and leave the version untouched — i.e. a same-version - # rebuild, which must be trusted from the signed manifest at launch. - $serverUpdater = Join-Path $serverBuildDir "AnakinRaW.ExternalUpdater.exe" - if (-not (Test-Path $serverUpdater)) { throw "Server external updater not found at '$serverUpdater'." } - Write-Host "--- Perturbing server external updater (trailing bytes) to force a hash mismatch ---" -ForegroundColor Cyan - $marker = [Text.Encoding]::ASCII.GetBytes("/*INTEGRITY-REGRESSION*/") - $fs = [IO.File]::Open($serverUpdater, [IO.FileMode]::Append, [IO.FileAccess]::Write) - try { $fs.Write($marker, 0, $marker.Length) } finally { $fs.Dispose() } - } - - $publishParams = @{ - AppExePath = Join-Path $serverBuildDir "RaW-DevLauncher.exe" - UpdaterExePath = Join-Path $serverBuildDir "AnakinRaW.ExternalUpdater.exe" - DeployRoot = $deployRoot - InstallBuildDir = $installBuildDir - Branch = "beta" - } - if ($DualPublish) { $publishParams.DualPublish = $true } - if ($CompatibilityUpdater) { $publishParams.CompatibilityUpdater = $CompatibilityUpdater } - - & $baseScript @publishParams -} -finally { - Restore-NbgvVersion -Snapshot $nbgv -} \ No newline at end of file diff --git a/src/DevLauncher/DevLauncher.csproj b/src/DevLauncher/DevLauncher.csproj index e249ea2..4514c2e 100644 --- a/src/DevLauncher/DevLauncher.csproj +++ b/src/DevLauncher/DevLauncher.csproj @@ -18,8 +18,8 @@ true - + $(DefineConstants);LOCAL_DEPLOY diff --git a/test-local-update-dual.ps1 b/test-local-update-dual.ps1 deleted file mode 100644 index 57ae3a8..0000000 --- a/test-local-update-dual.ps1 +++ /dev/null @@ -1,39 +0,0 @@ -#Requires -Version 7.0 - -[CmdletBinding()] -param( - [string]$InstalledVersion = '0.0.1-local', - [string]$ServerVersion = '99.99.99-local', - [string]$Branch = 'beta', - [string]$CompatibilityUpdater -) - -$ErrorActionPreference = 'Stop' - -$root = $PSScriptRoot -if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } - -$deployArgs = @{ - InstalledVersion = $InstalledVersion - ServerVersion = $ServerVersion - DualPublish = $true -} -if ($CompatibilityUpdater) { $deployArgs.CompatibilityUpdater = $CompatibilityUpdater } - -& (Join-Path $root 'deploy-local.ps1') @deployArgs -if ($LASTEXITCODE -ne 0) { throw "deploy-local.ps1 -DualPublish failed (exit $LASTEXITCODE)." } - -$nextServerDir = Join-Path $root '.local_deploy\server\v2' -if (-not (Test-Path $nextServerDir)) { - throw "Expected /v2/ server dir at '$nextServerDir' but it does not exist." -} -$nextServerUri = "file:///$(((Resolve-Path $nextServerDir).Path -replace '\\','/'))" - -& (Join-Path $root 'ModdingToolBase\scripts\Test-LocalUpdateCycle.ps1') ` - -AppExePath (Join-Path $root '.local_deploy\install\RaW-DevLauncher.exe') ` - -ServerUri $nextServerUri ` - -Branch $Branch ` - -NoUpdateMessage 'No update available.' ` - -ExpectedNewVersion $ServerVersion - -exit $LASTEXITCODE \ No newline at end of file diff --git a/test-local-update-updater-mismatch.ps1 b/test-local-update-updater-mismatch.ps1 deleted file mode 100644 index 3937fb9..0000000 --- a/test-local-update-updater-mismatch.ps1 +++ /dev/null @@ -1,40 +0,0 @@ -# ========================================================================================= -# Regression scenario for the external-updater launch-time integrity check. -# -# Deploys via deploy-local.ps1 -PerturbServerUpdater (server ships an updater byte-different -# from the installed app's embedded copy, same version) and runs the shared end-to-end cycle. -# The updater installs eagerly, so it must be trusted from the signed manifest at launch. -# Pre-fix this threw "match none of the trusted hashes" at step [1/5]. Windows-only. -# ========================================================================================= - -#Requires -Version 7.0 - -[CmdletBinding()] -param( - [string]$InstalledVersion = '0.0.1-local', - [string]$ServerVersion = '99.99.99-local', - [string]$Branch = 'beta' -) - -$ErrorActionPreference = 'Stop' - -$root = $PSScriptRoot -if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } - -& (Join-Path $root 'deploy-local.ps1') ` - -InstalledVersion $InstalledVersion ` - -ServerVersion $ServerVersion ` - -PerturbServerUpdater -if ($LASTEXITCODE -ne 0) { throw "deploy-local.ps1 -PerturbServerUpdater failed (exit $LASTEXITCODE)." } - -$serverDir = Join-Path $root '.local_deploy\server' -$serverUri = "file:///$(((Resolve-Path $serverDir).Path -replace '\\','/'))" - -& (Join-Path $root 'ModdingToolBase\scripts\Test-LocalUpdateCycle.ps1') ` - -AppExePath (Join-Path $root '.local_deploy\install\RaW-DevLauncher.exe') ` - -ServerUri $serverUri ` - -Branch $Branch ` - -NoUpdateMessage 'No update available.' ` - -ExpectedNewVersion $ServerVersion - -exit $LASTEXITCODE diff --git a/test-local-update.ps1 b/test-local-update.ps1 deleted file mode 100644 index 5a5f69e..0000000 --- a/test-local-update.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -# ========================================================================================= -# Local bootstrap for the self-update integration test. -# -# Stages a local deploy via deploy-local.ps1, then runs the shared end-to-end test from -# ModdingToolBase against the staged install dir + signed local server. -# -# Windows-only. -# ========================================================================================= - -#Requires -Version 7.0 - -[CmdletBinding()] -param( - [string]$InstalledVersion = '0.0.1-local', - [string]$ServerVersion = '99.99.99-local', - [string]$Branch = 'beta' -) - -$ErrorActionPreference = 'Stop' - -$root = $PSScriptRoot -if ([string]::IsNullOrEmpty($root)) { $root = Get-Location } - -& (Join-Path $root 'deploy-local.ps1') -InstalledVersion $InstalledVersion -ServerVersion $ServerVersion -if ($LASTEXITCODE -ne 0) { throw "deploy-local.ps1 failed (exit $LASTEXITCODE)." } - -$serverDir = Join-Path $root '.local_deploy\server' -$serverUri = "file:///$(((Resolve-Path $serverDir).Path -replace '\\','/'))" - -& (Join-Path $root 'ModdingToolBase\scripts\Test-LocalUpdateCycle.ps1') ` - -AppExePath (Join-Path $root '.local_deploy\install\RaW-DevLauncher.exe') ` - -ServerUri $serverUri ` - -Branch $Branch ` - -NoUpdateMessage 'No update available.' ` - -ExpectedNewVersion $ServerVersion - -exit $LASTEXITCODE \ No newline at end of file diff --git a/tools/AnakinRaW.ExternalUpdater.exe b/tools/AnakinRaW.ExternalUpdater.exe deleted file mode 100644 index 2bb788e..0000000 Binary files a/tools/AnakinRaW.ExternalUpdater.exe and /dev/null differ diff --git a/update-tooling.jsonc b/update-tooling.jsonc new file mode 100644 index 0000000..f498dd1 --- /dev/null +++ b/update-tooling.jsonc @@ -0,0 +1,40 @@ +// Per-app configuration for the shared ModdingToolBase release/deploy tooling. +// Consumed by ModdingToolBase/scripts/*.ps1 (local dev/test) and by .github/workflows/release.yml (CI). +// +// Parsed only with PowerShell's ConvertFrom-Json, which tolerates // and /* */ comments and +// trailing commas. Keep it that way: don't point a strict JSON validator at this file. +{ + // ===== SHARED -- consumed by BOTH dev/test (local scripts) and prod (release.yml CI) ======= + "toolProject": "./src/DevLauncher/DevLauncher.csproj", // app csproj to build + "appExe": "RaW-DevLauncher.exe", // built app exe name + "updaterExe": "AnakinRaW.ExternalUpdater.exe", // external updater exe name + "scriptsDir": "./ModdingToolBase/scripts", // ModdingToolBase scripts dir (its submodule path) + + // ===== DEV/TEST ONLY -- the local self-update test scripts; never used by a prod release === + // testUpdateBranch: the update-server channel the local test publishes to and reads from + // (feeds --updateBranch). It's an update channel, NOT a git branch; prod uses "stable" + // (set in release.yml's BRANCH_NAME). Note what is NOT here: + // - targetFramework: fixed at net481 by the tooling (external updater is net481-only), + // so it's a Build-LocalRelease.ps1 default, not a per-app value. + // - noUpdateMessage: the test now asserts the re-check exits 0 and leaves the exe version + // unchanged, instead of scraping a host-specific console string. + "testUpdateBranch": "beta", + "appLogDir": "RawDevLauncher", // %APPDATA% subfolder with the app log (failure diagnostics) + + // ===== PROD / RELEASE ONLY -- published by release.yml (CI); unused by the local test ====== + "embeddedTrustCert": "src/DevLauncher/Resources/Certs/anakinraw-trust.cer", // public trust .cer shipped in the app + "origin": "https://republicatwar.com/downloads/RawDevLauncher/v2", // manifest origin URL + "sftpBasePath": "downloads/RawDevLauncher/v2", // SFTP upload base path + "sftpHost": "republicatwar.com", + "sftpPort": 1579, + + // Migration release (cross-API). Leave all three empty for a normal release; populate them to + // publish a next-generation channel alongside the current one. See Publish-Release.ps1. + // nextOrigin - origin URL of the next-gen channel + // nextSftpBasePath - SFTP base path for the next-gen channel (set together with nextOrigin) + // compatUpdater - previous-gen updater used for the PRIMARY deploy during the cutover + // (only valid when nextOrigin + nextSftpBasePath are set) + "nextOrigin": "", + "nextSftpBasePath": "", + "compatUpdater": "" +}