diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b425b32a58..276e07748d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -98,15 +98,129 @@ jobs: - uses: actions/upload-artifact@v4 with: name: opencode-cli - path: packages/opencode/dist + path: | + packages/opencode/dist/opencode-darwin* + packages/opencode/dist/opencode-linux* + + - uses: actions/upload-artifact@v4 + with: + name: opencode-cli-windows + path: packages/opencode/dist/opencode-windows* outputs: version: ${{ needs.version.outputs.version }} + sign-cli-windows: + needs: + - build-cli + - version + runs-on: blacksmith-4vcpu-windows-2025 + if: github.repository == 'anomalyco/opencode' + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + steps: + - uses: actions/checkout@v3 + + - uses: actions/download-artifact@v4 + with: + name: opencode-cli-windows + path: packages/opencode/dist + + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + + - name: Azure login + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - uses: azure/artifact-signing-action@v1 + with: + endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }} + signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + files: | + ${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe + ${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe + ${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Verify Windows CLI signatures + shell: pwsh + run: | + $files = @( + "${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe", + "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe", + "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe" + ) + + foreach ($file in $files) { + $sig = Get-AuthenticodeSignature $file + if ($sig.Status -ne "Valid") { + throw "Invalid signature for ${file}: $($sig.Status)" + } + } + + - name: Repack Windows CLI archives + working-directory: packages/opencode/dist + shell: pwsh + run: | + Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force + Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force + Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force + + - name: Upload signed Windows CLI release assets + if: needs.version.outputs.release != '' + shell: pwsh + env: + GH_TOKEN: ${{ steps.committer.outputs.token }} + run: | + gh release upload "v${{ needs.version.outputs.version }}" ` + "${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" ` + "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" ` + "${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" ` + --clobber ` + --repo "${{ needs.version.outputs.repo }}" + + - uses: actions/upload-artifact@v4 + with: + name: opencode-cli-signed-windows + path: | + packages/opencode/dist/opencode-windows-arm64 + packages/opencode/dist/opencode-windows-x64 + packages/opencode/dist/opencode-windows-x64-baseline + build-tauri: needs: - build-cli - version continue-on-error: false + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} strategy: fail-fast: false matrix: @@ -152,6 +266,14 @@ jobs: - uses: ./.github/actions/setup-bun + - name: Azure login + if: runner.os == 'Windows' + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + - uses: actions/setup-node@v4 with: node-version: "24" @@ -190,6 +312,7 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }} + OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} GH_TOKEN: ${{ github.token }} GITHUB_RUN_ID: ${{ github.run_id }} @@ -246,11 +369,34 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 + - name: Verify signed Windows desktop artifacts + if: runner.os == 'Windows' + shell: pwsh + run: | + $files = @( + "${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe" + ) + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName + + foreach ($file in $files) { + $sig = Get-AuthenticodeSignature $file + if ($sig.Status -ne "Valid") { + throw "Invalid signature for ${file}: $($sig.Status)" + } + } + build-electron: needs: - build-cli - version continue-on-error: false + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} strategy: fail-fast: false matrix: @@ -292,6 +438,14 @@ jobs: - uses: ./.github/actions/setup-bun + - name: Azure login + if: runner.os == 'Windows' + uses: azure/login@v2 + with: + client-id: ${{ env.AZURE_CLIENT_ID }} + tenant-id: ${{ env.AZURE_TENANT_ID }} + subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + - uses: actions/setup-node@v4 with: node-version: "24" @@ -326,6 +480,7 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} GH_TOKEN: ${{ github.token }} GITHUB_RUN_ID: ${{ github.run_id }} @@ -358,6 +513,22 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + - name: Verify signed Windows Electron artifacts + if: runner.os == 'Windows' + shell: pwsh + run: | + $files = @() + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName + + foreach ($file in $files | Select-Object -Unique) { + $sig = Get-AuthenticodeSignature $file + if ($sig.Status -ne "Valid") { + throw "Invalid signature for ${file}: $($sig.Status)" + } + } + - uses: actions/upload-artifact@v4 with: name: opencode-electron-${{ matrix.settings.target }} @@ -373,6 +544,7 @@ jobs: needs: - version - build-cli + - sign-cli-windows - build-tauri - build-electron runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -411,6 +583,16 @@ jobs: name: opencode-cli path: packages/opencode/dist + - uses: actions/download-artifact@v4 + with: + name: opencode-cli-windows + path: packages/opencode/dist + + - uses: actions/download-artifact@v4 + with: + name: opencode-cli-signed-windows + path: packages/opencode/dist + - uses: actions/download-artifact@v4 if: needs.version.outputs.release with: diff --git a/.github/workflows/sign-cli.yml b/.github/workflows/sign-cli.yml deleted file mode 100644 index d9d61fd800..0000000000 --- a/.github/workflows/sign-cli.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: sign-cli - -on: - push: - branches: - - brendan/desktop-signpath - workflow_dispatch: - -permissions: - contents: read - actions: read - -jobs: - sign-cli: - runs-on: blacksmith-4vcpu-ubuntu-2404 - if: github.repository == 'anomalyco/opencode' - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: ./.github/actions/setup-bun - - - name: Build - run: | - ./packages/opencode/script/build.ts - - - name: Upload unsigned Windows CLI - id: upload_unsigned_windows_cli - uses: actions/upload-artifact@v4 - with: - name: unsigned-opencode-windows-cli - path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe - if-no-files-found: error - - - name: Submit SignPath signing request - id: submit_signpath_signing_request - uses: signpath/github-action-submit-signing-request@v1 - with: - api-token: ${{ secrets.SIGNPATH_API_KEY }} - organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} - project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }} - signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} - artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} - github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }} - wait-for-completion: true - output-artifact-directory: signed-opencode-cli - - - name: Upload signed Windows CLI - uses: actions/upload-artifact@v4 - with: - name: signed-opencode-windows-cli - path: signed-opencode-cli/*.exe - if-no-files-found: error diff --git a/.signpath/policies/opencode/test-signing.yml b/.signpath/policies/opencode/test-signing.yml deleted file mode 100644 index 683b27adb7..0000000000 --- a/.signpath/policies/opencode/test-signing.yml +++ /dev/null @@ -1,5 +0,0 @@ -github-policies: - runners: - allowed_groups: - - "GitHub Actions" - - "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt" diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index e6b4bcd2b9..70441d8d7f 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -1,5 +1,25 @@ +import { execFile } from "node:child_process" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" + import type { Configuration } from "electron-builder" +const execFileAsync = promisify(execFile) +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") +const signScript = path.join(rootDir, "script", "sign-windows.ps1") + +async function signWindows(configuration: { path: string }) { + if (process.platform !== "win32") return + if (process.env.GITHUB_ACTIONS !== "true") return + + await execFileAsync( + "pwsh", + ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path], + { cwd: rootDir }, + ) +} + const channel = (() => { const raw = process.env.OPENCODE_CHANNEL if (raw === "dev" || raw === "beta" || raw === "prod") return raw @@ -44,6 +64,9 @@ const getBase = (): Configuration => ({ }, win: { icon: `resources/icons/icon.ico`, + signtoolOptions: { + sign: signWindows, + }, target: ["nsis"], }, nsis: { diff --git a/packages/desktop-electron/scripts/prepare.ts b/packages/desktop-electron/scripts/prepare.ts index 3764db9210..3704b2e613 100755 --- a/packages/desktop-electron/scripts/prepare.ts +++ b/packages/desktop-electron/scripts/prepare.ts @@ -13,11 +13,12 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") console.log(`Updated package.json version to ${Script.version}`) const sidecarConfig = getCurrentSidecar() +const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli" const dir = "resources/opencode-binaries" await $`mkdir -p ${dir}` -await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir) +await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir) await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`)) diff --git a/packages/desktop-electron/scripts/utils.ts b/packages/desktop-electron/scripts/utils.ts index 1c0add87d3..19b96b0a16 100644 --- a/packages/desktop-electron/scripts/utils.ts +++ b/packages/desktop-electron/scripts/utils.ts @@ -63,6 +63,9 @@ export async function copyBinaryToSidecarFolder(source: string) { await $`mkdir -p ${dir}` const dest = windowsify(`${dir}/opencode-cli`) await $`cp ${source} ${dest}` + if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { + await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` + } if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` console.log(`Copied ${source} to ${dest}`) diff --git a/packages/desktop/scripts/prepare.ts b/packages/desktop/scripts/prepare.ts index d802f2d89e..729bf6ae13 100755 --- a/packages/desktop/scripts/prepare.ts +++ b/packages/desktop/scripts/prepare.ts @@ -10,10 +10,11 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") console.log(`Updated package.json version to ${Script.version}`) const sidecarConfig = getCurrentSidecar() +const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli" const dir = "src-tauri/target/opencode-binaries" await $`mkdir -p ${dir}` -await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir) +await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir) await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`)) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 06ff25946e..111871d3e3 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -48,6 +48,9 @@ export async function copyBinaryToSidecarFolder(source: string, target = RUST_TA await $`mkdir -p src-tauri/sidecars` const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`) await $`cp ${source} ${dest}` + if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { + await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` + } console.log(`Copied ${source} to ${dest}`) } diff --git a/packages/desktop/src-tauri/tauri.beta.conf.json b/packages/desktop/src-tauri/tauri.beta.conf.json index 4dd7879933..f2cf1d267e 100644 --- a/packages/desktop/src-tauri/tauri.beta.conf.json +++ b/packages/desktop/src-tauri/tauri.beta.conf.json @@ -12,6 +12,10 @@ "icons/beta/icon.ico" ], "windows": { + "signCommand": { + "cmd": "powershell", + "args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"] + }, "nsis": { "installerIcon": "icons/beta/icon.ico" } diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index d5ca15b8a7..265044625b 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -45,6 +45,10 @@ "entitlements": "./entitlements.plist" }, "windows": { + "signCommand": { + "cmd": "powershell", + "args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"] + }, "nsis": { "installerIcon": "icons/dev/icon.ico", "headerImage": "assets/nsis-header.bmp", diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 0416c59cbb..39561e45dc 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -12,6 +12,10 @@ "icons/prod/icon.ico" ], "windows": { + "signCommand": { + "cmd": "powershell", + "args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"] + }, "nsis": { "installerIcon": "icons/prod/icon.ico" } diff --git a/script/sign-windows.ps1 b/script/sign-windows.ps1 new file mode 100644 index 0000000000..aaf2a5b657 --- /dev/null +++ b/script/sign-windows.ps1 @@ -0,0 +1,70 @@ +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $Path +) + +$ErrorActionPreference = "Stop" + +if (-not $Path -or $Path.Count -eq 0) { + throw "At least one path is required" +} + +if ($env:GITHUB_ACTIONS -ne "true") { + Write-Host "Skipping Windows signing because this is not running on GitHub Actions" + exit 0 +} + +$vars = @{ + endpoint = $env:AZURE_TRUSTED_SIGNING_ENDPOINT + account = $env:AZURE_TRUSTED_SIGNING_ACCOUNT_NAME + profile = $env:AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE +} + +if ($vars.Values | Where-Object { -not $_ }) { + Write-Host "Skipping Windows signing because Azure Artifact Signing is not configured" + exit 0 +} + +$moduleVersion = "0.5.8" +$module = Get-Module -ListAvailable -Name TrustedSigning | Where-Object { $_.Version -eq [version] $moduleVersion } + +if (-not $module) { + try { + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null + } + catch { + Write-Host "NuGet package provider install skipped: $($_.Exception.Message)" + } + + Install-Module -Name TrustedSigning -RequiredVersion $moduleVersion -Force -Repository PSGallery -Scope CurrentUser +} + +Import-Module TrustedSigning -RequiredVersion $moduleVersion -Force + +$files = @($Path | ForEach-Object { Resolve-Path $_ -ErrorAction SilentlyContinue } | Select-Object -ExpandProperty Path -Unique) + +if (-not $files -or $files.Count -eq 0) { + throw "No files matched the requested paths" +} + +$params = @{ + Endpoint = $vars.endpoint + CodeSigningAccountName = $vars.account + CertificateProfileName = $vars.profile + Files = ($files -join ",") + FileDigest = "SHA256" + TimestampDigest = "SHA256" + TimestampRfc3161 = "http://timestamp.acs.microsoft.com" + ExcludeEnvironmentCredential = $true + ExcludeWorkloadIdentityCredential = $true + ExcludeManagedIdentityCredential = $true + ExcludeSharedTokenCacheCredential = $true + ExcludeVisualStudioCredential = $true + ExcludeVisualStudioCodeCredential = $true + ExcludeAzureCliCredential = $false + ExcludeAzurePowerShellCredential = $true + ExcludeAzureDeveloperCliCredential = $true + ExcludeInteractiveBrowserCredential = $true +} + +Invoke-TrustedSigning @params