diff --git a/.github/actions/prepare-windows-signing/action.yml b/.github/actions/prepare-windows-signing/action.yml new file mode 100644 index 00000000000..330bb935d08 --- /dev/null +++ b/.github/actions/prepare-windows-signing/action.yml @@ -0,0 +1,77 @@ +name: Prepare Windows signing certificate +description: >- + 用 secrets.SIGNATURE_KEYS(PEM 私钥)和 vars.SIGNATURE_CRT(PEM 证书)合成一个临时 PFX, + 并把 PFX 路径、口令以及 MSIX 用的 Publisher 写入 GITHUB_ENV。 + + 由 build.yml 的客户端构建 job 和安装包 job 共用,保证两边的签名与 Publisher 逻辑只有一份。 + +inputs: + signing-key: + description: PEM 私钥,来自 secrets.SIGNATURE_KEYS + required: true + signing-crt: + description: PEM 证书,来自 vars.SIGNATURE_CRT + required: true + signing-key-password: + description: 私钥口令,通常留空 + required: false + default: '' + +runs: + using: composite + steps: + - name: Prepare signing certificate (Windows) + shell: pwsh + env: + SIGNING_KEY: ${{ inputs.signing-key }} + SIGNING_CRT: ${{ inputs.signing-crt }} + SIGNATURE_KEYS_PASSWORD: ${{ inputs.signing-key-password }} + run: | + $ErrorActionPreference = 'Stop' + + if (-not $env:SIGNING_CRT) { throw 'SIGNATURE_CRT variable is not set; it must contain the PEM certificate' } + + $keyPath = Join-Path $env:RUNNER_TEMP 'blockide-signing.key' + $certPath = Join-Path $env:RUNNER_TEMP 'blockide-signing.crt' + [System.IO.File]::WriteAllText($keyPath, $env:SIGNING_KEY) + [System.IO.File]::WriteAllText($certPath, $env:SIGNING_CRT) + if (-not (Select-String -Path $certPath -Pattern 'BEGIN CERTIFICATE' -Quiet)) { + throw 'SIGNATURE_CRT does not contain a BEGIN CERTIFICATE block' + } + + $openssl = (Get-Command openssl.exe -ErrorAction SilentlyContinue).Source + if (-not $openssl) { + $gitOpenSsl = Join-Path $env:ProgramFiles 'Git\usr\bin\openssl.exe' + if (Test-Path $gitOpenSsl) { $openssl = $gitOpenSsl } + } + if (-not $openssl) { throw 'openssl.exe not found, cannot build the PFX from SIGNATURE_KEYS/SIGNATURE_CRT' } + + $password = [System.Guid]::NewGuid().ToString('N') + $pfxPath = Join-Path $env:RUNNER_TEMP 'blockide-signing.pfx' + $keyArgs = @() + if ($env:SIGNATURE_KEYS_PASSWORD) { $keyArgs = @('-passin', "pass:$env:SIGNATURE_KEYS_PASSWORD") } + + & $openssl pkcs12 -export -inkey $keyPath -in $certPath -out $pfxPath -passout "pass:$password" -name BlockIDE @keyArgs + if ($LASTEXITCODE -ne 0) { throw "openssl pkcs12 exited with code $LASTEXITCODE" } + + # MSIX 的 Publisher 必须与签名证书的 Subject 一致,否则签出来的包无法安装。 + # 优先从 PFX 里读 Subject:它正是 Windows 比较时使用的规范形式 + # ('CN=..., OU=..., O=..., C=CN')。若改用 openssl 的 RFC2253 输出 + # ('CN=...,OU=...,O=...',逗号后没有空格),MakeAppx 的清单校验会直接拒绝, + # 报 C00CE169 / 'Publisher ... failed to parse'。 + $publisher = $null + try { + $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pfxPath, $password) + try { $publisher = $certificate.Subject } finally { $certificate.Dispose() } + } catch { + Write-Warning "Cannot read the subject from the PFX ($($_.Exception.Message)); falling back to openssl" + $subject = ((& $openssl x509 -in $certPath -noout -subject -nameopt RFC2253) -join '') -replace '^subject\s*=\s*', '' + # RFC2253 用 ',' 分隔,MakeAppx 要求 ',' + 空格 + $publisher = $subject -replace ',(?=[^,=]+=)', ', ' + } + if (-not $publisher) { throw 'Failed to read the certificate subject for the MSIX publisher' } + + "SIGNING_PFX_PATH=$pfxPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "SIGNING_PFX_PASSWORD=$password" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + "BLOCKIDE_MSIX_PUBLISHER=$publisher" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "Signing certificate subject: $publisher" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e0f1da40cd..fa831546385 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,9 @@ name: Build # 可复用的单平台构建工作流,由 release.yml 按平台矩阵调用。 -# 每个平台的所有安装包在此完成并上传为 release-- 工件。 +# 每个平台的安装包在此完成并上传为 release-- 工件。 +# Windows 的 MSI / MSIX 由单独的 installers job 产出(release--installers-), +# 复用 build job 上传的客户端 zip,因此只改安装包时不必重跑整条 Windows 构建。 # # 签名:私钥放在秘密变量 SIGNATURE_KEYS。macOS 的证书来源按优先级: # 1) secrets.CRT 或 vars.CRT —— 完整证书链(叶子 + 逐级签发 CA,最后是自签名根) @@ -118,59 +120,49 @@ jobs: tar -czf snap-src.tar.gz -C "$(dirname "$SNAP_DIR")" "$(basename "$SNAP_DIR")" # -------------------------------------------------------------- Windows + # patchWin32Dependencies 会用 signtool.exe 剥掉依赖库上失效的 Authenticode 签名, + # 而 GitHub 的 Windows 镜像不保证 Windows SDK 的 bin 在 PATH 里;缺了会 spawn + # ENOENT,让整条客户端构建失败。 + - name: Add Windows SDK to PATH + if: inputs.platform == 'win32' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $sdkRoot = 'C:\Program Files (x86)\Windows Kits\10\bin' + # bin 下同时存在 x86/x64 等目录,只按版本号目录排序,避免选中 x86 拼出错路径 + $sdkDirectory = Get-ChildItem $sdkRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+(\.\d+)+$' } | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64' } | + Where-Object { Test-Path (Join-Path $_ 'signtool.exe') } | + Select-Object -First 1 + if (-not $sdkDirectory) { throw "signtool.exe not found under $sdkRoot" } + "PATH=$sdkDirectory;$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Host "Windows SDK tools: $sdkDirectory" + # 先产出客户端,签名后被打进 exe / zip / msi / msix,保证安装后主程序也是已签名的 - name: Build Windows client if: inputs.platform == 'win32' shell: pwsh run: | $ErrorActionPreference = 'Stop' + # $ErrorActionPreference 只对 cmdlet 生效,原生进程的非零退出码不会中断脚本, + # 因此每个 npm 调用都要显式检查 $LASTEXITCODE。 npm run gulp vscode-win32-x64-min + if ($LASTEXITCODE -ne 0) { throw "gulp vscode-win32-x64-min failed with exit code $LASTEXITCODE" } npm run gulp vscode-win32-x64-inno-updater + if ($LASTEXITCODE -ne 0) { throw "gulp vscode-win32-x64-inno-updater failed with exit code $LASTEXITCODE" } - # 私钥来自 secrets.SIGNATURE_KEYS,证书来自 vars.SIGNATURE_CRT,分别落盘后再合成 PFX; - # 证书 Subject 同时用作 MSIX 的 Publisher,否则 MSIX 签名会失败 + # 私钥来自 secrets.SIGNATURE_KEYS,证书来自 vars.SIGNATURE_CRT,合成 PFX 后 + # 用于给客户端和安装包签名;证书 Subject 同时作为 MSIX 的 Publisher。 + # 逻辑放在 .github/actions/prepare-windows-signing,供客户端 job 和安装包 job 共用。 - name: Prepare signing certificate (Windows) if: inputs.platform == 'win32' && env.HAS_SIGNING_KEYS == 'true' - shell: pwsh - env: - SIGNING_KEY: ${{ secrets.SIGNATURE_KEYS }} - SIGNING_CRT: ${{ vars.SIGNATURE_CRT }} - SIGNATURE_KEYS_PASSWORD: ${{ secrets.SIGNATURE_KEYS_PASSWORD }} - run: | - $ErrorActionPreference = 'Stop' - - if (-not $env:SIGNING_CRT) { throw 'SIGNATURE_CRT variable is not set; it must contain the PEM certificate' } - - $keyPath = Join-Path $env:RUNNER_TEMP 'blockide-signing.key' - $certPath = Join-Path $env:RUNNER_TEMP 'blockide-signing.crt' - [System.IO.File]::WriteAllText($keyPath, $env:SIGNING_KEY) - [System.IO.File]::WriteAllText($certPath, $env:SIGNING_CRT) - if (-not (Select-String -Path $certPath -Pattern 'BEGIN CERTIFICATE' -Quiet)) { - throw 'SIGNATURE_CRT does not contain a BEGIN CERTIFICATE block' - } - - $openssl = (Get-Command openssl.exe -ErrorAction SilentlyContinue).Source - if (-not $openssl) { - $gitOpenSsl = Join-Path $env:ProgramFiles 'Git\usr\bin\openssl.exe' - if (Test-Path $gitOpenSsl) { $openssl = $gitOpenSsl } - } - if (-not $openssl) { throw 'openssl.exe not found, cannot build the PFX from SIGNATURE_KEYS/SIGNATURE_CRT' } - - $password = [System.Guid]::NewGuid().ToString('N') - $pfxPath = Join-Path $env:RUNNER_TEMP 'blockide-signing.pfx' - $keyArgs = @() - if ($env:SIGNATURE_KEYS_PASSWORD) { $keyArgs = @('-passin', "pass:$env:SIGNATURE_KEYS_PASSWORD") } - - & $openssl pkcs12 -export -inkey $keyPath -in $certPath -out $pfxPath -passout "pass:$password" -name BlockIDE @keyArgs - if ($LASTEXITCODE -ne 0) { throw "openssl pkcs12 exited with code $LASTEXITCODE" } - - $subject = ((& $openssl x509 -in $certPath -noout -subject -nameopt RFC2253) -join '') -replace '^subject\s*=\s*', '' - if (-not $subject) { throw 'Failed to read certificate subject from SIGNATURE_CRT' } - - "SIGNING_PFX_PATH=$pfxPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "SIGNING_PFX_PASSWORD=$password" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "BLOCKIDE_MSIX_PUBLISHER=$subject" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Host "Signing certificate subject: $subject" + uses: ./.github/actions/prepare-windows-signing + with: + signing-key: ${{ secrets.SIGNATURE_KEYS }} + signing-crt: ${{ vars.SIGNATURE_CRT }} + signing-key-password: ${{ secrets.SIGNATURE_KEYS_PASSWORD }} - name: Sign Windows client if: inputs.platform == 'win32' && env.HAS_SIGNING_KEYS == 'true' @@ -197,48 +189,43 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "signtool failed for $($file.Name)" } } - - name: Package Windows (exe / zip / msi / msix) + - name: Package Windows (exe / zip) if: inputs.platform == 'win32' shell: pwsh run: | $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Path artifacts -Force | Out-Null - if (-not (Get-Command candle.exe -ErrorAction SilentlyContinue)) { - choco install wixtoolset --no-progress -y + # $ErrorActionPreference 只对 cmdlet 生效;npm/gulp 这类原生进程失败时脚本会继续往下跑, + # v1.0.2 的 MSIX 就是这样在失败后被静默跳过、只发布了 3 个安装包的。 + function Invoke-Gulp([string]$name) { + npm run gulp $name + if ($LASTEXITCODE -ne 0) { throw "gulp $name failed with exit code $LASTEXITCODE" } } - $wixDirectory = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' -ErrorAction SilentlyContinue | - Sort-Object Name -Descending | Select-Object -First 1 - if (-not $wixDirectory) { - throw 'WiX Toolset v3 is required to build the MSI' - } - $env:WIX_BIN = Join-Path $wixDirectory.FullName 'bin' - $env:PATH = "$env:WIX_BIN;$env:PATH" - - $sdkDirectory = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Directory -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '^\d+(\.\d+)+$' } | - Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 - if ($sdkDirectory) { - $env:PATH = "$(Join-Path $sdkDirectory.FullName 'x64');$env:PATH" - } - - npm run gulp vscode-win32-x64-system-setup - npm run gulp vscode-win32-x64-msi - npm run gulp vscode-win32-x64-msix + Invoke-Gulp vscode-win32-x64-system-setup Copy-Item ".build\win32-x64\system-setup\VSCodeSetup.exe" "artifacts\BlockIDE-win32-x64-$env:VERSION.exe" - Copy-Item ".build\win32-x64\msi\BlockIDE-*-x64.msi" "artifacts\BlockIDE-win32-x64-$env:VERSION.msi" - Copy-Item ".build\win32-x64\msix\BlockIDE-*-x64.msix" "artifacts\BlockIDE-win32-x64-$env:VERSION.msix" + # zip 既是免安装版,也是 installers job 构建 MSI/MSIX 的输入 Push-Location (Split-Path $env:GITHUB_WORKSPACE) try { 7z.exe a -tzip "$env:GITHUB_WORKSPACE\artifacts\BlockIDE-win32-x64-$env:VERSION.zip" "VSCode-win32-x64" + if ($LASTEXITCODE -ne 0) { throw "7z failed with exit code $LASTEXITCODE" } } finally { Pop-Location } - - name: Sign Windows installers + # 上传前确认两个产物都在,缺任何一个都说明有步骤被静默跳过 + $expected = foreach ($extension in 'exe', 'zip') { + Join-Path 'artifacts' "BlockIDE-win32-x64-$env:VERSION.$extension" + } + $missing = @($expected | Where-Object { -not (Test-Path $_) -or (Get-Item $_).Length -eq 0 }) + if ($missing.Count -gt 0) { + throw "Missing Windows artifacts: $($missing -join ', ')" + } + + - name: Sign Windows setup if: inputs.platform == 'win32' && env.HAS_SIGNING_KEYS == 'true' shell: pwsh run: | @@ -254,8 +241,8 @@ jobs: Select-Object -First 1 if (-not $signtool) { throw "signtool.exe not found under $sdkRoot" } - $targets = Get-ChildItem artifacts -File | Where-Object { $_.Extension -in '.exe', '.msi', '.msix' } - if (-not $targets) { throw 'No installer artifacts found to sign' } + $targets = Get-ChildItem artifacts -File | Where-Object { $_.Extension -in '.exe' } + if (-not $targets) { throw 'No Windows setup executable found to sign' } foreach ($file in $targets) { & $signtool sign /fd sha256 /f $env:SIGNING_PFX_PATH /p $env:SIGNING_PFX_PASSWORD $file.FullName @@ -542,3 +529,194 @@ jobs: path: snap-src.tar.gz if-no-files-found: error retention-days: 7 + + # MSI / MSIX 单独成 job:从 build job 上传的 zip 里还原已签名的客户端再打包。 + # 拆分不会缩短发版总时长(Windows 不是关键路径),但把「改安装包」的反馈回路 + # 从整条 Windows 构建缩短到只需要重跑本 job(GitHub 支持只重跑失败的 job)。 + installers: + name: Package Windows installers (${{ inputs.arch }}) + if: inputs.platform == 'win32' + needs: build + runs-on: ${{ inputs.os }} + timeout-minutes: 120 + env: + VERSION: ${{ inputs.version }} + VSCODE_ARCH: ${{ inputs.arch }} + HAS_SIGNING_KEYS: ${{ secrets.SIGNATURE_KEYS != '' }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + # build/npm/dirs.ts 里包含子模块 extensions/minecraft-workbench, + # node_modules 缓存键要读它的 package-lock.json + submodules: recursive + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + + # 本 job 只需要运行 gulp 打包任务,不需要编译产物(out/),也不该重付一次 + # 约 11 分钟的 npm ci。先复用仓库已有的 node_modules 缓存(PR / main 的 + # pr-node-modules 会预热),命中时直接解包;未命中才安装并把结果存回缓存。 + - name: Restore node_modules cache + id: node-modules-cache + uses: ./.github/actions/restore-node-modules + with: + key-prefix: node_modules-windows + key-args: "win32 ${{ inputs.arch }} $(node -p process.arch)" + + - name: Install dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + run: npm ci + + - name: Save node_modules cache + if: steps.node-modules-cache.outputs.cache-hit != 'true' + uses: ./.github/actions/save-node-modules + + - name: Download Windows client + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-${{ inputs.platform }}-${{ inputs.arch }} + path: staging + + # zip 就是 build job 里已签名的客户端(VSCode-win32-), + # 解压回 buildPath() 期望的位置(仓库目录的同级目录)。 + - name: Restore Windows client + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $zip = Get-ChildItem staging -Filter '*.zip' -File | Select-Object -First 1 + if (-not $zip) { throw 'Client zip not found in the release artifacts' } + + $extractRoot = Join-Path $env:RUNNER_TEMP 'client' + New-Item -ItemType Directory -Path $extractRoot -Force | Out-Null + 7z.exe x $zip.FullName "-o$extractRoot" -y + if ($LASTEXITCODE -ne 0) { throw "7z failed with exit code $LASTEXITCODE" } + + $source = Join-Path $extractRoot "VSCode-win32-$env:VSCODE_ARCH" + if (-not (Test-Path (Join-Path $source 'BlockIDE.exe'))) { + # 兜底:万一 zip 里没有顶层目录,就在解压结果里找含主程序的目录 + $candidate = Get-ChildItem $extractRoot -Directory | + Where-Object { Test-Path (Join-Path $_.FullName 'BlockIDE.exe') } | + Select-Object -First 1 + if (-not $candidate) { throw "Client not found in $($zip.Name)" } + $source = $candidate.FullName + } + + Move-Item $source (Join-Path (Split-Path $env:GITHUB_WORKSPACE) "VSCode-win32-$env:VSCODE_ARCH") + + # MSIX 的 Publisher 必须取自签名证书,和客户端 job 共用同一份逻辑 + - name: Prepare signing certificate (Windows) + if: env.HAS_SIGNING_KEYS == 'true' + uses: ./.github/actions/prepare-windows-signing + with: + signing-key: ${{ secrets.SIGNATURE_KEYS }} + signing-crt: ${{ vars.SIGNATURE_CRT }} + signing-key-password: ${{ secrets.SIGNATURE_KEYS_PASSWORD }} + + - name: Package MSI / MSIX + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + New-Item -ItemType Directory -Path artifacts -Force | Out-Null + $arch = $env:VSCODE_ARCH + $version = $env:VERSION + + # $ErrorActionPreference 只对 cmdlet 生效;npm/gulp 这类原生进程失败时脚本会继续往下跑, + # v1.0.2 的 MSIX 就是这样在失败后被静默跳过、只发布了 3 个安装包的。 + function Invoke-Gulp([string]$name) { + npm run gulp $name + if ($LASTEXITCODE -ne 0) { throw "gulp $name failed with exit code $LASTEXITCODE" } + } + + if (-not (Get-Command candle.exe -ErrorAction SilentlyContinue)) { + choco install wixtoolset --no-progress -y + if ($LASTEXITCODE -ne 0) { throw "choco install wixtoolset failed with exit code $LASTEXITCODE" } + } + + $wixDirectory = Get-ChildItem 'C:\Program Files (x86)' -Directory -Filter 'WiX Toolset v3*' -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -First 1 + if (-not $wixDirectory) { + throw 'WiX Toolset v3 is required to build the MSI' + } + $env:WIX_BIN = Join-Path $wixDirectory.FullName 'bin' + $env:PATH = "$env:WIX_BIN;$env:PATH" + + $sdkDirectory = Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin' -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+(\.\d+)+$' } | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + if (-not $sdkDirectory) { + throw 'Windows SDK is required to build the MSIX' + } + $env:PATH = "$(Join-Path $sdkDirectory.FullName 'x64');$env:PATH" + + Invoke-Gulp "vscode-win32-$arch-msi" + Invoke-Gulp "vscode-win32-$arch-msix" + + Copy-Item ".build\win32-$arch\msi\BlockIDE-*-$arch.msi" "artifacts\BlockIDE-win32-$arch-$version.msi" + Copy-Item ".build\win32-$arch\msix\BlockIDE-*-$arch.msix" "artifacts\BlockIDE-win32-$arch-$version.msix" + + # 上传前确认两个安装包都在,缺任何一个都说明有步骤被静默跳过 + $expected = foreach ($extension in 'msi', 'msix') { + "artifacts\BlockIDE-win32-$arch-$version.$extension" + } + $missing = @($expected | Where-Object { -not (Test-Path $_) -or (Get-Item $_).Length -eq 0 }) + if ($missing.Count -gt 0) { + throw "Missing Windows installers: $($missing -join ', ')" + } + + # 发行版同时提供已签名和未签名的 MSIX(便于用户自行签名/离线分发)。 + # 未签名的那份先放到 artifacts 之外,避免被签名步骤就地改写。 + if ($env:HAS_SIGNING_KEYS -eq 'true') { + New-Item -ItemType Directory -Path unsigned -Force | Out-Null + Copy-Item "artifacts\BlockIDE-win32-$arch-$version.msix" "unsigned\BlockIDE-win32-$arch-$version-unsigned.msix" + } + + - name: Sign Windows installers + if: env.HAS_SIGNING_KEYS == 'true' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $sdkRoot = 'C:\Program Files (x86)\Windows Kits\10\bin' + # bin 下同时存在 x86/x64 等目录,只按版本号目录排序,避免选中 x86 拼出错路径 + $signtool = Get-ChildItem $sdkRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+(\.\d+)+$' } | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName 'x64\signtool.exe' } | + Where-Object { Test-Path $_ } | + Select-Object -First 1 + if (-not $signtool) { throw "signtool.exe not found under $sdkRoot" } + + $targets = Get-ChildItem artifacts -File | Where-Object { $_.Extension -in '.msi', '.msix' } + if (-not $targets) { throw 'No Windows installers found to sign' } + + foreach ($file in $targets) { + & $signtool sign /fd sha256 /f $env:SIGNING_PFX_PATH /p $env:SIGNING_PFX_PASSWORD $file.FullName + if ($LASTEXITCODE -ne 0) { throw "signtool failed for $($file.Name)" } + } + + # 签名会就地改写 MSIX 文件,未签名的那份在签名完成后才放回 artifacts + - name: Add unsigned MSIX + if: env.HAS_SIGNING_KEYS == 'true' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $copies = @(Get-ChildItem unsigned -Filter '*-unsigned.msix' -File -ErrorAction SilentlyContinue) + if ($copies.Count -ne 1) { throw "Expected exactly one unsigned MSIX copy, found $($copies.Count)" } + Move-Item $copies[0].FullName artifacts + Write-Host "Unsigned MSIX: $($copies[0].Name)" + + - name: Upload artifacts + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-${{ inputs.platform }}-installers-${{ inputs.arch }} + path: artifacts/* + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37c0723a33e..fa49c2a6ce6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,35 @@ jobs: - name: List release assets run: ls -lh dist + # 发布前逐项核对产物清单:缺一个就中断,避免像 v1.0.2 那样静默发出少文件的版本 + - name: Verify release assets + shell: bash + run: | + set -euo pipefail + expected=( + 'BlockIDE-darwin-arm64-*.dmg' + 'BlockIDE-darwin-arm64-*.zip' + 'BlockIDE-linux-x64-*.deb' + 'BlockIDE-linux-x64-*.rpm' + 'BlockIDE-linux-x64-*.snap' + 'BlockIDE-linux-x64-*.tar.gz' + 'BlockIDE-win32-x64-*.exe' + 'BlockIDE-win32-x64-*.msi' + 'BlockIDE-win32-x64-*.msix' + 'BlockIDE-win32-x64-*-unsigned.msix' + 'BlockIDE-win32-x64-*.zip' + ) + + missing=() + for pattern in "${expected[@]}"; do + compgen -G "dist/${pattern}" > /dev/null || missing+=("${pattern}") + done + + if [ "${#missing[@]}" -ne 0 ]; then + printf '::error::Missing release assets: %s\n' "${missing[*]}" + exit 1 + fi + # 默认创建草稿发布;draft / prerelease 由上游 meta job 决定 - name: Create GitHub release shell: bash diff --git a/.github/workflows/win32-installers.yml b/.github/workflows/win32-installers.yml index 61490b868c9..8df77e6c233 100644 --- a/.github/workflows/win32-installers.yml +++ b/.github/workflows/win32-installers.yml @@ -17,6 +17,7 @@ on: - 'product.json' - 'package.json' - 'package-lock.json' + - '.github/actions/prepare-windows-signing/**' - '.github/workflows/build.yml' - '.github/workflows/win32-installers.yml' push: @@ -31,6 +32,7 @@ on: - 'product.json' - 'package.json' - 'package-lock.json' + - '.github/actions/prepare-windows-signing/**' - '.github/workflows/build.yml' - '.github/workflows/win32-installers.yml' workflow_dispatch: diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 0256e9eabef..4afda317d97 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -533,6 +533,18 @@ function patchWin32DependenciesTask(destinationFolderName: string) { const basename = path.basename(dep); const fullPath = path.join(cwd, dep); + // Extensions may ship native binaries for every platform in the same + // package (e.g. clipboard.darwin-x64.node). rcedit only understands PE + // files, so skip anything that is not a Windows binary. + const handle = await fs.promises.open(fullPath, 'r'); + const magic = Buffer.alloc(2); + const { bytesRead } = await handle.read(magic, 0, 2, 0); + await handle.close(); + if (bytesRead !== 2 || magic.toString('latin1') !== 'MZ') { + console.log(`Skipping non-PE binary (rcedit cannot patch it): ${dep}`); + return; + } + await stripAuthenticodeSignature(fullPath); await rcedit(fullPath, { 'file-version': baseVersion, diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index c6289e75956..1885b0f9b3e 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -182,6 +182,18 @@ function spawnTool(executable: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + cp.spawn(executable, args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] }) + .on('error', reject) + .on('exit', code => resolve(code ?? -1)); + }); +} + function findWixTool(name: string): string { const directories = [ process.env['WIX_BIN'], @@ -276,10 +288,22 @@ function buildWin32Msi(arch: string): task.CallbackTask { ], outputPath); const msiPath = path.join(outputPath, `${product.nameShort}-${normalizeMsiVersion(pkg.version)}-${arch}.msi`); - await spawnTool(findWixTool('light.exe'), [ + // light.exe encodes ICE warnings in its exit code (for example 204) even though + // the package was produced successfully. Treat an existing package as success so + // warnings are not reported as build failures (and real failures stay visible). + + const lightExitCode = await spawnToolCapturingExitCode(findWixTool('light.exe'), [ wixobjPath, '-o', msiPath ], outputPath); + + if (!fs.existsSync(msiPath) || fs.statSync(msiPath).size === 0) { + throw new Error(`light.exe exited with code: ${lightExitCode}`); + } + + if (lightExitCode !== 0) { + console.warn(`light.exe reported warnings (exit code ${lightExitCode}); the MSI was still produced.`); + } })().then(() => cb?.(), err => cb?.(err instanceof Error ? err : new Error(String(err)))); }; }