mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
ci(release): ship a native Windows ARM64 Studio
Build windows/arm64 on a native windows-11-arm runner and carry it through the signing chain as a second leg of every Windows stage: one tree per job, legs serial under the certum-signing group, a failed leg reruns alone. The package check learns app-arm64.7z and refuses any PE image that is not the leg's architecture, a new install smoke runs each installer on its own architecture before publish, and publish counts, names and hash-checks both architectures. The arm64 installer is packed with the 7-Zip BCJ filter: the default ARM64 filter yields an archive the NSIS extractor cannot decode, and the installer then exits 0 with every executable missing. Signing contract tests pin the matrix, the serial legs and the secret-free stages.
This commit is contained in:
1 parent
bbdcef877e
commit
0ae7693526
10 files changed
+621
-126
No files matched your search
@@ -37,6 +37,7 @@
|
||||
/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola
|
||||
/.github/actions/setup-certum/ @esengine @SivanCola
|
||||
/cmd/signpath-contract/ @esengine @SivanCola
|
||||
/scripts/install-smoke.ps1 @esengine @SivanCola
|
||||
/scripts/sign-certum.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-package.ps1 @esengine @SivanCola
|
||||
|
||||
@@ -149,10 +149,21 @@ jobs:
|
||||
# vocabularies disagree (x64 / amd64) and a reviewer has to see both.
|
||||
# Absent means the runner's own, which is what Windows and Linux build.
|
||||
# artifact is the exact name publish downloads this leg's packages by.
|
||||
# goarch and unpacked belong to the Windows legs: the release
|
||||
# architecture the package is named for, and the directory
|
||||
# electron-builder leaves its bundle in (win-unpacked for x64 alone).
|
||||
include:
|
||||
- platform: windows/amd64
|
||||
runner: windows-latest
|
||||
goarch: amd64
|
||||
unpacked: win-unpacked
|
||||
artifact: studio-build-windows-amd64
|
||||
- platform: windows/arm64
|
||||
runner: windows-11-arm
|
||||
ebarch: arm64
|
||||
goarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
artifact: studio-build-windows-arm64
|
||||
- platform: darwin/amd64
|
||||
runner: macos-latest
|
||||
ebarch: x64
|
||||
@@ -267,6 +278,7 @@ jobs:
|
||||
# kernel on a remote machine, and those routes name a release by it.
|
||||
env:
|
||||
REASONIX_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
REASONIX_GOARCH: ${{ matrix.goarch }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The helper the Linux self-update runs under pkexec, staged where the
|
||||
@@ -289,6 +301,14 @@ jobs:
|
||||
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
|
||||
pnpm exec electron-builder --dir $arch -c.extraMetadata.version="${version#v}"
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a bundle that starts
|
||||
# proves nothing about its architecture: every PE image in it has to be
|
||||
# built for the architecture the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: node desktop/electron/packaging/pe.js "dist/${{ matrix.unpacked }}" "${{ matrix.goarch }}"
|
||||
|
||||
# Notarization happens to the bundle, so it sits between the two packaging
|
||||
# steps the way Authenticode does on Windows. Stapling here is what lets a
|
||||
# first launch work offline; a ticket Apple has but the bundle does not is
|
||||
@@ -345,8 +365,8 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: dist/win-unpacked
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -357,17 +377,24 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS != 'true'
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# The arm64 installer has to be packed with the BCJ filter: 7-Zip
|
||||
# applies an ARM64 filter to ARM64 executables by default, the NSIS
|
||||
# extractor cannot decode it, and the installer then exits 0 with
|
||||
# every executable missing.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.goarch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
arch="--${{ matrix.ebarch || 'x64' }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
@@ -383,22 +410,36 @@ jobs:
|
||||
# package check run between them on their own runners with no secrets. Both
|
||||
# signing jobs share one group with the Studio smoke test, so no two runs
|
||||
# drive the same SimplySign account at once.
|
||||
#
|
||||
# Each signing stage is a matrix over the Windows architectures with one tree
|
||||
# per job. max-parallel: 1 keeps the legs strictly serial, which the shared
|
||||
# certum-signing group needs: a group holds one running and one waiting job,
|
||||
# so two legs started together would have the waiting one cancelled by the
|
||||
# next arrival. A failed leg reruns alone; the others' results are kept.
|
||||
windows-sign-payload:
|
||||
name: sign the Windows PE files (Certum)
|
||||
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
|
||||
needs: [resolve, signing-contract, build]
|
||||
if: vars.STUDIO_SIGNING_ENABLED == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: certum-signing
|
||||
cancel-in-progress: false
|
||||
# One output per architecture, because matrix legs share a job's output map:
|
||||
# a leg writes only its own and an empty value never replaces another's.
|
||||
# Recorded here because no later job can write this job's outputs: the
|
||||
# installer signing job refuses a payload artifact that no longer hashes to it.
|
||||
outputs:
|
||||
payload-digest: ${{ steps.verify.outputs.digest }}
|
||||
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest-arm64: ${{ matrix.arch == 'arm64' && steps.verify.outputs.digest || '' }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -439,7 +480,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-bundle
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
path: signing/app
|
||||
|
||||
# Stage one: every PE file in the application tree that Microsoft has
|
||||
@@ -486,6 +527,7 @@ jobs:
|
||||
$ErrorActionPreference = 'Stop'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-PayloadDirectory signing/app `
|
||||
-Architecture ${{ matrix.arch }} `
|
||||
-DigestPath "$env:RUNNER_TEMP/payload-digest" `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
@@ -493,7 +535,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: signing/app
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -501,10 +543,20 @@ jobs:
|
||||
# No environment and no secrets: electron-builder and its dependencies run
|
||||
# here, on a runner that never held a signing session.
|
||||
windows-package:
|
||||
name: package Windows from the signed tree
|
||||
name: package Windows from the signed tree (${{ matrix.arch }})
|
||||
needs: [resolve, windows-sign-payload]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
ebarch: x64
|
||||
unpacked: win-unpacked
|
||||
- arch: arm64
|
||||
ebarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -537,8 +589,8 @@ jobs:
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
path: dist/win-unpacked
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
|
||||
# Both packages come from the signed tree, so the archive carries the
|
||||
# signed files too rather than a second unsigned copy. The zip is packed
|
||||
@@ -546,24 +598,27 @@ jobs:
|
||||
- name: Package Windows from the signed bundle
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# See the build job: the arm64 installer must be packed with BCJ.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
dist/ReasonixStudio-windows-amd64.zip
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -572,15 +627,21 @@ jobs:
|
||||
# secrets, never in a job that holds the Certum credentials. Its outputs are
|
||||
# the hashes of the two files it checked, which the signing job requires.
|
||||
windows-verify-package:
|
||||
name: check the Windows packages against the signed tree
|
||||
name: check the Windows packages against the signed tree (${{ matrix.arch }})
|
||||
needs: [resolve, windows-sign-payload, windows-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
installer-sha256: ${{ steps.check.outputs.installer-sha256 }}
|
||||
archive-sha256: ${{ steps.check.outputs.archive-sha256 }}
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
archive-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
archive-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -589,12 +650,12 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
# 7-Zip is the runner image's, not the tag's.
|
||||
@@ -602,20 +663,26 @@ jobs:
|
||||
id: check
|
||||
shell: pwsh
|
||||
env:
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs.payload-digest }}
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
|
||||
run: |
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
|
||||
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT
|
||||
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
windows-sign-installer:
|
||||
name: sign the Windows installer (Certum)
|
||||
name: sign the Windows installer (Certum, ${{ matrix.arch }})
|
||||
needs: [resolve, windows-package, windows-verify-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -624,7 +691,8 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
# The signed installer's hash, which publish requires of what it downloads.
|
||||
outputs:
|
||||
installer-sha256: ${{ steps.signed.outputs.installer-sha256 }}
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -665,7 +733,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
# Only the two files windows-verify-package checked, byte for byte. This
|
||||
@@ -674,13 +742,13 @@ jobs:
|
||||
- name: Require the checked packages
|
||||
shell: pwsh
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs[format('installer-sha256-{0}', matrix.arch)] }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', matrix.arch)] }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = [ordered]@{
|
||||
'ReasonixStudio-windows-amd64-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-amd64.zip' = $env:ARCHIVE_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}.zip' = $env:ARCHIVE_SHA256
|
||||
}
|
||||
$present = @(Get-ChildItem -LiteralPath dist -Recurse -Force | ForEach-Object { $_.Name })
|
||||
if (Compare-Object -CaseSensitive @($expected.Keys) $present) { throw "dist holds $($present -join ', ')" }
|
||||
@@ -702,7 +770,7 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
|
||||
# The session signs for any process on the runner while it is up.
|
||||
- name: Disconnect from Certum
|
||||
@@ -718,7 +786,7 @@ jobs:
|
||||
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installer = 'dist/ReasonixStudio-windows-amd64-installer.exe'
|
||||
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-FilePath $installer `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
@@ -728,11 +796,60 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-windows-signed
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist/ReasonixStudio-*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Installs each Windows installer where it is meant to run (the same script
|
||||
# the Studio workflow runs on pull requests, against the unsigned installer).
|
||||
# The package check unpacks with the runner's modern 7-Zip, which reads
|
||||
# archives the NSIS extractor cannot, so only running the installer shows that
|
||||
# it installs every executable. No environment and no secrets.
|
||||
windows-install-smoke:
|
||||
name: install the Windows package (${{ matrix.arch }})
|
||||
needs: [resolve, build, windows-sign-installer]
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
(needs.windows-sign-installer.result == 'success' ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-installer.result == 'skipped')) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: windows-latest
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- name: Install, check and uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
# The kernel a Studio installs on another machine. This line publishes it
|
||||
# under its own tag, because the install routes name a release by the version
|
||||
# the local build reports — and a line that ships no archive leaves every one
|
||||
@@ -803,12 +920,13 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: sign, publish and mirror
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer]
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
|
||||
# The Windows signing jobs may be skipped only while the switch is off; with
|
||||
# it on, any of them failing, cancelled or absent blocks the release.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
needs.cli.result == 'success' && ((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.cli.result == 'success' && needs.windows-install-smoke.result == 'success' &&
|
||||
((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.windows-package.result == 'success' && needs.windows-verify-package.result == 'success' &&
|
||||
needs.windows-sign-installer.result == 'success') ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' &&
|
||||
@@ -870,43 +988,63 @@ jobs:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed
|
||||
path: staging/studio-windows
|
||||
name: studio-windows-signed-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
# Any job can upload an artifact, so the signed Windows packages are
|
||||
# taken only if they are the bytes the signing jobs recorded: the
|
||||
# installer as signed, the zip as windows-verify-package checked it.
|
||||
# taken only if they are the bytes the signing jobs recorded: each
|
||||
# installer as signed, each zip as windows-verify-package checked it.
|
||||
- name: Require the recorded Windows packages
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-sign-installer.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
INSTALLER_SHA256_AMD64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'amd64')] }}
|
||||
ARCHIVE_SHA256_AMD64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'amd64')] }}
|
||||
INSTALLER_SHA256_ARM64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'arm64')] }}
|
||||
ARCHIVE_SHA256_ARM64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'arm64')] }}
|
||||
run: |
|
||||
check() {
|
||||
local file="staging/studio-windows/$1" want="$2" got
|
||||
local file="$1/$2" want="$3" got
|
||||
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $1"
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
|
||||
return 1
|
||||
fi
|
||||
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$1 is $got, the signing jobs recorded $want"
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
files="$(cd staging/studio-windows && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-amd64-installer.exe ReasonixStudio-windows-amd64.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
check ReasonixStudio-windows-amd64-installer.exe "$INSTALLER_SHA256"
|
||||
check ReasonixStudio-windows-amd64.zip "$ARCHIVE_SHA256"
|
||||
for arch in amd64 arm64; do
|
||||
dir="staging/studio-windows-$arch"
|
||||
files="$(cd "$dir" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-$arch-installer.exe ReasonixStudio-windows-$arch.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed-$arch holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
upper="${arch^^}"
|
||||
installer="INSTALLER_SHA256_$upper"
|
||||
archive="ARCHIVE_SHA256_$upper"
|
||||
check "$dir" "ReasonixStudio-windows-$arch-installer.exe" "${!installer}"
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-amd64
|
||||
path: staging/studio-windows
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -929,13 +1067,13 @@ jobs:
|
||||
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
|
||||
find staging/cli-archives -type f -print0)
|
||||
ls -la dist
|
||||
# Two Windows artifacts (installer, portable zip), two per macOS
|
||||
# architecture (dmg, zip) and the Linux .deb. Fewer means an artifact
|
||||
# was lost between jobs, and a partial release is worse than a failed
|
||||
# one.
|
||||
# Two Windows artifacts (installer, portable zip) per architecture,
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 7 ]; then
|
||||
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
if [ "$count" -lt 9 ]; then
|
||||
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
@@ -946,7 +1084,11 @@ jobs:
|
||||
# Each macOS architecture by name, not darwin-*: a glob passes on
|
||||
# whichever one shipped, and the missing one is a platform left on
|
||||
# the version it is running with nothing saying so.
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
|
||||
# Each Windows architecture by name for the same reason: a glob passes
|
||||
# on whichever installer shipped.
|
||||
for required in 'ReasonixStudio-linux-*.deb' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
find dist -type f -name "$required" | grep -q . || {
|
||||
echo "::error::no $required in the release; its packager did not run"
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|\.github/workflows/studio\.yml$)'; then
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
fi
|
||||
@@ -254,58 +254,58 @@ jobs:
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# Run on the machine the package is for: the shell's own binary as a
|
||||
# node, the kernel's identity flag, then the installer end to end, per-user
|
||||
# and silent, into a folder of its own.
|
||||
- name: The package starts, installs and uninstalls
|
||||
# The release packages a signed copy of the bundle and then reads both
|
||||
# packages back against it. The same recipe on an unsigned copy, so the
|
||||
# packaging contract (the installer's one application archive, the pinned
|
||||
# elevate.exe, every PE image's architecture) is exercised on pull
|
||||
# requests and not first by a release.
|
||||
- name: The packages read back against the bundle they were made from
|
||||
if: matrix.platform == 'windows'
|
||||
shell: bash
|
||||
env:
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
rm -rf payload nsis-src packed zip-src
|
||||
cp -r "$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)" payload
|
||||
rm -f payload/resources/elevate.exe
|
||||
# The NSIS run writes elevate.exe into the tree it packs, so each
|
||||
# packager gets a copy and payload stays what was signed.
|
||||
cp -r payload zip-src
|
||||
cp -r payload nsis-src
|
||||
cd desktop/electron
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed-zip
|
||||
pnpm exec electron-builder --prepackaged ../../nsis-src --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed
|
||||
mv ../../packed-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../packed/
|
||||
ls -la ../../packed
|
||||
|
||||
- name: verify-windows-package.ps1 accepts them
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
REASONIX_HOME: ${{ runner.temp }}/reasonix-home
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$unpacked = Get-ChildItem dist -Directory -Filter 'win*-unpacked' | Select-Object -First 1
|
||||
# A windowed executable returns to the shell at once with no stdout, so
|
||||
# it is started and waited for with its output redirected to a file.
|
||||
$env:ELECTRON_RUN_AS_NODE = '1'
|
||||
$out = Join-Path $env:RUNNER_TEMP 'shell-arch.txt'
|
||||
$node = Start-Process -FilePath "$($unpacked.FullName)/Reasonix Studio.exe" -ArgumentList '-p', 'process.arch' -RedirectStandardOutput $out -Wait -PassThru
|
||||
Remove-Item Env:ELECTRON_RUN_AS_NODE
|
||||
$arch = (Get-Content -Raw $out).Trim()
|
||||
$want = if ($env:ARCH -eq 'amd64') { 'x64' } else { $env:ARCH }
|
||||
if ($node.ExitCode -ne 0 -or $arch -ne $want) { throw "the shell reports arch '$arch' (exit $($node.ExitCode)), want $want" }
|
||||
& "$($unpacked.FullName)/resources/bin/reasonix-studio-host.exe" -instance-id
|
||||
if ($LASTEXITCODE -ne 0) { throw "the kernel exited $LASTEXITCODE" }
|
||||
. ./scripts/windows-signing-lib.ps1
|
||||
$digest = Get-ManifestDigest -Manifest @(Get-TreeManifest -Root payload)
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $digest `
|
||||
-InstallerPath "packed/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "packed/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath "$env:RUNNER_TEMP/verify-output.txt" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
Get-Content "$env:RUNNER_TEMP/verify-output.txt"
|
||||
|
||||
$installer = (Get-ChildItem dist -Filter "ReasonixStudio-windows-$env:ARCH-installer.exe").FullName
|
||||
$target = Join-Path $env:RUNNER_TEMP 'rx-install'
|
||||
$run = Start-Process -FilePath $installer -ArgumentList '/S', "/D=$target" -Wait -PassThru
|
||||
if ($run.ExitCode -ne 0) { throw "the installer exited $($run.ExitCode)" }
|
||||
$clock = [Diagnostics.Stopwatch]::StartNew()
|
||||
$name = [IO.Path]::GetFileNameWithoutExtension($installer)
|
||||
while (-not (Test-Path "$target/Reasonix Studio.exe") -and $clock.Elapsed.TotalSeconds -lt 120) { Start-Sleep -Seconds 2 }
|
||||
Write-Host "application appeared after $([int]$clock.Elapsed.TotalSeconds)s past the installer's exit; installer processes still alive: $(@(Get-Process -Name $name -ErrorAction SilentlyContinue).Count)"
|
||||
if (-not (Test-Path "$target/Reasonix Studio.exe")) {
|
||||
Write-Host "target holds: $((Get-ChildItem -Force $target -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')"
|
||||
Write-Host "files under the target: $((Get-ChildItem -Recurse -File $target -ErrorAction SilentlyContinue).Count); resources holds: $((Get-ChildItem -Force "$target/resources" -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')"
|
||||
Write-Host "Defender detections: $((Get-MpThreatDetection -ErrorAction SilentlyContinue | ForEach-Object { $_.Resources }) -join '; ')"
|
||||
Write-Host "real-time protection: $((Get-MpComputerStatus -ErrorAction SilentlyContinue).RealTimeProtectionEnabled)"
|
||||
Write-Host "PE files present: $((Get-ChildItem -Recurse -File $target -Include *.exe,*.dll,*.node | ForEach-Object { $_.FullName.Substring($target.Length) }) -join ', ')"
|
||||
Write-Host "Defender events: $((Get-WinEvent -FilterHashtable @{LogName='Microsoft-Windows-Windows Defender/Operational'; Id=1116,1117,1121,1122} -MaxEvents 5 -ErrorAction SilentlyContinue | ForEach-Object { $_.Message -replace '\s+',' ' }) -join ' || ')"
|
||||
Set-MpPreference -DisableRealtimeMonitoring $true
|
||||
$again = Join-Path $env:RUNNER_TEMP 'rx-install-nodefender'
|
||||
Start-Process -FilePath $installer -ArgumentList '/S', "/D=$again" -Wait | Out-Null
|
||||
Start-Sleep -Seconds 20
|
||||
Write-Host "without real-time scanning the application is present: $(Test-Path "$again/Reasonix Studio.exe")"
|
||||
throw 'the installer reported success and installed nothing'
|
||||
}
|
||||
node desktop/electron/packaging/pe.js $target $env:ARCH
|
||||
if ($LASTEXITCODE -ne 0) { throw 'the installed tree is not the package architecture' }
|
||||
|
||||
$run = Start-Process -FilePath "$target/Uninstall Reasonix Studio.exe" -ArgumentList '/S', "_?=$target" -Wait -PassThru
|
||||
if ($run.ExitCode -ne 0) { throw "the uninstaller exited $($run.ExitCode)" }
|
||||
if (Test-Path "$target/Reasonix Studio.exe") { throw 'the uninstaller left the application behind' }
|
||||
# The release's own install smoke, against the unsigned installer: the
|
||||
# package is installed where it is meant to run, its tree checked, and
|
||||
# removed.
|
||||
- name: The package installs, runs and uninstalls
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
- name: Keep the arm64 installer for the x64 refusal check
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
|
||||
@@ -11,6 +11,7 @@ fingerprint_files:
|
||||
- .github/workflows/release-studio.yml
|
||||
- .github/workflows/studio-certum-signing-smoke.yml
|
||||
- .github/actions/setup-certum/action.yml
|
||||
- scripts/install-smoke.ps1
|
||||
- scripts/sign-certum.ps1
|
||||
- scripts/verify-windows-authenticode.ps1
|
||||
- scripts/verify-windows-package.ps1
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var windowsArchitectures = []string{"amd64", "arm64"}
|
||||
|
||||
func matrixArches(t *testing.T, job *yaml.Node) []string {
|
||||
t.Helper()
|
||||
matrix := mappingValue(mappingValue(job, "strategy"), "matrix")
|
||||
if matrix == nil {
|
||||
t.Fatal("job has no matrix")
|
||||
}
|
||||
var arches []string
|
||||
if list := mappingValue(matrix, "arch"); list != nil {
|
||||
for _, n := range list.Content {
|
||||
arches = append(arches, n.Value)
|
||||
}
|
||||
}
|
||||
if include := mappingValue(matrix, "include"); include != nil {
|
||||
for _, entry := range include.Content {
|
||||
if arch := mappingScalar(entry, "arch"); arch != "" {
|
||||
arches = append(arches, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(arches)
|
||||
return arches
|
||||
}
|
||||
|
||||
// One tree per job: each Windows stage runs once for every architecture the
|
||||
// build produces, so no architecture reaches publish without passing through
|
||||
// signing, packaging and verification.
|
||||
func TestEveryWindowsSigningStageCoversEveryArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-package", "windows-verify-package", "windows-sign-installer", "windows-install-smoke"} {
|
||||
if got := matrixArches(t, mappingValue(jobs, name)); !slices.Equal(got, windowsArchitectures) {
|
||||
t.Errorf("%s covers %v, want %v", name, got, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
build := mappingValue(mappingValue(mappingValue(mappingValue(jobs, "build"), "strategy"), "matrix"), "include")
|
||||
var built []string
|
||||
for _, entry := range build.Content {
|
||||
if goarch := mappingScalar(entry, "goarch"); goarch != "" {
|
||||
built = append(built, goarch)
|
||||
}
|
||||
}
|
||||
slices.Sort(built)
|
||||
if !slices.Equal(built, windowsArchitectures) {
|
||||
t.Errorf("build produces Windows bundles for %v, want %v", built, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
|
||||
// Certum's group holds one running and one waiting job, and a newer waiting job
|
||||
// cancels the older. Legs of one matrix would race for that single slot, so the
|
||||
// signing stages run them strictly one after another.
|
||||
func TestWindowsSigningLegsRunOneAtATime(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-sign-installer"} {
|
||||
job := mappingValue(jobs, name)
|
||||
strategy := mappingValue(job, "strategy")
|
||||
if got := mappingScalar(strategy, "max-parallel"); got != "1" {
|
||||
t.Errorf("%s max-parallel = %q, want 1", name, got)
|
||||
}
|
||||
if got := mappingScalar(strategy, "fail-fast"); got != "false" {
|
||||
t.Errorf("%s fail-fast = %q; a failed leg must leave the others to finish so only it reruns", name, got)
|
||||
}
|
||||
group := mappingScalar(mappingValue(job, "concurrency"), "group")
|
||||
if group != "certum-signing" {
|
||||
t.Errorf("%s concurrency group = %q, want certum-signing", name, group)
|
||||
}
|
||||
if mappingScalar(job, "environment") != "studio-release" {
|
||||
t.Errorf("%s lost its studio-release environment", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packaging, package verification and the install smoke parse archives and run
|
||||
// installers, so none of them may hold an environment or a secret.
|
||||
func TestWindowsPackagingStagesHoldNoSecrets(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-package", "windows-verify-package", "windows-install-smoke"} {
|
||||
job := mappingValue(jobs, name)
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Errorf("%s declares an environment", name)
|
||||
}
|
||||
if nodeContains(job, "secrets.") {
|
||||
t.Errorf("%s references a secret", name)
|
||||
}
|
||||
if nodeContains(job, "id-token") {
|
||||
t.Errorf("%s requests an id-token", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The arm64 installer reads back through the runner's 7-Zip, which decodes
|
||||
// filters the NSIS extractor cannot, so the filter that extractor reads is
|
||||
// pinned on every job that packs one, and publish waits for an installer to run.
|
||||
func TestArm64InstallerIsPackedWithTheFilterTheExtractorReads(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
pack := map[string]string{"build": "Package Windows from the bundle", "windows-package": "Package Windows from the signed bundle"}
|
||||
for job, step := range pack {
|
||||
env := mappingValue(stepNamed(t, mappingValue(jobs, job), step), "env")
|
||||
if got := mappingScalar(env, "ELECTRON_BUILDER_7Z_FILTER"); !strings.Contains(got, "'BCJ'") {
|
||||
t.Errorf("%s / %s: ELECTRON_BUILDER_7Z_FILTER = %q, want BCJ for arm64", job, step, got)
|
||||
}
|
||||
}
|
||||
needs := mappingValue(mappingValue(jobs, "publish"), "needs")
|
||||
var names []string
|
||||
for _, n := range needs.Content {
|
||||
names = append(names, n.Value)
|
||||
}
|
||||
if !slices.Contains(names, "windows-install-smoke") {
|
||||
t.Error("publish does not wait for windows-install-smoke")
|
||||
}
|
||||
if !strings.Contains(mappingScalar(mappingValue(jobs, "publish"), "if"), "needs.windows-install-smoke.result == 'success'") {
|
||||
t.Error("publish does not require windows-install-smoke to have succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix legs share one output map and the last non-empty write wins, so each
|
||||
// leg writes only its own architecture's key. A key that is not guarded by the
|
||||
// leg's architecture would let one leg's value stand in for the other's.
|
||||
func TestWindowsOutputsAreOnePerArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
want := map[string][]string{
|
||||
"windows-sign-payload": {"payload-digest"},
|
||||
"windows-verify-package": {"installer-sha256", "archive-sha256"},
|
||||
"windows-sign-installer": {"installer-sha256"},
|
||||
}
|
||||
for name, keys := range want {
|
||||
outputs := mappingValue(mappingValue(jobs, name), "outputs")
|
||||
if outputs == nil {
|
||||
t.Fatalf("%s has no outputs", name)
|
||||
}
|
||||
if len(outputs.Content)/2 != len(keys)*len(windowsArchitectures) {
|
||||
t.Errorf("%s declares %d outputs, want %d", name, len(outputs.Content)/2, len(keys)*len(windowsArchitectures))
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, arch := range windowsArchitectures {
|
||||
got := mappingScalar(outputs, key+"-"+arch)
|
||||
if !strings.Contains(got, "matrix.arch == '"+arch+"'") {
|
||||
t.Errorf("%s output %s-%s = %q, want it guarded by matrix.arch == '%s'", name, key, arch, got, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The install smoke gates publish, so it has to run exactly when a Windows
|
||||
// package exists to run: after a signed installer, or after the build when
|
||||
// signing is off, and never after a failed signing chain.
|
||||
func TestInstallSmokeRunsOnlyWhereAPackageExists(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
smoke := mappingValue(jobs, "windows-install-smoke")
|
||||
cond := mappingScalar(smoke, "if")
|
||||
for _, want := range []string{
|
||||
"!cancelled()",
|
||||
"needs.build.result == 'success'",
|
||||
"needs.windows-sign-installer.result == 'success'",
|
||||
"vars.STUDIO_SIGNING_ENABLED != 'true'",
|
||||
"needs.windows-sign-installer.result == 'skipped'",
|
||||
} {
|
||||
if !strings.Contains(cond, want) {
|
||||
t.Errorf("windows-install-smoke if lacks %q: %s", want, cond)
|
||||
}
|
||||
}
|
||||
run := mappingScalar(stepNamed(t, smoke, "Install, check and uninstall"), "run")
|
||||
if !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the smoke job does not run scripts/install-smoke.ps1, the script pull requests run")
|
||||
}
|
||||
studio := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
|
||||
pkg := mappingValue(jobsOf(t, studio), "package")
|
||||
if run := mappingScalar(stepNamed(t, pkg, "The package installs, runs and uninstalls"), "run"); !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the Studio package job does not run scripts/install-smoke.ps1, so pull requests would not exercise the release smoke")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
|
||||
if !strings.Contains(run, `"$count" -lt 9`) {
|
||||
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -53,13 +53,14 @@ Jobs in the run:
|
||||
| --- | --- | --- |
|
||||
| `resolve` | Validates the tag shape and that the commit is on `studio`. | fails on any other ref |
|
||||
| `signing-contract` | Validates `.signpath/contracts/release-signing.yml` against the workflows that reach the Certum credentials and prints its fingerprint. | fails on an undeclared signing workflow |
|
||||
| `build` | Builds windows/amd64, darwin/amd64, darwin/arm64, linux/amd64; signs macOS. With signing on, the Windows leg uploads its bundle instead of packaging it. | Apple secrets are required |
|
||||
| `windows-sign-payload` | Only with `STUDIO_SIGNING_ENABLED=true`. Refuses a bundle whose PE files differ from the declared list, signs the release PE files, verifies them and the two Microsoft-signed DLLs, and records a digest of the whole signed tree as a job output. Installs no toolchain. | shared concurrency group `certum-signing`, environment `studio-release` |
|
||||
| `windows-package` | Builds the installer and zip from the signed tree as it came. Holds no secrets. | none |
|
||||
| `windows-verify-package` | Checks the payload against the recorded digest, unpacks the zip and the installer, requires both trees to match the payload file for file, and outputs the SHA-256 of both packages. Holds no secrets and no environment. | none |
|
||||
| `windows-sign-installer` | Requires both packages to hash to the checked values, signs the installer, verifies its signature, and outputs the signed installer's SHA-256. Opens no archive. | shared concurrency group `certum-signing`, environment `studio-release` |
|
||||
| `build` | Builds windows/amd64, windows/arm64 (on a native `windows-11-arm` runner), darwin/amd64, darwin/arm64, linux/amd64; signs macOS. Every PE image in a Windows bundle must carry its architecture's machine type. With signing on, each Windows leg uploads its bundle instead of packaging it. | Apple secrets are required |
|
||||
| `windows-sign-payload` | Only with `STUDIO_SIGNING_ENABLED=true`. One leg per architecture, run one after the other (`max-parallel: 1`). Refuses a bundle whose PE files differ from the declared list or are not all the leg's architecture, signs the release PE files, verifies them and the two Microsoft-signed DLLs, and records a digest of the whole signed tree as a job output. Installs no toolchain. | shared concurrency group `certum-signing`, environment `studio-release` |
|
||||
| `windows-package` | Builds each architecture's installer and zip from its signed tree as it came; the arm64 installer is packed with `ELECTRON_BUILDER_7Z_FILTER=BCJ`. Holds no secrets. | none |
|
||||
| `windows-verify-package` | Per architecture: checks the payload against the recorded digest, unpacks the zip and the installer, requires both trees to match the payload file for file and the installer to carry exactly its architecture's application archive, and outputs the SHA-256 of both packages. Holds no secrets and no environment. | none |
|
||||
| `windows-sign-installer` | One leg per architecture, run one after the other. Requires both packages to hash to the checked values, signs the installer, verifies its signature, and outputs the signed installer's SHA-256. Opens no archive. | shared concurrency group `certum-signing`, environment `studio-release` |
|
||||
| `windows-install-smoke` | Runs `scripts/install-smoke.ps1` (the script pull requests run against the unsigned installer) with each architecture's installer on a runner of that architecture: silent per-user install, waits for the files to settle, checks the installed tree (the declared PE set, every image's architecture, the shell's own architecture, the kernel starting) and uninstalls. Holds no secrets and no environment. | `publish` waits for it |
|
||||
| `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive |
|
||||
| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub release (a stable tag is GitHub latest only when it is the highest stable `studio-v*` tag; a candidate is a prerelease), mirrors to R2. `latest.json` goes to R2 only, never to the GitHub release. | environment `studio-release`; skipped unless all four Windows signing jobs succeeded or signing is off; an unresolved `#N` stops it before signing |
|
||||
| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub release (a stable tag is GitHub latest only when it is the highest stable `studio-v*` tag; a candidate is a prerelease), mirrors to R2. `latest.json` goes to R2 only, never to the GitHub release. | environment `studio-release`; skipped unless `windows-install-smoke` and all four Windows signing jobs (every leg) succeeded, or signing is off (the smoke still runs); an unresolved `#N` stops it before signing |
|
||||
| `cli-gate` | Only with `STUDIO_PUBLISHES_CLI=true`. Requires `CLI_PUBLISH_FROZEN=true`. Checks out nothing and reads no secret. | fails while 1.x is not frozen, and then no other CLI job runs |
|
||||
| `cli-tag` | Only with `STUDIO_PUBLISHES_CLI=true`. For a stable or `-preview.N` version, creates the tag `vX.Y.Z` on the studio commit with the release tag identity (`RELEASE_TAG_TOKEN`, see below); an existing tag on that commit is kept, one elsewhere fails. | environment `studio-release`; runs after `publish` and `cli-gate`; checks out nothing, one inline step reads the token |
|
||||
| `cli-channels` | Only with `STUDIO_PUBLISHES_CLI=true` (unset today; 1.x owns the channels). Publishes `reasonix` and `@reasonix/cli-*` to npm with `--provenance`, then updates the Homebrew cask (not for a candidate). | no environment and no approval; `id-token: write` on this job only; runs after `publish`, `cli-gate` and `cli-tag` |
|
||||
@@ -108,10 +109,13 @@ Verify a published package with `npm view reasonix dist.attestations`: the attes
|
||||
|
||||
| ID | Expected | Command |
|
||||
| --- | --- | --- |
|
||||
| V1 | The release exists with 21 assets (per-platform packages, `.minisig` files, CLI archives, `SHA256SUMS`; no `latest.json`). A stable tag is not a prerelease and is GitHub latest when it is the highest stable tag; a candidate is a prerelease. | `gh release view studio-vX.Y.Z --json isPrerelease,assets --jq '.isPrerelease, (.assets \| length)'` |
|
||||
| V1 | The release exists with 25 assets (per-platform packages, `.minisig` files, CLI archives, `SHA256SUMS`; no `latest.json`). A stable tag is not a prerelease and is GitHub latest when it is the highest stable tag; a candidate is a prerelease. | `gh release view studio-vX.Y.Z --json isPrerelease,assets --jq '.isPrerelease, (.assets \| length)'` |
|
||||
| V2 | The catalog lists the new version first. | `curl -s https://dl.reasonix.io/studio/versions.json \| jq -r '.versions[0].tag'` |
|
||||
| V3 | The manifest is served from the mirror, and, once a Studio release is GitHub latest, the path 1.x updaters fall back to answers 404. | `curl -sI https://dl.reasonix.io/studio-vX.Y.Z/latest.json \| head -1`; `curl -sI https://github.com/esengine/DeepSeek-Reasonix/releases/latest/download/latest.json \| head -1` |
|
||||
| V4 | The body contains the version notes and the standing install text. | `gh release view studio-vX.Y.Z --json body --jq .body` |
|
||||
| V5 | The manifest carries both Windows platforms, each resolving to its own installer. | `curl -s https://dl.reasonix.io/studio-vX.Y.Z/latest.json \| jq -r '.platforms["windows-amd64"].url, .platforms["windows-arm64"].url'` |
|
||||
| V6 | The arm64 installer is signed, and every PE image in it is ARM64 except the 32-bit `resources/elevate.exe` and uninstaller. | On Windows: `Get-AuthenticodeSignature ReasonixStudio-windows-arm64-installer.exe`, then `node desktop/electron/packaging/pe.js <unpacked-zip-dir> arm64` over the extracted portable zip. |
|
||||
| V7 | `windows-install-smoke` ran both legs and `windows-sign-payload` / `windows-sign-installer` each show an amd64 and an arm64 leg. | `gh run view <run> --json jobs --jq '.jobs[] \| select(.name \| test("Windows\|Certum")) \| "\(.name) \(.conclusion)"'` |
|
||||
|
||||
## 5. Recovery
|
||||
|
||||
@@ -122,6 +126,7 @@ Verify a published package with `npm view reasonix dist.attestations`: the attes
|
||||
| A signing job fails with an error titled `studio-signing.*`. | A credential or an expected-signer variable is missing or malformed; the title names which. | Fix it (section 7), then dispatch the same tag. |
|
||||
| A signing job fails at `Connect to Certum` or `Sign the executables`. | SimplySign login, OTP or certificate problem. | Run the smoke test (section 7). To ship unsigned instead, set `STUDIO_SIGNING_ENABLED=false` and dispatch. |
|
||||
| A signing job fails with `Unexpected signer subject` or `thumbprint`. | The certificate changed, or a value was copied wrong. | Compare with the smoke test's summary; correct `STUDIO_SIGNING_SUBJECT` or `CERTUM_KEY_ID`. |
|
||||
| One architecture's signing leg failed. | The legs of `windows-sign-payload` and `windows-sign-installer` are independent and serial. | Rerun the failed jobs only (`gh run rerun <run> --failed`): the other architecture's legs keep their results, and the failed leg's dependents run again. The unsigned bundle, signed payload and packaged artifacts are kept for one day, so a rerun the next day fails on an expired artifact: start the whole run again instead (`gh workflow run release-studio.yml --ref studio -f tag=studio-vX.Y.Z`, section 5). |
|
||||
| A signing job waits before starting. | A Studio or 1.x smoke test or release holds `certum-signing`. | Wait. A second run queued behind the same group cancels the earlier queued one; dispatch again if that happens. |
|
||||
| Signing is restored after an unsigned release. | Artifacts were published unsigned. | Set `STUDIO_SIGNING_ENABLED=true` and dispatch the same tag; `publish` replaces the assets. |
|
||||
| The body is missing or wrong. | Notes are read from the tag's commit, not the branch. | `gh release edit studio-vX.Y.Z --notes-file <file>`; append the standing text from the previous body. |
|
||||
@@ -233,7 +238,9 @@ The release PE files as of 2.20.3:
|
||||
| --- | --- |
|
||||
| Embedded signature only | Verification reads each file's embedded signature through SignTool. `Get-AuthenticodeSignature` answers from the Windows catalog for `d3dcompiler_47.dll`. |
|
||||
| `elevate.exe` pin | The hash belongs to the electron-builder in the lockfile. An upgrade that changes it fails `windows-verify-package`; review the new file and update the pin. |
|
||||
| One architecture | The installer must carry exactly one application archive, `app-64.7z`. |
|
||||
| One architecture | The installer must carry exactly one application archive: `app-64.7z` for amd64, `app-arm64.7z` for arm64. |
|
||||
| Architecture of the files | Every PE image in a signed tree and in the installed tree is built for the leg's architecture; only the installer-written 32-bit `resources/elevate.exe` differs. |
|
||||
| arm64 packing filter | The arm64 installer is packed with `ELECTRON_BUILDER_7Z_FILTER=BCJ`. 7-Zip's default ARM64 filter produces an archive the NSIS extractor cannot decode: the installer exits 0 and installs no executable. The package check cannot see this, because it unpacks with the runner's newer 7-Zip, so `windows-install-smoke` runs the installer. |
|
||||
| Installer code | The NSIS code comes from `windows-package`. Its application tree is checked; its installer logic is not. |
|
||||
|
||||
How the payload is carried from signing to publishing:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Installs a Windows installer where it is meant to run, silently and per user,
|
||||
# checks what landed, and uninstalls. The package check unpacks with the
|
||||
# runner's modern 7-Zip, which reads archives the NSIS extractor cannot, so only
|
||||
# running the installer shows that it installs every executable. Holds no
|
||||
# credentials: it runs on pull requests against the unsigned installer and in
|
||||
# the release against the signed one.
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Installer,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("amd64", "arm64")]
|
||||
[string]$Architecture
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
. (Join-Path $PSScriptRoot "windows-signing-lib.ps1")
|
||||
|
||||
$temp = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() }
|
||||
$target = Join-Path $temp ("rx-install-" + [guid]::NewGuid().ToString("N"))
|
||||
$env:REASONIX_HOME = Join-Path $temp "reasonix-home"
|
||||
|
||||
$run = Start-Process -FilePath (Resolve-Path -LiteralPath $Installer).Path -ArgumentList "/S", "/D=$target" -Wait -PassThru
|
||||
if ($run.ExitCode -ne 0) { throw "the installer exited $($run.ExitCode)" }
|
||||
|
||||
# The installer can exit before its files are on disk (real-time scanning holds
|
||||
# them), so the tree is read once the application is there and the file count
|
||||
# has stopped moving.
|
||||
function Wait-InstallSettled {
|
||||
$clock = [Diagnostics.Stopwatch]::StartNew()
|
||||
$last = -1
|
||||
$stable = 0
|
||||
while ($clock.Elapsed.TotalSeconds -lt 120) {
|
||||
$count = @(Get-ChildItem -LiteralPath $target -Recurse -File -Force -ErrorAction SilentlyContinue).Count
|
||||
if ((Test-Path -LiteralPath (Join-Path $target "Reasonix Studio.exe")) -and $count -eq $last) { $stable++ } else { $stable = 0 }
|
||||
if ($stable -ge 3) { break }
|
||||
$last = $count
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
Write-Host "install settled $([int]$clock.Elapsed.TotalSeconds)s after the installer exited, $last files"
|
||||
}
|
||||
Wait-InstallSettled
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $target "Reasonix Studio.exe"))) {
|
||||
Write-Host "target holds: $((Get-ChildItem -Force $target -ErrorAction SilentlyContinue | ForEach-Object Name) -join ', ')"
|
||||
throw "the installer reported success and installed nothing"
|
||||
}
|
||||
|
||||
# Every executable the release declares is on disk, and nothing else that is a
|
||||
# PE image besides what the installer writes itself.
|
||||
$found = @(Get-PeFiles -Root $target | Where-Object { $script:InstallerWrittenX86Pe -cnotcontains $_ })
|
||||
$declared = @($script:ReleaseSignedPe + $script:MicrosoftSignedPe)
|
||||
$diff = @(Compare-Object -CaseSensitive ($declared | Sort-Object -CaseSensitive) ($found | Sort-Object -CaseSensitive))
|
||||
if ($diff.Count -gt 0) {
|
||||
foreach ($d in $diff) { Write-Host "::error title=studio-install.pe-set::$($d.InputObject) $($d.SideIndicator)" }
|
||||
throw "the installed PE set differs from the declared one"
|
||||
}
|
||||
foreach ($owned in $script:InstallerWrittenX86Pe) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $target $owned))) { throw "the installer did not write $owned" }
|
||||
}
|
||||
Assert-PeArchitecture -Root $target -Architecture $Architecture
|
||||
|
||||
$env:ELECTRON_RUN_AS_NODE = "1"
|
||||
$out = Join-Path $temp "shell-arch.txt"
|
||||
$node = Start-Process -FilePath (Join-Path $target "Reasonix Studio.exe") -ArgumentList "-p", "process.arch" -RedirectStandardOutput $out -Wait -PassThru
|
||||
Remove-Item Env:ELECTRON_RUN_AS_NODE
|
||||
$want = if ($Architecture -eq "amd64") { "x64" } else { $Architecture }
|
||||
$got = (Get-Content -Raw $out).Trim()
|
||||
if ($node.ExitCode -ne 0 -or $got -ne $want) { throw "the installed shell reports '$got' (exit $($node.ExitCode)), want $want" }
|
||||
& (Join-Path $target "resources/bin/reasonix-studio-host.exe") -instance-id
|
||||
if ($LASTEXITCODE -ne 0) { throw "the installed kernel exited $LASTEXITCODE" }
|
||||
|
||||
$run = Start-Process -FilePath (Join-Path $target "Uninstall Reasonix Studio.exe") -ArgumentList "/S", "_?=$target" -Wait -PassThru
|
||||
if ($run.ExitCode -ne 0) { throw "the uninstaller exited $($run.ExitCode)" }
|
||||
if (Test-Path -LiteralPath (Join-Path $target "Reasonix Studio.exe")) { throw "the uninstaller left the application behind" }
|
||||
Write-Host "Installed, ran and removed the $Architecture installer."
|
||||
@@ -11,6 +11,10 @@ param(
|
||||
[Parameter(ParameterSetName = "Payload")]
|
||||
[string]$DigestPath,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Payload")]
|
||||
[ValidateSet("amd64", "arm64")]
|
||||
[string]$Architecture,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "File")]
|
||||
[string]$FilePath,
|
||||
|
||||
@@ -33,6 +37,7 @@ if ($PSCmdlet.ParameterSetName -eq "File") {
|
||||
|
||||
$root = (Resolve-Path -LiteralPath $PayloadDirectory).Path
|
||||
Assert-DeclaredPeSet -Root $root
|
||||
Assert-PeArchitecture -Root $root -Architecture $Architecture
|
||||
foreach ($name in $script:ReleaseSignedPe) {
|
||||
Assert-EmbeddedSignature -Path (Join-Path $root $name) -Thumbprint $ExpectedThumbprint -Subject $ExpectedSubject
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ param(
|
||||
[string]$PortableArchivePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath
|
||||
[string]$OutputPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet("amd64", "arm64")]
|
||||
[string]$Architecture
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -71,6 +75,7 @@ if ($payloadDigest -ne $ExpectedPayloadDigest) {
|
||||
throw "Signed payload digest $payloadDigest does not match the $ExpectedPayloadDigest the signing job recorded"
|
||||
}
|
||||
Assert-DeclaredPeSet -Root $payloadRoot
|
||||
Assert-PeArchitecture -Root $payloadRoot -Architecture $Architecture
|
||||
|
||||
# Hashed before either is opened, so the hashes name the bytes that were checked.
|
||||
$installerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $InstallerPath).Hash.ToLowerInvariant()
|
||||
@@ -83,16 +88,19 @@ try {
|
||||
Assert-TreeMatchesPayload -Label "Portable archive" -Root $portableRoot -PayloadManifest $payloadManifest
|
||||
|
||||
# electron-builder's NSIS installer carries each architecture's application
|
||||
# as an app-<arch>.7z among its plugins; this release builds x64 alone.
|
||||
# as an app-<arch>.7z among its plugins; electron-builder names the x64 one
|
||||
# app-64.7z. A package for one architecture carries that architecture's alone.
|
||||
$installerRoot = Join-Path $extractRoot "installer"
|
||||
Invoke-SevenZip -Archive $InstallerPath -Destination $installerRoot
|
||||
$appArchives = @(Get-ChildItem -LiteralPath (Join-Path $installerRoot '$PLUGINSDIR') -Filter 'app-*.7z' -File -ErrorAction SilentlyContinue |
|
||||
ForEach-Object Name)
|
||||
if ($appArchives.Count -ne 1 -or $appArchives[0] -cne 'app-64.7z') {
|
||||
throw "Installer must carry exactly one application archive, app-64.7z; found: $($appArchives -join ', ')"
|
||||
$appArchive = @{ amd64 = 'app-64.7z'; arm64 = 'app-arm64.7z' }[$Architecture]
|
||||
if ($appArchives.Count -ne 1 -or $appArchives[0] -cne $appArchive) {
|
||||
throw "Installer must carry exactly one application archive, $appArchive; found: $($appArchives -join ', ')"
|
||||
}
|
||||
$installedRoot = Join-Path $extractRoot "installed"
|
||||
Invoke-SevenZip -Archive (Join-Path $installerRoot '$PLUGINSDIR\app-64.7z') -Destination $installedRoot
|
||||
Invoke-SevenZip -Archive (Join-Path $installerRoot "`$PLUGINSDIR\$appArchive") -Destination $installedRoot
|
||||
Assert-PeArchitecture -Root $installedRoot -Architecture $Architecture
|
||||
Assert-TreeMatchesPayload -Label "Installer" -Root $installedRoot -PayloadManifest $payloadManifest -Extra $script:InstallerOwnedFiles
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -32,6 +32,14 @@ $script:InstallerOwnedFiles = @{
|
||||
"resources/elevate.exe" = "9b1fbf0c11c520ae714af8aa9af12cfd48503eedecd7398d8992ee94d1b4dc37"
|
||||
}
|
||||
|
||||
# 32-bit images the installer writes into an installed tree on every
|
||||
# architecture: the elevation helper and the uninstaller. They are the only
|
||||
# images exempt from the architecture check, by exact path.
|
||||
$script:InstallerWrittenX86Pe = @(
|
||||
"resources/elevate.exe",
|
||||
"Uninstall Reasonix Studio.exe"
|
||||
)
|
||||
|
||||
function Get-TreeFiles {
|
||||
param([Parameter(Mandatory = $true)][string]$Root)
|
||||
|
||||
@@ -120,6 +128,49 @@ function Assert-DeclaredPeSet {
|
||||
}
|
||||
}
|
||||
|
||||
# The machine field of a PE image's COFF header, read from the bytes: an
|
||||
# emulated x64 program runs on ARM64 Windows, so what a package claims to be
|
||||
# built for has to come from the files themselves.
|
||||
function Get-PeMachine {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$dos = New-Object byte[] 64
|
||||
if ($stream.Read($dos, 0, 64) -ne 64 -or $dos[0] -ne 0x4D -or $dos[1] -ne 0x5A) { throw "Not a PE image: $Path" }
|
||||
[void]$stream.Seek([BitConverter]::ToInt32($dos, 0x3C), [IO.SeekOrigin]::Begin)
|
||||
$header = New-Object byte[] 6
|
||||
if ($stream.Read($header, 0, 6) -ne 6 -or $header[0] -ne 0x50 -or $header[1] -ne 0x45 -or $header[2] -ne 0 -or $header[3] -ne 0) {
|
||||
throw "No PE signature: $Path"
|
||||
}
|
||||
[BitConverter]::ToUInt16($header, 4)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Every PE image under Root must be built for Architecture; the 32-bit images
|
||||
# the installer writes (InstallerWrittenX86Pe) are the only exception, by path.
|
||||
function Assert-PeArchitecture {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Root,
|
||||
[Parameter(Mandatory = $true)][ValidateSet("amd64", "arm64")][string]$Architecture
|
||||
)
|
||||
|
||||
$want = @{ amd64 = 0x8664; arm64 = 0xAA64 }[$Architecture]
|
||||
$wrong = @()
|
||||
foreach ($name in Get-PeFiles -Root $Root) {
|
||||
$expected = if ($script:InstallerWrittenX86Pe -ccontains $name) { 0x014C } else { $want }
|
||||
$machine = Get-PeMachine -Path (Join-Path $Root $name)
|
||||
if ($machine -ne $expected) {
|
||||
Write-Host "::error title=studio-signing.wrong-architecture::$name is machine 0x$($machine.ToString('x')), want 0x$($expected.ToString('x')) for $Architecture"
|
||||
$wrong += $name
|
||||
}
|
||||
}
|
||||
if ($wrong.Count -gt 0) { throw "PE files under $Root are not all $Architecture`: $($wrong -join ', ')" }
|
||||
}
|
||||
|
||||
function Get-SignTool {
|
||||
$tool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
|
||||
Sort-Object FullName -Descending | Select-Object -First 1
|
||||
|
||||
Reference in new issue
Block a user