Build Electron App #77
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 Electron App | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| inputs: | |
| arch: | |
| description: "macOS architecture to build" | |
| required: true | |
| default: "both" | |
| type: choice | |
| options: | |
| - arm64 | |
| - x64 | |
| - both | |
| release_tag: | |
| description: "Optional release tag to create or update, e.g. v1.5.0" | |
| required: false | |
| type: string | |
| permissions: | |
| contents: write | |
| concurrency: | |
| group: build-${{ github.ref_name }}-${{ github.event.inputs.release_tag || 'artifacts' }} | |
| cancel-in-progress: false | |
| jobs: | |
| build-windows: | |
| name: Windows installer | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: ./.github/actions/setup | |
| # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token | |
| # timestamps); no VAD model is fetched here. The binary is built by | |
| # build-whisper-stt.yml and staged below — without that step the installer | |
| # ships without speech-to-text. See | |
| # technical-documentation/architecture/transcription-and-captions.md. | |
| - name: Stage whisper-stt binaries | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: bash scripts/stage-whisper-stt.sh win32-x64 | |
| - name: Build Windows app | |
| run: npm run build:win -- --publish never | |
| - name: Upload Windows installer | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: openscreen-windows | |
| # latest.yml is the update feed electron-updater reads; the .blockmap is what lets it | |
| # download a delta instead of the full ~243 MB installer. Both were already produced | |
| # by every build and thrown away here, because this glob only matched the .exe. | |
| path: | | |
| release/**/Openscreen.Setup.*.exe | |
| release/**/Openscreen.Setup.*.exe.blockmap | |
| release/**/latest.yml | |
| if-no-files-found: error | |
| retention-days: 30 | |
| # `if-no-files-found: error` evaluates the UNION of the globs above, so a dead pattern | |
| # among live ones never fails — that is exactly how the *.zsync glob rotted unnoticed on | |
| # the Linux job. Assert the update feed specifically. | |
| - name: Verify the update feed was produced | |
| shell: bash | |
| run: | | |
| test -f "$(find release -name latest.yml | head -1)" \ | |
| || { echo "::error::latest.yml missing — electron-updater has no feed to read"; exit 1; } | |
| test -f "$(find release -name 'Openscreen.Setup.*.exe.blockmap' | head -1)" \ | |
| || { echo "::error::blockmap missing — differential updates would silently degrade"; exit 1; } | |
| build-windows-store: | |
| name: Windows Store package | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: ./.github/actions/setup | |
| - name: Stage whisper-stt binaries | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: bash scripts/stage-whisper-stt.sh win32-x64 | |
| - name: Build Windows Store package | |
| run: npm run build:win:store -- --publish never | |
| # Store certification rejected 1.9.0 under 10.1.1.11 "On Device Tiles" because | |
| # the package carried electron-builder's vendored placeholder tiles: it reads | |
| # them from build/appx/ and, when a name is missing there, silently substitutes | |
| # a blank SampleAppx.*.png instead of failing. Nothing in the build output says | |
| # so — the only way to know is to look inside the package. | |
| # | |
| # The expected filenames come from the generator (`--list`), NOT from build/appx/. | |
| # Deriving them from the directory would defeat the check at the exact moment it | |
| # matters: delete an asset there and it drops out of the expected set too, so the | |
| # loop passes while electron-builder quietly packages a placeholder under that | |
| # name. The generator is the authority; build/appx/ is the artifact being checked. | |
| - name: Verify Store tiles are in the package | |
| shell: pwsh | |
| run: | | |
| $expected = @(node scripts/generate-appx-assets.mjs --list) | |
| if ($LASTEXITCODE -ne 0 -or $expected.Count -eq 0) { throw "generator produced no asset list" } | |
| $problems = @() | |
| # First: the committed directory must match the generator exactly. A deleted or | |
| # hand-added PNG is caught here, before it can reach the package. | |
| $committed = @(Get-ChildItem build/appx -Filter *.png | ForEach-Object { $_.Name }) | |
| foreach ($name in $expected) { | |
| if ($committed -notcontains $name) { $problems += "missing from build/appx: $name" } | |
| } | |
| foreach ($name in $committed) { | |
| if ($expected -notcontains $name) { $problems += "unexpected file in build/appx (run npm run assets:appx): $name" } | |
| } | |
| # Then: every expected asset must be in the package, byte-identical. | |
| $appx = Get-ChildItem release -Recurse -Filter *.appx | Select-Object -First 1 | |
| if (-not $appx) { throw "no .appx found under release/" } | |
| Add-Type -AssemblyName System.IO.Compression.FileSystem | |
| $archive = [System.IO.Compression.ZipFile]::OpenRead($appx.FullName) | |
| try { | |
| # OPC parts are "/" separated, but normalise anyway rather than trust it. | |
| $entries = @{} | |
| foreach ($e in $archive.Entries) { $entries[$e.FullName.Replace("\", "/")] = $e } | |
| $sha = [System.Security.Cryptography.SHA256]::Create() | |
| foreach ($name in $expected) { | |
| $entry = $entries["assets/$name"] | |
| if (-not $entry) { $problems += "missing from package: $name"; continue } | |
| $stream = $entry.Open() | |
| try { $packaged = [BitConverter]::ToString($sha.ComputeHash($stream)) } | |
| finally { $stream.Dispose() } | |
| $sourceFile = "build/appx/$name" | |
| if (-not (Test-Path $sourceFile)) { continue } | |
| $source = [BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes((Resolve-Path $sourceFile)))) | |
| if ($packaged -ne $source) { $problems += "packaged copy differs from build/appx: $name" } | |
| } | |
| } | |
| finally { $archive.Dispose() } | |
| if ($problems) { | |
| $problems | ForEach-Object { Write-Output "::error::$_" } | |
| throw "$($problems.Count) tile asset problem(s) in $($appx.Name)" | |
| } | |
| Write-Output "$($expected.Count) tile assets present in $($appx.Name), byte-identical to build/appx/" | |
| # Twice running, a release shipped a dependency that could not be resolved on | |
| # the target machine, and both times someone outside the project found it: | |
| # 1.9.0's compositor addon was reached through PATH, which MSIX ignores, and | |
| # 1.9.1's capture helper needed the Visual C++ Redistributable, which is not | |
| # part of Windows. Each fix arrived with a guard aimed at the failure already | |
| # understood, and neither guard would have caught the other. | |
| # | |
| # This step is the one that generalises: it registers the package and asks the | |
| # Windows loader to resolve every shipped binary for real. It deliberately does | |
| # not record anything — a runner has no useful GPU or desktop session, and a | |
| # flaky gate gets switched off. The loader is what broke both times. | |
| # | |
| # `powershell`, not `pwsh`: the Appx module is not loaded natively in | |
| # PowerShell 7 and needs -UseWindowsPowerShell to work at all. | |
| - name: Verify native binaries load under package identity | |
| shell: powershell | |
| run: | | |
| $ErrorActionPreference = "Stop" | |
| # Loose registration of an unsigned package needs Developer Mode. The runner | |
| # is discarded after the job, so enabling it here costs nothing; the script | |
| # itself refuses to touch this, because on a real machine it is the owner's | |
| # setting to make. | |
| $key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" | |
| New-Item -Path $key -Force | Out-Null | |
| Set-ItemProperty -Path $key -Name AllowDevelopmentWithoutDevLicense -Value 1 -Type DWord | |
| $appx = Get-ChildItem release -Recurse -Filter *.appx | Select-Object -First 1 | |
| if (-not $appx) { throw "no .appx found under release/" } | |
| # Explicit exit rather than letting the error surface on its own: a | |
| # terminating error does not set $LASTEXITCODE, and the runner's epilogue | |
| # only inspects that. Trusting it would let a failed verification report a | |
| # green step, which is the exact shape of bug this job exists to stop. | |
| try { | |
| & "$env:GITHUB_WORKSPACE\scripts\verify-appx-native.ps1" -Appx $appx.FullName | |
| } | |
| catch { | |
| Write-Output "::error::$($_.Exception.Message)" | |
| exit 1 | |
| } | |
| - name: Upload Windows Store package | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: openscreen-windows-store | |
| path: release/**/Openscreen.Setup.*.appx | |
| if-no-files-found: error | |
| retention-days: 30 | |
| build-macos: | |
| name: macOS ${{ matrix.arch }} DMG | |
| # Build each arch NATIVELY. `macos-latest` is Apple Silicon, and everything the | |
| # 1.8.0 macOS path added keys off the HOST arch: fetch-ffmpeg-macos.mjs configures | |
| # with `--arch=${process.arch}`, and build-macos-compositor-addon.mjs installs into | |
| # `darwin-${process.arch}` and runs cargo without `--target`. So the x64 job on an | |
| # arm64 runner produced arm64 output in darwin-arm64/, and packaging then failed | |
| # with "Refusing to package an incomplete macOS payload — looked in darwin-x64". | |
| # v1.7.0 shipped an x64 DMG because it had neither the compositor addon nor a | |
| # vendored ffmpeg to build; both arrived with 1.8.0 and nobody could see the | |
| # breakage while this job sat behind `if: false`. | |
| # Running x64 on an Intel runner fixes it without threading a target arch through | |
| # ffmpeg's configure, cargo and the output paths — four blind changes on a release | |
| # branch, none of them testable without a Mac. | |
| runs-on: ${{ matrix.arch == 'x64' && 'macos-15-intel' || 'macos-latest' }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| arch: ${{ fromJSON((github.event_name == 'workflow_dispatch' && github.event.inputs.arch != 'both') && format('["{0}"]', github.event.inputs.arch) || '["arm64", "x64"]') }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: ./.github/actions/setup | |
| - name: Setup Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: "3.11" | |
| - name: Ensure sharp prebuilt | |
| run: npm rebuild sharp | |
| env: | |
| npm_config_build_from_source: "false" | |
| - name: Stage whisper-stt binaries | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: bash scripts/stage-whisper-stt.sh darwin-${{ matrix.arch }} | |
| - name: Resolve macOS signing | |
| id: signing | |
| env: | |
| MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} | |
| MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} | |
| MAC_CSC_NAME: ${{ secrets.MAC_CSC_NAME }} | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| if [[ -n "$MAC_CERTIFICATE_P12" && -n "$MAC_CERTIFICATE_PASSWORD" && -n "$MAC_CSC_NAME" && -n "$APPLE_ID" && -n "$APPLE_TEAM_ID" && -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then | |
| # `CSC_NAME` must name the identity WITHOUT its certificate type. | |
| # electron-builder picks the type itself and rejects a qualified name | |
| # outright: | |
| # | |
| # ⨯ Please remove prefix "Developer ID Application:" from the | |
| # specified name — appropriate certificate will be chosen | |
| # automatically | |
| # | |
| # It does that at `Package .app bundle`, which sits after the ffmpeg | |
| # build and the compositor addon — about twelve minutes in, and only | |
| # on macOS. Since the same secret also feeds `codesign --sign` at | |
| # `Sign DMG`, the mistake is easy to make: codesign accepts the full | |
| # common name, so the qualified form looks right until electron-builder | |
| # sees it. The short form satisfies both, because codesign matches on a | |
| # substring of the common name. | |
| case "$MAC_CSC_NAME" in | |
| # Every pattern ends at the colon on purpose, so a company whose | |
| # name merely starts with one of these words is not rejected. | |
| "Developer ID Application:"*|"Developer ID Installer:"*|"Apple Development:"*|"Apple Distribution:"*|"3rd Party Mac Developer Application:"*|"3rd Party Mac Developer Installer:"*) | |
| echo "::error::MAC_CSC_NAME carries a certificate-type prefix. Set it to the identity name alone, e.g. 'Jane Doe (AB12CD34EF)' rather than 'Developer ID Application: Jane Doe (AB12CD34EF)'. Read it from: security find-identity -v -p codesigning" | |
| exit 1 | |
| ;; | |
| esac | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Import code signing certificate | |
| if: steps.signing.outputs.enabled == 'true' | |
| env: | |
| MAC_CERTIFICATE_P12: ${{ secrets.MAC_CERTIFICATE_P12 }} | |
| MAC_CERTIFICATE_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} | |
| run: | | |
| KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" | |
| KEYCHAIN_PASSWORD="$(openssl rand -base64 32)" | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| echo "$MAC_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" | |
| security import "$RUNNER_TEMP/certificate.p12" \ | |
| -k "$KEYCHAIN_PATH" \ | |
| -P "$MAC_CERTIFICATE_PASSWORD" \ | |
| -T /usr/bin/codesign \ | |
| -T /usr/bin/security | |
| security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" | |
| security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') | |
| security find-identity -v -p codesigning "$KEYCHAIN_PATH" | |
| rm -f "$RUNNER_TEMP/certificate.p12" | |
| - name: Build Vite + Electron | |
| run: npx tsc && npx vite build | |
| - name: Build native macOS helpers | |
| run: npm run build:native:mac | |
| env: | |
| OPENSCREEN_MAC_HELPER_ARCHS: ${{ matrix.arch }} | |
| # The two steps below are what `npm run build:mac` does and this job did not. | |
| # Windows gets them for free because its job just runs `npm run build:win`, | |
| # which chains fetch:ffmpeg + build:native:compositor; macOS spells its steps | |
| # out (it needs `--dir` plus a hand-rolled DMG and signing) and drifted. The | |
| # result was a .app with no compositor addon — preview and export dead in the | |
| # installed app, silently. `scripts/before-pack.cjs` now refuses to package | |
| # that, so this is also what keeps the job from failing at the pack step. | |
| - name: Cache LGPL ffmpeg tree | |
| uses: actions/cache@v6 | |
| with: | |
| # fetch-ffmpeg-macos.mjs BUILDS ffmpeg from source (~5 min): BtbN ships no | |
| # macOS target and every circulating macOS build is GPL, which would | |
| # relicense this MIT app. The script pins the release and checksums it, so | |
| # keying on the script itself busts the cache when the pin moves. | |
| path: crates/thirdparty | |
| key: ffmpeg-macos-${{ matrix.arch }}-${{ hashFiles('scripts/fetch-ffmpeg-macos.mjs') }} | |
| - name: Vendor LGPL ffmpeg | |
| run: npm run fetch:ffmpeg:mac | |
| - name: Cache cargo + compositor build tree | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| crates/target | |
| key: cargo-macos-${{ matrix.arch }}-${{ hashFiles('crates/Cargo.lock') }} | |
| restore-keys: | | |
| cargo-macos-${{ matrix.arch }}- | |
| - name: Build Metal compositor addon | |
| run: npm run build:native:compositor:mac | |
| - name: Package .app bundle | |
| run: npx electron-builder --mac --${{ matrix.arch }} --dir --publish never | |
| env: | |
| CSC_NAME: ${{ secrets.MAC_CSC_NAME }} | |
| CSC_IDENTITY_AUTO_DISCOVERY: ${{ steps.signing.outputs.enabled == 'true' && 'true' || 'false' }} | |
| - name: Get version | |
| id: version | |
| run: | | |
| VERSION="$(node -e "console.log(require('./package.json').version)")" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| # `--${{ matrix.arch }}` above does NOT restrict the architecture: the | |
| # `arch` list in electron-builder.json5's `mac.target` names both x64 and | |
| # arm64 and the config wins, so BOTH bundles are produced in every job — | |
| # x64 in release/<ver>/mac/, arm64 in release/<ver>/mac-arm64/. The old | |
| # `find release/<ver> ... | head -n1` took whichever came first in | |
| # directory order (x64, in practice), so the arm64 job could package the | |
| # x64 bundle into a DMG named `-arm64-`. Nothing downstream compared the | |
| # name against the contents, so that would have published silently. | |
| - name: Find .app bundle | |
| id: find_app | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| if [[ "${{ matrix.arch }}" == "arm64" ]]; then ARCH_DIR="mac-arm64"; else ARCH_DIR="mac"; fi | |
| APP_BUNDLE="$(find "release/${VERSION}/${ARCH_DIR}" -maxdepth 2 -name "*.app" -type d | head -n1)" | |
| if [[ -z "$APP_BUNDLE" ]]; then | |
| echo "::error::No .app bundle found in release/${VERSION}/${ARCH_DIR}/" | |
| find "release/${VERSION}" -maxdepth 4 -print || true | |
| exit 1 | |
| fi | |
| echo "app_bundle=$APP_BUNDLE" >> "$GITHUB_OUTPUT" | |
| # The guard for the above: refuse to build a DMG whose name would not | |
| # match its contents. An Intel bundle on an Apple Silicon Mac runs under | |
| # Rosetta 2 — compositor, encoder and whisper all translated — which is | |
| # slow enough to be unusable, so a mislabelled DMG is a real user harm. | |
| - name: Verify .app architecture matches the job | |
| run: | | |
| BIN="${{ steps.find_app.outputs.app_bundle }}/Contents/MacOS/Openscreen" | |
| if [[ "${{ matrix.arch }}" == "arm64" ]]; then EXPECTED="arm64"; else EXPECTED="x86_64"; fi | |
| ACTUAL="$(lipo -archs "$BIN")" | |
| echo "job arch=${{ matrix.arch }} expected=${EXPECTED} actual=${ACTUAL}" | |
| if [[ " ${ACTUAL} " != *" ${EXPECTED} "* ]]; then | |
| echo "::error::The ${{ matrix.arch }} job produced a '${ACTUAL}' bundle — refusing to publish a mislabelled DMG" | |
| exit 1 | |
| fi | |
| # electron-builder used to do this itself. Its macPackager carried a | |
| # `noIdentity && fallBackToAdhoc` branch that handed back `Identity("-")` | |
| # whenever no certificate was found — mandatory on arm64, where an unsigned | |
| # binary will not launch at all. 26.15.3 replaced that path with | |
| # `findSigningIdentity`, which returns null instead, and `sign()` leaves on | |
| # `return false`. Nothing signs the bundle, and what ships is the bare | |
| # linker signature on the Electron binary: `Identifier=Electron`, | |
| # `Sealed Resources=none`. | |
| # | |
| # That is not cosmetic. macOS keys TCC grants to an app's code signature, | |
| # so a bundle signed as "Electron" cannot hold one. v1.9.0-rc.1 asked for | |
| # Accessibility, the user granted it, `AXIsProcessTrusted()` still returned | |
| # false, and the editable-cursor preflight in useScreenRecorder re-opened | |
| # the same dialog on every press of record — recording was impossible. | |
| # | |
| # Signed with the same runtime and entitlements electron-builder applies, | |
| # so a locally signed build and a certificate-signed one differ only in the | |
| # identity. Both arches on purpose: 26.8.1 only fell back on arm64, which | |
| # left Intel DMGs unsigned for their whole existence. | |
| - name: Ad-hoc sign the .app | |
| if: steps.signing.outputs.enabled != 'true' | |
| run: | | |
| codesign --force --deep --sign - \ | |
| --options runtime \ | |
| --entitlements macos.entitlements \ | |
| "${{ steps.find_app.outputs.app_bundle }}" | |
| # UNCONDITIONAL. Gated on `enabled == 'true'`, this step never ran for the | |
| # RC builds — the only ones that could be unsigned — so the regression | |
| # above shipped with every macOS check in this job green. | |
| - name: Verify .app code signature | |
| run: | | |
| APP="${{ steps.find_app.outputs.app_bundle }}" | |
| codesign --verify --deep --strict "$APP" | |
| # The identifier, not just the structure: `--verify` passes on the bare | |
| # linker signature too, so it alone would not have caught this. What | |
| # distinguishes a bundle macOS can attach permissions to is that its | |
| # signing identifier matches the bundle id. | |
| EXPECTED="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist")" | |
| ACTUAL="$(codesign -dv --verbose=2 "$APP" 2>&1 | sed -n 's/^Identifier=//p')" | |
| echo "signature identifier=${ACTUAL} expected=${EXPECTED}" | |
| if [[ "$ACTUAL" != "$EXPECTED" ]]; then | |
| echo "::error::The .app is signed as '${ACTUAL}', not '${EXPECTED}' — macOS cannot attach Accessibility or Screen Recording permissions to a bundle whose signature does not carry its own identifier" | |
| exit 1 | |
| fi | |
| # Squirrel.Mac installs from a ZIP and nothing else — `MacUpdater` looks for one and | |
| # throws ERR_UPDATER_ZIP_FILE_NOT_FOUND when the feed offers only a DMG. Built here, from | |
| # the same signed bundle the DMG is about to be built from, so the two can never diverge. | |
| # | |
| # `ditto`, never `zip`: `zip` flattens the symlinks inside Contents/Frameworks and drops | |
| # extended attributes, producing an archive whose .app fails signature validation on | |
| # arrival — and Squirrel validates the downloaded bundle against the INSTALLED app's | |
| # designated requirement before swapping it. | |
| # | |
| # ponytail: the .app inside this ZIP is signed but not stapled — only the DMG is notarized | |
| # today. Squirrel does not check notarization, and a ZIP fetched by the app's own process | |
| # carries no com.apple.quarantine, so Gatekeeper never re-scans the swapped bundle. | |
| # Notarizing the .app as well would cost a second notarytool round trip per arch; revisit | |
| # if Apple ever tightens this. | |
| - name: Create update ZIP | |
| id: update_zip | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| APP="${{ steps.find_app.outputs.app_bundle }}" | |
| RELEASE_DIR="release/${VERSION}" | |
| # The instruction set, NOT the marketing name used for the DMG: electron-updater picks | |
| # the file for an Apple Silicon client by matching the literal substring "arm64". | |
| ZIP="${RELEASE_DIR}/Openscreen-Mac-${{ matrix.arch }}-${VERSION}.zip" | |
| ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" | |
| node scripts/mac-update-feed.mjs describe "$ZIP" "$VERSION" \ | |
| "${RELEASE_DIR}/update-info-${{ matrix.arch }}.json" | |
| echo "zip_path=${ZIP}" >> "$GITHUB_OUTPUT" | |
| echo "info_path=${RELEASE_DIR}/update-info-${{ matrix.arch }}.json" >> "$GITHUB_OUTPUT" | |
| # A ZIP whose .app is unreadable, or which lost the bundle root, downloads fine and then | |
| # fails at install time on a user's machine. Nothing else in CI can catch that. | |
| - name: Verify update ZIP | |
| run: | | |
| ZIP="${{ steps.update_zip.outputs.zip_path }}" | |
| unzip -l "$ZIP" | grep -q "Openscreen.app/Contents/MacOS/" \ | |
| || { echo "::error::${ZIP} does not contain Openscreen.app/Contents/MacOS — ditto lost the bundle root"; exit 1; } | |
| if [ "${{ matrix.arch }}" = "arm64" ]; then | |
| case "$ZIP" in | |
| *arm64*) ;; | |
| *) echo "::error::the arm64 ZIP must carry 'arm64' in its name or Apple Silicon clients silently receive the Intel build"; exit 1 ;; | |
| esac | |
| fi | |
| - name: Create DMG | |
| id: dmg | |
| run: | | |
| VERSION="${{ steps.version.outputs.version }}" | |
| ARCH="${{ matrix.arch }}" | |
| # Name the DMG after the machine, not the instruction set. "x64" reads | |
| # to most people as "the normal 64-bit one" and "arm64" as the exotic | |
| # variant, which is exactly backwards on any Mac sold since 2020 — and | |
| # picking the wrong one silently costs Rosetta 2. `Intel` and | |
| # `Apple-Silicon` are what About This Mac shows the user. | |
| case "$ARCH" in | |
| arm64) ARCH_LABEL="Apple-Silicon" ;; | |
| x64) ARCH_LABEL="Intel" ;; | |
| *) ARCH_LABEL="$ARCH" ;; | |
| esac | |
| DMG_NAME="Openscreen-macOS-${ARCH_LABEL}-${VERSION}.dmg" | |
| RELEASE_DIR="release/${VERSION}" | |
| DMG_OUTPUT="${RELEASE_DIR}/${DMG_NAME}" | |
| STAGING="${RELEASE_DIR}/dmg-staging" | |
| rm -rf "$STAGING" | |
| rm -f "$DMG_OUTPUT" | |
| mkdir -p "$STAGING" | |
| cp -R "${{ steps.find_app.outputs.app_bundle }}" "$STAGING/" | |
| ln -s /Applications "$STAGING/Applications" | |
| hdiutil create \ | |
| -srcfolder "$STAGING" \ | |
| -volname "Openscreen" \ | |
| -fs HFS+ \ | |
| -fsargs "-c c=64,a=16,e=16" \ | |
| -format UDBZ \ | |
| "$DMG_OUTPUT" | |
| rm -rf "$STAGING" | |
| echo "dmg_path=$DMG_OUTPUT" >> "$GITHUB_OUTPUT" | |
| # The four steps below used to carry `&& !contains(github.ref_name, '-')`, | |
| # which skipped them for every pre-release, `-rc.N` tags included. Two | |
| # costs, and the second is the one that mattered. | |
| # | |
| # Testers paid the first: a DMG signed with Developer ID but not notarized | |
| # is still refused by Gatekeeper — `spctl` answers `rejected, source= | |
| # Unnotarized Developer ID` — so every RC tester had to know about | |
| # `xattr -rd com.apple.quarantine` before they could open the thing they | |
| # were being asked to test. | |
| # | |
| # The release paid the second. With the skip in place, notarization never | |
| # ran until the stable tag, so the first exercise of the credentials, the | |
| # certificate chain and Apple's acceptance of every nested Mach-O landed on | |
| # the highest-stakes build there is. That is not theoretical: the run that | |
| # first enabled signing here died in `Package .app bundle` on a malformed | |
| # `MAC_CSC_NAME`, and it was only visible because a full build was run | |
| # deliberately. Notarizing each RC turns every candidate into a rehearsal. | |
| # | |
| # The trade is a few minutes per macOS job and a dependency on Apple's | |
| # notary service being reachable — `--wait` is capped at 15 minutes below. | |
| # If that ever becomes flaky enough to block RCs, the fix is | |
| # `continue-on-error` on pre-releases, not going back to skipping them. | |
| - name: Sign DMG | |
| if: steps.signing.outputs.enabled == 'true' | |
| run: | | |
| codesign --force \ | |
| --sign "${{ secrets.MAC_CSC_NAME }}" \ | |
| --timestamp \ | |
| "${{ steps.dmg.outputs.dmg_path }}" | |
| - name: Notarize DMG | |
| if: steps.signing.outputs.enabled == 'true' | |
| run: | | |
| xcrun notarytool submit "${{ steps.dmg.outputs.dmg_path }}" \ | |
| --apple-id "${{ secrets.APPLE_ID }}" \ | |
| --team-id "${{ secrets.APPLE_TEAM_ID }}" \ | |
| --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ | |
| --wait | |
| timeout-minutes: 15 | |
| - name: Staple notarization ticket | |
| if: steps.signing.outputs.enabled == 'true' | |
| run: xcrun stapler staple "${{ steps.dmg.outputs.dmg_path }}" | |
| - name: Validate stapled DMG | |
| if: steps.signing.outputs.enabled == 'true' | |
| run: | | |
| xcrun stapler validate "${{ steps.dmg.outputs.dmg_path }}" | |
| spctl -a -vv -t install "${{ steps.dmg.outputs.dmg_path }}" | |
| - name: Upload macOS DMG | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: openscreen-mac-${{ matrix.arch }} | |
| # The DMG is what a new user downloads; the ZIP is what an existing install updates | |
| # from. The JSON sidecar is folded into a single latest-mac.yml in publish-release — | |
| # each arch is built on a different runner, so neither job can write the feed alone. | |
| path: | | |
| ${{ steps.dmg.outputs.dmg_path }} | |
| ${{ steps.update_zip.outputs.zip_path }} | |
| ${{ steps.update_zip.outputs.info_path }} | |
| if-no-files-found: error | |
| retention-days: 30 | |
| - name: Cleanup keychain | |
| if: always() && steps.signing.outputs.enabled == 'true' | |
| run: security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true | |
| build-linux: | |
| name: Linux packages | |
| # PINNED, and not to `ubuntu-latest`. The linker binds every symbol to the newest | |
| # version its BUILD machine offers, so the runner image silently decides the oldest | |
| # distro these packages can run on — nothing in the source asks for any of it. | |
| # On ubuntu-latest (24.04) that floor was glibc 2.38 / GLIBCXX_3.4.32, which put | |
| # Ubuntu 22.04, Debian 12 and RHEL 9 out of range: whisper-stt-server and the ggml | |
| # backends died in ld.so before main(), and compositor_view.node (2.35, from a single | |
| # `hypotf`) died on RHEL 9 as well. Both fail SILENTLY — the app still launches, | |
| # captions just report a developer error and the preview renders nothing — and no | |
| # package format catches it, because the deb/rpm/pacman `depends` lists are | |
| # hand-written in electron-builder.json5 and electron-builder passes fpm none of | |
| # --rpm-autoreq*, so not even dnf generates the libc.so.6(GLIBC_2.38) requirement | |
| # that would have refused the install. | |
| # | |
| # 22.04 is the oldest distro the README claims (it names it as the PipeWire | |
| # baseline), and it is the binding one: glibc 2.35 and libstdc++6 from GCC 12 | |
| # (GLIBCXX_3.4.30, CXXABI_1.3.13), against Debian 12's 2.36/3.4.30/1.3.13. | |
| # scripts/before-pack.cjs enforces that ceiling on the built payload, so bumping | |
| # this image alone cannot quietly raise the floor again. | |
| runs-on: ubuntu-22.04 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: ./.github/actions/setup | |
| # bsdtar (libarchive-tools) is fpm's mtree generator for the pacman target. | |
| # rpmbuild (rpm) is what fpm shells out to for the rpm target; without it the | |
| # build fails at packaging, not at config parse. | |
| - name: Install Linux packaging dependencies | |
| run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm | |
| # patchelf is what build-linux-compositor-addon.mjs renames the ffmpeg symbols | |
| # with, so the addon cannot bind to Chromium's bundled ffmpeg — an unconditional | |
| # dependency that resolvePatchelf() throws on. | |
| # | |
| # NOT from apt, which is the whole reason this is its own step: 22.04 carries | |
| # patchelf 0.14.3 and `--rename-dynamic-symbols` first shipped in 0.18.0, so the | |
| # apt copy fails the rename outright. 0.18.0 is also exactly what the 24.04 image | |
| # provided, so the renaming behaviour is unchanged from what already ships. | |
| # resolvePatchelf() checks ~/.local/bin before /usr/bin, so this wins over | |
| # whatever the image happens to carry. | |
| - name: Install patchelf | |
| run: | | |
| curl -fsSLo /tmp/patchelf.tar.gz \ | |
| https://github.com/NixOS/patchelf/releases/download/0.18.0/patchelf-0.18.0-x86_64.tar.gz | |
| echo "ce84f2447fb7a8679e58bc54a20dc2b01b37b5802e12c57eece772a6f14bf3f0 /tmp/patchelf.tar.gz" | sha256sum -c - | |
| mkdir -p ~/.local/bin | |
| tar -xzf /tmp/patchelf.tar.gz -C /tmp ./bin/patchelf | |
| mv /tmp/bin/patchelf ~/.local/bin/patchelf | |
| ~/.local/bin/patchelf --version | |
| - name: Stage whisper-stt binaries | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: bash scripts/stage-whisper-stt.sh linux-x64 | |
| - name: Build Linux app | |
| run: npm run build:linux -- --publish never | |
| # The guard for the trap documented on the upload step below: `if-no-files-found` | |
| # evaluates the union of the globs, so a format that stops being produced is | |
| # invisible there. It is a real failure mode and not a hypothetical one — the rpm | |
| # target was added to electron-builder.json5's `linux.target` alone, where the CLI | |
| # list in `build:linux` overrides it, and the upload glob for it would have matched | |
| # nothing on every release with the job still green. One assertion per format. | |
| - name: Verify every Linux format was produced | |
| run: | | |
| if [[ ! -d release ]]; then | |
| echo "::error::electron-builder produced no release/ directory" | |
| exit 1 | |
| fi | |
| MISSING=() | |
| for ext in AppImage deb pacman rpm; do | |
| COUNT="$(find release -type f -name "*.${ext}" -printf . | wc -c)" | |
| echo "${ext}: ${COUNT}" | |
| if [[ "$COUNT" -eq 0 ]]; then MISSING+=("$ext"); fi | |
| done | |
| if [[ "${#MISSING[@]}" -gt 0 ]]; then | |
| echo "::error::No artifact produced for: ${MISSING[*]} — check the target list in package.json's build:linux, which overrides linux.target in electron-builder.json5" | |
| find release -maxdepth 2 -type f -print | |
| exit 1 | |
| fi | |
| # The Linux counterpart of the Windows job's "Verify native binaries load under | |
| # package identity", added for the same reason and after the same kind of miss: | |
| # 1.9.1 shipped three sonames that nothing declared and nothing bundled — libgbm.so.1 | |
| # and libasound.so.2, needed by the Electron binary itself, so a clean Ubuntu 22.04 | |
| # exited 127 before any window, and libgomp.so.1, needed by the whole STT stack, so | |
| # transcription died in ld.so. The symbol-version guard in before-pack.cjs could not | |
| # have seen any of it: it checks how NEW the required symbols are, not whether the | |
| # libraries carrying them are ever installed. | |
| # | |
| # Both misses hid behind the same thing. Desktop metapackages pull all three, so | |
| # every machine anyone tested on had them — libgomp1 only via libfftw3-single3, | |
| # libimagequant0 and libsoxr0, three peripheral media libraries. The check has to run | |
| # somewhere empty or it is not a check, which is why this uses containers rather than | |
| # the runner it is already standing on. | |
| # | |
| # rpm and pacman are verified too, and they are the ones with no other safety net: | |
| # their depends lists are hand-written, no user installs them often enough to report | |
| # a gap quickly, and package names genuinely differ (libgomp.so.1 is `libgomp1` on | |
| # Debian, `libgomp` on Fedora AND on Arch, where it was split out of `gcc-libs`). | |
| # | |
| # The AppImage is deliberately NOT covered. It has no dependency mechanism at all, so | |
| # there is no declaration to verify against — every system soname is "missing" by | |
| # construction and the check would have nothing to say. It stays exposed, which is | |
| # what d3d_linux::diagnose naming the Mesa package is for. | |
| - name: Verify packages resolve on a clean machine | |
| run: | | |
| for fmt in deb rpm pacman; do | |
| PKG="$(find release -type f -name "*.${fmt}" | head -1)" | |
| echo "::group::${fmt}" | |
| bash scripts/verify-linux-package.sh "$fmt" "$PKG" | |
| echo "::endgroup::" | |
| done | |
| - name: Upload Linux packages | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: openscreen-linux | |
| path: | | |
| release/**/*.AppImage | |
| release/**/*.deb | |
| release/**/*.pacman | |
| release/**/*.rpm | |
| release/**/latest-linux.yml | |
| # Still no *.zsync, and there never will be: zsync was electron-updater's old delta | |
| # format and app-builder-lib 26.x dropped it in favour of the block map it embeds | |
| # directly in the AppImage. That glob had matched nothing since the dependency bump, | |
| # silently — `if-no-files-found: error` evaluates the union of these patterns, so one | |
| # dead glob among live ones never fails. Hence the explicit assertion below. | |
| # | |
| # latest-linux.yml serves all four formats from one file: each updater picks its own | |
| # extension out of the `files:` list. app-update.yml and the `package-type` marker are | |
| # already inside the deb/rpm/pacman payloads — only this feed was missing. | |
| if-no-files-found: error | |
| retention-days: 30 | |
| - name: Verify the update feed was produced | |
| run: | | |
| test -f "$(find release -name latest-linux.yml | head -1)" \ | |
| || { echo "::error::latest-linux.yml missing — electron-updater has no feed to read"; exit 1; } | |
| publish-release: | |
| name: Publish GitHub release | |
| runs-on: ubuntu-latest | |
| needs: | |
| - build-windows | |
| - build-macos | |
| - build-linux | |
| if: ${{ (github.event_name == 'push' && github.ref_type == 'tag') || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '') }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| # Full history + tags: the RC notes below are built from `git log` over the | |
| # range since the previous RC tag, and resolving that tag needs the tags. | |
| fetch-depth: 0 | |
| - name: Resolve release tag | |
| id: release | |
| env: | |
| INPUT_TAG: ${{ github.event.inputs.release_tag }} | |
| run: | | |
| if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then | |
| TAG="${GITHUB_REF_NAME}" | |
| else | |
| TAG="${INPUT_TAG}" | |
| fi | |
| if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)\.[0-9]+)?$ ]]; then | |
| echo "::error::Release tag must look like v1.5.0 or v1.5.0-rc.1; got '${TAG}'" | |
| exit 1 | |
| fi | |
| VERSION="${TAG#v}" | |
| # For an RC tag (e.g. v1.5.0-rc.1) package.json is at the pre-release version | |
| # (1.5.0-rc.1), not the stable version (1.5.0). Compare against the full tag version. | |
| PACKAGE_VERSION="$(node -p 'require("./package.json").version')" | |
| if [[ "$PACKAGE_VERSION" != "$VERSION" ]]; then | |
| echo "::error::package.json version ${PACKAGE_VERSION} does not match ${VERSION} from tag ${TAG}" | |
| exit 1 | |
| fi | |
| if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-(rc|beta|alpha)\.[0-9]+$ ]]; then | |
| PRERELEASE_FLAG="--prerelease" | |
| IS_PRERELEASE="true" | |
| else | |
| PRERELEASE_FLAG="" | |
| IS_PRERELEASE="false" | |
| fi | |
| # Compute the previous stable tag for auto-generated release notes. We don't use | |
| # GitHub's "most recent prior release by date" because the fork carries re-published | |
| # upstream releases whose published_at is more recent than the fork's own first release. | |
| # For SemVer X.Y.Z: previous is vX.Y.(Z-1) if Z>0, else vX.(Y-1).0, else v(X-1).0.0. | |
| STABLE_VERSION="${VERSION%%-*}" | |
| IFS='.' read -r PX PY PZ <<< "$STABLE_VERSION" | |
| if (( PZ > 0 )); then | |
| NOTES_START_TAG="v${PX}.${PY}.$((PZ - 1))" | |
| elif (( PY > 0 )); then | |
| NOTES_START_TAG="v${PX}.$((PY - 1)).0" | |
| else | |
| NOTES_START_TAG="v$((PX - 1)).0.0" | |
| fi | |
| # For an RC, compare against the PREVIOUS RC of the same line, not the previous | |
| # stable. Deriving the start tag from STABLE_VERSION alone made every RC of a | |
| # line span the same range, so each re-cut just repeated the last RC's notes | |
| # plus its own handful, and testers could not see what the re-cut changed. | |
| # Walk down from the current rc number so a skipped or failed RC doesn't break it. | |
| if [[ "$IS_PRERELEASE" == "true" ]]; then | |
| RC_NUMBER="${VERSION##*.}" | |
| for (( n = RC_NUMBER - 1; n >= 1; n-- )); do | |
| CANDIDATE="v${STABLE_VERSION}-rc.${n}" | |
| if git rev-parse -q --verify "refs/tags/${CANDIDATE}" >/dev/null; then | |
| NOTES_START_TAG="$CANDIDATE" | |
| break | |
| fi | |
| done | |
| fi | |
| # Everything above computes what the previous release was *called* and | |
| # never checks that it exists. A version line that stopped at its RC | |
| # makes that a name for nothing: 1.9.3 shipped only as rc.1, so | |
| # v1.9.4-rc.1 asked git for v1.9.3..v1.9.4-rc.1 and the publish step | |
| # died on `unknown revision` -- after all four platforms had already | |
| # built, and with publish-msstore sitting behind publish-release, so | |
| # the same gap would silently block a stable release's Store | |
| # deployment too. The rc walk-down above handles a skipped RC; nothing | |
| # handled a skipped stable, and it does not run at all for an rc.1. | |
| if ! git rev-parse -q --verify "refs/tags/${NOTES_START_TAG}" >/dev/null; then | |
| # The nearest tag reachable from the release commit's parent, which | |
| # is what "since the last release" meant in the first place. Left | |
| # EMPTY when no tag is reachable at all, rather than filled with the | |
| # root commit: the stable path hands this to `gh release create | |
| # --notes-start-tag`, which is the API's previous_tag_name and takes | |
| # a tag NAME -- a commit SHA there is not a lenient fallback, it is | |
| # an invalid argument. Each consumer below decides what "no previous | |
| # release" means for it. | |
| NOTES_START_TAG="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)" | |
| echo "Previous-release tag did not exist; using ${NOTES_START_TAG:-<none>}" | |
| fi | |
| echo "Computed notes_start_tag=${NOTES_START_TAG} for tag=${TAG}" | |
| echo "tag=$TAG" >> "$GITHUB_OUTPUT" | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "stable_version=$STABLE_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" | |
| echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT" | |
| echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT" | |
| - name: Download Windows installer | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: openscreen-windows | |
| path: artifacts/windows | |
| - name: Download macOS arm64 DMG | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: openscreen-mac-arm64 | |
| path: artifacts/mac-arm64 | |
| - name: Download macOS x64 DMG | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: openscreen-mac-x64 | |
| path: artifacts/mac-x64 | |
| - name: Download Linux packages | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: openscreen-linux | |
| path: artifacts/linux | |
| # The two arches are built on different runners, so neither macOS job can write the feed: | |
| # electron-builder would have each emit its own latest-mac.yml and the second upload would | |
| # overwrite the first, serving one architecture the wrong build (electron-builder#5592). | |
| # Fold the two JSON sidecars into ONE latest-mac.yml listing both, then drop the sidecars | |
| # so they never reach the release. | |
| - name: Build the macOS update feed | |
| run: | | |
| node scripts/mac-update-feed.mjs merge \ | |
| artifacts/mac-arm64/update-info-arm64.json \ | |
| artifacts/mac-x64/update-info-x64.json \ | |
| artifacts/latest-mac.yml | |
| rm -f artifacts/mac-arm64/update-info-arm64.json artifacts/mac-x64/update-info-x64.json | |
| test -f artifacts/latest-mac.yml | |
| # Exactly two `- url:` entries. One means an arch is being served the other's build. | |
| ENTRIES="$(grep -c '^ - url:' artifacts/latest-mac.yml)" | |
| [ "$ENTRIES" -eq 2 ] \ | |
| || { echo "::error::latest-mac.yml lists ${ENTRIES} builds, expected 2 (one per arch)"; exit 1; } | |
| grep -q 'arm64' artifacts/latest-mac.yml \ | |
| || { echo "::error::latest-mac.yml has no arm64 entry — Apple Silicon would update onto the Intel build"; exit 1; } | |
| - name: Publish release assets | |
| env: | |
| GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} | |
| TAG: ${{ steps.release.outputs.tag }} | |
| PRERELEASE_FLAG: ${{ steps.release.outputs.prerelease_flag }} | |
| NOTES_START_TAG: ${{ steps.release.outputs.notes_start_tag }} | |
| run: | | |
| mapfile -t FILES < <(find artifacts -type f | sort) | |
| if [[ "${#FILES[@]}" -eq 0 ]]; then | |
| echo "::error::No installer artifacts were downloaded" | |
| exit 1 | |
| fi | |
| if gh release view "$TAG" >/dev/null 2>&1; then | |
| gh release upload "$TAG" "${FILES[@]}" --clobber | |
| else | |
| if [[ -n "$PRERELEASE_FLAG" ]]; then | |
| # RC notes come from `git log`, not --generate-notes. GitHub's generator | |
| # lists only the PRs it manages to associate, and on this repo it silently | |
| # drops real ones — #254 and #261 were merged into the release branch and | |
| # never appeared in v1.9.0-rc.2's body — so an RC could omit the very fix | |
| # the re-cut was for. The commit range is the actual diff and can't lie. | |
| # Stable releases keep --generate-notes below: they're the public-facing | |
| # ones and want the PR links and the New Contributors section. | |
| # With no previous tag at all, the range is the whole history and | |
| # there is nothing to compare against, so say so rather than | |
| # emitting "since " with a blank where a tag should be. | |
| if [[ -n "$NOTES_START_TAG" ]]; then | |
| RC_RANGE="${NOTES_START_TAG}..${TAG}" | |
| RC_HEADING="## Changes since ${NOTES_START_TAG}" | |
| RC_LINK="**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${NOTES_START_TAG}...${TAG}" | |
| else | |
| RC_RANGE="$TAG" | |
| RC_HEADING="## Changes" | |
| RC_LINK="**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commits/${TAG}" | |
| fi | |
| { | |
| echo "$RC_HEADING" | |
| echo | |
| git log --no-merges --reverse --pretty='- %s' \ | |
| --invert-grep --grep='^chore(release): bump to' \ | |
| "$RC_RANGE" | |
| echo | |
| echo "$RC_LINK" | |
| } > "${RUNNER_TEMP}/rc-notes.md" | |
| cat "${RUNNER_TEMP}/rc-notes.md" | |
| NOTES_ARGS=(--notes-file "${RUNNER_TEMP}/rc-notes.md") | |
| else | |
| # --notes-start-tag controls which previous tag GitHub compares against | |
| # when auto-generating the release notes. Default behaviour (most recent | |
| # prior release by date) doesn't work for this fork because the v1.4.0 | |
| # release in the fork was re-published after v1.5.0, which makes GitHub | |
| # pick v1.4.0 as the "previous" for any v1.5.x release. | |
| # | |
| # Omitted entirely when there is no previous tag: this maps to the | |
| # API's previous_tag_name, which takes a tag NAME. Passing an empty | |
| # string or a commit SHA is an invalid argument, not a graceful | |
| # degradation. Without it GitHub falls back to its own choice of | |
| # previous release, which is exactly right when there isn't one. | |
| if [[ -n "$NOTES_START_TAG" ]]; then | |
| NOTES_ARGS=(--generate-notes --notes-start-tag "$NOTES_START_TAG") | |
| else | |
| NOTES_ARGS=(--generate-notes) | |
| fi | |
| fi | |
| # shellcheck disable=SC2086 | |
| gh release create "$TAG" "${FILES[@]}" \ | |
| --target "$GITHUB_SHA" \ | |
| --title "$TAG" \ | |
| "${NOTES_ARGS[@]}" \ | |
| $PRERELEASE_FLAG | |
| fi | |
| if [[ -n "$PRERELEASE_FLAG" ]]; then | |
| gh release edit "$TAG" \ | |
| --draft=false \ | |
| --latest=false \ | |
| --title "$TAG" | |
| else | |
| gh release edit "$TAG" \ | |
| --draft=false \ | |
| --latest \ | |
| --title "$TAG" | |
| fi | |
| - name: Refresh the docs /download page | |
| # Only a stable release changes what /releases/latest resolves to, so a | |
| # pre-release would rebuild the site to byte-identical output. | |
| # | |
| # Dispatched against main on purpose: the github-pages environment only | |
| # permits `main` to deploy, so docs.yml's old `on: release` trigger ran | |
| # with a tag ref and failed its deploy every time. See docs.yml. | |
| if: ${{ steps.release.outputs.is_prerelease == 'false' }} | |
| timeout-minutes: 20 | |
| env: | |
| GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} | |
| run: | | |
| latest_dispatch() { | |
| gh run list \ | |
| --repo "$GITHUB_REPOSITORY" \ | |
| --workflow docs.yml \ | |
| --event workflow_dispatch \ | |
| --branch main \ | |
| --limit 1 \ | |
| --json databaseId \ | |
| --jq '.[0].databaseId // empty' | |
| } | |
| # `gh workflow run` prints nothing we can key off, so remember which | |
| # dispatch was newest beforehand and wait for a different one to appear. | |
| PREVIOUS_RUN_ID="$(latest_dispatch)" | |
| gh workflow run docs.yml --ref main --repo "$GITHUB_REPOSITORY" | |
| RUN_ID="" | |
| for _ in $(seq 1 30); do | |
| sleep 5 | |
| CANDIDATE="$(latest_dispatch)" | |
| if [[ -n "$CANDIDATE" && "$CANDIDATE" != "$PREVIOUS_RUN_ID" ]]; then | |
| RUN_ID="$CANDIDATE" | |
| break | |
| fi | |
| done | |
| if [[ -z "$RUN_ID" ]]; then | |
| echo "::error::Dispatched docs.yml but no new run appeared within 150s" | |
| exit 1 | |
| fi | |
| gh run watch "$RUN_ID" --repo "$GITHUB_REPOSITORY" --interval 15 || true | |
| CONCLUSION="$(gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion')" | |
| case "$CONCLUSION" in | |
| success) | |
| echo "Docs rebuilt and deployed by run $RUN_ID" | |
| ;; | |
| cancelled) | |
| # docs.yml cancels in-flight runs sharing a ref, so a push to main | |
| # landing right now replaces this rebuild with a newer one. | |
| echo "::warning::Docs run $RUN_ID was cancelled, most likely superseded by a newer main run" | |
| ;; | |
| *) | |
| echo "::error::Docs run $RUN_ID concluded '$CONCLUSION' - /download may still list the previous release" | |
| exit 1 | |
| ;; | |
| esac | |
| # Publishing the Store package is the last manual step in the release: the appx | |
| # had to be downloaded from this run's artifacts and uploaded by hand in Partner | |
| # Center. That is also how 1.8.0 ended up with two different packages under one | |
| # version — the artifact was downloaded twice and both copies were uploaded. | |
| # | |
| # This job lives in build.yml rather than beside publish-winget.yml because the | |
| # appx never becomes a release asset: the GitHub release carries only the NSIS | |
| # installer, so `release: published` has nothing to hand a downstream workflow. | |
| # The package exists solely as this run's artifact. | |
| publish-msstore: | |
| name: Publish to Microsoft Store | |
| runs-on: windows-latest | |
| # The workflow-wide token is `contents: write` because publish-release needs | |
| # it. This job only reads: it checks the tree out so the CLI can identify the | |
| # project, downloads a same-run artifact (which uses the runtime token, not | |
| # this one), and talks to Partner Center with its own Entra credentials. | |
| permissions: | |
| contents: read | |
| needs: | |
| - build-windows-store | |
| - publish-release | |
| # Stable tags only, and never a fork: an RC reaching the Store would go through | |
| # certification and land on every user's machine as an automatic update. | |
| # `!contains(…, '-')` is what separates v1.9.1 from v1.9.1-rc.2. | |
| if: >- | |
| ${{ vars.MSSTORE_PRODUCT_ID != '' | |
| && ((github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref_name, '-')) | |
| || (github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag != '' && !contains(github.event.inputs.release_tag, '-'))) }} | |
| steps: | |
| # Same all-or-nothing gate as the macOS signing job: a half-configured | |
| # publisher is a misnamed secret, and the quiet failure mode — shipping | |
| # nothing while the release looks complete — is the one worth making loud. | |
| - name: Resolve Store credentials | |
| id: store | |
| shell: bash | |
| env: | |
| AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} | |
| AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} | |
| AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} | |
| SELLER_ID: ${{ secrets.SELLER_ID }} | |
| run: | | |
| required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID | |
| AZURE_AD_APPLICATION_SECRET SELLER_ID) | |
| missing=() | |
| for name in "${required[@]}"; do | |
| [[ -n "${!name}" ]] || missing+=("$name") | |
| done | |
| if [[ ${#missing[@]} -eq 0 ]]; then | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| echo "Store credentials present — the package will be submitted." | |
| elif [[ ${#missing[@]} -eq ${#required[@]} ]]; then | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "::warning::No Store credentials configured; upload the appx by hand in Partner Center." | |
| else | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| echo "::error::Store publishing is partially configured; missing: ${missing[*]}" | |
| exit 1 | |
| fi | |
| # `msstore publish` takes a PROJECT root, not a package: it detects the app | |
| # type there (Electron, via package.json) and only then accepts the built | |
| # package through `--inputDirectory`. This job used to check nothing out, so | |
| # there was no project to point it at. Checkout runs before the artifact | |
| # download on purpose — actions/checkout cleans the workspace, and would | |
| # delete the package if it ran after. | |
| - name: Check out the project | |
| if: steps.store.outputs.enabled == 'true' | |
| uses: actions/checkout@v7 | |
| with: | |
| # Nothing here pushes; the tree is only read so the CLI can see it is | |
| # an Electron project. Left at the default, checkout writes the | |
| # workflow's `contents: write` token into .git/config, where every | |
| # later step can read it — including a third-party CLI action and the | |
| # Store submission. See the job-level `permissions` above: same reason, | |
| # other half. | |
| persist-credentials: false | |
| - name: Download Store package | |
| if: steps.store.outputs.enabled == 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: openscreen-windows-store | |
| path: artifacts/store | |
| - name: Configure Microsoft Store CLI | |
| if: steps.store.outputs.enabled == 'true' | |
| uses: microsoft/microsoft-store-apppublisher@v1.1 | |
| # PINNED, and it has to stay pinned. The action defaults to `latest`, and | |
| # msstore-cli v0.4.0 (published 2026-08-18, the first release since v0.3.9 | |
| # in January) ships an Azure SDK client whose `ClientOptions.Retry.NetworkTimeout` | |
| # is 0:00:00 — every blob upload is cancelled the instant it starts. The CLI | |
| # reports that as "Error while uploading the application package.", so the | |
| # submission is created and configured and then dies at "Uploading Bundle to | |
| # Azure blob: 0%", six instant retries later. v1.9.6 hit it three times in a | |
| # row; the same package and account had submitted fine on v0.3.9 three days | |
| # earlier. Unpin only against a CLI release that fixes the timeout. | |
| with: | |
| version: v0.3.9 | |
| - name: Submit the package to the Store | |
| id: submit | |
| if: steps.store.outputs.enabled == 'true' | |
| shell: pwsh | |
| env: | |
| PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} | |
| run: | | |
| msstore reconfigure ` | |
| --tenantId ${{ secrets.AZURE_AD_TENANT_ID }} ` | |
| --sellerId ${{ secrets.SELLER_ID }} ` | |
| --clientId ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} ` | |
| --clientSecret ${{ secrets.AZURE_AD_APPLICATION_SECRET }} | |
| # Resolve the appx rather than hardcode its name: artifactName carries the | |
| # version, and download-artifact keeps the release/<version>/ prefix. | |
| $appx = Get-ChildItem artifacts/store -Recurse -Filter '*.appx' | Select-Object -First 1 | |
| if (-not $appx) { throw 'no .appx in the downloaded artifact' } | |
| if (@(Get-ChildItem artifacts/store -Recurse -Filter '*.appx').Count -ne 1) { | |
| throw 'more than one .appx in the artifact — refusing to guess which one to submit' | |
| } | |
| Write-Output "Submitting $($appx.Name) to product $env:PRODUCT_ID" | |
| # The positional argument is the project root, NOT the package — passing | |
| # the .appx there is what failed the first real run of this job on | |
| # v1.9.5: "We could not find a project publisher for the project at | |
| # ...Openscreen.Setup.1.9.5.appx". The package goes through the option | |
| # below, which takes the DIRECTORY holding it — the CLI's own usage | |
| # says `-i, --inputDirectory`, and rejects the `--inputFile` that | |
| # Microsoft Learn documents. The binary wins. | |
| # -v for the same reason as the retry workflow: without it an upload | |
| # failure is the bare line "Error while uploading the application package." | |
| # with no status code, no URL and no body. This is the path a real release | |
| # takes, so it is the path that most needs to be diagnosable. | |
| msstore publish . --inputDirectory $appx.Directory.FullName --appId $env:PRODUCT_ID -v | |
| # Report what happened, not what was configured. Keyed off `enabled` alone | |
| # under always(), this claimed "Submitted to the Store" when `msstore | |
| # publish` had failed, and "skipped, no credentials" when the resolver had | |
| # rejected a half-configured one — a release summary asserting success over | |
| # a package that never left the runner, which is the failure mode this whole | |
| # job exists to remove. | |
| # | |
| # Values go through env rather than being interpolated into the script: the | |
| # outcome of a step is not attacker-controlled here, but `${{ }}` expanding | |
| # straight into shell source is the habit worth not having. | |
| - name: Summary | |
| if: always() | |
| shell: bash | |
| env: | |
| RESOLVE: ${{ steps.store.outcome }} | |
| ENABLED: ${{ steps.store.outputs.enabled }} | |
| SUBMIT: ${{ steps.submit.outcome }} | |
| run: | | |
| case "${RESOLVE}:${ENABLED}:${SUBMIT}" in | |
| success:true:success) | |
| echo "Submitted to the Store. Certification still has to pass before it goes live." | |
| ;; | |
| success:false:*) | |
| echo "Store publishing skipped: no credentials configured. Upload the appx from this run's artifacts by hand." | |
| ;; | |
| failure:*) | |
| echo "Store credentials are half-configured, so nothing was submitted. The job log names the missing secrets." | |
| ;; | |
| *) | |
| echo "Store submission did not complete (submit step: ${SUBMIT:-not reached}). The package is still attached to this run as an artifact." | |
| ;; | |
| esac >> "$GITHUB_STEP_SUMMARY" |