name: build-windows on: push: branches: [pro-features] workflow_dispatch: inputs: version_suffix: description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla." type: string default: "cst" # Workflow-level env is visible to every job. Runner-specific paths # (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the # Linux signing runner has no use for them and shouldn't see them. env: TIMESTAMP_URL: "http://timestamp.digicert.com" SIGN_DESCRIPTION: "HelloAgent Remote Support" SIGN_INFO_URL: "https://cstudio.ch/hello-agent" jobs: # ---- 1. BUILD on Windows --------------------------------------------------- # Produces an unsigned hello-agent.exe and a SHA-256 of those bytes that # the sign job re-checks before signing. The pre-sign hash is the audit # link between "what the build runner produced" and "what the signer was # asked to sign" — a transit corruption or tampered artifact gets caught # at sign time, not at the customer. build-x64: name: build-hello-agent-x64 runs-on: [self-hosted, windows-10] timeout-minutes: 90 outputs: version_tag: ${{ steps.version.outputs.tag }} env: RUST_VERSION: "1.75" LLVM_VERSION: "15.0.6" LLVM_HOME: 'C:\tools\llvm-15.0.6' VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_ROOT: C:\vcpkg VCPKG_BINARY_SOURCES: "clear;files,C:\\vcpkg-cache,readwrite" LIBCLANG_PATH: 'C:\tools\llvm-15.0.6\bin' steps: - name: Checkout hello-agent (with vendored rustdesk) uses: actions/checkout@v4 # We vendor the rustdesk source under vendor/rustdesk/ so this # checkout is fully self-contained — no sibling repo, no submodules. - name: Verify host toolchain shell: pwsh run: | $required = 'pwsh','git','bash','python','rustc','cargo','rustup','clang' $missing = @() foreach ($tool in $required) { $cmd = Get-Command $tool -ErrorAction SilentlyContinue if (-not $cmd) { $missing += $tool; continue } $ver = & $tool --version 2>&1 | Select-Object -First 1 Write-Host ("{0,-10} {1} ({2})" -f $tool, $cmd.Source, $ver) } if ($missing.Count -gt 0) { Write-Error ("Missing tools on runner: {0}" -f ($missing -join ', ')) exit 1 } if (-not $env:VCPKG_ROOT -or -not (Test-Path "$env:VCPKG_ROOT\vcpkg.exe")) { Write-Error "VCPKG_ROOT not set or vcpkg.exe missing at $env:VCPKG_ROOT" exit 1 } if (-not (Test-Path "$env:LIBCLANG_PATH\libclang.dll")) { Write-Error "libclang.dll not found at $env:LIBCLANG_PATH" exit 1 } - name: Configure Rust toolchain shell: pwsh run: | rustup toolchain install $env:RUST_VERSION --profile minimal --component rustfmt if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install failed ($LASTEXITCODE)" } rustup default $env:RUST_VERSION if ($LASTEXITCODE -ne 0) { throw "rustup default failed ($LASTEXITCODE)" } rustup target add x86_64-pc-windows-msvc rustc --version cargo --version - name: Configure git safe.directory shell: pwsh run: git config --global --add safe.directory '*' - name: vcpkg install dependencies (x64-windows-static) shell: bash env: VCPKG_DEFAULT_HOST_TRIPLET: x64-windows-static # vcpkg.json sits at vendor/rustdesk/vcpkg.json (alongside the # rustdesk Cargo.toml). Run from there so manifest mode picks it up. run: | mkdir -p /c/vcpkg-cache cd vendor/rustdesk if ! "$VCPKG_ROOT/vcpkg" install \ --triplet x64-windows-static \ --x-install-root="$VCPKG_ROOT/installed"; then find "$VCPKG_ROOT/" -name "*.log" -exec sh -c 'echo "===== {} ====="; cat "{}"' \; exit 1 fi # Build hello-agent. We do NOT pre-build vendor/rustdesk/libs/virtual_display/dylib # the way the upstream rustdesk workflow does. That dylib produces a # standalone `dylib_virtual_display.dll` runtime artifact that the # rustdesk Flutter exe ships side-by-side; hello-agent doesn't bundle # it (no virtual-display feature in v0), and the `virtual_display` # crate that librustdesk *does* link against has no compile-time dep # on the dylib — it loads it by name at runtime if present. # # Pre-building it would also force a second cargo invocation inside # the vendor/rustdesk/ workspace, which has no Cargo.lock of its own # and would re-resolve git deps from HEAD (breaking the tray-icon # 0.21.3 pin we keep at the hello-agent root). - name: Cargo build hello-agent shell: pwsh run: | cargo build --release --bin hello-agent --locked if ($LASTEXITCODE -ne 0) { throw "hello-agent build failed" } if (-not (Test-Path target\release\hello-agent.exe)) { throw "target\release\hello-agent.exe missing after cargo build" } - name: Compute version tag id: version shell: pwsh env: # On push events `inputs.*` is empty — the workflow_dispatch default # ("cst") doesn't apply. Fall back to "cst" in-script so push and # dispatch produce the same default tag shape. VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }} run: | $base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value if (-not $base) { throw "could not parse version from Cargo.toml" } if ($env:VERSION_SUFFIX) { $tag = "$base-$env:VERSION_SUFFIX" } else { $tag = $base } "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append Write-Host "Version tag: $tag" - name: Stage unsigned artifact + record pre-sign hash shell: pwsh # The pre-sign hash is the chain-of-custody primitive: build runner # publishes it with the binary, signing runner re-hashes the binary # it actually received and refuses to sign on mismatch. Catches # corruption in transit and tampering between jobs. run: | New-Item -ItemType Directory -Force -Path .\BuildOutput | Out-Null Copy-Item -Force target\release\hello-agent.exe ` .\BuildOutput\hello-agent.exe # Lowercase hex + LF-only line ending is the canonical sha256 # format (matches sha256sum, openssl, etc.). PowerShell's # Get-FileHash returns uppercase, and Out-File writes CRLF on # Windows — both bite the Linux signer's hash compare. We use # WriteAllText with an explicit "`n" so the file ends up exactly # 65 bytes: 64 hex chars + one LF. $h = Get-FileHash .\BuildOutput\hello-agent.exe -Algorithm SHA256 $hash = $h.Hash.ToLower() [IO.File]::WriteAllText( "$PWD\BuildOutput\hello-agent.exe.presig.sha256", "$hash`n") Write-Host "::notice title=Pre-sign SHA-256::$hash" - name: Upload unsigned artifact uses: actions/upload-artifact@v3 with: name: hello-agent-unsigned-${{ github.sha }} path: | BuildOutput/hello-agent.exe BuildOutput/hello-agent.exe.presig.sha256 if-no-files-found: error # Short retention — superseded by the signed artifact within a few # minutes. Keeps the artifact store from filling up with # unsigned-and-therefore-not-shippable binaries. retention-days: 1 # ---- 2. SIGN on the Linux LXC --------------------------------------------- # Runs on the dedicated signing host provisioned by ci/runners/linux/provision.sh. # Has access to /etc/pki/hello-agent/{chain.pem,codesign.key}; doesn't have # a build toolchain. The runner can sign — it can't produce a binary from # source. sign-x64: name: sign-hello-agent-x64 needs: build-x64 runs-on: [self-hosted, linux, signing] timeout-minutes: 10 env: PKI_DIR: /etc/pki/hello-agent steps: - name: Download unsigned artifact uses: actions/download-artifact@v3 with: name: hello-agent-unsigned-${{ github.sha }} path: ./incoming - name: Check signing-cert expiry # Fail HARD if the cert expires within 30 days; warn at 60. Without # this, an expired cert silently produces a signature that's invalid # at customer install time (and lifetimeSigning isn't set, but # signtool still rejects expired-at-sign-time leaves). run: | end="$(openssl x509 -in "$PKI_DIR/chain.pem" -noout -enddate | cut -d= -f2)" end_epoch="$(date -d "$end" +%s)" now_epoch="$(date +%s)" days_left=$(( (end_epoch - now_epoch) / 86400 )) echo "Signing cert NotAfter: $end ($days_left days)" if [ "$days_left" -lt 30 ]; then echo "::error title=Signing cert expiring::$days_left days left — rotate before next sign" exit 1 elif [ "$days_left" -lt 60 ]; then echo "::warning title=Signing cert::$days_left days left — schedule rotation" fi - name: Verify pre-sign hash matches received bytes # Pre-sign hash check: confirms the file we're about to sign matches # what the build runner emitted. A mismatch means corruption or # tampering between jobs — abort, do not sign. # # `tr -d '\r' | tr A-Z a-z` defensively normalizes the expected # value: `awk '{print $1}'` strips the trailing LF (its RS) but NOT # CR, and PowerShell's default Out-File writes CRLF on Windows — # so without -d '\r' a CRLF-encoded sha256 file ends up with $1 = # "\r", which compares unequal to the actual sha256sum output # and renders as visually identical in logs (the \r is a no-op in # most terminals). Lowercasing covers a similar case-mismatch trap. # The build job already writes LF + lowercase, but the verifier # shouldn't trust that contract. run: | expected="$(tr -d '\r' < incoming/hello-agent.exe.presig.sha256 | awk '{print $1}' | tr 'A-Z' 'a-z')" actual="$(sha256sum incoming/hello-agent.exe | awk '{print $1}')" if [ "$expected" != "$actual" ]; then echo "::error title=Pre-sign hash mismatch::artifact corrupted or tampered" echo "expected=$expected" echo "actual=$actual" exit 1 fi echo "Pre-sign SHA-256 OK: $actual" - name: Authenticode sign (osslsigncode, SHA-256, RFC 3161) # -h sha256: file digest. -ts: RFC 3161 timestamp (NOT -t which is # legacy Authenticode timestamp). -n / -i: cosmetic, shown in the # UAC prompt and "signtool verify /v" output. run: | mkdir -p signed osslsigncode sign \ -certs "$PKI_DIR/chain.pem" \ -key "$PKI_DIR/codesign.key" \ -h sha256 \ -ts "$TIMESTAMP_URL" \ -n "$SIGN_DESCRIPTION" \ -i "$SIGN_INFO_URL" \ -in incoming/hello-agent.exe \ -out signed/hello-agent.exe - name: Verify signature (osslsigncode self-check) run: osslsigncode verify -in signed/hello-agent.exe - name: Record post-sign hash run: | h="$(sha256sum signed/hello-agent.exe | awk '{print $1}')" echo "$h" > signed/hello-agent.exe.signed.sha256 echo "::notice title=Post-sign SHA-256::$h" - name: Stage signed bundle for upload # upload-artifact@v3 stores files using paths relative to the # *most root common directory* of all matched paths. Mixing # `signed/...` and `incoming/...` makes that common directory the # workflow root, so the artifact ends up containing `signed/file` # and `incoming/file` — and `download-artifact@v3 path: ./signed` # then double-nests it as `./signed/signed/file`. Flatten into a # single staging dir so the artifact has a flat layout that # extracts to `./signed/file` cleanly on the validate runner. run: | mkdir -p bundle cp signed/hello-agent.exe bundle/ cp signed/hello-agent.exe.signed.sha256 bundle/ cp incoming/hello-agent.exe.presig.sha256 bundle/ - name: Upload signed artifact uses: actions/upload-artifact@v3 with: name: hello-agent-signed-${{ github.sha }} path: bundle/ if-no-files-found: error retention-days: 90 # ---- 3. VALIDATE on Windows ----------------------------------------------- # Trust-but-verify: osslsigncode produces signatures that should validate # on Windows, but "should" isn't "did". This job is the cross-OS smoke # test — a Windows runner running CryptoAPI's verifier against the same # bytes the customer will see. Catches the rare cases where Linux thinks # a sig is fine but Windows rejects it. validate-x64: name: validate-hello-agent-x64 needs: [build-x64, sign-x64] runs-on: [self-hosted, windows-10] timeout-minutes: 10 steps: - name: Download signed artifact uses: actions/download-artifact@v3 with: name: hello-agent-signed-${{ github.sha }} path: ./signed - name: Verify Authenticode signature (Windows CryptoAPI) shell: pwsh run: | # signtool ships with Windows Kits; version dir varies. Find the # newest x64 build present rather than hardcoding 10.0.22621.0. $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" ` -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue ` | Where-Object { $_.FullName -match '\\x64\\' } ` | Sort-Object FullName -Descending ` | Select-Object -First 1 -ExpandProperty FullName if (-not $signtool) { throw "signtool.exe not found in any Windows Kits 10 bin\*\x64\" } Write-Host "Using signtool: $signtool" & $signtool verify /pa /v signed\hello-agent.exe if ($LASTEXITCODE -ne 0) { throw "signtool verify failed ($LASTEXITCODE)" } $sig = Get-AuthenticodeSignature signed\hello-agent.exe if ($sig.Status -ne 'Valid') { throw "AuthenticodeSignature.Status = $($sig.Status); $($sig.StatusMessage)" } if (-not $sig.TimeStamperCertificate) { throw "no RFC 3161 timestamp present — would expire with the cert" } Write-Host "" Write-Host "Signed by: $($sig.SignerCertificate.Subject)" Write-Host "Cert expires: $($sig.SignerCertificate.NotAfter.ToString('u'))" Write-Host "Timestamp: $($sig.TimeStamperCertificate.Subject)" Write-Host "TS expires: $($sig.TimeStamperCertificate.NotAfter.ToString('u'))" - name: Stage final release artifact shell: pwsh env: VERSION_TAG: ${{ needs.build-x64.outputs.version_tag }} # Final artifact is named with the canonical "hello-agent--x86_64.exe" # form so it lands in MDM / release storage with a stable, versioned # filename rather than a generic "hello-agent.exe". run: | if (-not $env:VERSION_TAG) { throw "build-x64 did not export version_tag" } New-Item -ItemType Directory -Force -Path .\SignOutput | Out-Null $final = "hello-agent-$env:VERSION_TAG-x86_64.exe" Copy-Item -Force signed\hello-agent.exe ".\SignOutput\$final" $h = Get-FileHash ".\SignOutput\$final" -Algorithm SHA256 $hash = $h.Hash.ToLower() # ` \n` is the standard `sha256sum -c` format — # lowercase hex, two spaces, filename, LF terminator. Out-File # would write CRLF; WriteAllText with explicit "`n" matches what # GNU coreutils emits, so `sha256sum -c` works without complaint. [IO.File]::WriteAllText( "$PWD\SignOutput\$final.sha256", "$hash $final`n") Write-Host "::notice title=Release SHA-256::$hash $final" - name: Upload final release artifact uses: actions/upload-artifact@v3 with: name: hello-agent-windows-x64-${{ github.sha }} path: SignOutput/hello-agent-*.exe* if-no-files-found: error retention-days: 90