diff --git a/.woodpecker/builder.yaml b/.woodpecker/builder.yaml new file mode 100644 index 0000000..16d760d --- /dev/null +++ b/.woodpecker/builder.yaml @@ -0,0 +1,23 @@ +# Build and push the image the other pipelines run in, from ci/builder. +# +# Trigger: open any pipeline for this repo in Woodpecker, click "Deploy", and +# set the deploy target to `builder`. + +when: + - event: deployment + evaluate: 'CI_PIPELINE_DEPLOY_TARGET == "builder"' + +steps: + - name: build + image: woodpeckerci/plugin-docker-buildx:6.1.1 + settings: + registry: registry.apps.millslan.net + repo: registry.apps.millslan.net/garrettmills/mtgc-builder + tags: latest + platforms: linux/amd64 + context: ci/builder + dockerfile: ci/builder/Dockerfile + username: + from_secret: registry_username + password: + from_secret: registry_password diff --git a/.woodpecker/check.yaml b/.woodpecker/check.yaml new file mode 100644 index 0000000..9471a90 --- /dev/null +++ b/.woodpecker/check.yaml @@ -0,0 +1,42 @@ +# Test every push to main. Its pipeline is also the one to click "Deploy" on; +# see release.yaml. + +when: + - event: push + branch: main + +steps: + - name: prepare + image: alpine:3.22 + volumes: + - mtgc-ci-cache:/cache + commands: + - chown -R 1000:1000 /woodpecker + - chown 1000:1000 /cache + + - name: test + image: registry.apps.millslan.net/garrettmills/mtgc-builder:latest + pull: true + volumes: + - mtgc-ci-cache:/cache + environment: + HOME: /cache/home + CARGO_HOME: /cache/cargo + CARGO_TARGET_DIR: /cache/target-test + CARGO_BUILD_JOBS: "4" + backend_options: + kubernetes: + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + resources: + requests: + cpu: "4" + memory: 6Gi + limits: + memory: 12Gi + commands: + - mkdir -p "$HOME" + - npm ci + - npm run build + - cargo test --locked -p mtgc-core -p mtgc -p mtgc-server diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..94d19fa --- /dev/null +++ b/.woodpecker/release.yaml @@ -0,0 +1,103 @@ +# Build the release packages and publish them. +# +# Trigger: open a pipeline for this repo in Woodpecker, click "Deploy", and set +# the deploy target to `all`, `windows`, `rpm`, `deb` or `fdroid`. +# +# These are steps in one workflow, not a workflow per target: Woodpecker drops a +# workflow whose dependency was filtered out, and the cache volume is +# ReadWriteOnce. Each target also runs after a failure, so one bad target doesn't +# hold back the rest, and the pipeline still ends up red. +# +# The volumes are defined in the platform repo (src/apps/woodpecker), and this +# repo must be marked Trusted → Volumes in Woodpecker to mount them. + +when: + - event: deployment + evaluate: 'CI_PIPELINE_DEPLOY_TARGET != "builder"' + +# The version is the commit count, and the default clone fetches a single commit. +clone: + git: + image: woodpeckerci/plugin-git + settings: + partial: false + depth: 0 + +variables: + - &builder + image: registry.apps.millslan.net/garrettmills/mtgc-builder:latest + pull: true + backend_options: + kubernetes: + # The owner of the release directories on the NAS. + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + resources: + requests: + cpu: "4" + memory: 6Gi + limits: + memory: 12Gi + - &publish + volumes: + - mtgc-release:/nfs/hal9000/mtgc + - mtgc-ci-cache:/cache + environment: + HOME: /cache/home + CARGO_HOME: /cache/cargo + CARGO_BUILD_JOBS: "4" + GRADLE_OPTS: -Dorg.gradle.daemon=false + +steps: + - name: prepare + image: alpine:3.22 + volumes: + - mtgc-ci-cache:/cache + commands: + - chown -R 1000:1000 /woodpecker + - chown 1000:1000 /cache + + - name: windows + <<: [*builder, *publish] + commands: + - scripts/ci-publish.sh windows + when: + - status: [success, failure] + evaluate: 'CI_PIPELINE_DEPLOY_TARGET in ["all", "windows"]' + + - name: rpm + <<: [*builder, *publish] + commands: + - scripts/ci-publish.sh rpm + when: + - status: [success, failure] + evaluate: 'CI_PIPELINE_DEPLOY_TARGET in ["all", "rpm"]' + + - name: deb + <<: [*builder, *publish] + commands: + - scripts/ci-publish.sh deb + when: + - status: [success, failure] + evaluate: 'CI_PIPELINE_DEPLOY_TARGET in ["all", "deb"]' + + - name: fdroid + <<: [*builder, *publish] + commands: + - scripts/ci-publish.sh fdroid + when: + - status: [success, failure] + evaluate: 'CI_PIPELINE_DEPLOY_TARGET in ["all", "fdroid"]' + + # update-repo.sh finds ../static/mtgc, so this mounts it there. + - name: update-repo + <<: *builder + volumes: + - mtgc-release:/nfs/hal9000/mtgc + - mtgc-public:/nfs/hal9000/static/mtgc + commands: + - cd /nfs/hal9000/mtgc + - ./update-repo.sh + when: + - status: [success, failure] diff --git a/README.md b/README.md index 295986a..ea5e0a6 100644 --- a/README.md +++ b/README.md @@ -136,8 +136,9 @@ From then on, `sudo dnf upgrade` picks up new releases. trust a new key before it takes another update. - Versions work as on Android: `0.1.`, see [Distributing to phones](#distributing-to-phones-f-droid). -- The RPM is built against this machine's libraries, so it installs on this - Fedora release and newer. +- The RPM is built against the build machine's libraries, so it needs at least + that machine's glibc. Built by [CI](#publishing-from-ci), that's glibc 2.36; + built on Fedora 44, it's Fedora 44 and newer. ### Distributing to Debian/Ubuntu (apt repo) @@ -186,9 +187,10 @@ From then on, `sudo apt upgrade` picks up new releases. - Versions are the same as the RPM's. A build published with `--allow-dirty` gets a `~dirty` suffix, which apt sorts below the clean build of the same version. -- The `.deb` is built against this machine's libraries, so it needs a distro - with at least this Fedora's glibc (2.43 on Fedora 44). Older Debian and - Ubuntu releases can't install it. +- The `.deb` is built against the build machine's libraries, so it needs a + distro with at least that machine's glibc. Built by + [CI](#publishing-from-ci), that's glibc 2.36: Debian 12 and Ubuntu 24.04 or + newer. Built on Fedora 44, it's glibc 2.43, which they can't install. ### Distributing to Windows @@ -229,6 +231,37 @@ installed app in place. - Versions work as on Android: `0.1.`, see [Distributing to phones](#distributing-to-phones-f-droid). +### Publishing from CI + +Woodpecker can do all of the above, through the same scripts. Every push to +`main` runs the tests (`.woodpecker/check.yaml`). To publish, open a pipeline, +click **Deploy**, and set the deploy target: + +| Target | What it does | +|---|---| +| `all` | Publishes every package below | +| `windows`, `rpm`, `deb`, `fdroid` | Publishes just that package | +| `builder` | Rebuilds the image the pipelines run in, from `ci/builder` | + +`all` doesn't include `builder`. Rebuild the image after changing its +Dockerfile, and before the first run of anything else. + +- **It publishes to the same place you do.** The pipeline mounts the release + directory and its keys from the NAS at `/nfs/hal9000/mtgc`, then runs that + directory's `update-repo.sh` to copy the public files out, so CI and a dev + machine can each publish the next release. `scripts/ci-publish.sh` sets up + what a fresh container lacks and runs the publish script. +- A target that fails doesn't stop the others, but the pipeline ends up red. + Restarting it is safe: targets that already have this version are skipped. +- The volumes are defined in the platform repo (`src/apps/woodpecker`). This + repo must be marked **Trusted → Volumes** in Woodpecker to mount them, and + needs the `registry_username` and `registry_password` secrets to push the + image. +- `sdk_path` in the F-Droid repo's `config.yml` must be `$ANDROID_HOME`, as the + SDK is somewhere else on each machine. +- Builds are cached on the `mtgc-ci-cache` volume. It's safe to delete anything + on it; the next build just takes longer. + ## Using it - **Add Cards** — type a name (typos OK); ↑/↓ then Enter adds a copy. diff --git a/ci/builder/Dockerfile b/ci/builder/Dockerfile new file mode 100644 index 0000000..80a1939 --- /dev/null +++ b/ci/builder/Dockerfile @@ -0,0 +1,99 @@ +# The image every step in .woodpecker runs in. It builds the desktop app, the +# Windows installer and the Android APK, and holds the tools that publish them. +# +# Build it in Woodpecker: open a pipeline, click "Deploy", and set the deploy +# target to `builder`. Or locally: +# +# podman build -t mtgc-builder ci/builder +# +# Debian 12 on purpose: a Linux binary needs at least the glibc it was built +# against, so the oldest libraries we can build on reach the most machines. +# Versions match a working dev machine; bump them here, not in the scripts. + +FROM debian:bookworm + +ENV RUST_VERSION=1.97.1 \ + TAURI_CLI_VERSION=2.11.4 \ + CARGO_XWIN_VERSION=0.23.1 \ + NODE_VERSION=22.14.0 \ + REPREPRO_VERSION=5.4.4 \ + REPREPRO_SHA256=5b028f79183659d441d1b340deb88341a7f29335ae7370eff4b3c094fbe90bc4 \ + FDROIDSERVER_VERSION=2.4.5 \ + CMDLINE_TOOLS_BUILD=13114758 \ + NDK_VERSION=28.2.13676358 \ + ANDROID_PLATFORM=android-36 \ + ANDROID_BUILD_TOOLS=36.0.0 + +ENV RUSTUP_HOME=/opt/rustup \ + CARGO_HOME=/opt/cargo \ + ANDROID_HOME=/opt/android-sdk \ + PATH=/opt/cargo/bin:/opt/node/bin:/opt/android-sdk/cmdline-tools/latest/bin:/usr/lib/llvm-14/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# System packages. The first group builds the app, then Windows, then the +# package repos and Android. libgpgme and below only build reprepro. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl file git pkg-config unzip xz-utils \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev \ + clang lld llvm nsis \ + createrepo-c dpkg-dev gnupg rpm \ + openjdk-17-jdk-headless python3-venv \ + automake libarchive-dev libbz2-dev libdb-dev libgpgme-dev liblzma-dev libzstd-dev zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +# reprepro: bookworm's 5.3 can't read a repo that keeps several versions of a +# package, which deb-publish.sh's `Limit:` makes, so use the dev machine's. +RUN curl -fsSL -o /tmp/reprepro.tar.gz \ + "https://salsa.debian.org/debian/reprepro/-/archive/reprepro-$REPREPRO_VERSION/reprepro-reprepro-$REPREPRO_VERSION.tar.gz" \ + && echo "$REPREPRO_SHA256 /tmp/reprepro.tar.gz" | sha256sum -c - \ + && mkdir /tmp/reprepro \ + && tar xzf /tmp/reprepro.tar.gz -C /tmp/reprepro --strip-components=1 \ + && cd /tmp/reprepro \ + && autoreconf -fi \ + && ./configure --with-libbz2 --with-liblzma --with-libarchive \ + && make -j"$(nproc)" install \ + && rm -rf /tmp/reprepro /tmp/reprepro.tar.gz + +# Rust +RUN curl -fsSL -o /tmp/rustup-init https://static.rust-lang.org/rustup/dist/x86_64-unknown-linux-gnu/rustup-init \ + && chmod +x /tmp/rustup-init \ + && /tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain "$RUST_VERSION" \ + --target x86_64-pc-windows-msvc \ + --target aarch64-linux-android \ + --target armv7-linux-androideabi \ + --target i686-linux-android \ + --target x86_64-linux-android \ + && rm /tmp/rustup-init \ + && cargo install --locked tauri-cli --version "$TAURI_CLI_VERSION" \ + && cargo install --locked cargo-xwin --version "$CARGO_XWIN_VERSION" \ + && rm -rf /opt/cargo/registry /opt/cargo/git + +# Node +RUN mkdir /opt/node \ + && curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \ + | tar xJ -C /opt/node --strip-components=1 + +# Android SDK and NDK +RUN mkdir -p /opt/android-sdk/cmdline-tools \ + && curl -fsSL -o /tmp/cmdline-tools.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_BUILD}_latest.zip" \ + && unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools \ + && mv /tmp/cmdline-tools/cmdline-tools /opt/android-sdk/cmdline-tools/latest \ + && rm -rf /tmp/cmdline-tools /tmp/cmdline-tools.zip \ + && (yes || true) | sdkmanager --licenses >/dev/null \ + && sdkmanager --install \ + "platforms;$ANDROID_PLATFORM" \ + "build-tools;$ANDROID_BUILD_TOOLS" \ + "platform-tools" \ + "ndk;$NDK_VERSION" >/dev/null + +# fdroidserver +RUN python3 -m venv /opt/fdroidserver \ + && /opt/fdroidserver/bin/pip install --no-cache-dir "fdroidserver==$FDROIDSERVER_VERSION" \ + && ln -s /opt/fdroidserver/bin/fdroid /usr/local/bin/fdroid + +# Steps run as uid 1000, the owner of the release directories on the NAS. It +# gets an account because keytool and Gradle ask who they're running as, and +# the SDK because Gradle may add a build-tools package it wants. +RUN useradd --uid 1000 --create-home builder \ + && chown -R 1000:1000 /opt/android-sdk diff --git a/scripts/ci-publish.sh b/scripts/ci-publish.sh new file mode 100755 index 0000000..e1874e9 --- /dev/null +++ b/scripts/ci-publish.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# ci-publish.sh — publish one target from a Woodpecker step in .woodpecker/release.yaml. +# +# Usage: +# ./scripts/ci-publish.sh windows|rpm|deb|fdroid +# +# Does what a fresh container lacks and a dev machine already has, then runs that +# target's publish script. The repos and keys are the same directories a dev +# machine publishes to, mounted at the same path, so either can publish next. +# +# Environment: +# MTGC_RELEASE_DIR the mounted release directory (default /nfs/hal9000/mtgc) +# MTGC_CI_CACHE the build cache volume (default /cache) +# +set -euo pipefail + +usage() { + sed -n '3,14p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; } + +RELEASE_DIR="${MTGC_RELEASE_DIR:-/nfs/hal9000/mtgc}" +CACHE="${MTGC_CI_CACHE:-/cache}" + +[ $# -eq 1 ] || usage 1 +case "$1" in + windows) subdir=win ;; + rpm) subdir=rpm ;; + deb) subdir=deb ;; + fdroid) subdir=repo ;; + -h | --help) usage ;; + *) usage 1 ;; +esac +target="$1" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +# --- 1. Checks -------------------------------------------------------------- +# +# The version is the commit count, so a shallow clone would call this release +# 0.1.1. The publish script would refuse that, but not with a helpful message. + +if [ "$(git rev-parse --is-shallow-repository)" != false ]; then + err "this is a shallow clone; the commit count needs the full history" + exit 1 +fi + +# --- 2. Build directories --------------------------------------------------- +# +# The publish scripts name target/ directly, so link it rather than setting +# CARGO_TARGET_DIR. It lives on the cache volume, which outlasts the pipeline +# and has the room: the workspace volume is 10G. + +mkdir -p "$CACHE/target" "$HOME" +ln -sfn "$CACHE/target" target + +cargo fetch --locked +if [ ! -d node_modules ]; then + npm ci +fi + +# --- 3. Keys ---------------------------------------------------------------- +# +# gpg wants its home to be private, and the agent wants a socket there; neither +# works on the NFS copy. /tmp goes away with the step's pod. + +case "$target" in + rpm | deb) + gnupg="$(mktemp -d)" + cp -r "$RELEASE_DIR/keys/gnupg/." "$gnupg/" + chmod -R go-rwx "$gnupg" + export MTGC_RPM_GNUPGHOME="$gnupg" MTGC_DEB_GNUPGHOME="$gnupg" + ;; + fdroid) + source scripts/android-env.sh >/dev/null + android="src-tauri/gen/android" + # RustPlugin reads the SDK and NDK from here; a clone doesn't have it. + printf 'sdk.dir=%s\nndk.dir=%s\n' "$ANDROID_HOME" "$NDK_HOME" >"$android/local.properties" + ( + umask 077 + sed "s|^storeFile=.*|storeFile=$RELEASE_DIR/keys/android-release.jks|" \ + "$RELEASE_DIR/keys/keystore.properties" >"$android/keystore.properties" + ) + ;; +esac + +# --- 4. Publish ------------------------------------------------------------- + +info "publishing $target to $RELEASE_DIR/$subdir" +exec "scripts/$target-publish.sh" --skip-published "$RELEASE_DIR/$subdir" diff --git a/scripts/deb-publish.sh b/scripts/deb-publish.sh index df4f290..6b6a279 100755 --- a/scripts/deb-publish.sh +++ b/scripts/deb-publish.sh @@ -13,6 +13,7 @@ # --init-key generate the package signing key (once, ever; skip it if # rpm-publish.sh already made one, the two share it) # --allow-dirty publish uncommitted changes; the version gets a ~dirty suffix +# --skip-published finish without error if the repo already has this version # # Environment: # MTGC_DEB_DIR repo directory, if not given as an argument @@ -27,7 +28,7 @@ set -euo pipefail usage() { - sed -n '3,26p' "$0" | sed 's/^# \{0,1\}//' + sed -n '3,27p' "$0" | sed 's/^# \{0,1\}//' exit "${1:-0}" } @@ -42,11 +43,13 @@ KEEP=3 # versions kept in the repo, so a bad release can be downgraded from init_key=false allow_dirty=false +skip_published=false dir="${MTGC_DEB_DIR:-}" while [ $# -gt 0 ]; do case "$1" in --init-key) init_key=true ;; --allow-dirty) allow_dirty=true ;; + --skip-published) skip_published=true ;; -h | --help) usage ;; -*) usage 1 ;; *) dir="$1" ;; @@ -141,7 +144,9 @@ EOF sed 's/^$/./; s/^/ /' "$dir/$PACKAGE.asc" } >"$dir/$PACKAGE.sources" -# A landing page for whoever opens the repo's address in a browser. +# A landing page for whoever opens the repo's address in a browser. The package +# needs at least the glibc it's built against, so that depends on the build host. +glibc="$(ldd --version | awk 'NR == 1 { print $NF }')" cat >"$dir/README.html" < @@ -173,8 +178,8 @@ sudo apt update && sudo apt install $PACKAGE

