diff --git a/.github/workflows/publish-ready-desktop.yml b/.github/workflows/publish-ready-desktop.yml index 30821543c5..9cb66d12c9 100644 --- a/.github/workflows/publish-ready-desktop.yml +++ b/.github/workflows/publish-ready-desktop.yml @@ -9,8 +9,20 @@ on: type: string source_run_id: description: Desktop Package run with successful Windows and both Linux jobs - required: true + required: false + type: string + macos_run_id: + description: Publish only one verified macOS producer instead of Windows and Linux + required: false type: string + macos_platform: + description: Platform produced by macos_run_id + required: true + default: macos-arm64 + type: choice + options: + - macos-arm64 + - macos-x64 permissions: actions: read @@ -22,6 +34,7 @@ concurrency: jobs: publish: + if: ${{ inputs.macos_run_id == '' }} runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} @@ -43,8 +56,8 @@ jobs: shell: bash run: | set -euo pipefail - [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - [[ "$SOURCE_RUN" =~ ^[0-9]+$ ]] + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 + [[ "$SOURCE_RUN" =~ ^[0-9]+$ ]] || exit 1 test -n "$OPENBITFUN_SIGNING_KEY" test -n "$OPENBITFUN_SIGNING_PUBKEY" git fetch origin main --tags @@ -164,3 +177,124 @@ jobs: cmp "$asset" "readback/$name" done gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json url,isDraft,assets + + publish-macos: + if: ${{ inputs.macos_run_id != '' }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag_name }} + SOURCE_RUN: ${{ inputs.source_run_id }} + MACOS_RUN: ${{ inputs.macos_run_id }} + MACOS_PLATFORM: ${{ inputs.macos_platform }} + OPENBITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + OPENBITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + OPENBITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-node@v5 + with: + node-version: '22.12.0' + package-manager-cache: false + + - name: Validate verified macOS producer + shell: bash + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 + [[ "$MACOS_RUN" =~ ^[0-9]+$ ]] || exit 1 + [[ "$MACOS_PLATFORM" == macos-arm64 || "$MACOS_PLATFORM" == macos-x64 ]] || exit 1 + test -z "$SOURCE_RUN" + test -n "$OPENBITFUN_SIGNING_KEY" + test -n "$OPENBITFUN_SIGNING_PUBKEY" + git fetch origin main --tags + git merge-base --is-ancestor "${RELEASE_TAG}^{commit}" origin/main + test "$(git show "${RELEASE_TAG}:package.json" | jq -r .version)" = "${RELEASE_TAG#v}" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$MACOS_RUN" > macos-run.json + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$MACOS_RUN/jobs?per_page=100" > macos-jobs.json + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$MACOS_RUN/artifacts?per_page=100" > macos-artifacts.json + jq -e --arg repo "$GITHUB_REPOSITORY" ' + .repository.full_name == $repo and .head_repository.full_name == $repo and + .path == ".github/workflows/desktop-package.yml" and + (.event == "workflow_dispatch" or .event == "release") and + (.head_sha | test("^[a-f0-9]{40}$"))' macos-run.json + source_sha="$(jq -r .head_sha macos-run.json)" + git merge-base --is-ancestor "$source_sha" origin/main + git merge-base --is-ancestor "${RELEASE_TAG}^{commit}" "$source_sha" + for job in Prepare "Package ($MACOS_PLATFORM)"; do + jq -e --arg job "$job" '.jobs | any(.name == $job and .conclusion == "success")' macos-jobs.json + done + jq -e --arg job "Package ($MACOS_PLATFORM)" '.jobs | any(.name == $job and + (.steps | any(.name == "Verify Apple signature and notarization" and .conclusion == "success")))' macos-jobs.json + artifact="openbitfun-${RELEASE_TAG}-${MACOS_PLATFORM}-bundle" + jq -e --arg name "$artifact" ' + [.artifacts[] | select(.name == $name)] | length == 1 and .[0].expired == false' macos-artifacts.json + gh run download "$MACOS_RUN" --repo "$GITHUB_REPOSITORY" \ + --name "$artifact" --dir "release-assets/$MACOS_PLATFORM" + echo "RELEASE_VERSION=${RELEASE_TAG#v}" >> "$GITHUB_ENV" + + - name: Sign and verify ready macOS assets + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends minisign + case "$MACOS_PLATFORM" in + macos-arm64) updater_platform=darwin-aarch64; dmg_arch=aarch64 ;; + macos-x64) updater_platform=darwin-x86_64; dmg_arch=x64 ;; + esac + mapfile -t dmgs < <(find release-assets -type f -name '*.dmg') + test "${#dmgs[@]}" -eq 1 + test "$(basename "${dmgs[0]}")" = "OpenBitFun_${RELEASE_VERSION}_${dmg_arch}.dmg" + node scripts/write-minisign-public-key.mjs --out release-assets/minisign.pub + # Keep the existing release trust root; never replace it in this path. + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern minisign.pub --dir existing-key + cmp release-assets/minisign.pub existing-key/minisign.pub + # A retry must retain the original signature's trusted timestamp. + name="$(basename "${dmgs[0]}")" + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets > existing-assets.json + if jq -e --arg name "$name.sig" '.assets | any(.name == $name)' existing-assets.json >/dev/null; then + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "$name" --pattern "$name.sig" --dir existing-macos + cmp "${dmgs[0]}" "existing-macos/$name" + cp "existing-macos/$name.sig" "${dmgs[0]}.sig" + fi + bash scripts/sign-release-assets.sh "${dmgs[0]}" + node scripts/collect-tauri-updater-assets.mjs --assets-dir release-assets \ + --version "$RELEASE_VERSION" --out-dir release-updater-assets \ + --required-platforms "$updater_platform" + node scripts/stage-github-release-assets.mjs --out-dir release-upload-assets \ + release-updater-assets/* "${dmgs[0]}" "${dmgs[0]}.sig" + test "$(find release-upload-assets -type f | wc -l)" -eq 4 + for asset in release-upload-assets/*.dmg release-upload-assets/*.app.tar.gz; do + test -s "$asset.sig" + base64 -d < "$asset.sig" > verify.minisig + minisign -Vm "$asset" -p existing-key/minisign.pub -x verify.minisig + done + + - name: Publish ready macOS assets without changing update feeds + shell: bash + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > release-state.json + jq -e '.draft == false and .prerelease == false and + (.assets | all(.name != "latest-v1.json"))' release-state.json + mkdir -p readback + for asset in release-upload-assets/*; do + name="$(basename "$asset")" + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets > existing-assets.json + if jq -e --arg name "$name" '.assets | any(.name == $name)' existing-assets.json >/dev/null; then + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir readback --clobber + cmp "$asset" "readback/$name" + else + gh release upload "$RELEASE_TAG" "$asset" --repo "$GITHUB_REPOSITORY" + fi + done + for asset in release-upload-assets/*; do + name="$(basename "$asset")" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --pattern "$name" --dir readback --clobber + cmp "$asset" "readback/$name" + done diff --git a/docs/development/releasing.md b/docs/development/releasing.md index c470981b83..f74476efa7 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -60,6 +60,16 @@ receipt. The archive preserves executable modes and symlinks for re-verification It is unverified recovery input, never a release installer: it does not match the normal `*-bundle` publication pattern or satisfy the complete producer gate. +To add a verified Mac package to an already public partial stable release, +dispatch `Publish Ready Desktop Assets` with `tag_name`, `macos_run_id`, and +`macos_platform`; leave `source_run_id` empty. The selected producer must have +successful Prepare and Package jobs on protected-main history, plus an unexpired +normal bundle artifact. The publisher verifies the existing release public key, +signs the DMG, verifies both installer and updater signatures, and reads uploaded +files back. This path only adds the four Mac assets; it does not change release +notes, GitHub Latest, or any update pointer. Use the full finalization above once +every platform is ready. + ## Mobile Android releases Mobile Android packages are released independently from Desktop packages. Use a diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 3c31660c5e..948a17d97a 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -71,22 +71,29 @@ test('partial Desktop publication requires successful trusted producers and leav const file = path.join(root, 'bin', name); writeFileSync(file, `#!/usr/bin/env bash\nset -euo pipefail\n${source}\n`, { mode: 0o755 }); } - executable('git', 'if [[ "$1" == show ]]; then echo \'{"version":"1.0.6"}\'; fi'); + executable('git', 'if [[ "$1" == merge-base && "${REJECT_ANCESTRY:-}" == true ]]; then exit 1; fi; if [[ "$1" == show ]]; then echo \'{"version":"1.0.6"}\'; fi'); executable('gh', ` if [[ "$1" == api ]]; then - if [[ "$2" == *'/jobs?'* ]]; then cat "$FIXTURE/jobs.json"; else cat "$FIXTURE/run.json"; fi + case "$2" in + *'/jobs?'*) cat "$FIXTURE/jobs.json" ;; + *'/artifacts?'*) cat "$FIXTURE/artifacts.json" ;; + *'/releases/tags/'*) cat "$FIXTURE/state.json" ;; + *) cat "$FIXTURE/run.json" ;; + esac exit 0 fi printf '%s\\n' "$*" >> "$FIXTURE/calls" case "$1 $2" in - 'release view') echo '{"assets":[]}' ;; + 'release view') cat "$FIXTURE/state.json" ;; 'release download') shift 2 + names=() while [[ $# -gt 0 ]]; do - case "$1" in --pattern) name="$2"; shift;; --dir) dest="$2"; shift;; esac + case "$1" in --pattern) names+=("$2"); shift;; --dir) dest="$2"; shift;; esac shift done - cp "$FIXTURE/release-upload-assets/$name" "$dest/$name" + mkdir -p "$dest" + for name in "\${names[@]}"; do cp "$FIXTURE/release-upload-assets/$name" "$dest/$name"; done ;; esac `); @@ -149,6 +156,78 @@ test('partial Desktop publication requires successful trusted producers and leav assert.match(stage, /minisign -Vm/); assert.match(stage, /sha256sum --check/); assert.doesNotMatch(stage, /generate-tauri-latest-json/); + + const mac = workflow.jobs['publish-macos']; + assert.equal(workflow.jobs.publish.if, "${{ inputs.macos_run_id == '' }}"); + assert.equal(mac.if, "${{ inputs.macos_run_id != '' }}"); + const macValidate = mac.steps.find((step) => step.name === 'Validate verified macOS producer').run; + const macPublish = mac.steps.find((step) => step.name === 'Publish ready macOS assets without changing update feeds').run; + const macEnv = { SOURCE_RUN: '', MACOS_RUN: '456', MACOS_PLATFORM: 'macos-arm64' }; + const macJobs = { jobs: ['Prepare', 'Package (macos-arm64)'].map((name) => ({ + name, conclusion: 'success', steps: [{ name: 'Verify Apple signature and notarization', conclusion: 'success' }], + })) }; + const artifact = { name: 'openbitfun-v1.0.6-macos-arm64-bundle', expired: false }; + function macFixtures(nextRun = run, nextJobs = macJobs, artifacts = [artifact]) { + fixtures(nextRun, nextJobs); + writeFileSync(path.join(root, 'artifacts.json'), JSON.stringify({ artifacts })); + } + macFixtures(); + result = execute(macValidate, macEnv); + assert.equal(result.status, 0, result.stderr); + for (const nextJobs of [ + { jobs: macJobs.jobs.filter((job) => job.name !== 'Prepare') }, + { jobs: macJobs.jobs.map((job) => ({ ...job, conclusion: 'failure' })) }, + { jobs: macJobs.jobs.map((job) => ({ ...job, steps: [{ name: 'Verify Apple signature and notarization', conclusion: 'skipped' }] })) }, + { jobs: [{ name: 'Prepare', conclusion: 'success' }, { name: 'Package (macos-x64)', conclusion: 'success' }] }, + ]) { + macFixtures(run, nextJobs); + assert.notEqual(execute(macValidate, macEnv).status, 0); + } + for (const artifacts of [[], [{ ...artifact, expired: true }], [artifact, artifact], + [{ ...artifact, name: 'openbitfun-v1.0.6-macos-arm64-verification-recovery' }]]) { + macFixtures(run, macJobs, artifacts); + assert.notEqual(execute(macValidate, macEnv).status, 0); + } + for (const nextRun of [ + { ...run, repository: { full_name: 'untrusted/fork' } }, + { ...run, head_repository: { full_name: 'untrusted/fork' } }, + { ...run, path: '.github/workflows/ci.yml' }, + { ...run, event: 'pull_request' }, { ...run, head_sha: 'invalid' }, + ]) { + macFixtures(nextRun); + assert.notEqual(execute(macValidate, macEnv).status, 0); + } + macFixtures(); + for (const overrides of [ + { SOURCE_RUN: '123' }, { MACOS_RUN: '456;echo injected' }, + { MACOS_PLATFORM: 'linux-x64' }, { REJECT_ANCESTRY: 'true' }, + ]) { + const rejected = execute(macValidate, { ...macEnv, ...overrides }); + assert.notEqual(rejected.status, 0, `${JSON.stringify(overrides)}\n${rejected.stderr}`); + } + const macStage = mac.steps.find((step) => step.name === 'Sign and verify ready macOS assets').run; + assert.match(macStage, /cmp release-assets\/minisign.pub existing-key\/minisign.pub/); + assert.match(macStage, /minisign -Vm/); + assert.doesNotMatch(macStage, /generate-tauri-latest-json/); + writeFileSync(path.join(root, 'state.json'), JSON.stringify({ draft: false, prerelease: false, assets: [{ name: 'latest-v1.json' }] })); + assert.notEqual(execute(macPublish, macEnv).status, 0); + writeFileSync(path.join(root, 'state.json'), JSON.stringify({ draft: false, prerelease: false, assets: [] })); + result = execute(macPublish, macEnv); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(macPublish, /release edit|--clobber.*upload|generate-tauri-latest-json/); + // Retrying publication must reuse the existing signature only for identical bytes. + writeFileSync(path.join(root, 'source.dmg'), 'verified dmg bytes'); + writeFileSync(path.join(root, 'release-upload-assets/source.dmg'), 'verified dmg bytes'); + writeFileSync(path.join(root, 'release-upload-assets/source.dmg.sig'), 'original signature'); + writeFileSync(path.join(root, 'state.json'), JSON.stringify({ assets: [{ name: 'source.dmg.sig' }] })); + const reuseSignature = 'set -euo pipefail\ndmgs=("$FIXTURE/source.dmg")\n' + macStage.slice( + macStage.indexOf('name="$(basename'), macStage.indexOf('bash scripts/sign-release-assets.sh'), + ); + result = execute(reuseSignature, macEnv); + assert.equal(result.status, 0, result.stderr); + assert.equal(readFileSync(path.join(root, 'source.dmg.sig'), 'utf8'), 'original signature'); + writeFileSync(path.join(root, 'source.dmg'), 'different dmg bytes'); + assert.notEqual(execute(reuseSignature, macEnv).status, 0); }); test('rejects setup-node node-version-file below the project baseline', (t) => {