diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index eb71a7e..360bb64 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -6,12 +6,19 @@ on: upgrade-key: type: string default: '' + run-stage-multisig: + type: boolean + default: false workflow_dispatch: inputs: upgrade-key: description: Value for package.json upgrade field required: false + run-stage-multisig: + type: boolean + description: Stage the build and create a multisig request + default: false env: CHANNEL: production @@ -44,7 +51,7 @@ jobs: build-linux-x64: needs: [prebuild] - timeout-minutes: 20 + timeout-minutes: 60 permissions: contents: read packages: read @@ -53,6 +60,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: holepunchto/actions/make-pear-app@v1 + id: build with: channel: ${{ env.CHANNEL }} upgrade_key: ${{ inputs['upgrade-key'] }} @@ -74,7 +82,7 @@ jobs: build-linux-arm: needs: [prebuild] - timeout-minutes: 20 + timeout-minutes: 60 permissions: contents: read packages: read @@ -123,6 +131,11 @@ jobs: needs.prebuild.outputs.package-name, needs.prebuild.outputs.version, 'arm64') }} + macos_app: >- + ${{ format('{0}-{1}-{2}.app.zip', + needs.prebuild.outputs.package-name, + needs.prebuild.outputs.version, + 'arm64') }} macos_certificate_base64: ${{ secrets.CERTIFICATE_P12 }} macos_p12_password: ${{ secrets.CERTIFICATE_PASSWORD }} macos_codesign_identity: ${{ secrets.MAC_CODESIGN_IDENTITY }} @@ -151,6 +164,11 @@ jobs: needs.prebuild.outputs.package-name, needs.prebuild.outputs.version, 'x64') }} + macos_app: >- + ${{ format('{0}-{1}-{2}.app.zip', + needs.prebuild.outputs.package-name, + needs.prebuild.outputs.version, + 'x64') }} macos_certificate_base64: ${{ secrets.CERTIFICATE_P12 }} macos_p12_password: ${{ secrets.CERTIFICATE_PASSWORD }} macos_codesign_identity: ${{ secrets.MAC_CODESIGN_IDENTITY }} @@ -177,6 +195,217 @@ jobs: windows_msix: >- ${{ format('{0}.msix', needs.prebuild.outputs.package-name) }} - windows_signing_method: windows_cert_pfx + windows_signing_method: cert_pfx windows_cert_pfx_base64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }} windows_cert_password: ${{ secrets.WINDOWS_CERT_PASSWORD }} + + stage: + needs: [prebuild, build-linux-x64, build-linux-arm, build-macos, build-macos-x64, build-win32] + if: >- + ${{ + !cancelled() + && !failure() + && inputs['run-stage-multisig'] == true + && needs.build-linux-x64.result == 'success' + && needs.build-linux-arm.result == 'success' + && needs.build-macos.result == 'success' + && needs.build-macos-x64.result == 'success' + && needs.build-win32.result == 'success' + }} + timeout-minutes: 60 + permissions: + actions: read + contents: write + pull-requests: write + runs-on: macos-latest + environment: release + env: + STAGE_TARGET: ./out/stage + STAGE_NAME: production + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - uses: actions/download-artifact@v4 + with: + pattern: '*.app.zip' + path: out/artifacts + + - uses: actions/download-artifact@v4 + with: + pattern: '*.AppImage' + path: out/artifacts + + - uses: actions/download-artifact@v4 + with: + pattern: '*.msix' + path: out/artifacts + + - name: Install staging tools + run: npm install -g pear-build pear-ci-multisig corestore hyperdrive pear-link + + - name: Build stage directory + env: + UPGRADE_KEY: ${{ inputs['upgrade-key'] }} + run: | + set -euo pipefail + + app_name="$(jq -r '.productName // .name' package.json)" + package_json="$RUNNER_TEMP/package.json" + artifact_dir="out/artifacts" + extract_dir="$RUNNER_TEMP/stage-artifacts" + mkdir -p "$extract_dir" + while IFS= read -r -d '' archive; do + dest="$extract_dir/$(basename "$archive" .zip)" + mkdir -p "$dest" + ditto -x -k "$archive" "$dest" + done < <(find "$artifact_dir" -name '*.zip' -type f -print0) + + if [[ -n "$UPGRADE_KEY" ]]; then + jq --arg upgrade "$UPGRADE_KEY" '.upgrade = $upgrade' package.json > "$package_json" + else + jq '.' package.json > "$package_json" + fi + + darwin_arm64_app="$(find "$extract_dir" -path "*arm64*" -name "$app_name.app" -type d -print -quit)" + darwin_x64_app="$(find "$extract_dir" -path "*x64*" -name "$app_name.app" -type d -print -quit)" + linux_arm64_src="$(find "$artifact_dir" "$extract_dir" -path "*arm64*" -name "$app_name*.AppImage" -type f -print -quit)" + linux_x64_src="$(find "$artifact_dir" "$extract_dir" -path "*x64*" -name "$app_name*.AppImage" -type f -print -quit)" + win32_x64_app="$(find "$artifact_dir" "$extract_dir" -name "$app_name.msix" -type f -print -quit)" + + if [[ -z "$darwin_arm64_app" || -z "$darwin_x64_app" || -z "$linux_arm64_src" || -z "$linux_x64_src" || -z "$win32_x64_app" ]]; then + echo "Missing one or more Pear build artifacts" >&2 + find "$artifact_dir" "$extract_dir" -maxdepth 6 -print | sort >&2 + exit 1 + fi + + linux_arm64_app="$RUNNER_TEMP/pear-build/linux-arm64/$app_name.AppImage" + linux_x64_app="$RUNNER_TEMP/pear-build/linux-x64/$app_name.AppImage" + mkdir -p "$(dirname "$linux_arm64_app")" "$(dirname "$linux_x64_app")" + cp "$linux_arm64_src" "$linux_arm64_app" + cp "$linux_x64_src" "$linux_x64_app" + chmod +x "$linux_arm64_app" "$linux_x64_app" + + rm -rf "$STAGE_TARGET" + pear-build \ + --package="$package_json" \ + --darwin-arm64-app="$darwin_arm64_app" \ + --darwin-x64-app="$darwin_x64_app" \ + --linux-arm64-app="$linux_arm64_app" \ + --linux-x64-app="$linux_x64_app" \ + --win32-x64-app="$win32_x64_app" \ + --target="$STAGE_TARGET" + + find "$STAGE_TARGET" -type f | sort + + - uses: holepunchto/actions/pear-ci@v1 + id: pear-ci + with: + namespace: ${{ env.STAGE_NAME }} + primary-key: ${{ secrets.PEAR_PRIMARY_KEY }} + target: ${{ env.STAGE_TARGET }} + + - name: Resolve source verlink + id: stage + env: + PEAR_PRIMARY_KEY: ${{ secrets.PEAR_PRIMARY_KEY }} + run: | + set -euo pipefail + + node_path="$(npm root -g)" + export NODE_PATH="$node_path" + verlink="$(node <<'NODE' + const Corestore = require('corestore') + const Hyperdrive = require('hyperdrive') + const plink = require('pear-link') + + async function main () { + const store = new Corestore('/tmp/store', { + primaryKey: Buffer.from(process.env.PEAR_PRIMARY_KEY, 'hex'), + unsafe: true + }) + await store.ready() + + const drive = new Hyperdrive(store.namespace(process.env.STAGE_NAME)) + await drive.ready() + + console.log(plink.serialize({ + drive: { + key: drive.key, + fork: drive.db.core.fork, + length: drive.db.core.length + } + })) + + await drive.close() + await store.close() + } + + main().catch((err) => { + console.error(err) + process.exit(1) + }) + NODE + )" + + echo "$verlink" + echo "source-verlink=$verlink" >> "$GITHUB_OUTPUT" + + - name: Create multisig request + env: + SOURCE_VERLINK: ${{ steps.stage.outputs.source-verlink }} + MULTISIG_QUORUM: ${{ vars.MULTISIG_QUORUM }} + MULTISIG_NAMESPACE: ${{ vars.MULTISIG_NAMESPACE }} + MULTISIG_PUBKEYS: ${{ vars.MULTISIG_PUBKEYS }} + run: | + set -euo pipefail + + if [[ -z "$MULTISIG_QUORUM" || -z "$MULTISIG_NAMESPACE" || -z "$MULTISIG_PUBKEYS" ]]; then + echo "Missing MULTISIG_QUORUM, MULTISIG_NAMESPACE, or MULTISIG_PUBKEYS variables" >&2 + exit 1 + fi + + read -r -a pubkeys <<< "$MULTISIG_PUBKEYS" + multisig_args=(--quorum "$MULTISIG_QUORUM" --namespace "$MULTISIG_NAMESPACE") + for key in "${pubkeys[@]}"; do + multisig_args+=(--pubkey "$key") + done + + link="$(pear-ci-multisig link "${multisig_args[@]}")" + request="$(pear-ci-multisig request "${multisig_args[@]}" --peer-update-timeout 60000 "$SOURCE_VERLINK")" + + { + echo "### Multisig Request" + echo + echo "Channel: \`$CHANNEL\`" + echo + echo "Source verlink: \`$SOURCE_VERLINK\`" + echo + echo "Multisig link: \`$link\`" + echo + echo "Request: \`$request\`" + echo + echo "Signers:" + for key in "${pubkeys[@]}"; do + echo "- \`$key\`" + done + echo + echo "Each signer runs:" + echo + echo "\`\`\`sh" + echo "pear multisig sign '$request' " + echo "\`\`\`" + echo + echo "After collecting enough responses for the quorum, verify and commit:" + echo + echo "\`\`\`sh" + echo "pear multisig verify --config ./pear.json '$SOURCE_VERLINK' '$request' " + echo "pear multisig commit --config ./pear.json '$SOURCE_VERLINK' '$request' " + echo "\`\`\`" + echo + echo "This workflow only creates the request and prints the details. It does not run \`multisig commit\`." + } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 8594d29..8117de8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ End-to-end boilerplate for embedding [pear-runtime][pear-runtime] into [Electron - [Release Line Builds](#release-line-builds) - [Custom Builds](#custom-builds) - [CI Configuration](#ci-configuration) + - [CI Multisig](#ci-multisig) - [Store Submissions](#store-submissions) - [Flathub](#flathub) - [Scripts](#scripts) @@ -1013,6 +1014,110 @@ Create a GitHub environment (Settings -> Environments) named `release`. Run the - Windows certificate 'subject' must match the `Publisher` in [AppxManifest.xml](build/AppxManifest.xml). - Linux builds are not signed, no configuration needed. +### CI Multisig + +The `Build Release` workflow can stage build artifacts and create a multisig signing request in the same run. + +1. Create a GitHub environment named `release`. + +2. Configure one secret to the `release` environment: + +| Secret | Notes | +| ------------------ | ------------------------------------------------------------ | +| `PEAR_PRIMARY_KEY` | Hex-encoded primary key for the CI-owned staged source drive | + +Generate `PEAR_PRIMARY_KEY` once and save it as a `release` environment secret: + +```sh +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +Keep this value a secret and use the same value for future staged builds. `PEAR_PRIMARY_KEY` is the persistent write key for the CI-owned staged source drive. Changing it creates a different staged source drive and breaks continuity with the existing `ci/snapshot.json`, so rotate it only when intentionally starting a new staged source drive or recovering from a corrupted/conflicted one. + +Configure three plain variables (not secrets) to the `release` environment: + +| Variable | Notes | +| -------------------- | ---------------------------------------------------------- | +| `MULTISIG_QUORUM` | Required signature count, e.g. `2` | +| `MULTISIG_PUBKEYS` | Space-separated signer public keys | +| `MULTISIG_NAMESPACE` | Multisig namespace, e.g. `holepunchto/hello-pear-electron` | + +Note: public keys order matters, changing the order changes the output. + +3. In `Settings -> Actions -> General -> Workflow permissions`, select `Read and write permissions` and enable `Allow GitHub Actions to create and approve pull requests`. + The stage job uses `GITHUB_TOKEN` to commit `ci/snapshot.json` to `main`. If direct push to `main` fails, it opens a snapshot update PR instead. + Without this, the stage job can push the snapshot branch but fails when creating the PR with `GitHub Actions is not permitted to create or approve pull requests`. + +4. Set the same multisig values in `pear.json` and compute the multisig link: + +```sh +pear multisig link --config ./pear.json +``` + +Use the printed `pear://...` link as the `Build Release` `upgrade-key`, or leave `upgrade-key` empty if the same link is already set in `package.json`. + +5. For an OTA release, bump the version before running CI and push the changed files: + +```sh +npm version minor --no-git-tag-version +``` + +6. Run `Build Release` with: + - `upgrade-key`: the `pear://...` link from `pear multisig link --config ./pear.json` and `run-stage-multisig`: `true` + - CI builds the OS distributables. + - `make-pear-app` uploads the release artifacts. + - CI downloads artifacts like macOS `.app`, Linux AppImages, and Windows MSIX into `out/artifacts`. + - CI builds `out/stage` with `pear-build`. + - `holepunchto/actions/pear-ci` fetches `ci/snapshot.json` from `main`. + - `pear-ci` stages `out/stage` into the production staging drive and waits until connected remote peers have synced the staged source drive. + - `holepunchto/actions/pear-ci` writes the updated `ci/snapshot.json` back to `main`, or creates a PR if direct push fails. + - CI creates a multisig request with `pear-ci-multisig request`. + - CI prints the source verlink, multisig request, and signing command in the `Multisig Request` summary section. + +7. Keep the staged source drive well seeded. + The staged source drive contains this CI build. Note: this is a different link not the multisig release target used as the `upgrade-key`. + + In the stage job logs find the staged source key printed by `pear-ci`. Seed that drive from two independent Pear instances or machines and keep both running through the final commit: + + ```sh + pear seed pear:// + ``` + + `pear-ci-multisig request` checks that both the staged drive DB and blob cores are fully available from two peers. If CI fails with `SOURCE_CORE_INSUFFICIENT_PEERS (1/2 peers)`, add another seeder and rerun the workflow. + +8. Be sure the automated `ci/snapshot.json` PR is merged before the next staged build if the action could not push directly to `main`. + The snapshot lets future CI runs reopen the staged drive state before appending the next version. + +9. After CI finishes, open the `Build Release` run summary. Each signer copies the request from the `Multisig Request` section and runs this from the project root. + `` is the local signing key name, e.g. `signer-a`. + + ```sh + pear multisig sign + ``` + + After collecting enough responses for the quorum, copy the source verlink from the `Multisig Request` section and verify and commit with the matching `pear.json` config: + + ```sh + pear multisig verify --config ./pear.json + pear multisig commit --config ./pear.json + ``` + + If commit asks for the multisig target to be seeded, seed the printed multisig link from two independent Pear instances and keep them running until commit completes: + + ```sh + pear seed pear:// + ``` + + After commit, verify the release target can be read: + + ```sh + pear dump --list + ``` + + The output should include `/package.json` and the staged files under `/by-arch/...`. + +Note: CI workflow only creates the multisig request, it does not run the final `multisig commit` which is done manually by a signer. + ## Store Submissions Applications built from this template can also be distributed through platform-specific application stores. @@ -1175,6 +1280,10 @@ Runs: `electron-forge make` ## Troubleshooting +### CI stage fails with Hypercore conflict or `SESSION_CLOSED` + +If `pear-ci` logs `[hypercore] conflict detected` or `SESSION_CLOSED: Cannot append to a closed session` during staging, the CI staged source drive is conflicted or was reused incorrectly. Recovery is to start a new staged source drive: generate a fresh `PEAR_PRIMARY_KEY`, replace the `release` environment secret, keep `ci/snapshot.json` as `[]`, and rerun the workflow. Do not rotate `PEAR_PRIMARY_KEY` for normal releases; it is the persistent write key for appending future staged builds to the same CI source drive. + ### App did not update #### Was the version updated? diff --git a/ci/snapshot.json b/ci/snapshot.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/ci/snapshot.json @@ -0,0 +1 @@ +[]