Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions .github/security-drafts/macos-release-resume.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
name: macOS notarization resume

on:
workflow_dispatch:
inputs:
source_run_id:
description: Producer run ID containing the signed checkpoint
required: true
type: string
source_run_attempt:
description: Exact producer attempt, including canceled attempts
required: true
type: string
version:
description: Exact stable version of the existing Windows release
required: true
type: string
source_sha:
description: Full lowercase source SHA of that existing release
required: true
type: string
submission_id:
description: Optional recovery UUID from diagnostics, requires Apple log hash match
required: false
type: string
publish:
description: Explicitly authorize attaching verified DMG and checksum after Environment approval
required: true
default: false
type: boolean

permissions:
contents: read
actions: read

concurrency:
group: macos-release
cancel-in-progress: false

jobs:
preflight:
name: Validate existing release before any Apple credentials
if: github.ref == 'refs/heads/main'
runs-on: macos-15
timeout-minutes: 5
outputs:
source_sha: ${{ steps.validate.outputs.sourceSha }}
version: ${{ steps.validate.outputs.version }}
release_id: ${{ steps.validate.outputs.releaseId }}
steps:
- name: Checkout trusted workflow source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false

- name: Node 22
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: 22
package-manager-cache: false

- name: Validate existing release
id: validate
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_SOURCE_SHA: ${{ inputs.source_sha }}
GH_TOKEN: ${{ github.token }}
run: |
node --input-type=module <<'NODE'
import { appendFileSync } from 'node:fs';
import { preflightRelease } from './scripts/macos-release-preflight.mjs';
const result = await preflightRelease();
for (const [key, value] of Object.entries(result)) {
appendFileSync(process.env.GITHUB_OUTPUT, key + '=' + value + '\n');
}
NODE

resume:
name: Validate retained bytes, query exact UUID and verify accepted DMG
needs: preflight
if: github.ref == 'refs/heads/main'
environment: macos-signing
runs-on: macos-15
timeout-minutes: 30
outputs:
outcome: ${{ steps.complete.outputs.outcome }}
final_sha256: ${{ steps.complete.outputs.finalSha256 }}
artifact_id: ${{ steps.final.outputs.artifact-id }}
artifact_digest: ${{ steps.final.outputs.artifact-digest }}
env:
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_ID: ${{ needs.preflight.outputs.release_id }}
SOURCE_RUN_ID: ${{ inputs.source_run_id }}
SOURCE_RUN_ATTEMPT: ${{ inputs.source_run_attempt }}
SUBMISSION_ID: ${{ inputs.submission_id }}
steps:
- name: Checkout trusted verification tools, never build source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false

- name: Node 22
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: 22
package-manager-cache: false

- name: Authenticate producer run and artifacts, fatal ZIP digest and content validation
id: checkpoint
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/macos-release-resume.mjs validate