From then on, sudo apt upgrade picks up new releases.

Requirements

-

A 64-bit x86 system with glibc 2.43 or newer (check with ldd --version). -Older releases, such as Ubuntu 24.04 and Debian 13, can't install it.

+

A 64-bit x86 system with glibc $glibc or newer (check with ldd --version). +Older releases can't install it.

Uninstall

sudo apt remove $PACKAGE
@@ -208,6 +213,10 @@ while read -r version; do
     [ "$patch" -gt "$newest" ] && newest="$patch"
 done < <(repo_ list "$SUITE" "$PACKAGE" 2>/dev/null | awk '{ print $NF }')
 if [ "$release_count" -le "$newest" ]; then
+    if $skip_published; then
+        info "version $release_version is already published; nothing to do"
+        exit 0
+    fi
     err "version $release_version is not newer than the repo's x.y.$newest; commit something first"
     exit 1
 fi
diff --git a/scripts/fdroid-publish.sh b/scripts/fdroid-publish.sh
index 97ee03b..23f7866 100755
--- a/scripts/fdroid-publish.sh
+++ b/scripts/fdroid-publish.sh
@@ -12,6 +12,7 @@
 # Options:
 #   --init-keystore   generate the app signing key (once, ever) and keystore.properties
 #   --allow-dirty     publish uncommitted changes; the version gets a -dirty suffix
