From cdf27da246b6d8012e0cbb389d4a00802c5ad31b Mon Sep 17 00:00:00 2001 From: Arnold HAO Date: Fri, 29 May 2026 11:40:48 +0800 Subject: [PATCH] ci: publish signed releases atomically --- .github/workflows/publish-release.yml | 236 +++++++++++++++++++++++++- .github/workflows/release-macos.yml | 114 ------------- .github/workflows/release-windows.yml | 105 ------------ dev-app-update.yml | 5 +- electron-builder.yml | 7 +- scripts/verify-macos-signing.mjs | 37 +++- 6 files changed, 274 insertions(+), 230 deletions(-) delete mode 100644 .github/workflows/release-macos.yml delete mode 100644 .github/workflows/release-windows.yml diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3aae556..f881158 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -3,23 +3,41 @@ name: Publish Release on: workflow_dispatch: +env: + NODE_VERSION: '22' + jobs: - publish: + prepare_release: + name: Prepare draft release permissions: contents: write pull-requests: read issues: read runs-on: ubuntu-24.04 + outputs: + release_id: ${{ steps.release_drafter.outputs.id }} + tag_name: ${{ steps.release_drafter.outputs.tag_name }} + version: ${{ steps.release_info.outputs.version }} steps: - name: Release Drafter id: release_drafter uses: release-drafter/release-drafter@v6 with: - publish: true + publish: false commitish: ${{ github.sha }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Validate release metadata + id: release_info + shell: bash + run: | + release_id="${{ steps.release_drafter.outputs.id }}" + tag_name="${{ steps.release_drafter.outputs.tag_name }}" + test -n "$release_id" || { echo "::error::Release Drafter did not return a release id"; exit 1; } + test -n "$tag_name" || { echo "::error::Release Drafter did not return a tag name"; exit 1; } + echo "version=${tag_name#v}" >> "$GITHUB_OUTPUT" + - name: Enrich release notes with PR descriptions if: ${{ steps.release_drafter.outputs.id != '' && steps.release_drafter.outputs.body != '' }} env: @@ -204,3 +222,217 @@ jobs: console.log(`Enriched release notes with PR descriptions for ${detailLinesByPr.size} pull request(s).`); NODE + + release-macos: + name: Build macOS artifacts + needs: prepare_release + runs-on: macos-26 + env: + TELEMETRYDECK_APP_ID: ${{ secrets.TELEMETRYDECK_APP_ID }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Sync package version + run: npm version "${{ needs.prepare_release.outputs.version }}" --no-git-tag-version --allow-same-version + + - name: Install deps + run: npm ci + + - name: Build app + run: npm run build + + - name: Validate macOS signing secrets + shell: bash + env: + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + test -n "$CSC_LINK" || { echo "::error::Missing MAC_CSC_LINK secret"; exit 1; } + test -n "$CSC_KEY_PASSWORD" || { echo "::error::Missing MAC_CSC_KEY_PASSWORD secret"; exit 1; } + test -n "$APPLE_ID" || { echo "::error::Missing APPLE_ID secret"; exit 1; } + test -n "$APPLE_APP_SPECIFIC_PASSWORD" || { echo "::error::Missing APPLE_APP_SPECIFIC_PASSWORD secret"; exit 1; } + test -n "$APPLE_TEAM_ID" || { echo "::error::Missing APPLE_TEAM_ID secret"; exit 1; } + + - name: Package app (macOS) + env: + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: npx electron-builder --mac --universal --publish never + + - name: Verify native module + run: node scripts/verify-native-module.mjs darwin universal dist/mac-universal/Hush.app + + - name: Verify macOS signing + run: npm run verify:macos-signing -- --require-developer-id dist/mac-universal/Hush.app + + - name: Verify macOS notarization + shell: bash + run: | + xcrun stapler validate dist/mac-universal/Hush.app + spctl --assess --type execute --verbose=4 dist/mac-universal/Hush.app + + - name: Inspect macOS build metadata + shell: bash + run: | + sw_vers + xcodebuild -version + xcrun --show-sdk-version --sdk macosx + find dist -maxdepth 1 -type f -print + + - name: SHA256 + shell: bash + run: | + find dist -maxdepth 1 -type f -print0 | while IFS= read -r -d '' file; do + shasum -a 256 "$file" > "$file.sha256" + done + + - name: List dist artifacts + shell: bash + run: ls -la dist + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-artifacts-universal + path: dist/* + if-no-files-found: error + + release-windows: + name: Build Windows artifacts + needs: prepare_release + runs-on: windows-latest + env: + TELEMETRYDECK_APP_ID: ${{ secrets.TELEMETRYDECK_APP_ID }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Sync package version + run: npm version "$env:VERSION" --no-git-tag-version --allow-same-version + shell: pwsh + env: + VERSION: ${{ needs.prepare_release.outputs.version }} + + - name: Install deps + run: npm ci + + - name: Build app + run: npm run build + + - name: Package app (Windows) + run: npx electron-builder --win --x64 --publish never + + - name: Verify native module + run: node scripts/verify-native-module.mjs win32 x64 + + - name: SHA256 + shell: pwsh + run: | + Get-ChildItem dist -File | ForEach-Object { + $hash = (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLower() + "$hash $($_.Name)" | Out-File -FilePath "$($_.FullName).sha256" -Encoding ascii -NoNewline + } + + - name: List dist artifacts + shell: pwsh + run: Get-ChildItem dist | Format-Table Name,Length,LastWriteTime + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-artifacts + path: dist/* + if-no-files-found: error + + publish_release: + name: Publish release after assets + needs: + - prepare_release + - release-macos + - release-windows + permissions: + contents: write + runs-on: ubuntu-24.04 + steps: + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: List release assets + shell: bash + run: find release-assets -maxdepth 1 -type f -print | sort + + - name: Verify required release assets + shell: bash + env: + VERSION: ${{ needs.prepare_release.outputs.version }} + run: | + required=( + "hush-macos-universal-$VERSION.dmg" + "hush-macos-universal-$VERSION.zip" + "hush-macos-universal-$VERSION.zip.blockmap" + "hush-windows-x64-$VERSION-setup.exe" + "hush-windows-x64-$VERSION-setup.exe.blockmap" + "latest-mac.yml" + "latest.yml" + ) + + for asset in "${required[@]}"; do + test -f "release-assets/$asset" || { echo "::error::Missing release asset: $asset"; exit 1; } + done + + - name: Clear existing draft assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ needs.prepare_release.outputs.tag_name }} + run: | + is_draft="$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft' --repo "$GITHUB_REPOSITORY")" + test "$is_draft" = "true" || { echo "::error::Release $TAG_NAME is not a draft"; exit 1; } + + mapfile -t existing_assets < <(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' --repo "$GITHUB_REPOSITORY") + for asset in "${existing_assets[@]}"; do + gh release delete-asset "$TAG_NAME" "$asset" --yes --repo "$GITHUB_REPOSITORY" + done + + - name: Upload assets to draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ needs.prepare_release.outputs.tag_name }} + run: | + mapfile -d '' files < <(find release-assets -maxdepth 1 -type f -print0) + test "${#files[@]}" -gt 0 || { echo "::error::No release assets found"; exit 1; } + gh release upload "$TAG_NAME" "${files[@]}" --clobber --repo "$GITHUB_REPOSITORY" + + - name: Publish draft release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ needs.prepare_release.outputs.tag_name }} + run: gh release edit "$TAG_NAME" --draft=false --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml deleted file mode 100644 index 91284ef..0000000 --- a/.github/workflows/release-macos.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: Release macOS - -on: - workflow_run: - workflows: ['Publish Release'] - types: [completed] - -env: - NODE_VERSION: '22' - -jobs: - release-macos: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - permissions: - contents: write - runs-on: macos-26 - env: - TELEMETRYDECK_APP_ID: ${{ secrets.TELEMETRYDECK_APP_ID }} - steps: - - name: Resolve release metadata - id: release_info - uses: actions/github-script@v7 - with: - script: | - const run = context.payload.workflow_run; - if (!run) throw new Error('workflow_run payload is missing'); - const { owner, repo } = context.repo; - const headSha = (run.head_sha || '').trim(); - const headBranch = (run.head_branch || '').trim(); - const commitishCandidates = new Set([headSha, headBranch, headBranch ? `refs/heads/${headBranch}` : ''].filter(Boolean)); - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - let matchedRelease = null; - for (let attempt = 1; attempt <= 6; attempt += 1) { - const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }); - matchedRelease = releases - .filter((release) => !release.draft) - .sort((left, right) => Date.parse(right.published_at || right.created_at || 0) - Date.parse(left.published_at || left.created_at || 0)) - .find((release) => commitishCandidates.has((release.target_commitish || '').trim())); - if (matchedRelease) break; - core.info(`No published release matched commitish ${Array.from(commitishCandidates).join(', ')} on attempt ${attempt}/6`); - if (attempt < 6) await sleep(5000); - } - if (!matchedRelease) throw new Error(`No published release found for commit ${headSha}`); - core.setOutput('tag_name', matchedRelease.tag_name); - core.setOutput('version', matchedRelease.tag_name.replace(/^v/, '')); - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ steps.release_info.outputs.tag_name }} - - - name: Resolve version from tag - shell: bash - run: | - version="${{ steps.release_info.outputs.version }}" - echo "VERSION=$version" >> "$GITHUB_ENV" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - - - name: Sync package version - run: npm version "$VERSION" --no-git-tag-version --allow-same-version - - - name: Install deps - run: npm ci - - - name: Build app - run: npm run build - - - name: Package app (macOS) - run: npx electron-builder --mac --universal --publish never - - - name: Verify native module - run: node scripts/verify-native-module.mjs darwin universal dist/mac-universal/Hush.app - - - name: Verify macOS signing - run: npm run verify:macos-signing -- dist/mac-universal/Hush.app - - - name: Inspect macOS build metadata - shell: bash - run: | - sw_vers - xcodebuild -version - xcrun --show-sdk-version --sdk macosx - find dist -maxdepth 1 -type f -print - - - name: SHA256 - shell: bash - run: | - find dist -maxdepth 1 -type f -print0 | while IFS= read -r -d '' file; do - shasum -a 256 "$file" > "$file.sha256" - done - - - name: List dist artifacts - shell: bash - run: ls -la dist - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: macos-artifacts-universal - path: dist/* - if-no-files-found: error - - - name: Release upload - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.release_info.outputs.tag_name }} - files: dist/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml deleted file mode 100644 index af5a90a..0000000 --- a/.github/workflows/release-windows.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Release Windows - -on: - workflow_run: - workflows: ['Publish Release'] - types: [completed] - -env: - NODE_VERSION: '22' - -jobs: - release-windows: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - runs-on: windows-latest - permissions: - contents: write - env: - TELEMETRYDECK_APP_ID: ${{ secrets.TELEMETRYDECK_APP_ID }} - steps: - - name: Resolve release metadata - id: release_info - uses: actions/github-script@v7 - with: - script: | - const run = context.payload.workflow_run; - if (!run) throw new Error('workflow_run payload is missing'); - const { owner, repo } = context.repo; - const headSha = (run.head_sha || '').trim(); - const headBranch = (run.head_branch || '').trim(); - const commitishCandidates = new Set([headSha, headBranch, headBranch ? `refs/heads/${headBranch}` : ''].filter(Boolean)); - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - let matchedRelease = null; - for (let attempt = 1; attempt <= 6; attempt += 1) { - const releases = await github.paginate(github.rest.repos.listReleases, { owner, repo, per_page: 100 }); - matchedRelease = releases - .filter((release) => !release.draft) - .sort((left, right) => Date.parse(right.published_at || right.created_at || 0) - Date.parse(left.published_at || left.created_at || 0)) - .find((release) => commitishCandidates.has((release.target_commitish || '').trim())); - if (matchedRelease) break; - core.info(`No published release matched commitish ${Array.from(commitishCandidates).join(', ')} on attempt ${attempt}/6`); - if (attempt < 6) await sleep(5000); - } - if (!matchedRelease) throw new Error(`No published release found for commit ${headSha}`); - core.setOutput('tag_name', matchedRelease.tag_name); - core.setOutput('version', matchedRelease.tag_name.replace(/^v/, '')); - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ steps.release_info.outputs.tag_name }} - - - name: Resolve version from tag - shell: bash - run: | - version="${{ steps.release_info.outputs.version }}" - echo "VERSION=$version" >> "$GITHUB_ENV" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: npm - - - name: Sync package version - run: npm version "$env:VERSION" --no-git-tag-version --allow-same-version - shell: pwsh - - - name: Install deps - run: npm ci - - - name: Build app - run: npm run build - - - name: Package app (Windows) - run: npx electron-builder --win --x64 --publish never - - - name: Verify native module - run: node scripts/verify-native-module.mjs win32 x64 - - - name: SHA256 - shell: pwsh - run: | - Get-ChildItem dist -File | ForEach-Object { - $hash = (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLower() - "$hash $($_.Name)" | Out-File -FilePath "$($_.FullName).sha256" -Encoding ascii -NoNewline - } - - - name: List dist artifacts - shell: pwsh - run: Get-ChildItem dist | Format-Table Name,Length,LastWriteTime - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: windows-artifacts - path: dist/* - if-no-files-found: error - - - name: Release upload - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.release_info.outputs.tag_name }} - files: dist/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/dev-app-update.yml b/dev-app-update.yml index a9ff4bf..b562729 100644 --- a/dev-app-update.yml +++ b/dev-app-update.yml @@ -1,4 +1,3 @@ -provider: github -owner: arnoldhao -repo: hush +provider: generic +url: https://updates.dreamapp.cc/hush/downloads updaterCacheDirName: hush-updater diff --git a/electron-builder.yml b/electron-builder.yml index 5703988..640c176 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -41,7 +41,7 @@ mac: artifactName: ${name}-macos-${arch}-${version}.${ext} entitlements: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist - notarize: false + notarize: true dmg: artifactName: ${name}-macos-${arch}-${version}.${ext} background: build/dmg-background.png @@ -74,6 +74,5 @@ appImage: artifactName: ${name}-linux-${arch}-${version}.${ext} npmRebuild: true publish: - provider: github - owner: arnoldhao - repo: hush + provider: generic + url: https://updates.dreamapp.cc/hush/downloads diff --git a/scripts/verify-macos-signing.mjs b/scripts/verify-macos-signing.mjs index 991c51b..f0f792c 100644 --- a/scripts/verify-macos-signing.mjs +++ b/scripts/verify-macos-signing.mjs @@ -4,7 +4,18 @@ import { existsSync } from 'node:fs' import { isAbsolute, join } from 'node:path' import { spawnSync } from 'node:child_process' -const [, , appPathArg = 'dist/mac-universal/Hush.app'] = process.argv +const args = process.argv.slice(2) +const supportedFlags = new Set(['--require-developer-id']) + +for (const arg of args) { + if (arg.startsWith('--') && !supportedFlags.has(arg)) { + console.error(`Unknown option: ${arg}`) + process.exit(2) + } +} + +const requireDeveloperId = args.includes('--require-developer-id') +const appPathArg = args.find((arg) => !arg.startsWith('--')) ?? 'dist/mac-universal/Hush.app' const appPath = isAbsolute(appPathArg) ? appPathArg : join(process.cwd(), appPathArg) if (process.platform !== 'darwin') { @@ -19,6 +30,28 @@ if (!existsSync(appPath)) { run('codesign', ['--verify', '--deep', '--strict', '--verbose=4', appPath], { capture: true }) +if (requireDeveloperId) { + const signatureInfo = run('codesign', ['-dv', '--verbose=4', appPath], { + capture: true, + includeStderr: true + }) + + if (/Signature=adhoc/.test(signatureInfo)) { + console.error('App bundle is ad-hoc signed, not Developer ID signed') + process.exit(1) + } + + if (!signatureInfo.includes('Authority=Developer ID Application:')) { + console.error('App bundle is not signed with a Developer ID Application certificate') + process.exit(1) + } + + if (!/TeamIdentifier=\S+/.test(signatureInfo)) { + console.error('App bundle signature is missing a TeamIdentifier') + process.exit(1) + } +} + const entitlements = run('codesign', ['-d', '--entitlements', ':-', appPath], { capture: true }) const requiredEntitlements = [ 'com.apple.security.cs.allow-jit', @@ -72,7 +105,7 @@ function run(command, args, options = {}) { } if (options.capture) { - return result.stdout + return `${result.stdout}${options.includeStderr ? result.stderr : ''}` } if (result.stdout) process.stdout.write(result.stdout)