Skip to content

ci: build the boot stub, sign the manifest, and produce an installer #334

ci: build the boot stub, sign the manifest, and produce an installer

ci: build the boot stub, sign the manifest, and produce an installer #334

Workflow file for this run

name: build
on:
push:
branches:
- main
- dev
- release/*
- feat/app-hub
workflow_dispatch:
inputs:
sign:
description: Code-sign with SignPath (release builds only)
type: boolean
default: false
jobs:
build:
name: build-${{ matrix.platform }}
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
matrix:
include:
- platform: windows
os: windows-2022
- platform: macos
os: macos-26
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
submodules: true
- name: Prepare environment variables
run: |
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
echo "APP_VERSION=$(node -p "require('./package.json').version")" >> "$GITHUB_ENV"
# ─── Toolchains (shared) ────────────────────────────────────────────
# .NET 10 SDK for both Pengu.Windows (AOT) and Pengu.MacOS (workload).
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
run_install: |
- recursive: true
# ─── Toolchains (Windows) ───────────────────────────────────────────
# MSBuild + the VS 2022 C++ workload (preinstalled on windows-2022).
# PublishAot relies on the C++ toolchain shipped with the workload.
- name: Setup MSBuild
if: runner.os == 'Windows'
uses: microsoft/setup-msbuild@v2
# ─── Toolchains (macOS) ─────────────────────────────────────────────
# Microsoft.macOS.Sdk.net10.0_26.4 requires Xcode 26.x. Select the
# latest stable Xcode on the runner; the workload install fails loudly
# if 26+ isn't available.
- name: Select latest Xcode
if: runner.os == 'macOS'
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Install .NET macOS workload
if: runner.os == 'macOS'
run: sudo dotnet workload install macos
# ─── Build (shared) ─────────────────────────────────────────────────
# Preload bundle is #include'd into core (renderer.cc embeds preload.g.h).
# Build it before core so the header exists.
- name: Build preload
run: pnpm --filter @pengujs/preload build
# ─── Build (Windows) ────────────────────────────────────────────────
# C++ core -> core/bin/core.dll (the vcxproj writes there directly).
- name: Build core (windows)
if: runner.os == 'Windows'
run: msbuild.exe core/core.vcxproj -p:Configuration=Release -p:Platform=x64 -nologo -m
# The IFEO boot stub. Shares bootstrap.cc with core but links nothing
# else from it, so it stays a few tens of KB with no CEF surface.
# Writes to boot/bin/ (OutDir is $(SolutionDir)bin\ and there is no .sln,
# so SolutionDir resolves to the project directory — same as core).
- name: Build boot stub (windows)
if: runner.os == 'Windows'
run: msbuild.exe boot/boot.vcxproj -p:Configuration=Release -p:Platform=x64 -nologo -m
# AOT-publish the .NET 10 host. The csproj's BuildHubBundle target runs
# `pnpm --filter @pengujs/hub build` and zips packages/hub/dist/ into
# app.dat as part of the build, and the PublishAppDat target adds app.dat
# to ResolvedFileToPublish — so a single dotnet publish produces
# Pengu.exe + app.dat in the output dir.
- name: Publish app/Pengu.Windows (AOT)
if: runner.os == 'Windows'
run: |
dotnet publish app/Pengu.Windows \
-c Release \
-r win-x64 \
--output app-publish
# Clean portable payload: extract the artifact archive anywhere, run Pengu.exe.
# app.dat is embedded inside Pengu.exe via <EmbeddedResource> — no
# separate file ships alongside the exe.
- name: Stage portable payload (windows)
if: runner.os == 'Windows'
run: |
mkdir -p out/
cp app-publish/Pengu.exe out/
cp app-publish/WebView2Loader.dll out/
cp core/bin/core.dll out/
cp boot/bin/pengu-boot.exe out/
# ─── Build (macOS) ──────────────────────────────────────────────────
# C++ core -> core/bin/core.dylib (the makefile's LIB_OUT_PATH).
- name: Build core (macos)
if: runner.os == 'macOS'
run: make -C core -j release
# Publish the .NET 10 macOS host as AOT + universal (arm64 + x86_64).
# The csproj's <RuntimeIdentifiers> drives multi-arch — no -r flag.
# The macOS workload publishes each RID, lipo's the resulting native
# binaries, and stages the universal Pengu binary plus app.dat inside
# Pengu.app/Contents/{MacOS,Resources}/. Expect 30–60 minutes due to
# AOT compilation + per-RID lipo on a CI runner.
- name: Publish app/Pengu.MacOS (AOT, universal)
if: runner.os == 'macOS'
run: |
dotnet publish app/Pengu.MacOS \
-c Release \
--output app-publish
# Stage two deliverables for the macOS artifact:
# - Pengu-<ver>.pkg — installer with the universal AOT app inside
# - Pengu.app — universal AOT build (x86_64 + arm64, ~28 MB),
# drag-to-Applications form
#
# The .pkg lands in --output (app-publish/). The universal .app
# always lands at bin/Release/net10.0-macos/Pengu.app regardless of
# --output — `--output` only stages the .pkg installer, not the
# bundle. The bin/.../Pengu.app already has full <BundleResource>
# staging (app-icon.icns, core.dylib), lipo'd Mach-O, and the
# workload's codesign applied. The per-RID .apps under
# bin/Release/.../{osx-arm64,osx-x64}/ are intermediate skeletons —
# not used for distribution.
- name: Stage macOS deliverables
if: runner.os == 'macOS'
run: |
mkdir -p out/
cp app-publish/*.pkg out/
cp -R app/Pengu.MacOS/bin/Release/net10.0-macos/Pengu.app out/Pengu.app
codesign --verify --verbose=2 out/Pengu.app
# ─── Code signing (Windows, opt-in) ─────────────────────────────────
# Off by default. Every run still produces a complete, installable
# payload — signing only changes whether it is trusted by the boot's
# Authenticode gate and by SmartScreen.
#
# Two rounds, because the installer embeds the binaries: sign the
# binaries, build the installer from the signed ones, then sign the
# installer. Same shape v1.1.6 used.
- name: Upload unsigned payload for signing
if: runner.os == 'Windows' && inputs.sign
uses: actions/upload-artifact@v4
id: unsigned-payload
with:
name: unsigned-payload-${{ env.SHORT_SHA }}
path: out/
if-no-files-found: error
- name: Sign binaries
if: runner.os == 'Windows' && inputs.sign
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORG_ID }}
project-slug: PenguLoader
signing-policy-slug: release-signing
artifact-configuration-slug: zipped-app
github-artifact-id: ${{ steps.unsigned-payload.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed
# From here on, the payload is whichever directory holds the binaries we
# intend to ship — signed when we signed, otherwise the build output.
- name: Resolve payload directory (windows)
if: runner.os == 'Windows'
run: |
if [ "${{ inputs.sign }}" = "true" ]; then
echo "PAYLOAD=signed" >> "$GITHUB_ENV"
else
echo "PAYLOAD=out" >> "$GITHUB_ENV"
fi
# MUST come after signing. Authenticode rewrites the PE, so a manifest
# generated before signing would name a hash that no longer exists and
# the boot would refuse the very core it shipped with.
- name: Sign release manifest (windows)
if: runner.os == 'Windows'
env:
PENGU_MANIFEST_KEY: ${{ secrets.PENGU_MANIFEST_KEY }}
shell: pwsh
run: |
if (-not $env:PENGU_MANIFEST_KEY) {
Write-Warning "PENGU_MANIFEST_KEY is not set — shipping without a manifest."
Write-Warning "The boot will refuse this build and League will launch without plugins."
exit 0
}
./build/sign-manifest.ps1 -InstallDir "$env:PAYLOAD" -Version "${{ env.APP_VERSION }}+${{ env.SHORT_SHA }}"
# The release gate. A manifest signed with a key the shipped boot does
# not trust produces a client that launches without plugins and no
# obvious cause, so fail the build rather than ship it.
- name: Verify manifest against the shipped boot (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
if (-not (Test-Path "$env:PAYLOAD/pengu.manifest")) {
Write-Warning "no manifest to verify"
exit 0
}
$pub = (Select-String -Path boot/trust_key.h -Pattern '0x[0-9A-F]{2}' -AllMatches |
ForEach-Object { $_.Matches.Value }) -join ''
./build/sign-manifest.ps1 -InstallDir "$env:PAYLOAD" -Verify -PublicKey $pub
# ─── Installer (Windows) ────────────────────────────────────────────
- name: Build installer (windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# Version-agnostic: runner images and dev machines carry different
# Inno majors, and pinning one turns an image bump into a red build.
$iscc = Get-ChildItem "$env:ProgramFiles\Inno Setup *","${env:ProgramFiles(x86)}\Inno Setup *" `
-Directory -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
ForEach-Object { Join-Path $_.FullName 'ISCC.exe' } |
Where-Object { Test-Path $_ } |
Select-Object -First 1
if (-not $iscc) { throw "ISCC.exe not found" }
Write-Host "using $iscc"
New-Item -ItemType Directory -Force -Path installer | Out-Null
& $iscc "/DPayloadDir=$(Resolve-Path $env:PAYLOAD)" "/DOutputDirectory=$(Resolve-Path installer)" build\setup.iss
- name: Upload unsigned installer for signing
if: runner.os == 'Windows' && inputs.sign
uses: actions/upload-artifact@v4
id: unsigned-installer
with:
name: unsigned-installer-${{ env.SHORT_SHA }}
path: installer/
if-no-files-found: error
- name: Sign installer
if: runner.os == 'Windows' && inputs.sign
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORG_ID }}
project-slug: PenguLoader
signing-policy-slug: release-signing
artifact-configuration-slug: zipped-installer
github-artifact-id: ${{ steps.unsigned-installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: installer
# Drop the intermediates so the run doesn't publish unsigned copies
# alongside the signed ones.
- name: Delete signing intermediates
if: runner.os == 'Windows' && inputs.sign
uses: geekyeggo/delete-artifact@v5
with:
name: |
unsigned-payload-${{ env.SHORT_SHA }}
unsigned-installer-${{ env.SHORT_SHA }}
# ─── Artifact upload (shared) ───────────────────────────────────────
- name: Upload portable artifact
uses: actions/upload-artifact@v4
with:
name: pengu-v${{ env.APP_VERSION }}-${{ env.SHORT_SHA }}-${{ matrix.platform }}
path: ${{ runner.os == 'Windows' && env.PAYLOAD || 'out' }}/
if-no-files-found: error
compression-level: 9
- name: Upload installer artifact (windows)
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
with:
name: pengu-v${{ env.APP_VERSION }}-${{ env.SHORT_SHA }}-installer
path: installer/
if-no-files-found: error
compression-level: 9