ci(build): run PR installer builds only with needs: build label #38
Workflow file for this run
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 | |
| # Reusable build workflow. `release.yml` calls it with `sign: true` to produce | |
| # the signed/notarized installers it publishes; PRs labeled `needs: build` and | |
| # manual dispatches build the SAME installers UNSIGNED as run artifacts. | |
| # Every signing step and the protected `release` environment are gated on | |
| # `inputs.sign`, so an unsigned run needs no secrets at all — fork PRs (which | |
| # get none) build fine. | |
| on: | |
| workflow_call: | |
| inputs: | |
| sign: | |
| description: Sign and notarize the installers (release only). | |
| type: boolean | |
| required: false | |
| default: false | |
| outputs: | |
| # The caller's publish gate ships the macOS DMG even when a Windows/Linux | |
| # leg failed, so it needs each leg's result — a single reusable-workflow | |
| # job result would be `failure` if any platform failed, collapsing that | |
| # graceful degradation. Sourced from the always()-running `results` job. | |
| macos_result: | |
| description: Result of the macOS build job. | |
| value: ${{ jobs.results.outputs.macos }} | |
| windows_result: | |
| description: Result of the Windows EXE build job. | |
| value: ${{ jobs.results.outputs.windows }} | |
| windows_msi_result: | |
| description: Result of the Windows MSI build job. | |
| value: ${{ jobs.results.outputs.windows_msi }} | |
| linux_result: | |
| description: Result of the Linux packages build job. | |
| value: ${{ jobs.results.outputs.linux }} | |
| # PR builds are opt-in: add the `needs: build` label to produce unsigned | |
| # installers for testing. `closed` / unlabel cancel an in-flight run via | |
| # concurrency (jobs below no-op on those actions). | |
| pull_request: | |
| types: [opened, synchronize, reopened, labeled, unlabeled, closed] | |
| workflow_dispatch: | |
| inputs: | |
| sign: | |
| description: Sign and notarize (needs the release-environment secrets). | |
| type: boolean | |
| default: false | |
| concurrency: | |
| # PR number keeps the group stable across open/sync/label/close — on a | |
| # merged close, github.ref becomes the base branch, which would not match | |
| # the in-flight PR run. Non-PR events fall back to ref+event. | |
| group: build-${{ github.event.pull_request.number || format('{0}-{1}', github.ref, github.event_name) }} | |
| # Cancel only when this event should supersede a PR build. Unrelated | |
| # label add/remove must not kill an in-flight installer build. | |
| cancel-in-progress: "${{ github.event_name == 'pull_request' && (github.event.action == 'synchronize' || github.event.action == 'reopened' || github.event.action == 'closed' || (github.event.action == 'labeled' && github.event.label.name == 'needs: build') || (github.event.action == 'unlabeled' && github.event.label.name == 'needs: build')) }}" | |
| env: | |
| CARGO_TERM_COLOR: always | |
| CARGO_INCREMENTAL: 0 | |
| MACOSX_DEPLOYMENT_TARGET: "13.0" | |
| DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer | |
| jobs: | |
| # Opt-in gate for PR builds. Non-PR triggers (workflow_call / dispatch) | |
| # always pass. closed/unlabeled still start a run so concurrency can cancel | |
| # the previous one, but this job fails the gate and all build jobs skip. | |
| should-build: | |
| name: Should build | |
| runs-on: ubuntu-latest | |
| outputs: | |
| run: ${{ steps.gate.outputs.run }} | |
| steps: | |
| - id: gate | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| EVENT_ACTION: ${{ github.event.action }} | |
| HAS_BUILD_LABEL: "${{ contains(github.event.pull_request.labels.*.name, 'needs: build') }}" | |
| EVENT_LABEL: ${{ github.event.label.name }} | |
| run: | | |
| if [ "$EVENT_NAME" != "pull_request" ]; then | |
| echo "run=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$EVENT_ACTION" = "closed" ] || [ "$EVENT_ACTION" = "unlabeled" ]; then | |
| echo "run=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$EVENT_ACTION" = "labeled" ] && [ "$EVENT_LABEL" != "needs: build" ]; then | |
| echo "run=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if [ "$HAS_BUILD_LABEL" = "true" ]; then | |
| echo "run=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "run=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| macos: | |
| name: macOS DMG (${{ matrix.arch }}) | |
| needs: should-build | |
| if: ${{ needs.should-build.outputs.run == 'true' }} | |
| runs-on: ${{ matrix.runner }} | |
| # Bound a stuck brew/linker/notarize hang so it can't hold a PR build slot | |
| # for the 6-hour runner default (the signed path also notarizes). | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: arm64 | |
| runner: macos-latest | |
| - arch: x86_64 | |
| runner: macos-15-intel | |
| permissions: | |
| contents: read | |
| env: | |
| OPENLOGI_BUNDLE_ASSETS: ${{ vars.OPENLOGI_BUNDLE_ASSETS }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Verify runner architecture | |
| run: test "$(uname -m)" = "${{ matrix.arch }}" | |
| - name: Load release secrets from 1Password | |
| id: load_secrets | |
| if: ${{ inputs.sign }} | |
| uses: 1password/load-secrets-action@v4 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.OP_APPLE_SECRET_ITEM }}/APPLE_SIGNING_IDENTITY | |
| APPLE_CERTIFICATE: ${{ secrets.OP_APPLE_SECRET_ITEM }}/APPLE_CERTIFICATE | |
| APPLE_CERTIFICATE_PASSWORD: ${{ secrets.OP_APPLE_SECRET_ITEM }}/APPLE_CERTIFICATE_PASSWORD | |
| APPLE_ID: ${{ secrets.OP_APPLE_SECRET_ITEM }}/APPLE_ID | |
| APPLE_PASSWORD: ${{ secrets.OP_APPLE_SECRET_ITEM }}/APPLE_PASSWORD | |
| APPLE_TEAM_ID: ${{ secrets.OP_APPLE_SECRET_ITEM }}/APPLE_TEAM_ID | |
| GITHUB_APP_ID: ${{ secrets.OP_GITHUB_APP_ITEM }}/GITHUB_APP_ID | |
| GITHUB_APP_PRIVATE_KEY: ${{ secrets.OP_GITHUB_APP_ITEM }}/GITHUB_APP_PRIVATE_KEY | |
| OPENLOGI_UPDATE_BASE_URL: ${{ secrets.OP_R2_SECRET_ITEM }}/OPENLOGI_UPDATE_BASE_URL | |
| OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY: ${{ secrets.OP_R2_SECRET_ITEM }}/OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY | |
| - name: Install packaging tools | |
| run: | | |
| brew install librsvg | |
| brew install create-dmg | |
| - name: Validate release secrets | |
| if: ${{ inputs.sign }} | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ steps.load_secrets.outputs.APPLE_SIGNING_IDENTITY }} | |
| APPLE_CERTIFICATE: ${{ steps.load_secrets.outputs.APPLE_CERTIFICATE }} | |
| APPLE_CERTIFICATE_PASSWORD: ${{ steps.load_secrets.outputs.APPLE_CERTIFICATE_PASSWORD }} | |
| APPLE_ID: ${{ steps.load_secrets.outputs.APPLE_ID }} | |
| APPLE_PASSWORD: ${{ steps.load_secrets.outputs.APPLE_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ steps.load_secrets.outputs.APPLE_TEAM_ID }} | |
| GITHUB_APP_ID: ${{ steps.load_secrets.outputs.GITHUB_APP_ID }} | |
| GITHUB_APP_PRIVATE_KEY: ${{ steps.load_secrets.outputs.GITHUB_APP_PRIVATE_KEY }} | |
| OPENLOGI_UPDATE_BASE_URL: ${{ steps.load_secrets.outputs.OPENLOGI_UPDATE_BASE_URL }} | |
| OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY: ${{ steps.load_secrets.outputs.OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY }} | |
| run: | | |
| set -euo pipefail | |
| : "${APPLE_SIGNING_IDENTITY:?Configure APPLE_SIGNING_IDENTITY in 1Password}" | |
| : "${APPLE_CERTIFICATE:?Configure APPLE_CERTIFICATE in 1Password}" | |
| : "${APPLE_CERTIFICATE_PASSWORD:?Configure APPLE_CERTIFICATE_PASSWORD in 1Password}" | |
| : "${APPLE_ID:?Configure APPLE_ID in 1Password}" | |
| : "${APPLE_PASSWORD:?Configure APPLE_PASSWORD in 1Password}" | |
| : "${APPLE_TEAM_ID:?Configure APPLE_TEAM_ID in 1Password}" | |
| : "${GITHUB_APP_ID:?Configure GITHUB_APP_ID in 1Password}" | |
| : "${GITHUB_APP_PRIVATE_KEY:?Configure GITHUB_APP_PRIVATE_KEY in 1Password}" | |
| : "${OPENLOGI_UPDATE_BASE_URL:?Configure OPENLOGI_UPDATE_BASE_URL in 1Password}" | |
| : "${OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY:?Configure OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY in 1Password}" | |
| - name: Import Apple signing certificate | |
| if: ${{ inputs.sign }} | |
| uses: apple-actions/import-codesign-certs@v7 | |
| with: | |
| p12-file-base64: ${{ steps.load_secrets.outputs.APPLE_CERTIFICATE }} | |
| p12-password: ${{ steps.load_secrets.outputs.APPLE_CERTIFICATE_PASSWORD }} | |
| - name: Build OpenLogi.app with cargo-bundle | |
| env: | |
| OPENLOGI_UPDATE_BASE_URL: ${{ steps.load_secrets.outputs.OPENLOGI_UPDATE_BASE_URL }} | |
| OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY: ${{ steps.load_secrets.outputs.OPENLOGI_UPDATE_MINISIGN_PUBLIC_KEY }} | |
| run: | | |
| set -euo pipefail | |
| export OPENLOGI_UPDATE_MANIFEST_URL="${OPENLOGI_UPDATE_BASE_URL%/}/channels/stable/latest.json" | |
| cargo run -p xtask -- macos bundle | |
| - name: Sign app bundle | |
| if: ${{ inputs.sign }} | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ steps.load_secrets.outputs.APPLE_SIGNING_IDENTITY }} | |
| run: | | |
| set -euo pipefail | |
| app="target/release/bundle/osx/OpenLogi.app" | |
| helper="$app/Contents/Library/LoginItems/OpenLogiAgent.app" | |
| cli="$app/Contents/MacOS/openlogi" | |
| # Inside-out signing: seal the nested agent helper with its own | |
| # signature first, then the outer app (which seals the signed helper). | |
| # --deep is deprecated and can't give the helper an independent | |
| # signature, which a stable per-helper TCC identity — so the agent's | |
| # Accessibility grant survives app updates — requires. | |
| if [ -d "$helper" ]; then | |
| codesign --force --options runtime --timestamp \ | |
| --sign "$APPLE_SIGNING_IDENTITY" "$helper" | |
| fi | |
| # The embedded CLI is a second Mach-O under Contents/MacOS; sign it | |
| # with the hardened runtime before the outer app or notarization | |
| # rejects its as-built ad-hoc signature. | |
| if [ -f "$cli" ]; then | |
| codesign --force --options runtime --timestamp \ | |
| --sign "$APPLE_SIGNING_IDENTITY" "$cli" | |
| fi | |
| codesign --force --options runtime --timestamp \ | |
| --sign "$APPLE_SIGNING_IDENTITY" "$app" | |
| codesign --verify --strict --verbose=2 "$app" | |
| [ -d "$helper" ] && codesign --verify --strict --verbose=2 "$helper" | |
| [ -f "$cli" ] && codesign --verify --strict --verbose=2 "$cli" | |
| - name: Create and sign DMG | |
| if: ${{ inputs.sign }} | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ steps.load_secrets.outputs.APPLE_SIGNING_IDENTITY }} | |
| run: | | |
| set -euo pipefail | |
| cargo run -p xtask -- macos dmg --sign-identity "$APPLE_SIGNING_IDENTITY" | |
| # Unsigned PR/dispatch build: `--sign-identity` is optional, so the DMG is | |
| # produced without it (Gatekeeper will warn on open — fine for testing). | |
| - name: Create unsigned DMG | |
| if: ${{ !inputs.sign }} | |
| run: | | |
| set -euo pipefail | |
| cargo run -p xtask -- macos dmg | |
| - name: Notarize DMG | |
| if: ${{ inputs.sign }} | |
| env: | |
| APPLE_ID: ${{ steps.load_secrets.outputs.APPLE_ID }} | |
| APPLE_PASSWORD: ${{ steps.load_secrets.outputs.APPLE_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ steps.load_secrets.outputs.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| xcrun notarytool submit target/release/OpenLogi.dmg \ | |
| --apple-id "$APPLE_ID" \ | |
| --password "$APPLE_PASSWORD" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --wait | |
| - name: Staple DMG | |
| if: ${{ inputs.sign }} | |
| run: | | |
| set -euo pipefail | |
| xcrun stapler staple target/release/OpenLogi.dmg | |
| xcrun stapler validate target/release/OpenLogi.dmg | |
| - name: Collect DMG artifact | |
| run: | | |
| mkdir -p dist | |
| ref_name="${GITHUB_REF_NAME:-dev}" | |
| ref_name="${ref_name//\//-}" | |
| cp target/release/OpenLogi.dmg "dist/OpenLogi-${ref_name}-macos-${{ matrix.arch }}.dmg" | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: OpenLogi-macos-dmg-${{ matrix.arch }} | |
| path: dist/*.dmg | |
| # 'warn' (the default) would let an empty-dist job stay green while | |
| # `results.macos` reports success and the publish gate ships nothing — | |
| # same reasoning as the windows zip upload. | |
| if-no-files-found: error | |
| windows: | |
| name: Windows EXE (${{ matrix.arch }}) | |
| needs: should-build | |
| if: ${{ needs.should-build.outputs.run == 'true' }} | |
| # Both arches build on the x86_64 runner — arm64 as an MSVC cross-compile. | |
| # Native Arm runners are not an option: artifact-signing-action refuses to | |
| # run on them, while the signing itself is arch-agnostic over the PE file. | |
| runs-on: windows-2025 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: x86_64 | |
| target: x86_64-pc-windows-msvc | |
| - arch: arm64 | |
| target: aarch64-pc-windows-msvc | |
| # A hung signing/login call must become a normal failure (which publish | |
| # tolerates), not a 6-hour stall: publish waits for this job to reach a | |
| # terminal state before its own gate is even evaluated. The GUI release | |
| # build (thin LTO, codegen-units=1, the whole GPUI stack) dominates the | |
| # runtime on the 4-core runner, hence a looser budget than the ~8-minute | |
| # CLI-only runs needed. | |
| timeout-minutes: 60 | |
| env: | |
| # Static CRT: a bare exe must not depend on the VC++ Redistributable — | |
| # the v0.6.6 exe died on clean machines with "VCRUNTIME140.dll was not | |
| # found". The explicit --target below keeps the flag off host build | |
| # scripts, which RUSTFLAGS would otherwise also reach. | |
| RUSTFLAGS: -C target-feature=+crt-static | |
| permissions: | |
| # Mint an OIDC token so azure/login can exchange it for an Azure access token | |
| # via a federated credential — no client secret is stored in GitHub. | |
| id-token: write | |
| contents: read | |
| # The federated credential's subject is bound to this GitHub environment, so the | |
| # OIDC `sub` claim stays stable (repo:AprilNEA/OpenLogi:environment:release) | |
| # instead of changing with every tag ref. Only signed (release) runs enter it; | |
| # an unsigned run needs no secrets, so it takes no environment (fork PRs, which | |
| # can't access a protected environment at all, then build fine). | |
| environment: ${{ inputs.sign && 'release' || '' }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: ${{ matrix.target }} | |
| # No rust-cache here: this workflow runs only on tag refs, whose saved | |
| # caches no future tag can restore (and no default-branch job produces a | |
| # matching key), so the save was pure overhead churning the cache quota. | |
| # Azure signing config lives in 1Password, matching the macOS job's secret flow. | |
| # load-secrets-action is a node action, so it runs on Windows runners too. The | |
| # client/tenant/subscription IDs are not secrets under OIDC, but are centralised | |
| # here so all release config has a single source of truth. | |
| - name: Load Azure signing config from 1Password | |
| id: azure_config | |
| if: ${{ inputs.sign }} | |
| uses: 1password/load-secrets-action@v4 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| AZURE_CLIENT_ID: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_CLIENT_ID | |
| AZURE_TENANT_ID: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_TENANT_ID | |
| AZURE_SUBSCRIPTION_ID: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_SUBSCRIPTION_ID | |
| AZURE_SIGNING_ENDPOINT: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_SIGNING_ENDPOINT | |
| AZURE_SIGNING_ACCOUNT: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_SIGNING_ACCOUNT | |
| AZURE_CERT_PROFILE: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_CERT_PROFILE | |
| # load-secrets-action silently skips values that aren't op:// references | |
| # (e.g. when OP_AZURE_SECRET_ITEM is unset) and emits empty outputs for | |
| # empty fields — without this gate the job only fails minutes later, in | |
| # azure/login, with an error that never names the real cause. | |
| - name: Validate Azure signing config | |
| if: ${{ inputs.sign }} | |
| shell: bash | |
| env: | |
| AZURE_CLIENT_ID: ${{ steps.azure_config.outputs.AZURE_CLIENT_ID }} | |
| AZURE_TENANT_ID: ${{ steps.azure_config.outputs.AZURE_TENANT_ID }} | |
| AZURE_SUBSCRIPTION_ID: ${{ steps.azure_config.outputs.AZURE_SUBSCRIPTION_ID }} | |
| AZURE_SIGNING_ENDPOINT: ${{ steps.azure_config.outputs.AZURE_SIGNING_ENDPOINT }} | |
| AZURE_SIGNING_ACCOUNT: ${{ steps.azure_config.outputs.AZURE_SIGNING_ACCOUNT }} | |
| AZURE_CERT_PROFILE: ${{ steps.azure_config.outputs.AZURE_CERT_PROFILE }} | |
| run: | | |
| set -euo pipefail | |
| : "${AZURE_CLIENT_ID:?Configure AZURE_CLIENT_ID in the Azure 1Password item}" | |
| : "${AZURE_TENANT_ID:?Configure AZURE_TENANT_ID in the Azure 1Password item}" | |
| : "${AZURE_SUBSCRIPTION_ID:?Configure AZURE_SUBSCRIPTION_ID in the Azure 1Password item}" | |
| : "${AZURE_SIGNING_ENDPOINT:?Configure AZURE_SIGNING_ENDPOINT in the Azure 1Password item}" | |
| : "${AZURE_SIGNING_ACCOUNT:?Configure AZURE_SIGNING_ACCOUNT in the Azure 1Password item}" | |
| : "${AZURE_CERT_PROFILE:?Configure AZURE_CERT_PROFILE in the Azure 1Password item}" | |
| # No separate CLI artifact will ever ship on Windows (cargo install | |
| # covers that audience), so job and artifact names carry no GUI | |
| # qualifier. The GUI is a thin IPC client; the headless agent (ported in | |
| # #167) owns the device I/O, input hook, and IPC server, and the GUI | |
| # spawns a sibling `openlogi-agent.exe` when the pipe is absent — so the | |
| # agent MUST ship next to the GUI or the app connects to nothing (#347). | |
| - name: Build OpenLogi (GUI + agent) | |
| run: cargo build --release -p openlogi-gui -p openlogi-agent --target ${{ matrix.target }} | |
| # Federated (passwordless) login. Requires an app registration whose federated | |
| # credential subject is `repo:AprilNEA/OpenLogi:environment:release`, granted the | |
| # "Artifact Signing Certificate Profile Signer" role on the signing account. | |
| - name: Azure login (OIDC) | |
| if: ${{ inputs.sign }} | |
| uses: azure/login@v3 | |
| with: | |
| client-id: ${{ steps.azure_config.outputs.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ steps.azure_config.outputs.AZURE_TENANT_ID }} | |
| subscription-id: ${{ steps.azure_config.outputs.AZURE_SUBSCRIPTION_ID }} | |
| # artifact-signing-action installs its ArtifactSigning module from PSGallery, | |
| # which is intermittently missing on hosted windows runners (the action then | |
| # dies with "Unable to find repository 'PSGallery'" — actions/runner-images#13758). | |
| # That flake silently dropped the arm64 exe + msi from v0.6.14. Re-register the | |
| # default PSGallery so a mis-provisioned runner can't lose an artifact again. | |
| - name: Ensure PSGallery is registered | |
| if: ${{ inputs.sign }} | |
| shell: pwsh | |
| run: | | |
| if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { | |
| Register-PSRepository -Default | |
| } | |
| # Authenticode-signs via Artifact Signing (ex-Trusted Signing). The action only | |
| # runs on Windows runners. azure/login leaves an az CLI session that the action's | |
| # default AzureCliCredential picks up, so no azure-* secret inputs are needed. | |
| - name: Sign openlogi-gui.exe and openlogi-agent.exe with Artifact Signing | |
| if: ${{ inputs.sign }} | |
| uses: azure/artifact-signing-action@v2 | |
| with: | |
| endpoint: ${{ steps.azure_config.outputs.AZURE_SIGNING_ENDPOINT }} | |
| signing-account-name: ${{ steps.azure_config.outputs.AZURE_SIGNING_ACCOUNT }} | |
| certificate-profile-name: ${{ steps.azure_config.outputs.AZURE_CERT_PROFILE }} | |
| # `files` is a newline-separated list; one call signs both PE files. | |
| files: | | |
| ${{ github.workspace }}\target\${{ matrix.target }}\release\openlogi-gui.exe | |
| ${{ github.workspace }}\target\${{ matrix.target }}\release\openlogi-agent.exe | |
| # Timestamping is mandatory: without it the signature expires after | |
| # 3 days. The URL must stay http — SignTool rejects an https /tr up | |
| # front ("SignTool Error: Invalid Timestamp URL", v0.6.5) before | |
| # even contacting Azure. The RFC3161 response is itself signed, so | |
| # http transport does not weaken it. The file/timestamp digests stay | |
| # on the action's SHA256 defaults. | |
| timestamp-rfc3161: http://timestamp.acs.microsoft.com | |
| # Tag-only workflow: the action's four dependency caches save to | |
| # per-tag refs no future run can restore, so skip them. | |
| cache-dependencies: false | |
| - name: Verify the binaries are signed | |
| if: ${{ inputs.sign }} | |
| shell: pwsh | |
| run: | | |
| # The signing step above already fails on error; this guards against it | |
| # silently producing an unsigned or corrupted binary. NotSigned and | |
| # HashMismatch (bytes no longer match the signature) are trust-store | |
| # independent and always ship-blockers; NotTrusted/UnknownError depend | |
| # on the runner's trust store, so they stay log-only. | |
| foreach ($exe in 'openlogi-gui.exe', 'openlogi-agent.exe') { | |
| $sig = Get-AuthenticodeSignature "target\${{ matrix.target }}\release\$exe" | |
| $sig | Format-List | |
| if ($null -eq $sig.SignerCertificate -or | |
| "$($sig.Status)" -in 'NotSigned', 'HashMismatch') { | |
| Write-Error "$exe Authenticode signature is invalid: $($sig.Status)" | |
| exit 1 | |
| } | |
| } | |
| # The portable artifact is a zip of both exes: a bare exe has nowhere to | |
| # put the agent, and GUI-only can never work (#347). Inside the zip the | |
| # GUI carries the product name (`OpenLogi.exe`, matching the MSI layout) | |
| # and the agent keeps the exact name the GUI's sibling lookup expects. | |
| - name: Collect signed portable zip artifact | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path dist, stage | Out-Null | |
| Copy-Item target\${{ matrix.target }}\release\openlogi-gui.exe stage\OpenLogi.exe | |
| Copy-Item target\${{ matrix.target }}\release\openlogi-agent.exe stage\openlogi-agent.exe | |
| # Zipping after signing is safe: Authenticode lives in the PE files. | |
| $ref = $env:GITHUB_REF_NAME -replace '/', '-' | |
| Compress-Archive -Path stage\* -DestinationPath "dist\OpenLogi-$ref-windows-${{ matrix.arch }}.zip" | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: OpenLogi-windows-${{ matrix.arch }} | |
| path: dist/*.zip | |
| # 'warn' (the default) would let a no-zip job stay green, and publish | |
| # would then die at the by-name download instead of shipping DMG-only. | |
| if-no-files-found: error | |
| windows-msi: | |
| name: Windows MSI (${{ matrix.arch }}) | |
| runs-on: windows-2025 | |
| needs: [should-build, windows] | |
| # `needs` only orders the jobs; this gate runs the MSI legs even when the | |
| # windows matrix AGGREGATE failed. The by-name artifact download below | |
| # then fails exactly the leg whose exe never shipped, and the other arch | |
| # keeps going. Skip when the PR gate is off; `!cancelled()` alone would | |
| # still run when `windows` was skipped. | |
| if: ${{ !cancelled() && needs.should-build.outputs.run == 'true' }} | |
| # No Rust build here — dotnet tool install + wix build + signing. | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: x86_64 | |
| msi_platform: x64 | |
| - arch: arm64 | |
| msi_platform: arm64 | |
| permissions: | |
| # Same OIDC setup as the windows job — the MSI is signed with the same | |
| # Artifact Signing profile as the exe it wraps. | |
| id-token: write | |
| contents: read | |
| environment: ${{ inputs.sign && 'release' || '' }} | |
| steps: | |
| # Needed for packaging/windows/OpenLogi.wxs. | |
| - uses: actions/checkout@v6 | |
| # The portable zip from this arch's windows leg, containing the | |
| # already-signed OpenLogi.exe and openlogi-agent.exe. The MSI packages | |
| # exactly the bytes the portable artifact ships. | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: OpenLogi-windows-${{ matrix.arch }} | |
| path: signed-zip | |
| - name: Extract the signed exes | |
| shell: pwsh | |
| run: | | |
| $zip = Get-ChildItem signed-zip\*.zip | Select-Object -First 1 | |
| if ($null -eq $zip) { | |
| Write-Error "No portable zip found in signed-zip\ — nothing to package" | |
| exit 1 | |
| } | |
| Expand-Archive -Path $zip.FullName -DestinationPath signed-exe | |
| - name: Load Azure signing config from 1Password | |
| id: azure_config | |
| if: ${{ inputs.sign }} | |
| uses: 1password/load-secrets-action@v4 | |
| with: | |
| export-env: false | |
| env: | |
| OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} | |
| AZURE_CLIENT_ID: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_CLIENT_ID | |
| AZURE_TENANT_ID: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_TENANT_ID | |
| AZURE_SUBSCRIPTION_ID: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_SUBSCRIPTION_ID | |
| AZURE_SIGNING_ENDPOINT: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_SIGNING_ENDPOINT | |
| AZURE_SIGNING_ACCOUNT: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_SIGNING_ACCOUNT | |
| AZURE_CERT_PROFILE: ${{ secrets.OP_AZURE_SECRET_ITEM }}/AZURE_CERT_PROFILE | |
| - name: Validate Azure signing config | |
| if: ${{ inputs.sign }} | |
| shell: bash | |
| env: | |
| AZURE_CLIENT_ID: ${{ steps.azure_config.outputs.AZURE_CLIENT_ID }} | |
| AZURE_TENANT_ID: ${{ steps.azure_config.outputs.AZURE_TENANT_ID }} | |
| AZURE_SUBSCRIPTION_ID: ${{ steps.azure_config.outputs.AZURE_SUBSCRIPTION_ID }} | |
| AZURE_SIGNING_ENDPOINT: ${{ steps.azure_config.outputs.AZURE_SIGNING_ENDPOINT }} | |
| AZURE_SIGNING_ACCOUNT: ${{ steps.azure_config.outputs.AZURE_SIGNING_ACCOUNT }} | |
| AZURE_CERT_PROFILE: ${{ steps.azure_config.outputs.AZURE_CERT_PROFILE }} | |
| run: | | |
| set -euo pipefail | |
| : "${AZURE_CLIENT_ID:?Configure AZURE_CLIENT_ID in the Azure 1Password item}" | |
| : "${AZURE_TENANT_ID:?Configure AZURE_TENANT_ID in the Azure 1Password item}" | |
| : "${AZURE_SUBSCRIPTION_ID:?Configure AZURE_SUBSCRIPTION_ID in the Azure 1Password item}" | |
| : "${AZURE_SIGNING_ENDPOINT:?Configure AZURE_SIGNING_ENDPOINT in the Azure 1Password item}" | |
| : "${AZURE_SIGNING_ACCOUNT:?Configure AZURE_SIGNING_ACCOUNT in the Azure 1Password item}" | |
| : "${AZURE_CERT_PROFILE:?Configure AZURE_CERT_PROFILE in the Azure 1Password item}" | |
| - name: Install the WiX toolset | |
| # Pinned exactly: a WiX point release can silently change the produced | |
| # MSI (validation, cab layout), and nothing in CI installs the MSI, so | |
| # drift would only surface on end-user machines. Bump deliberately. | |
| # The Util extension (util:CloseApplication in OpenLogi.wxs) is pinned | |
| # to the same version — WiX extensions release in lockstep with wix. | |
| run: | | |
| dotnet tool install --global wix --version 6.0.2 | |
| wix extension add --global WixToolset.Util.wixext/6.0.2 | |
| - name: Build the MSI | |
| shell: pwsh | |
| run: | | |
| # ProductVersion must be a numeric x.y.z; non-tag dispatches build a | |
| # throwaway 0.0.0 package. | |
| if ($env:GITHUB_REF_NAME -match '^v(\d+\.\d+\.\d+)$') { | |
| $version = $Matches[1] | |
| } else { | |
| $version = '0.0.0' | |
| } | |
| # Selected by exact name, never by glob order: the zip carries two | |
| # exes, and Get-ChildItem's alphabetical sort would put the agent | |
| # first. Upstream invariants (upload's if-no-files-found: error, the | |
| # by-name download, the extract step's guard) should make the misses | |
| # unreachable; the guards turn a confusing "$null.FullName -> empty | |
| # -d" wix error into a named cause if those invariants ever shift. | |
| $gui = Get-Item signed-exe\OpenLogi.exe -ErrorAction SilentlyContinue | |
| $agent = Get-Item signed-exe\openlogi-agent.exe -ErrorAction SilentlyContinue | |
| if ($null -eq $gui -or $null -eq $agent) { | |
| Write-Error "signed-exe\ must contain OpenLogi.exe and openlogi-agent.exe — nothing to package" | |
| exit 1 | |
| } | |
| New-Item -ItemType Directory -Force -Path out | Out-Null | |
| wix build packaging\windows\OpenLogi.wxs ` | |
| -ext WixToolset.Util.wixext ` | |
| -arch ${{ matrix.msi_platform }} ` | |
| -d Version=$version ` | |
| -d ExeFile="$($gui.FullName)" ` | |
| -d AgentExeFile="$($agent.FullName)" ` | |
| -o out\OpenLogi.msi | |
| - name: Azure login (OIDC) | |
| if: ${{ inputs.sign }} | |
| uses: azure/login@v3 | |
| with: | |
| client-id: ${{ steps.azure_config.outputs.AZURE_CLIENT_ID }} | |
| tenant-id: ${{ steps.azure_config.outputs.AZURE_TENANT_ID }} | |
| subscription-id: ${{ steps.azure_config.outputs.AZURE_SUBSCRIPTION_ID }} | |
| # Same PSGallery flake guard as the exe job — see that step's comment. | |
| - name: Ensure PSGallery is registered | |
| if: ${{ inputs.sign }} | |
| shell: pwsh | |
| run: | | |
| if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { | |
| Register-PSRepository -Default | |
| } | |
| - name: Sign OpenLogi.msi with Artifact Signing | |
| if: ${{ inputs.sign }} | |
| uses: azure/artifact-signing-action@v2 | |
| with: | |
| endpoint: ${{ steps.azure_config.outputs.AZURE_SIGNING_ENDPOINT }} | |
| signing-account-name: ${{ steps.azure_config.outputs.AZURE_SIGNING_ACCOUNT }} | |
| certificate-profile-name: ${{ steps.azure_config.outputs.AZURE_CERT_PROFILE }} | |
| files: ${{ github.workspace }}\out\OpenLogi.msi | |
| # http on purpose — see the exe signing step. | |
| timestamp-rfc3161: http://timestamp.acs.microsoft.com | |
| cache-dependencies: false | |
| - name: Verify the MSI is signed | |
| if: ${{ inputs.sign }} | |
| shell: pwsh | |
| run: | | |
| $sig = Get-AuthenticodeSignature out\OpenLogi.msi | |
| $sig | Format-List | |
| # Same gate as the exe: NotSigned/HashMismatch are trust-store | |
| # independent ship-blockers; NotTrusted/UnknownError stay log-only. | |
| if ($null -eq $sig.SignerCertificate -or | |
| "$($sig.Status)" -in 'NotSigned', 'HashMismatch') { | |
| Write-Error "OpenLogi.msi Authenticode signature is invalid: $($sig.Status)" | |
| exit 1 | |
| } | |
| - name: Collect signed MSI artifact | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path dist | Out-Null | |
| # Renaming after signing is safe: the signature lives in the MSI. | |
| $ref = $env:GITHUB_REF_NAME -replace '/', '-' | |
| Copy-Item out\OpenLogi.msi "dist\OpenLogi-$ref-windows-${{ matrix.arch }}.msi" | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: OpenLogi-windows-msi-${{ matrix.arch }} | |
| path: dist/*.msi | |
| if-no-files-found: error | |
| linux-packages: | |
| name: Linux packages (${{ matrix.arch }}) | |
| needs: should-build | |
| if: ${{ needs.should-build.outputs.run == 'true' }} | |
| runs-on: ${{ matrix.runner }} | |
| # Bound a hang so it can't hold a PR build slot for the 6-hour runner | |
| # default; a cargo build + nfpm package is well under this. | |
| timeout-minutes: 30 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: amd64 | |
| runner: ubuntu-latest | |
| nfpm_sha256: "d6417f99d5fa32bba7a4e007084615d3897651498c2e443118c26b9ec3b698a8" | |
| - arch: arm64 | |
| runner: ubuntu-24.04-arm | |
| nfpm_sha256: "dc63aa7228ec70490bae67ad3146883055a055639dcf0dffc82fa965bac75a31" | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| # The nfpm `arch` and the runner's native arch must agree — the packages | |
| # are built natively, not cross-compiled, so a mismatched runner would | |
| # silently ship a wrongly-labelled binary. | |
| - name: Verify runner architecture | |
| env: | |
| EXPECTED_ARCH: ${{ matrix.arch }} | |
| run: | | |
| case "$(uname -m)" in | |
| x86_64) got=amd64 ;; | |
| aarch64) got=arm64 ;; | |
| *) echo "unexpected machine $(uname -m)" >&2; exit 1 ;; | |
| esac | |
| test "$got" = "$EXPECTED_ARCH" | |
| - name: Install system dependencies and nfpm | |
| env: | |
| NFPM_VERSION: "2.46.3" | |
| NFPM_ARCH: ${{ matrix.arch }} | |
| NFPM_SHA256: ${{ matrix.nfpm_sha256 }} | |
| run: | | |
| sudo apt-get update | |
| # The Linux packaging task builds openlogi-gui, which links GPUI's full | |
| # wayland / x11 stack — keep this in sync with ci.yml's Linux deps | |
| # (the proven set). The previous short list was missing | |
| # libxkbcommon-x11-dev / libwayland-dev / libx11-xcb-dev, so every | |
| # tagged Linux build failed to link (`-lxkbcommon-x11`) and took the | |
| # whole GitHub Release down with it. | |
| sudo apt-get install -y \ | |
| libudev-dev \ | |
| gcc g++ clang libfontconfig-dev libwayland-dev \ | |
| libxkbcommon-x11-dev libx11-xcb-dev \ | |
| libssl-dev libzstd-dev pkg-config | |
| curl -fsSLo /tmp/nfpm.deb \ | |
| "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${NFPM_ARCH}.deb" | |
| echo "${NFPM_SHA256} /tmp/nfpm.deb" | sha256sum -c | |
| sudo dpkg -i /tmp/nfpm.deb | |
| - name: Build and package | |
| run: cargo run -p xtask -- linux package | |
| - name: Collect packages | |
| env: | |
| PKG_ARCH: ${{ matrix.arch }} | |
| run: | | |
| mkdir -p dist | |
| ref_name="${GITHUB_REF_NAME:-dev}" | |
| ref_name="${ref_name//\//-}" | |
| for pkg in target/release/*.deb target/release/*.rpm; do | |
| [ -f "$pkg" ] || continue | |
| ext="${pkg##*.}" | |
| cp "$pkg" "dist/openlogi-${ref_name}-linux-${PKG_ARCH}.${ext}" | |
| done | |
| # .pkg.tar.zst has a compound extension; handle it separately. | |
| for pkg in target/release/*.pkg.tar.zst; do | |
| [ -f "$pkg" ] || continue | |
| cp "$pkg" "dist/openlogi-${ref_name}-linux-${PKG_ARCH}.pkg.tar.zst" | |
| done | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: OpenLogi-linux-packages-${{ matrix.arch }} | |
| path: dist/* | |
| # Single owner of the GitHub Release. softprops/action-gh-release@v3 creates the | |
| # release as a draft, uploads every asset to that (mutable) draft, then publishes | |
| # it as its final step — so the release only becomes immutable once all DMGs and | |
| # checksums are attached. The static updater assets are pushed to R2 *before* that | |
| # softprops call, so the GitHub Release is the last thing published. | |
| # Surfaces each platform's result as a workflow output so the caller's publish | |
| # gate can ship a partial release (e.g. the macOS DMG even when a Windows leg | |
| # failed). The reusable workflow's own job result would be `failure` if any leg | |
| # failed, collapsing that graceful degradation. `always()` so the outputs are | |
| # set regardless of which legs failed. | |
| results: | |
| name: Build results | |
| if: ${{ always() }} | |
| needs: [should-build, macos, windows, windows-msi, linux-packages] | |
| runs-on: ubuntu-latest | |
| outputs: | |
| macos: ${{ needs.macos.result }} | |
| windows: ${{ needs.windows.result }} | |
| windows_msi: ${{ needs['windows-msi'].result }} | |
| linux: ${{ needs['linux-packages'].result }} | |
| steps: | |
| - name: Summarize | |
| run: | | |
| echo "macos=${{ needs.macos.result }}" | |
| echo "windows=${{ needs.windows.result }}" | |
| echo "windows-msi=${{ needs['windows-msi'].result }}" | |
| echo "linux-packages=${{ needs['linux-packages'].result }}" |