+#   --skip-published  finish without error if the repo already has this version
 #
 # Environment:
 #   MTGC_FDROID_DIR   repo directory, if not given as an argument
@@ -26,7 +27,7 @@
 set -euo pipefail
 
 usage() {
-    sed -n '3,24p' "$0" | sed 's/^# \{0,1\}//'
+    sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//'
     exit "${1:-0}"
 }
 
@@ -40,11 +41,13 @@ APP_KEY_ALIAS="mtgc"
 
 init_keystore=false
 allow_dirty=false
+skip_published=false
 dir="${MTGC_FDROID_DIR:-}"
 while [ $# -gt 0 ]; do
     case "$1" in
         --init-keystore) init_keystore=true ;;
         --allow-dirty) allow_dirty=true ;;
+        --skip-published) skip_published=true ;;
         -h | --help) usage ;;
         -*) usage 1 ;;
         *) dir="$1" ;;
@@ -187,6 +190,10 @@ for apk in "$dir"/repo/"${APP_ID}"_*.apk "$dir"/archive/"${APP_ID}"_*.apk; do
     [ "$code" -gt "$newest" ] && newest="$code"
 done
 if [ "$version_code" -le "$newest" ]; then
+    if $skip_published; then
+        info "versionCode $version_code is already published; nothing to do"
+        exit 0
+    fi
     err "versionCode $version_code is not newer than $newest, already in the repo; commit something first"
     exit 1
 fi
