Explorar el Código

Merge branch 'next' of github.com:OliveTin/OliveTin into next

jamesread hace 1 mes
padre
commit
25fd92396c

+ 103 - 0
.github/workflows/build-and-release.yml

@@ -37,6 +37,11 @@ on:
 jobs:
   build:
     runs-on: ubuntu-latest
+    outputs:
+      new_release_published: ${{ steps.release.outputs.new_release_published }}
+      new_release_git_tag: ${{ steps.release.outputs.new_release_git_tag }}
+      windows_zip_artifact_id: ${{ steps.upload-windows-zip.outputs.artifact-id }}
+      windows_msi_artifact_id: ${{ steps.upload-windows-msi.outputs.artifact-id }}
     steps:
       - name: Checkout
         uses: actions/checkout@v6
@@ -129,6 +134,7 @@ jobs:
         uses: docker/setup-buildx-action@v4
 
       - name: release
+        id: release
         if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
         uses: cycjimmy/semantic-release-action@v5
         with:
@@ -139,6 +145,29 @@ jobs:
         env:
           GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
           GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
+          MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
+          MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
+          MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
+          MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
+          MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
+
+      - name: Upload unsigned Windows zip for SignPath
+        id: upload-windows-zip
+        if: steps.release.outputs.new_release_published == 'true'
+        uses: actions/upload-artifact@v7
+        with:
+          name: unsigned-windows-zip
+          path: dist/OliveTin-windows-amd64.zip
+          if-no-files-found: error
+
+      - name: Upload unsigned Windows MSI for SignPath
+        id: upload-windows-msi
+        if: steps.release.outputs.new_release_published == 'true'
+        uses: actions/upload-artifact@v7
+        with:
+          name: unsigned-windows-msi
+          path: dist/OliveTin-windows-amd64.msi
+          if-no-files-found: error
 
       - name: Archive binaries
         if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
@@ -146,3 +175,77 @@ jobs:
         with:
           name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
           path: dist/OliveTin*.*
+
+  sign-windows:
+    name: Sign Windows artifacts (SignPath)
+    needs: build
+    if: needs.build.outputs.new_release_published == 'true'
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: write
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+        with:
+          persist-credentials: false
+
+      - name: Require SignPath configuration
+        env:
+          SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
+          SIGNPATH_ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+          SIGNPATH_PROJECT_SLUG: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+          SIGNPATH_SIGNING_POLICY_SLUG: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+        run: |
+          missing=0
+          for name in SIGNPATH_API_TOKEN SIGNPATH_ORGANIZATION_ID SIGNPATH_PROJECT_SLUG SIGNPATH_SIGNING_POLICY_SLUG; do
+            if [[ -z "${!name}" ]]; then
+              echo "Missing required SignPath setting: ${name}" >&2
+              missing=1
+            fi
+          done
+          if [[ "${missing}" -ne 0 ]]; then
+            echo "Windows signing is required before a draft release can be published. Configure SignPath secrets/vars (see docs/modules/dev/pages/signing.adoc)." >&2
+            exit 1
+          fi
+
+      - name: Sign Windows zip
+        uses: signpath/github-action-submit-signing-request@v2
+        with:
+          api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+          organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+          project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+          signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+          artifact-configuration-slug: windows-zip
+          github-artifact-id: ${{ needs.build.outputs.windows_zip_artifact_id }}
+          wait-for-completion: true
+          output-artifact-directory: signed-windows-zip
+
+      - name: Sign Windows MSI
+        uses: signpath/github-action-submit-signing-request@v2
+        with:
+          api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
+          organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
+          project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG }}
+          signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG }}
+          artifact-configuration-slug: windows-msi
+          github-artifact-id: ${{ needs.build.outputs.windows_msi_artifact_id }}
+          wait-for-completion: true
+          output-artifact-directory: signed-windows-msi
+
+      - name: Publish signed Windows assets and undraft release
+        env:
+          GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
+          GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
+        run: |
+          zip_path="$(find signed-windows-zip -type f -name 'OliveTin-windows-amd64.zip' | head -n 1)"
+          msi_path="$(find signed-windows-msi -type f -name 'OliveTin-windows-amd64.msi' | head -n 1)"
+          if [[ -z "${zip_path}" || -z "${msi_path}" ]]; then
+            echo "Signed Windows artifacts not found after SignPath:" >&2
+            find signed-windows-zip signed-windows-msi -type f >&2 || true
+            exit 1
+          fi
+          ./var/windows/signpath-publish-signed.sh \
+            "${{ needs.build.outputs.new_release_git_tag }}" \
+            "${zip_path}" \
+            "${msi_path}"

