Build Electron App #53
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@v4 | |
| - 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@v4 | |
| with: | |
| name: openscreen-windows | |
| path: release/**/Openscreen.Setup.*.exe | |
| if-no-files-found: error | |
| retention-days: 30 | |
| build-windows-store: | |
| name: Windows Store package | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - 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 | |
| - name: Upload Windows Store package | |
| uses: actions/upload-artifact@v4 | |
| 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@v4 | |
| - name: Setup Node.js | |
| uses: ./.github/actions/setup | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| 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@v4 | |
| 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@v4 | |
| 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 | |
| - 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@v4 | |
| with: | |
| name: openscreen-mac-${{ matrix.arch }} | |
| path: ${{ steps.dmg.outputs.dmg_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 | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup Node.js | |
| uses: ./.github/actions/setup | |
| - name: Install pacman build dependencies | |
| run: sudo apt-get update && sudo apt-get install -y libarchive-tools | |
| - 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 | |
| - name: Upload Linux packages | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: openscreen-linux | |
| path: | | |
| release/**/*.AppImage | |
| release/**/*.zsync | |
| release/**/*.deb | |
| release/**/*.pacman | |
| if-no-files-found: error | |
| retention-days: 30 | |
| 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@v4 | |
| 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 | |
| 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@v4 | |
| with: | |
| name: openscreen-windows | |
| path: artifacts/windows | |
| - name: Download macOS arm64 DMG | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: openscreen-mac-arm64 | |
| path: artifacts/mac-arm64 | |
| - name: Download macOS x64 DMG | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: openscreen-mac-x64 | |
| path: artifacts/mac-x64 | |
| - name: Download Linux packages | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: openscreen-linux | |
| path: artifacts/linux | |
| - 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. | |
| { | |
| echo "## Changes since ${NOTES_START_TAG}" | |
| echo | |
| git log --no-merges --reverse --pretty='- %s' \ | |
| --invert-grep --grep='^chore(release): bump to' \ | |
| "${NOTES_START_TAG}..${TAG}" | |
| echo | |
| echo "**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${NOTES_START_TAG}...${TAG}" | |
| } > "${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. | |
| NOTES_ARGS=(--generate-notes --notes-start-tag "$NOTES_START_TAG") | |
| 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 |