diff --git a/scripts/rpm-publish.sh b/scripts/rpm-publish.sh
index d3c58f4..abd0f59 100755
--- a/scripts/rpm-publish.sh
+++ b/scripts/rpm-publish.sh
@@ -12,6 +12,7 @@
 # Options:
 #   --init-key      generate the package signing key (once, ever)
 #   --allow-dirty   publish uncommitted changes; the RPM release becomes 0.dirty
+#   --skip-published  finish without error if the repo already has this version
 #
 # Environment:
 #   MTGC_RPM_DIR    repo directory, if not given as an argument
@@ -26,7 +27,7 @@
 set -euo pipefail
 
 usage() {
-    sed -n '3,25p' "$0" | sed 's/^# \{0,1\}//'
+    sed -n '3,26p' "$0" | sed 's/^# \{0,1\}//'
     exit "${1:-0}"
 }
 
@@ -40,11 +41,13 @@ KEEP=3  # versions kept in the repo, so a bad release can be downgraded from
 
 init_key=false
 allow_dirty=false
+skip_published=false
 dir="${MTGC_RPM_DIR:-}"
 while [ $# -gt 0 ]; do
     case "$1" in
         --init-key) init_key=true ;;
         --allow-dirty) allow_dirty=true ;;
+        --skip-published) skip_published=true ;;
         -h | --help) usage ;;
         -*) usage 1 ;;
         *) dir="$1" ;;
