name: Release on: push: tags: - 'v*' permissions: contents: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: prepare-meta: runs-on: windows-latest environment: 软件发布 env: GH_TOKEN: ${{ secrets.GH_TOKEN }} AI_API_URL: ${{ vars.AI_API_URL }} AI_MODEL: ${{ vars.AI_MODEL }} FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }} FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }} FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }} FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }} FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }} outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} steps: - name: Checkout uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22.12.0 cache: npm - name: Read package version id: version shell: pwsh run: | $pkg = Get-Content package.json -Raw | ConvertFrom-Json "version=$($pkg.version)" >> $env:GITHUB_OUTPUT "tag=${env:GITHUB_REF_NAME}" >> $env:GITHUB_OUTPUT - name: Validate tag matches package version shell: pwsh run: | $expectedTag = "v${{ steps.version.outputs.version }}" $actualTag = "${{ steps.version.outputs.tag }}" if ($actualTag -ne $expectedTag) { Write-Error "Tag $actualTag does not match package.json version $expectedTag" exit 1 } - name: Install dependencies run: npm ci - name: Generate force update manifest run: npm run build:force-update-manifest - name: Generate release context env: RELEASE_TAG: ${{ steps.version.outputs.tag }} run: npm run build:release-context - name: Upload release metadata uses: actions/upload-artifact@v4 with: name: release-meta path: | release/force-update.json release/release-context.json if-no-files-found: error build-windows: runs-on: windows-latest environment: 软件发布 needs: prepare-meta env: NODE_OPTIONS: --max-old-space-size=6144 GH_TOKEN: ${{ secrets.GH_TOKEN }} AI_API_KEY: ${{ secrets.AI_API_KEY }} AI_API_URL: ${{ vars.AI_API_URL }} AI_MODEL: ${{ vars.AI_MODEL }} FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }} FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }} FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }} FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }} FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }} steps: - name: Checkout uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22.12.0 cache: npm - name: Install dependencies run: npm ci - name: Rebuild native modules run: npx electron-rebuild - name: Download release metadata uses: actions/download-artifact@v4 with: name: release-meta path: release - name: Generate embedded release body run: npm run build:release-body - name: Build app run: npm run build:win - name: Validate build artifacts shell: pwsh run: | $version = "${{ needs.prepare-meta.outputs.version }}" $installer = "release/CipherTalk-$version-Setup.exe" if (-not (Test-Path $installer)) { Write-Error "Installer not found: $installer" exit 1 } - name: Upload release binaries uses: actions/upload-artifact@v4 with: name: release-binaries-windows path: | release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe release/latest.yml if-no-files-found: error build-macos: runs-on: macos-latest environment: 软件发布 needs: prepare-meta env: NODE_OPTIONS: --max-old-space-size=6144 GH_TOKEN: ${{ secrets.GH_TOKEN }} AI_API_KEY: ${{ secrets.AI_API_KEY }} AI_API_URL: ${{ vars.AI_API_URL }} AI_MODEL: ${{ vars.AI_MODEL }} FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }} FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }} FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }} FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }} FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }} steps: - name: Checkout uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22.12.0 cache: npm - name: Install dependencies run: npm ci - name: Rebuild native modules run: npx electron-rebuild - name: Download release metadata uses: actions/download-artifact@v4 with: name: release-meta path: release - name: Generate embedded release body run: npm run build:release-body - name: Build mac app run: npm run build:mac - name: Validate build artifacts run: | version="${{ needs.prepare-meta.outputs.version }}" dmg="release/CipherTalk-${version}-Setup.dmg" if [ ! -f "$dmg" ]; then echo "DMG not found: $dmg" >&2 exit 1 fi - name: Upload release binaries uses: actions/upload-artifact@v4 with: name: release-binaries-macos path: | release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.dmg release/latest-mac.yml if-no-files-found: error generate-release-body: runs-on: windows-latest environment: 软件发布 needs: prepare-meta env: GH_TOKEN: ${{ secrets.GH_TOKEN }} AI_API_KEY: ${{ secrets.AI_API_KEY }} AI_API_URL: ${{ vars.AI_API_URL }} AI_MODEL: ${{ vars.AI_MODEL }} FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }} FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }} FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }} FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }} FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }} steps: - name: Checkout uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22.12.0 cache: npm - name: Install dependencies run: npm ci - name: Download release metadata uses: actions/download-artifact@v4 with: name: release-meta path: release - name: Generate AI release body run: npm run build:release-body - name: Validate release body shell: pwsh run: | if (-not (Test-Path "release/release-body.md")) { Write-Error "release-body.md not found" exit 1 } - name: Upload release body uses: actions/upload-artifact@v4 with: name: release-body path: release/release-body.md if-no-files-found: error publish-github-release: runs-on: windows-latest environment: 软件发布 needs: - prepare-meta - build-windows - build-macos - generate-release-body steps: - name: Download release metadata uses: actions/download-artifact@v4 with: name: release-meta path: release - name: Download release binaries uses: actions/download-artifact@v4 with: name: release-binaries-windows path: release - name: Download macOS release binaries uses: actions/download-artifact@v4 with: name: release-binaries-macos path: release - name: Download release body uses: actions/download-artifact@v4 with: name: release-body path: release - name: Validate release package shell: pwsh run: | $version = "${{ needs.prepare-meta.outputs.version }}" $installer = "release/CipherTalk-$version-Setup.exe" $macInstaller = "release/CipherTalk-$version-Setup.dmg" if (-not (Test-Path $installer)) { Write-Error "Installer not found: $installer" exit 1 } if (-not (Test-Path $macInstaller)) { Write-Error "macOS installer not found: $macInstaller" exit 1 } if (-not (Test-Path "release/latest.yml")) { Write-Error "latest.yml not found" exit 1 } if (-not (Test-Path "release/latest-mac.yml")) { Write-Error "latest-mac.yml not found" exit 1 } $sizeLines = @(Select-String -Path "release/latest.yml" -Pattern '^\s+size:\s+\d+\s*$') if ($sizeLines.Count -ne 1) { Write-Error "latest.yml should contain exactly one size entry, found $($sizeLines.Count)" exit 1 } $macSizeLines = @(Select-String -Path "release/latest-mac.yml" -Pattern '^\s+size:\s+\d+\s*$') if ($macSizeLines.Count -ne 1) { Write-Error "latest-mac.yml should contain exactly one size entry, found $($macSizeLines.Count)" exit 1 } if (-not (Test-Path "release/force-update.json")) { Write-Error "force-update.json not found" exit 1 } if (-not (Test-Path "release/release-body.md")) { Write-Error "release-body.md not found" exit 1 } $latestYml = Get-Content "release/latest.yml" -Raw $shaMatch = [regex]::Match($latestYml, '(?m)^sha512:\s*(.+)$') if (-not $shaMatch.Success) { Write-Error "sha512 not found in latest.yml" exit 1 } $hashHex = (Get-FileHash -Algorithm SHA512 $installer).Hash $hashBytes = [byte[]]::new($hashHex.Length / 2) for ($i = 0; $i -lt $hashHex.Length; $i += 2) { $hashBytes[$i / 2] = [Convert]::ToByte($hashHex.Substring($i, 2), 16) } $actualSha512 = [Convert]::ToBase64String($hashBytes) $expectedSha512 = $shaMatch.Groups[1].Value.Trim() if ($actualSha512 -ne $expectedSha512) { Write-Error "latest.yml sha512 does not match installer" exit 1 } $latestMacYml = Get-Content "release/latest-mac.yml" -Raw $macShaMatch = [regex]::Match($latestMacYml, '(?m)^sha512:\s*(.+)$') if (-not $macShaMatch.Success) { Write-Error "sha512 not found in latest-mac.yml" exit 1 } $macHashHex = (Get-FileHash -Algorithm SHA512 $macInstaller).Hash $macHashBytes = [byte[]]::new($macHashHex.Length / 2) for ($i = 0; $i -lt $macHashHex.Length; $i += 2) { $macHashBytes[$i / 2] = [Convert]::ToByte($macHashHex.Substring($i, 2), 16) } $actualMacSha512 = [Convert]::ToBase64String($macHashBytes) $expectedMacSha512 = $macShaMatch.Groups[1].Value.Trim() if ($actualMacSha512 -ne $expectedMacSha512) { Write-Error "latest-mac.yml sha512 does not match dmg" exit 1 } - name: Create or update GitHub Release uses: softprops/action-gh-release@v2.5.0 with: token: ${{ secrets.GITHUB_TOKEN }} tag_name: ${{ needs.prepare-meta.outputs.tag }} name: CipherTalk v${{ needs.prepare-meta.outputs.version }} body_path: release/release-body.md fail_on_unmatched_files: false overwrite_files: false files: | release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.exe release/CipherTalk-${{ needs.prepare-meta.outputs.version }}-Setup.dmg release/latest.yml release/latest-mac.yml release/force-update.json mirror-r2: runs-on: windows-latest environment: 软件发布 needs: - prepare-meta - build-windows - build-macos env: R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} steps: - name: Download release metadata uses: actions/download-artifact@v4 with: name: release-meta path: release - name: Download release binaries uses: actions/download-artifact@v4 with: name: release-binaries-windows path: release - name: Download macOS release binaries uses: actions/download-artifact@v4 with: name: release-binaries-macos path: release - name: Ensure AWS CLI shell: pwsh run: | if (-not (Get-Command aws -ErrorAction SilentlyContinue)) { choco install awscli -y } aws --version - name: Upload mirrored files to R2 shell: pwsh run: | if (-not $env:R2_ACCOUNT_ID -or -not $env:R2_BUCKET_NAME -or -not $env:R2_ACCESS_KEY_ID -or -not $env:R2_SECRET_ACCESS_KEY) { Write-Error "R2 secrets are required" exit 1 } $env:AWS_ACCESS_KEY_ID = $env:R2_ACCESS_KEY_ID $env:AWS_SECRET_ACCESS_KEY = $env:R2_SECRET_ACCESS_KEY $env:AWS_DEFAULT_REGION = "auto" $endpoint = "https://$($env:R2_ACCOUNT_ID).r2.cloudflarestorage.com" $bucket = "s3://$($env:R2_BUCKET_NAME)" $version = "${{ needs.prepare-meta.outputs.version }}" $currentInstaller = "CipherTalk-$version-Setup.exe" $currentMacInstaller = "CipherTalk-$version-Setup.dmg" $existingInstallers = aws s3 ls $bucket --endpoint-url $endpoint | ForEach-Object { $line = $_.ToString().Trim() if ($line -match 'CipherTalk-.*-Setup\.exe$') { ($line -split '\s+')[-1] } } | Where-Object { $_ } $existingMacInstallers = aws s3 ls $bucket --endpoint-url $endpoint | ForEach-Object { $line = $_.ToString().Trim() if ($line -match 'CipherTalk-.*-Setup\.dmg$') { ($line -split '\s+')[-1] } } | Where-Object { $_ } $existingBlockmaps = aws s3 ls $bucket --endpoint-url $endpoint | ForEach-Object { $line = $_.ToString().Trim() if ($line -match '\.blockmap$') { ($line -split '\s+')[-1] } } | Where-Object { $_ } foreach ($installer in $existingInstallers) { if ($installer -ne $currentInstaller) { aws s3 rm "$bucket/$installer" --endpoint-url $endpoint } } foreach ($installer in $existingMacInstallers) { if ($installer -ne $currentMacInstaller) { aws s3 rm "$bucket/$installer" --endpoint-url $endpoint } } foreach ($blockmap in $existingBlockmaps) { aws s3 rm "$bucket/$blockmap" --endpoint-url $endpoint } aws s3 cp "release/$currentInstaller" "$bucket/$currentInstaller" --endpoint-url $endpoint aws s3 cp "release/$currentMacInstaller" "$bucket/$currentMacInstaller" --endpoint-url $endpoint aws s3 cp "release/latest.yml" "$bucket/latest.yml" --endpoint-url $endpoint aws s3 cp "release/latest-mac.yml" "$bucket/latest-mac.yml" --endpoint-url $endpoint aws s3 cp "release/force-update.json" "$bucket/force-update.json" --endpoint-url $endpoint notify-telegram-success: runs-on: windows-latest environment: 软件发布 needs: - prepare-meta - generate-release-body - publish-github-release env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_IDS: ${{ vars.TELEGRAM_CHAT_IDS }} TELEGRAM_RELEASE_COVER_URL: ${{ vars.TELEGRAM_RELEASE_COVER_URL }} RELEASE_VERSION: ${{ needs.prepare-meta.outputs.version }} TELEGRAM_NOTIFY_MODE: success steps: - name: Checkout uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22.12.0 cache: npm - name: Download release metadata uses: actions/download-artifact@v4 with: name: release-meta path: release - name: Download release body uses: actions/download-artifact@v4 with: name: release-body path: release - name: Notify Telegram success run: npm run notify:telegram continue-on-error: true notify-failure: runs-on: windows-latest environment: 软件发布 if: failure() env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_IDS: ${{ vars.TELEGRAM_CHAT_IDS }} TELEGRAM_RELEASE_COVER_URL: ${{ vars.TELEGRAM_RELEASE_COVER_URL }} RELEASE_VERSION: ${{ github.ref_name }} TELEGRAM_NOTIFY_MODE: failure steps: - name: Checkout uses: actions/checkout@v5 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: 22.12.0 cache: npm - name: Notify Telegram failure run: npm run notify:telegram continue-on-error: true