diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 00000000..b641f33e --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,164 @@ +name: Release for Windows + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Git tag to build" + required: true + type: string + +jobs: + resolve-release: + runs-on: ubuntu-latest + outputs: + tag_name: ${{ steps.parse-tag.outputs.tag_name }} + tag_version: ${{ steps.parse-tag.outputs.tag_version }} + release_env: ${{ steps.parse-tag.outputs.release_env }} + steps: + - name: Resolve tag and release environment + id: parse-tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG_NAME="${{ inputs.tag }}" + else + TAG_NAME="${GITHUB_REF_NAME}" + fi + + if [[ ! "$TAG_NAME" =~ ^v.+$ ]]; then + echo "::error title=Invalid tag::Tag must start with 'v'. Got: $TAG_NAME" + exit 1 + fi + + TAG_NO_PREFIX="${TAG_NAME#v}" + if [[ "$TAG_NAME" == *-dev ]]; then + RELEASE_ENV="dev" + TAG_VERSION="${TAG_NO_PREFIX%-dev}" + else + RELEASE_ENV="prod" + TAG_VERSION="$TAG_NO_PREFIX" + fi + + if [ -z "$TAG_VERSION" ]; then + echo "::error title=Invalid tag::Could not derive version from tag: $TAG_NAME" + exit 1 + fi + + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "tag_version=$TAG_VERSION" >> "$GITHUB_OUTPUT" + echo "release_env=$RELEASE_ENV" >> "$GITHUB_OUTPUT" + + build-windows-unsigned: + needs: resolve-release + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: windows-2022 + - arch: arm64 + runner: windows-11-arm + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout repository at release tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ needs.resolve-release.outputs.tag_name }} + + - name: Install node + uses: actions/setup-node@v6 + with: + node-version: "24.x" + cache: "npm" + + - name: Make sure the version string matches the tag version + shell: pwsh + run: | + $pkgVersion = node -e "console.log(require('./package.json').version)" + $pkgBaseVersion = $pkgVersion -replace '-dev$','' + $tagVersion = "${{ needs.resolve-release.outputs.tag_version }}" + if ($pkgBaseVersion -ne $tagVersion) { + Write-Error "Version check failed: package.json version '$pkgVersion' does not match tag version '$tagVersion'" + exit 1 + } + + - name: Build unsigned Windows release artifacts + shell: pwsh + run: | + npm run make-windows-unsigned -- "${{ needs.resolve-release.outputs.release_env }}" "${{ matrix.arch }}" + + - name: Generate checksums for unsigned artifacts + shell: pwsh + run: | + $checksumFile = "windows-unsigned-checksums-${{ matrix.arch }}.txt" + if (Test-Path "out/make") { + $artifactDir = "out/make" + } elseif (Test-Path "out") { + $artifactDir = "out" + } else { + Write-Error "No artifact output directory found. Expected out/make or out" + exit 1 + } + + "ARTIFACT_DIR=$artifactDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + $files = Get-ChildItem -Path $artifactDir -File -Recurse | Sort-Object FullName + if (-not $files -or $files.Count -eq 0) { + Write-Error "No files found under $artifactDir to checksum" + exit 1 + } + + $lines = @() + foreach ($file in $files) { + $relativePath = [System.IO.Path]::GetRelativePath($PWD.Path, $file.FullName) -replace '\\','/' + $hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLower() + $lines += "$hash $relativePath" + } + + Set-Content -Path $checksumFile -Value $lines + + - name: Upload unsigned Windows artifacts + uses: actions/upload-artifact@v7 + with: + name: windows-unsigned-${{ needs.resolve-release.outputs.release_env }}-${{ matrix.arch }} + path: | + ${{ env.ARTIFACT_DIR }}/** + windows-unsigned-checksums-${{ matrix.arch }}.txt + + publish-manifest: + needs: + - resolve-release + - build-windows-unsigned + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Download unsigned Windows artifacts + uses: actions/download-artifact@v8 + with: + pattern: windows-unsigned-${{ needs.resolve-release.outputs.release_env }}-* + path: downloaded-artifacts + + - name: Build windows-release-manifest.json + env: + RELEASE_ENV: ${{ needs.resolve-release.outputs.release_env }} + TAG_NAME: ${{ needs.resolve-release.outputs.tag_name }} + TAG_VERSION: ${{ needs.resolve-release.outputs.tag_version }} + COMMIT_SHA: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + ARTIFACTS_ROOT: downloaded-artifacts + run: | + node ./scripts/build-windows-release-manifest.mjs + + - name: Upload windows release manifest + uses: actions/upload-artifact@v7 + with: + name: windows-release-manifest-${{ needs.resolve-release.outputs.release_env }} + path: windows-release-manifest.json diff --git a/package-lock.json b/package-lock.json index 8c1fab1d..00c06360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cyd", - "version": "1.1.22", + "version": "1.1.23-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cyd", - "version": "1.1.22", + "version": "1.1.23-dev", "hasInstallScript": true, "license": "proprietary", "workspaces": [ diff --git a/package.json b/package.json index d7c7b46e..086c4c9a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cyd", "private": true, - "version": "1.1.22", + "version": "1.1.23-dev", "main": ".vite/build/main.js", "description": "Automatically delete your data from tech platforms, except for what you want to keep", "license": "proprietary", @@ -16,6 +16,10 @@ "make-local": "node ./scripts/make.js make local", "make-dev": "node ./scripts/make.js make dev", "make-prod": "node ./scripts/make.js make prod", + "make-windows-unsigned": "node ./scripts/make-windows-unsigned.mjs", + "finalize-windows-release": "node ./scripts/finalize-windows-release.mjs", + "release-windows-dev": "powershell -ExecutionPolicy Bypass -File ./scripts/release-windows-dev.ps1", + "release-windows-prod": "powershell -ExecutionPolicy Bypass -File ./scripts/release-windows-prod.ps1", "make-dev-docker": "docker build -t cyd-builder . && docker run --rm -v \"$(pwd)\":/workspace -w /workspace cyd-builder npm run make-dev", "make-prod-docker": "docker build -t cyd-builder . && docker run --rm -v \"$(pwd)\":/workspace -w /workspace cyd-builder npm run make-prod", "publish-dev": "node ./scripts/make.js publish dev", diff --git a/scripts/build-windows-release-manifest.mjs b/scripts/build-windows-release-manifest.mjs new file mode 100644 index 00000000..f7727cd7 --- /dev/null +++ b/scripts/build-windows-release-manifest.mjs @@ -0,0 +1,83 @@ +/* global process */ +import fs from "fs"; +import path from "path"; + +const root = process.env.ARTIFACTS_ROOT || "downloaded-artifacts"; + +const artifactDirs = fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + +if (artifactDirs.length === 0) { + throw new Error(`No downloaded artifacts found in ${root}.`); +} + +function parseChecksums(text) { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + return lines.map((line) => { + const match = line.match(/^([a-f0-9]{64})\s+(.+)$/i); + if (!match) { + throw new Error(`Invalid checksum line: ${line}`); + } + + return { + sha256: match[1].toLowerCase(), + path: match[2], + }; + }); +} + +const artifacts = artifactDirs.map((artifactName) => { + const artifactPath = path.join(root, artifactName); + const files = fs.readdirSync(artifactPath); + const checksumFile = files.find( + (name) => + name.startsWith("windows-unsigned-checksums-") && name.endsWith(".txt"), + ); + + if (!checksumFile) { + throw new Error( + `Missing checksum file in artifact directory: ${artifactName}`, + ); + } + + const arch = checksumFile + .replace("windows-unsigned-checksums-", "") + .replace(".txt", ""); + + const checksumText = fs.readFileSync( + path.join(artifactPath, checksumFile), + "utf8", + ); + const checksums = parseChecksums(checksumText); + + return { + name: artifactName, + arch, + checksumFile, + fileCount: checksums.length, + checksums, + }; +}); + +const manifest = { + tag: process.env.TAG_NAME, + version: process.env.TAG_VERSION, + env: process.env.RELEASE_ENV, + commitSha: process.env.COMMIT_SHA, + workflowRunId: Number(process.env.RUN_ID), + workflowRunAttempt: Number(process.env.RUN_ATTEMPT), + generatedAt: new Date().toISOString(), + artifacts, +}; + +fs.writeFileSync( + "windows-release-manifest.json", + JSON.stringify(manifest, null, 2) + "\n", +); diff --git a/scripts/finalize-windows-release.mjs b/scripts/finalize-windows-release.mjs new file mode 100644 index 00000000..efb02b00 --- /dev/null +++ b/scripts/finalize-windows-release.mjs @@ -0,0 +1,34 @@ +/* global console */ +import process from "process"; +import { execSync } from "child_process"; + +const validModes = ["dev", "prod"]; +const mode = process.argv[2]; +if (!validModes.includes(mode)) { + console.error( + `Invalid mode: "${mode}". Valid modes are: ${validModes.join(", ")}`, + ); + process.exit(1); +} + +const args = process.argv.slice(3); +const arch = args.find((value) => !value.startsWith("--")); +const skipClean = args.includes("--skip-clean"); + +process.env.CYD_ENV = mode; +process.env.WINDOWS_RELEASE = "true"; +process.env.SQUIRREL_TEMP = "build\\SquirrelTemp"; +process.env.DEBUG = + "electron-packager,electron-universal,electron-forge*,electron-installer*"; + +try { + if (!skipClean) { + execSync("node ./scripts/clean.mjs", { stdio: "inherit" }); + } + + const archFlag = arch ? ` --arch ${arch}` : ""; + execSync(`electron-forge publish${archFlag}`, { stdio: "inherit" }); +} catch (error) { + console.error("Error executing Windows finalization:", error.message); + process.exit(1); +} diff --git a/scripts/make-windows-unsigned.mjs b/scripts/make-windows-unsigned.mjs new file mode 100644 index 00000000..a15341e0 --- /dev/null +++ b/scripts/make-windows-unsigned.mjs @@ -0,0 +1,34 @@ +/* global console */ +import process from "process"; +import { execSync } from "child_process"; + +const validModes = ["dev", "prod"]; +const mode = process.argv[2]; +if (!validModes.includes(mode)) { + console.error( + `Invalid mode: "${mode}". Valid modes are: ${validModes.join(", ")}`, + ); + process.exit(1); +} + +const arch = process.argv.slice(3).find((value) => !value.startsWith("--")); + +process.env.CYD_ENV = mode; +process.env.WINDOWS_RELEASE = "false"; +process.env.SQUIRREL_TEMP = "build\\SquirrelTemp"; +process.env.DEBUG = + "electron-packager,electron-universal,electron-forge*,electron-installer*"; + +try { + // Keep parity with existing release flow until CI-only build path is introduced. + execSync("node ./scripts/clean.mjs", { stdio: "inherit" }); + + const archFlag = arch ? ` --arch ${arch}` : ""; + execSync( + `electron-forge make --platform win32 --targets @electron-forge/maker-squirrel${archFlag}`, + { stdio: "inherit" }, + ); +} catch (error) { + console.error("Error executing unsigned Windows build:", error.message); + process.exit(1); +} diff --git a/scripts/make.js b/scripts/make.js index 63316ee0..88538dcd 100644 --- a/scripts/make.js +++ b/scripts/make.js @@ -38,6 +38,26 @@ if (platform == "win32") { } try { + if (platform === "win32" && command === "publish") { + if (mode === "local") { + console.error('Windows publish supports only "dev" and "prod" modes.'); + process.exit(1); + } + + // Phase 1 split: keep current publish entrypoint but delegate to explicit + // unsigned-build and finalization scripts. + execSync(`node ./scripts/make-windows-unsigned.mjs ${mode}`, { + stdio: "inherit", + }); + execSync( + `node ./scripts/finalize-windows-release.mjs ${mode} --skip-clean`, + { + stdio: "inherit", + }, + ); + process.exit(0); + } + // Clean up previous builds and install dependencies execSync(`node ./scripts/clean.mjs`, { stdio: "inherit" }); diff --git a/scripts/release-windows-common.ps1 b/scripts/release-windows-common.ps1 new file mode 100644 index 00000000..7457055e --- /dev/null +++ b/scripts/release-windows-common.ps1 @@ -0,0 +1,241 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('dev', 'prod')] + [string]$ReleaseEnv, + + [string]$Repo = 'lockdown-systems/cyd', + + [string]$WorkflowName = 'Release for Windows', + + [string]$RunId, + + [switch]$SkipUploadVerification +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Message) + Write-Host "[] $Message" +} + +function Test-TagMatchesEnv { + param( + [string]$Tag, + [string]$Env + ) + + if ([string]::IsNullOrWhiteSpace($Tag)) { + return $false + } + + if (-not $Tag.StartsWith('v')) { + return $false + } + + if ($Env -eq 'dev') { + return $Tag.EndsWith('-dev') + } + + return -not $Tag.EndsWith('-dev') +} + +function Assert-Command { + param([string]$Name) + + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command '$Name' is not installed or not in PATH" + } +} + +function Get-LatestRunId { + param( + [string]$RepoName, + [string]$Workflow, + [string]$Env + ) + + Write-Step "Finding latest successful '$Workflow' run for $Env" + + $runsJson = gh run list ` + --repo $RepoName ` + --workflow $Workflow ` + --status completed ` + --limit 100 ` + --json databaseId,headBranch,event,conclusion,createdAt,url + + $runs = $runsJson | ConvertFrom-Json + + $matching = $runs | Where-Object { + $_.conclusion -eq 'success' -and + $_.event -eq 'push' -and + (Test-TagMatchesEnv -Tag $_.headBranch -Env $Env) + } + + if (-not $matching -or $matching.Count -eq 0) { + throw "Could not find a successful tag-push run for '$Workflow' in environment '$Env'" + } + + $selected = $matching | Sort-Object createdAt -Descending | Select-Object -First 1 + Write-Step "Using run $($selected.databaseId) ($($selected.headBranch))" + Write-Step "Run URL: $($selected.url)" + return [string]$selected.databaseId +} + +function Get-ManifestPath { + param( + [string]$DownloadRoot, + [string]$Env + ) + + $manifestDir = Join-Path $DownloadRoot "windows-release-manifest-$Env" + $manifestPath = Join-Path $manifestDir 'windows-release-manifest.json' + + if (-not (Test-Path $manifestPath)) { + throw "Manifest file not found at: $manifestPath" + } + + return $manifestPath +} + +function Assert-ExpectedArtifacts { + param( + [string]$DownloadRoot, + [string]$Env + ) + + $required = @( + "windows-unsigned-$Env-x64", + "windows-unsigned-$Env-arm64", + "windows-release-manifest-$Env" + ) + + foreach ($name in $required) { + $path = Join-Path $DownloadRoot $name + if (-not (Test-Path $path)) { + throw "Expected artifact folder missing: $path" + } + } +} + +function Assert-ManifestChecksums { + param( + [string]$DownloadRoot, + [string]$ManifestPath, + [string]$Env + ) + + $manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json + + if ($manifest.env -ne $Env) { + throw "Manifest env '$($manifest.env)' does not match expected env '$Env'" + } + + foreach ($artifact in $manifest.artifacts) { + $artifactPath = Join-Path $DownloadRoot $artifact.name + if (-not (Test-Path $artifactPath)) { + throw "Manifest artifact folder missing: $artifactPath" + } + + foreach ($item in $artifact.checksums) { + $normalized = ($item.path -replace '/', '\\') + $targetPath = Join-Path $artifactPath $normalized + if (-not (Test-Path $targetPath)) { + throw "Expected file from manifest not found: $targetPath" + } + + $actualHash = (Get-FileHash -Path $targetPath -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $item.sha256) { + throw "Checksum mismatch for $targetPath. Expected $($item.sha256), got $actualHash" + } + } + } +} + +function Invoke-FinalizeBothArchitectures { + param( + [string]$Env, + [string]$RepoRoot + ) + + $arches = @('x64', 'arm64') + + Write-Step 'Preparing workspace for local finalization' + Push-Location $RepoRoot + try { + node ./scripts/clean.mjs + + foreach ($arch in $arches) { + Write-Step "Finalizing signed release for $arch" + npm run finalize-windows-release -- $Env $arch --skip-clean + } + } + finally { + Pop-Location + } +} + +function Assert-UploadReachable { + param([string]$Env) + + $arches = @('x64', 'arm64') + + foreach ($arch in $arches) { + $url = "https://releases.lockdown.systems/cyd/$Env/windows/$arch/RELEASES" + Write-Step "Verifying uploaded RELEASES: $url" + + try { + $response = Invoke-WebRequest -Uri $url -Method Get -TimeoutSec 30 + } + catch { + throw "Upload verification failed for $url: $($_.Exception.Message)" + } + + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { + throw "Upload verification returned status code $($response.StatusCode) for $url" + } + } +} + +Assert-Command -Name 'gh' +Assert-Command -Name 'node' +Assert-Command -Name 'npm' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') + +if ([string]::IsNullOrWhiteSpace($RunId)) { + $RunId = Get-LatestRunId -RepoName $Repo -Workflow $WorkflowName -Env $ReleaseEnv +} +else { + Write-Step "Using provided run id: $RunId" +} + +$downloadRoot = Join-Path $repoRoot "build/windows-release/$ReleaseEnv/$RunId" + +if (Test-Path $downloadRoot) { + Write-Step "Cleaning previous download folder: $downloadRoot" + Remove-Item -Path $downloadRoot -Recurse -Force +} + +New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null + +Write-Step "Downloading artifacts for run $RunId" +gh run download $RunId -R $Repo -D $downloadRoot + +Assert-ExpectedArtifacts -DownloadRoot $downloadRoot -Env $ReleaseEnv +$manifestPath = Get-ManifestPath -DownloadRoot $downloadRoot -Env $ReleaseEnv + +Write-Step "Verifying artifact checksums from manifest" +Assert-ManifestChecksums -DownloadRoot $downloadRoot -ManifestPath $manifestPath -Env $ReleaseEnv + +Write-Step 'Checksums verified successfully' +Write-Step 'Running local finalization for x64 + arm64' +Invoke-FinalizeBothArchitectures -Env $ReleaseEnv -RepoRoot $repoRoot + +if (-not $SkipUploadVerification) { + Assert-UploadReachable -Env $ReleaseEnv + Write-Step 'Upload verification completed' +} + +Write-Step 'Windows release flow completed successfully' diff --git a/scripts/release-windows-dev.ps1 b/scripts/release-windows-dev.ps1 new file mode 100644 index 00000000..977198fe --- /dev/null +++ b/scripts/release-windows-dev.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param( + [string]$RunId, + [string]$Repo = 'lockdown-systems/cyd', + [string]$WorkflowName = 'Release for Windows', + [switch]$SkipUploadVerification +) + +$commonScript = Join-Path $PSScriptRoot 'release-windows-common.ps1' + +$commonArgs = @{ + ReleaseEnv = 'dev' + Repo = $Repo + WorkflowName = $WorkflowName +} + +if (-not [string]::IsNullOrWhiteSpace($RunId)) { + $commonArgs.RunId = $RunId +} + +if ($SkipUploadVerification) { + $commonArgs.SkipUploadVerification = $true +} + +& $commonScript @commonArgs diff --git a/scripts/release-windows-prod.ps1 b/scripts/release-windows-prod.ps1 new file mode 100644 index 00000000..5d4d4402 --- /dev/null +++ b/scripts/release-windows-prod.ps1 @@ -0,0 +1,25 @@ +[CmdletBinding()] +param( + [string]$RunId, + [string]$Repo = 'lockdown-systems/cyd', + [string]$WorkflowName = 'Release for Windows', + [switch]$SkipUploadVerification +) + +$commonScript = Join-Path $PSScriptRoot 'release-windows-common.ps1' + +$commonArgs = @{ + ReleaseEnv = 'prod' + Repo = $Repo + WorkflowName = $WorkflowName +} + +if (-not [string]::IsNullOrWhiteSpace($RunId)) { + $commonArgs.RunId = $RunId +} + +if ($SkipUploadVerification) { + $commonArgs.SkipUploadVerification = $true +} + +& $commonScript @commonArgs diff --git a/scripts/windows-release.md b/scripts/windows-release.md new file mode 100644 index 00000000..3de2e7ef --- /dev/null +++ b/scripts/windows-release.md @@ -0,0 +1,55 @@ +# Windows Local Release Scripts + +These scripts implement the local smart-card finalization flow for Windows releases. + +## Prerequisites + +1. Run from a Windows machine with the smart card connected. +2. Authenticate GitHub CLI: + +```powershell +gh auth login +``` + +3. Ensure release upload credentials are present in environment variables used by Electron Forge (`DO_SPACES_KEY`, `DO_SPACES_SECRET`). +4. Ensure signing certificate is available through the smart card and `signtool` is working. + +## Scripts + +- `release-windows-dev.ps1` +- `release-windows-prod.ps1` + +Both wrappers call `release-windows-common.ps1`, which: + +1. Resolves a successful run id from the `Release for Windows` workflow (or uses a provided run id). +2. Downloads workflow artifacts. +3. Verifies file checksums against `windows-release-manifest.json`. +4. Runs local finalization for x64 and arm64 in one execution. +5. Verifies uploaded `RELEASES` files are reachable (unless skipped). + +## Usage + +Use latest successful run for dev: + +```powershell +./scripts/release-windows-dev.ps1 +``` + +Use latest successful run for prod: + +```powershell +./scripts/release-windows-prod.ps1 +``` + +Use a specific run id: + +```powershell +./scripts/release-windows-dev.ps1 -RunId 1234567890 +./scripts/release-windows-prod.ps1 -RunId 1234567890 +``` + +Skip URL verification step: + +```powershell +./scripts/release-windows-prod.ps1 -SkipUploadVerification +```