@@ -142,6 +145,10 @@ for rpm_file in "$dir/$PACKAGE"-*.rpm; do
     [ "$patch" -gt "$newest" ] && newest="$patch"
 done
 if [ "$release_count" -le "$newest" ]; then
+    if $skip_published; then
+        info "version $release_version is already published; nothing to do"
+        exit 0
+    fi
     err "version $release_version is not newer than the repo's x.y.$newest; commit something first"
     exit 1
 fi
@@ -159,7 +166,9 @@ if [ -z "$built" ]; then
     err "the build produced no RPM under $outputs"
     exit 1
 fi
-rpmsign --addsign --define "_gpg_path $GNUPGHOME" --define "_gpg_name $fpr" "$built" >/dev/null
+# Debian's rpm looks for gpg2, which only Fedora has, so name the gpg in use.
+rpmsign --addsign --define "__gpg $(command -v gpg)" --define "_gpg_path $GNUPGHOME" \
+    --define "_gpg_name $fpr" "$built" >/dev/null
 
 # --- 7. Publish -------------------------------------------------------------
 
diff --git a/scripts/windows-publish.sh b/scripts/windows-publish.sh
index 455c315..86e96f1 100755
--- a/scripts/windows-publish.sh
+++ b/scripts/windows-publish.sh
@@ -9,6 +9,7 @@
 #
 # Options:
 #   --allow-dirty   publish uncommitted changes; the file name gets a -dirty suffix
