Skip to content

Restyle the recording HUD on the brand tokens #4

Restyle the recording HUD on the brand tokens

Restyle the recording HUD on the brand tokens #4

Workflow file for this run

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
# `rm` alongside rc/beta/alpha: this fork ships builds of upstream releases
# with additions of its own, and they have to be tellable apart from the
# upstream version they are based on — in About This Mac, in a cask, and
# in a bug report. Calling one `-rc.1` would say it is a candidate for
# 1.9.6, which is a different and untrue claim.
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha|rm)\.[0-9]+)?$ ]]; then
echo "::error::Release tag must look like v1.5.0, v1.5.0-rc.1 or v1.5.0-rm.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
# `rm` is NOT in this list, on purpose. A prerelease is excluded from
# /releases/latest, and that endpoint is what the Homebrew cask's
# livecheck and `update-cask.mjs --latest` both read — marking these as
# prereleases would hide every shipping build of this fork from the only
# channel that installs it. An -rm build is not a candidate for
# anything; it is the release.
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"