+ 18 - 0
.goreleaser.yml

@@ -182,7 +182,25 @@ nfpms:
       - src: var/manpage/OliveTin.1.gz
         dst: /usr/share/man/man1/OliveTin.1.gz
 
+# Sign and notarize darwin binaries via quill (no macOS runner required).
+# Skipped when MACOS_SIGN_P12 is unset so local/snapshot builds stay unsigned.
+notarize:
+  macos:
+    - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
+      # Defaults to project_name when omitted; must match builds[].id (also project_name).
+      sign:
+        certificate: "{{.Env.MACOS_SIGN_P12}}"
+        password: "{{.Env.MACOS_SIGN_PASSWORD}}"
+      notarize:
+        issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
+        key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
+        key: "{{.Env.MACOS_NOTARY_KEY}}"
+        wait: true
+        timeout: 30m
+
 release:
+  # Stay draft until the sign-windows job replaces unsigned Windows assets and undrafts.
+  draft: true
   extra_files:
     - glob: ./dist/OliveTin-windows-amd64.msi
   footer: |

+ 137 - 19
docs/modules/dev/pages/signing.adoc

@@ -1,31 +1,67 @@
-# macOS release signing
+# Release signing
 
-Release builds can sign and notarize the `darwin` binaries using [quill](https://github.com/anchore/quill) via GoReleaser. This runs on the existing Linux CI runner; no macOS runner is required.
+OliveTin signs release binaries on two platforms:
 
-Signing is **optional**. If the GitHub secrets below are not all set, GoReleaser skips macOS signing and publishes unsigned binaries (the previous behaviour).
+* **macOS** — Developer ID + notarization via [quill](https://github.com/anchore/quill) inside GoReleaser (optional only when `MACOS_SIGN_P12` is unset).
+* **Windows** — Authenticode via [SignPath Foundation](https://signpath.org/) in a separate GitHub Actions job (required before a draft release is published).
 
-## Prerequisites
+## macOS release signing
+
+Release builds can sign and notarize the `darwin` binaries using [quill](https://github.com/anchore/quill) via GoReleaser. This runs on the existing Linux CI runner; no macOS runner or Xcode is required.
+
+Signing is **optional** only when `MACOS_SIGN_P12` is unset — GoReleaser then skips macOS signing and publishes unsigned binaries. When `MACOS_SIGN_P12` is set, the companion macOS secrets below are required or the release fails.
+
+### Prerequisites
 
 - An active [Apple Developer Program](https://developer.apple.com/programs/) membership.
 - A **Developer ID Application** certificate (not "Apple Development" or "Mac App Distribution").
 - An [App Store Connect API key](https://appstoreconnect.apple.com/access/integrations/api) with at least **Developer** access.
 
-## One-time setup
+### One-time setup
 
-### 1. Create the signing certificate
+#### 1. Create the signing certificate (OpenSSL, no Mac/Xcode)
+
+Work in a private directory. Keep the private key offline and never commit it.
+
+```sh
+mkdir -p ~/apple-signing && cd ~/apple-signing
+chmod 700 .
+
+openssl genrsa -out developer_id_app.key 2048
+openssl req -new -key developer_id_app.key -out developer_id_app.csr \
+  -subj "/emailAddress=you@example.com/CN=Your Name/C=GB"
+```
 
 1. Open [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/certificates/list).
-2. Create a certificate of type **Developer ID Application**.
-3. Download the `.cer` file and double-click it to add it to **Keychain Access** on a Mac.
-4. In Keychain Access, export the certificate as a **Personal Information Exchange (`.p12`)** file. You will set an export password — remember it; this becomes `MACOS_SIGN_PASSWORD`.
+2. Create a certificate of type **Developer ID Application**. Prefer **G2 Sub-CA** if the portal asks.
+3. Upload `developer_id_app.csr` and download the resulting `.cer` (often named `developerID_application.cer`).
+
+Build a `.p12` that includes Apple's Developer ID G2 intermediate:
+
+```sh
+curl -fsSLO https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer
+
+openssl x509 -inform DER -in developerID_application.cer -out developerID_application.pem
+openssl x509 -inform DER -in DeveloperIDG2CA.cer -out DeveloperIDG2CA.pem
+
+# Export password becomes MACOS_SIGN_PASSWORD.
+# On OpenSSL 3 (e.g. Fedora), -legacy improves compatibility with some tooling:
+openssl pkcs12 -export -legacy \
+  -inkey developer_id_app.key \
+  -in developerID_application.pem \
+  -certfile DeveloperIDG2CA.pem \
+  -out Certificates.p12
+```
+
+If you already have a Mac with the certificate in Keychain Access, you can export a `.p12` from there instead; the OpenSSL path above is enough when you do not.
 
-### 2. Create the notarization API key
+#### 2. Create the notarization API key
 
 1. Open [App Store Connect → Users and Access → Integrations → App Store Connect API](https://appstoreconnect.apple.com/access/integrations/api).
 2. Create a key with **Developer** role (or Admin).
 3. Download the `.p8` file once (it cannot be downloaded again). Note the **Key ID** shown in the portal and the **Issuer ID** at the top of the API keys page.
 
-### 3. Base64-encode the key files
+#### 3. Base64-encode the key files
 
 Run on a machine that has the files (Linux or macOS):
 
@@ -36,7 +72,7 @@ base64 -w0 < ./AuthKey_XXXXXX.p8   # MACOS_NOTARY_KEY
 
 On macOS without GNU coreutils, use `base64 -i file | tr -d '\n'`.
 
-### 4. Add GitHub repository secrets
+#### 4. Add GitHub repository secrets
 
 In **Settings → Secrets and variables → Actions**, create:
 
@@ -48,9 +84,9 @@ In **Settings → Secrets and variables → Actions**, create:
 | `MACOS_NOTARY_KEY_ID` | Key ID from App Store Connect (e.g. `ABC123DEF4`) |
 | `MACOS_NOTARY_ISSUER_ID` | Issuer UUID from App Store Connect |
 
-All five must be present for signing to run. Any missing secret disables signing for that release.
+All five must be present for signing to run. GoReleaser enables the step when `MACOS_SIGN_P12` is set; missing companion secrets will fail that release.
 
-## Renewal
+### Renewal
 
 | Item | Typical lifetime | What to do |
 |------|------------------|------------|
@@ -60,7 +96,7 @@ All five must be present for signing to run. Any missing secret disables signing
 
 After updating secrets, the next release on `main` (via semantic-release) will use the new credentials automatically.
 
-## Verifying a signed release
+### Verifying a signed release
 
 On a Mac, download a `OliveTin-darwin-*.tar.gz` release artifact and run:
 
@@ -71,8 +107,90 @@ spctl -a -vv -t execute OliveTin-darwin-arm64/OliveTin
 
 A signed and notarized binary should report `accepted` with `source=Notarized Developer ID`.
 
-## Configuration reference
+### Configuration reference
+
+- GoReleaser: `notarize.macos` in link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`]
+- CI secrets: link:https://github.com/OliveTin/OliveTin/blob/main/.github/workflows/build-and-release.yml[`.github/workflows/build-and-release.yml`] (`release` step)
+- link:https://goreleaser.com/customization/notarize/[GoReleaser notarization docs]
+
+## Windows release signing (SignPath)
+
+Windows Authenticode signing uses [SignPath Foundation](https://signpath.org/) (free for qualifying open-source projects). It does **not** use GoReleaser Pro.
+
+GoReleaser creates a **draft** GitHub release with unsigned Windows assets. A separate `sign-windows` job submits those assets to SignPath, replaces them on the draft (including updated `checksums.txt`), then publishes the release.
+
+Signed artifacts:
+
+* `OliveTin.exe` inside `OliveTin-windows-amd64.zip`
+* nested `OliveTin.exe` and the `OliveTin-windows-amd64.msi` installer (deep signing)
+
+Signing is **required** to publish. If SignPath secrets/vars are missing, `sign-windows` fails and the draft stays unpublished.
+
+### Prerequisites
+
+- Approval for the [SignPath Foundation open-source program](https://signpath.io/product/open-source).
+- The SignPath GitHub App installed on the OliveTin organization/repository.
+- A SignPath project linked to this repository, with a release signing policy.
+
+### One-time setup
+
+#### 1. Apply for SignPath Foundation
+
+1. Open https://signpath.io/product/open-source and apply with the OliveTin GitHub repository URL.
+2. After approval, create (or confirm) the organization and project in the SignPath portal.
+
+#### 2. Install the SignPath GitHub App
+
+1. Install the SignPath GitHub App and grant access to the OliveTin repository.
+2. Link the Trusted Build System **GitHub.com** to the SignPath project (required so SignPath can verify the workflow artifact origin).
+
+#### 3. Create artifact configurations
+
+In the SignPath project, create two artifact configurations with these slugs (must match CI). Paste the XML from the reference copies in this repo (SignPath does **not** load them automatically):
+
+* slug `windows-zip` ← link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-zip.xml[`signpath/windows-zip.xml`]
+* slug `windows-msi` ← link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-msi.xml[`signpath/windows-msi.xml`]
+
+Use **Custom** XML in the SignPath UI and paste the file contents. Do **not** use **Upload an artifact sample** on these `.xml` files — SignPath will treat them as XML documents to sign (`xml-file`), which is unavailable on the Foundation/Open Source plan. See link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/README.md[`signpath/README.md`].
+
+#### 4. Add GitHub secrets and variables
+
+In **Settings → Secrets and variables → Actions**:
+
+| Kind | Name | Value |
+|------|------|-------|
+| Secret | `SIGNPATH_API_TOKEN` | CI submitter API token from SignPath |
+| Variable | `SIGNPATH_ORGANIZATION_ID` | SignPath organization ID |
+| Variable | `SIGNPATH_PROJECT_SLUG` | SignPath project slug (e.g. `olivetin`) |
+| Variable | `SIGNPATH_SIGNING_POLICY_SLUG` | Signing policy slug (e.g. `release-signing`) |
+
+#### 5. Pipeline behaviour
+
+On a new semantic-release from `main`:
+
+1. GoReleaser publishes container images and creates a **draft** GitHub release (including unsigned Windows zip/MSI).
+2. The build job uploads those Windows files as GitHub Actions artifacts.
+3. The `sign-windows` job submits each artifact to SignPath, waits for completion, then runs `var/windows/signpath-publish-signed.sh` to clobber-upload signed files, refresh `checksums.txt`, and undraft the release.
+
+All jobs in this chain use GitHub-hosted runners (required by SignPath for OSS projects).
+
+### Verifying a signed release
+
+On Windows, download `OliveTin-windows-amd64.msi` or extract `OliveTin.exe` from the zip, then either:
+
+* Right-click → **Properties** → **Digital Signatures**, or
+* Run:
+
+```bat
+signtool verify /pa OliveTin.exe
+signtool verify /pa OliveTin-windows-amd64.msi
+```
+
+### Configuration reference
 
-- GoReleaser: `notarize.macos` in [`.goreleaser.yml`](.goreleaser.yml)
-- CI secrets: [`.github/workflows/build-and-release.yml`](.github/workflows/build-and-release.yml) (`release` step)
-- [GoReleaser notarization docs](https://goreleaser.com/customization/notarize/)
+- Draft release: `release.draft: true` in link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`]
+- CI job: `sign-windows` in link:https://github.com/OliveTin/OliveTin/blob/main/.github/workflows/build-and-release.yml[`.github/workflows/build-and-release.yml`]
+- Publish helper: link:https://github.com/OliveTin/OliveTin/blob/main/var/windows/signpath-publish-signed.sh[`var/windows/signpath-publish-signed.sh`]
+- SignPath artifact configs (reference only): link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-zip.xml[`signpath/windows-zip.xml`], link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-msi.xml[`signpath/windows-msi.xml`]
+- link:https://docs.signpath.io/trusted-build-systems/github[SignPath GitHub Actions docs]
+- link:https://docs.signpath.io/artifact-configuration/examples[SignPath artifact configuration examples]

+ 18 - 0
docs/modules/dev/signpath/README.md

@@ -0,0 +1,18 @@
+# SignPath artifact configurations (reference only)
+
+These XML files are **paste templates** for the SignPath web UI. SignPath does not load them from this repository.
+
+## Do not upload these `.xml` files as artifact samples
+
+If you use **Upload an artifact sample** on `windows-zip.xml` / `windows-msi.xml`, SignPath treats them as XML documents to sign and generates an `<xml-file>` configuration. That feature is not available on SignPath Foundation / Open Source, and you get an error like:
+
+> feature which is not currently available (XML element name: 'xml-file')
+
+## Correct setup
+
+1. In the SignPath project, **Add** an artifact configuration.
+2. Choose **Custom** (edit XML), not “upload sample” of these files.
+3. Paste the full contents of `windows-zip.xml` or `windows-msi.xml`.
+4. Set the slug to `windows-zip` or `windows-msi` (must match CI).
+
+Optional: use **Upload an artifact sample** with a real `OliveTin-windows-amd64.zip` or `.msi` from a build, then trim the generated config to match these references.

+ 8 - 0
docs/modules/dev/signpath/windows-msi.xml

@@ -0,0 +1,8 @@
+<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
+  <msi-file path="OliveTin-windows-amd64.msi">
+    <pe-file path="OliveTin.exe">
+      <authenticode-sign/>
+    </pe-file>
+    <authenticode-sign/>
+  </msi-file>
+</artifact-configuration>

+ 7 - 0
docs/modules/dev/signpath/windows-zip.xml

@@ -0,0 +1,7 @@
+<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
+  <zip-file path="OliveTin-windows-amd64.zip">
+    <pe-file path="OliveTin-windows-amd64/OliveTin.exe">
+      <authenticode-sign/>
+    </pe-file>
+  </zip-file>
+</artifact-configuration>

+ 92 - 0
var/windows/signpath-publish-signed.sh

@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+DIST_DIR="${DIST_DIR:-${REPO_ROOT}/dist}"
+ARCH="${ARCH:-amd64}"
+ZIP_NAME="OliveTin-windows-${ARCH}.zip"
+MSI_NAME="OliveTin-windows-${ARCH}.msi"
+CHECKSUMS_NAME="checksums.txt"
+
+usage() {
+  echo "Usage: $(basename "$0") <release-tag> <signed-zip-path> <signed-msi-path>" >&2
+  exit 1
+}
+
+TAG="${1:-}"
+SIGNED_ZIP="${2:-}"
+SIGNED_MSI="${3:-}"
+
+if [[ -z "${TAG}" || -z "${SIGNED_ZIP}" || -z "${SIGNED_MSI}" ]]; then
+  usage
+fi
+
+if [[ ! -f "${SIGNED_ZIP}" ]]; then
+  echo "Signed zip not found: ${SIGNED_ZIP}" >&2
+  exit 1
+fi
+
+if [[ ! -f "${SIGNED_MSI}" ]]; then
+  echo "Signed MSI not found: ${SIGNED_MSI}" >&2
+  exit 1
+fi
+
+if ! command -v gh >/dev/null; then
+  echo "gh is required to update the GitHub release" >&2
+  exit 1
+fi
+
+mkdir -p "${DIST_DIR}"
+cp -f "${SIGNED_ZIP}" "${DIST_DIR}/${ZIP_NAME}"
+cp -f "${SIGNED_MSI}" "${DIST_DIR}/${MSI_NAME}"
+
+checksums_path="${DIST_DIR}/${CHECKSUMS_NAME}"
+checksums_backup="${DIST_DIR}/${CHECKSUMS_NAME}.orig"
+if ! gh release download "${TAG}" --pattern "${CHECKSUMS_NAME}" --dir "${DIST_DIR}" --clobber; then
+  echo "Failed to download ${CHECKSUMS_NAME} from release ${TAG}" >&2
+  exit 1
+fi
+if [[ ! -f "${checksums_path}" ]]; then
+  echo "${CHECKSUMS_NAME} not found after download from release ${TAG}" >&2
+  exit 1
+fi
+cp -f "${checksums_path}" "${checksums_backup}"
+
+update_checksum() {
+  local file_name="${1}"
+  local new_checksum
+  new_checksum="$(cd "${DIST_DIR}" && sha256sum "${file_name}")"
+
+  if [[ -f "${checksums_path}" ]] && grep -qF " ${file_name}" "${checksums_path}"; then
+    local tmp
+    tmp="$(mktemp)"
+    grep -vF " ${file_name}" "${checksums_path}" > "${tmp}" || true
+    printf '%s\n' "${new_checksum}" >> "${tmp}"
+    mv "${tmp}" "${checksums_path}"
+  else
+    printf '%s\n' "${new_checksum}" >> "${checksums_path}"
+  fi
+}
+
+update_checksum "${ZIP_NAME}"
+update_checksum "${MSI_NAME}"
+
+# Replace binaries first so a failed checksums upload leaves the draft recoverable.
+gh release upload "${TAG}" \
+  "${DIST_DIR}/${ZIP_NAME}" \
+  "${DIST_DIR}/${MSI_NAME}" \
+  --clobber
+
+if ! gh release upload "${TAG}" "${checksums_path}" --clobber; then
+  echo "Failed to upload updated ${CHECKSUMS_NAME}; restoring previous asset" >&2
+  restore_dir="$(mktemp -d)"
+  cp -f "${checksums_backup}" "${restore_dir}/${CHECKSUMS_NAME}"
+  gh release upload "${TAG}" "${restore_dir}/${CHECKSUMS_NAME}" --clobber
+  rm -rf "${restore_dir}"
+  exit 1
+fi
+
+gh release edit "${TAG}" --draft=false
+
+echo "Published signed ${ZIP_NAME} and ${MSI_NAME} on release ${TAG}"