- name: Query Apple and only if Accepted staple and verify retained DMG
id: complete
env:
GH_TOKEN: ${{ github.token }}
PRODUCER_WORKFLOW_SHA: ${{ steps.checkpoint.outputs.producerWorkflowSha }}
VALIDATED_SUBMISSION_ID: ${{ steps.checkpoint.outputs.submissionId }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: node scripts/macos-release-resume.mjs complete

- name: Stage final bytes and distinct post-staple checksum
if: steps.complete.outputs.outcome == 'accepted'
env:
FINAL_SHA256: ${{ steps.complete.outputs.finalSha256 }}
run: node scripts/macos-release-resume.mjs stage-final

- name: Retain verified final bytes for separately authorized publication
if: steps.complete.outputs.outcome == 'accepted'
id: final
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: macos-notary-final-${{ github.run_id }}-${{ github.run_attempt }}
path: notary-final/*
if-no-files-found: error
retention-days: 14
compression-level: 0
overwrite: false

- name: Remove private notarization key even on cancellation
if: always()
run: |
node --input-type=module <<'NODE'
import { unlinkSync } from 'node:fs';
import path from 'node:path';
const key = path.join(process.env.RUNNER_TEMP, 'quorb-notary-' + process.env.GITHUB_RUN_ID + '-' + process.env.GITHUB_RUN_ATTEMPT + '.p8');
try { unlinkSync(key); }
catch (error) { if (error.code !== 'ENOENT') throw new Error('Private key cleanup failed'); }
NODE

publish:
name: Attach only verified final DMG and checksum, no overwrite
needs: [preflight, resume]
if: github.ref == 'refs/heads/main' && inputs.publish && needs.resume.outputs.outcome == 'accepted'
environment: macos-signing
runs-on: macos-15
timeout-minutes: 10
permissions:
contents: write
actions: read
steps:
- name: Checkout trusted publication tools
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false

- name: Node 22
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: 22
package-manager-cache: false

- name: Verify final artifact ID and ZIP digest, revalidate release, upload without overwrite
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_ID: ${{ needs.preflight.outputs.release_id }}
FINAL_ARTIFACT_ID: ${{ needs.resume.outputs.artifact_id }}
FINAL_ARTIFACT_DIGEST: ${{ needs.resume.outputs.artifact_digest }}
FINAL_SHA256: ${{ needs.resume.outputs.final_sha256 }}
run: node scripts/macos-release-resume-publish.mjs
125 changes: 40 additions & 85 deletions .github/security-drafts/macos-release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: macOS signed release
name: macOS signed release prepare and submit

on:
workflow_dispatch:
Expand Down Expand Up @@ -60,14 +60,12 @@ jobs:
NODE

signing:
name: Sign, notarize and attach macOS ARM64 DMG
name: Prepare signed checkpoint and submit once, without waiting
needs: preflight
if: github.ref == 'refs/heads/main'
environment: macos-signing
runs-on: macos-15
timeout-minutes: 120
permissions:
contents: write
env:
RELEASE_VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
Expand Down Expand Up @@ -109,95 +107,52 @@ jobs:
working-directory: source
run: npm ci

- name: Write private notarization key
env:
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8
run: |
node --input-type=module <<'NODE'
import { writeFileSync } from 'node:fs';
const key = process.env.APPLE_API_KEY_P8;
if (!key || !key.trim()) throw new Error('Missing notarization key');
writeFileSync(process.env.APPLE_API_KEY_PATH, key, { mode: 0o600, flag: 'wx' });
NODE

- name: Build, sign, notarize and staple
working-directory: source
- name: Build and sign without any Tauri notarization credentials
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: node scripts/macos-notary-checkpoint.mjs build

- name: Verify signed app and signed DMG and create strict checkpoint
run: node scripts/macos-notary-checkpoint.mjs stage

- name: Retain immutable signed DMG and manifest BEFORE private key or submit
id: checkpoint
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: macos-notary-checkpoint-${{ github.run_id }}-${{ github.run_attempt }}
path: notary-checkpoint/*
if-no-files-found: error
retention-days: 14
compression-level: 0
overwrite: false

- name: Submit retained DMG once and record UUID, never wait or retry
env:
CHECKPOINT_ARTIFACT_ID: ${{ steps.checkpoint.outputs.artifact-id }}
CHECKPOINT_ARTIFACT_DIGEST: ${{ steps.checkpoint.outputs.artifact-digest }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8
run: |
set -euo pipefail
test -n "$APPLE_CERTIFICATE"
test -n "$APPLE_CERTIFICATE_PASSWORD"
test -n "$APPLE_SIGNING_IDENTITY"
test -n "$APPLE_API_ISSUER"
test -n "$APPLE_API_KEY"
npm run app:build:mac
dmg="src-tauri/target/release/bundle/dmg/Quorb_${RELEASE_VERSION}_aarch64.dmg"
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait
xcrun stapler staple "$dmg"

- name: Remove private notarization key
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: node scripts/macos-notary-submit.mjs

- name: Retain submission receipt separately
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: macos-notary-receipt-${{ github.run_id }}-${{ github.run_attempt }}
path: notary-receipt/receipt.json
if-no-files-found: error
retention-days: 14
overwrite: false

- name: Remove private notarization key even on cancellation
if: always()
env:
APPLE_API_KEY_PATH: ${{ runner.temp }}/quorb-notary-${{ github.run_id }}-${{ github.run_attempt }}.p8
run: |
node --input-type=module <<'NODE'
import { unlinkSync } from 'node:fs';
try { unlinkSync(process.env.APPLE_API_KEY_PATH); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
NODE

- name: Verify signed app and stapled DMG, then hash final bytes
run: |
node --input-type=module <<'NODE'
import path from 'node:path';
import { writeFileSync } from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { verifyMacosBundle } from './scripts/verify-macos-bundle.mjs';
const result = verifyMacosBundle({ root: path.resolve('source'), sourceSha: process.env.RELEASE_SOURCE_SHA });
const expected = path.resolve('source/src-tauri/target/release/bundle/dmg', 'Quorb_' + process.env.RELEASE_VERSION + '_aarch64.dmg');
if (result.version !== process.env.RELEASE_VERSION || result.dmg !== expected) throw new Error('Unexpected release bundle');
const run = (command, args) => execFileSync(command, args, { stdio: 'inherit', timeout: 120000 });
run('codesign', ['--verify', '--deep', '--strict', result.app]);
const signature = spawnSync('codesign', ['-dv', '--verbose=4', result.app], { encoding: 'utf8', timeout: 120000 });
if (signature.error || signature.status !== 0 ||
!/^Authority=Developer ID Application: .+ \(SRD8T33RU5\)$/m.test(signature.stderr) ||
!/^TeamIdentifier=SRD8T33RU5$/m.test(signature.stderr)) throw new Error('Unexpected Developer ID authority or team');
console.log(signature.stderr);
run('spctl', ['--assess', '--type', 'execute', result.app]);
run('spctl', ['--assess', '--type', 'open', '--context', 'context:primary-signature', result.dmg]);
run('xcrun', ['stapler', 'validate', result.dmg]);
writeFileSync(result.dmg + '.sha256', result.dmgSha256 + ' ' + path.basename(result.dmg) + '\n', { flag: 'wx' });
NODE

- name: Revalidate release and upload only DMG and SHA-256 without overwrite
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import { preflightRelease } from './scripts/macos-release-preflight.mjs';
const result = await preflightRelease();
if (String(result.releaseId) !== process.env.RELEASE_ID) throw new Error('Release identity changed');
const key = path.join(process.env.RUNNER_TEMP, 'quorb-notary-' + process.env.GITHUB_RUN_ID + '-' + process.env.GITHUB_RUN_ATTEMPT + '.p8');
try { unlinkSync(key); }
catch (error) { if (error.code !== 'ENOENT') throw new Error('Private key cleanup failed'); }
NODE
dmg="source/src-tauri/target/release/bundle/dmg/Quorb_${RELEASE_VERSION}_aarch64.dmg"
name="${dmg##*/}"
dmg_id="$(gh api --method POST -H 'Content-Type: application/x-apple-diskimage' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name" --input "$dmg" --jq .id)"
if ! [[ "$dmg_id" =~ ^[0-9]+$ ]]; then
echo "::error::DMG upload returned a non-numeric asset id; deleting nothing."
exit 1
fi
if ! gh api --method POST -H 'Content-Type: text/plain' "https://uploads.github.com/repos/egdev6/quorb/releases/$RELEASE_ID/assets?name=$name.sha256" --input "$dmg.sha256" --jq .id; then
echo "::error::SHA-256 upload failed; removing only the DMG this run uploaded."
if ! gh api --method DELETE "repos/egdev6/quorb/releases/assets/$dmg_id"; then
echo "::error::Rollback failed; manual cleanup needed for $name (asset id $dmg_id)."
exit 1
fi
exit 1
fi
Loading
Loading