+#   --skip-published  finish without error if the repo already has this version
 #
 # Environment:
 #   MTGC_WINDOWS_DIR  downloads directory, if not given as an argument
@@ -23,7 +24,7 @@
 set -euo pipefail
 
 usage() {
-    sed -n '3,22p' "$0" | sed 's/^# \{0,1\}//'
+    sed -n '3,23p' "$0" | sed 's/^# \{0,1\}//'
     exit "${1:-0}"
 }
 
@@ -37,10 +38,12 @@ LATEST="$PACKAGE-latest-x64-setup.exe"
 KEEP=3  # versions kept in the folder, so a bad release can be rolled back
 
 allow_dirty=false
+skip_published=false
 dir="${MTGC_WINDOWS_DIR:-}"
 while [ $# -gt 0 ]; do
     case "$1" in
         --allow-dirty) allow_dirty=true ;;
+        --skip-published) skip_published=true ;;
         -h | --help) usage ;;
         -*) usage 1 ;;
         *) dir="$1" ;;
@@ -98,6 +101,10 @@ for exe in "$dir/${PACKAGE}"_*_x64-setup.exe; do
     [ "$patch" -gt "$newest" ] && newest="$patch"
 done
 if [ "$release_count" -le "$newest" ]; then
+    if $skip_published; then
+        info "version $release_version is already published; nothing to do"
+        exit 0
+    fi
     err "version $release_version is not newer than the folder's x.y.$newest; commit something first"
     exit 1
 fi