diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 4d416c4ed8..04541b4859 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -193,7 +193,7 @@ the same sitting as the tag. - Keep unrelated work out of the release commit. A lint fix made on the way goes first, as its own Conventional Commit of at most 72 characters. -The tag runs `.github/workflows/build.yml`: arm64 and x86_64 builds, DMG and ZIP, Sparkle signatures, +The tag runs `.github/workflows/build.yml`: arm64 and x86_64 builds, DMG and tar.xz, Sparkle signatures, deltas from the previous five releases, the GitHub Release with notes from `CHANGELOG.md`, then `appcast.xml`. The release is created as a draft holding every asset and published in one step, only when `lint`, `test`, `build` and `registry-readiness` pass. The job fails when the tag disagrees with diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 463396d68a..cf0dff07a6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,13 +126,16 @@ jobs: NOTARIZE: "true" run: scripts/ci/package-artifacts.sh ${{ matrix.arch }} + - name: Verify the update archive + run: scripts/ci/verify-update-archive.sh "build/Release/TablePro-${{ matrix.arch }}.tar.xz" + - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: artifacts-${{ matrix.arch }} path: | build/Release/TablePro-*.dmg - build/Release/TablePro-*.zip + build/Release/TablePro-*.tar.xz # Reads two integers out of a Swift file and fetches a JSON manifest, so it wants nothing # from macOS and does not need to spend a macOS runner minute (billed at ten times Linux). @@ -177,7 +180,7 @@ jobs: # test-products, which is 874MB fetched to use 106MB of it, and every megabyte is another # chance for the blob store to drop the transfer: v0.69.0 failed here twice in a row with # "Artifact download failed after 5 retries", both times inside two minutes. Flatten takes any - # *.dmg or *.zip it finds, so a zip inside a test bundle could also reach the release. + # *.dmg or *.tar.xz it finds, so one inside a test bundle could also reach the release. - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -188,7 +191,7 @@ jobs: - name: Flatten artifacts run: | mkdir -p artifacts/ - find artifacts-raw/ -type f \( -name "*.dmg" -o -name "*.zip" \) -exec mv {} artifacts/ \; + find artifacts-raw/ -type f \( -name "*.dmg" -o -name "*.tar.xz" \) -exec mv {} artifacts/ \; rm -rf artifacts-raw/ echo "Artifacts:" ls -lh artifacts/ @@ -252,17 +255,17 @@ jobs: echo "Contents of artifacts directory:" ls -la artifacts/ - # The DMGs are already versioned by package-artifacts.sh; the ZIPs are not. + # The DMGs are already versioned by package-artifacts.sh; the update archives are not. # Both renames used to be guarded by `if [ -f ... ]` with no else, so a missing or - # differently named ZIP printed "organized successfully" and the release published + # differently named archive printed "organized successfully" and the release published # whatever the upload glob happened to match. for ARCH in arm64 x86_64; do - if [ ! -f "artifacts/TablePro-${ARCH}.zip" ]; then - echo "::error::artifacts/TablePro-${ARCH}.zip is missing; the ${ARCH} build did not produce an update archive" + if [ ! -f "artifacts/TablePro-${ARCH}.tar.xz" ]; then + echo "::error::artifacts/TablePro-${ARCH}.tar.xz is missing; the ${ARCH} build did not produce an update archive" ls -la artifacts/ >&2 exit 1 fi - mv "artifacts/TablePro-${ARCH}.zip" "artifacts/TablePro-${VERSION}-${ARCH}.zip" + mv "artifacts/TablePro-${ARCH}.tar.xz" "artifacts/TablePro-${VERSION}-${ARCH}.tar.xz" if [ ! -f "artifacts/TablePro-${VERSION}-${ARCH}.dmg" ]; then echo "::error::artifacts/TablePro-${VERSION}-${ARCH}.dmg is missing" @@ -348,7 +351,7 @@ jobs: # A first release has nothing to build deltas from. shopt -s nullglob scripts/ci/draft-release.sh --prerelease "$PRERELEASE" "$TAG" "$TAG" release_notes.md \ - artifacts/*.dmg artifacts/*.zip artifacts/*.delta + artifacts/*.dmg artifacts/*.tar.xz artifacts/*.delta - name: Publish the release if: steps.draft.outputs.id != '' diff --git a/AGENTS.md b/AGENTS.md index 58f1c55a32..7ef08a47df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,7 @@ For everything: docs, commits, CHANGELOG, UI strings, errors, PR descriptions. S ## CI - **PRs:** `macos-tests.yml`, `ios-tests.yml`, `swiftlint.yml` (pinned, `--strict`), `pr-title.yml` (Conventional Commits, at most 72 characters) and `repo-hygiene.yml` (actionlint, shellcheck and the repo's source-scanning checks). -- **Releases:** `build.yml` runs on `v*` tags and produces the DMG, the ZIP and the Sparkle feed, with notes taken from `CHANGELOG.md`. +- **Releases:** `build.yml` runs on `v*` tags and produces the DMG, the tar.xz update archive, its deltas and the Sparkle feed, with notes taken from `CHANGELOG.md`. - **Plugins:** `build-plugin.yml` runs on `plugin--v*` tags, where the slug is a key in `.github/plugin-registry.json`. ## Area rules diff --git a/CHANGELOG.md b/CHANGELOG.md index 2339de5aa0..2c4b352241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - tablepro command ships inside the app. Settings > General shows the Terminal line that links it; no administrator prompt. - MCP server runs only while Enable MCP Server is on in Settings > MCP. Clients and pairing no longer start it while it is off. - Edit > Data shown only in data file windows. -- Delta updates from the last five releases, about 6 MB instead of the full 34 MB download. +- Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB. ### Removed diff --git a/scripts/ci/fetch-delta-bases.sh b/scripts/ci/fetch-delta-bases.sh index 333217509e..f2a4db0027 100755 --- a/scripts/ci/fetch-delta-bases.sh +++ b/scripts/ci/fetch-delta-bases.sh @@ -35,8 +35,10 @@ while IFS= read -r tag; do if [ "$PICKED" -ge "$COUNT" ]; then break fi + # Releases before the switch to tar.xz carry a zip. for arch in arm64 x86_64; do - gh release download "$tag" --pattern "TablePro-${tag#v}-${arch}.zip" --dir "$DEST/$arch" + gh release download "$tag" --dir "$DEST/$arch" \ + --pattern "TablePro-${tag#v}-${arch}.tar.xz" --pattern "TablePro-${tag#v}-${arch}.zip" done PICKED=$((PICKED + 1)) done < /dev/null @@ -63,7 +64,7 @@ cd - > /dev/null if [ -n "$STAGING" ]; then mkdir -p "$STAGING" cp build/Release/*.dmg "$STAGING/" 2>/dev/null || true - cp "build/Release/TablePro-${ARCH}.zip" "$STAGING/" 2>/dev/null || true + cp "build/Release/TablePro-${ARCH}.tar.xz" "$STAGING/" 2>/dev/null || true echo "Artifacts staged to $STAGING" ls -lh "$STAGING" fi diff --git a/scripts/ci/sign-and-appcast.sh b/scripts/ci/sign-and-appcast.sh index d345d0016f..99bd8ebb92 100755 --- a/scripts/ci/sign-and-appcast.sh +++ b/scripts/ci/sign-and-appcast.sh @@ -17,8 +17,9 @@ set -euo pipefail # architecture gets its own staging directory and its own generate_appcast run. # # Usage: sign-and-appcast.sh -# Requires: SPARKLE_PRIVATE_KEY env var, artifacts/ directory with both architectures' ZIPs, -# and $DELTA_BASES// holding the earlier archives (fetch-delta-bases.sh), which may be empty. +# Requires: SPARKLE_PRIVATE_KEY env var, artifacts/ directory with both architectures' tar.xz +# archives, and $DELTA_BASES// holding the earlier archives (fetch-delta-bases.sh), which may +# be empty. # Optional: CRITICAL_UPDATE=1 to mark the release critical. BASE_APPCAST="${BASE_APPCAST:-appcast.xml}" @@ -85,18 +86,18 @@ echo "$SPARKLE_PRIVATE_KEY" > "$KEY_FILE" # --------------------------------------------------------------------------- generate_item() { local arch="$1" - local zip="artifacts/TablePro-${VERSION}-${arch}.zip" + local archive="artifacts/TablePro-${VERSION}-${arch}.tar.xz" local staging="$STAGING_ROOT/$arch" local bases=0 local base - if [ ! -f "$zip" ]; then - echo "::error::$zip is missing, so the $arch build produced no update archive" + if [ ! -f "$archive" ]; then + echo "::error::$archive is missing, so the $arch build produced no update archive" return 1 fi mkdir -p "$staging" - cp "$zip" "$staging/" + cp "$archive" "$staging/" for base in "$DELTA_BASES/$arch"/*; do if [ -f "$base" ]; then cp "$base" "$staging/" @@ -107,8 +108,8 @@ generate_item() { # Sparkle 2.9 renders Markdown natively, including code and links. Feeding hand-built HTML # left Markdown visible and interpreted literal SQL/XML angle brackets as HTML tags. # - # The name matches the archive's, which is how generate_appcast pairs notes to an archive. - # Without the pairing the item ships with no description at all. + # The name matches the archive's without .tar.xz, which is how generate_appcast pairs notes to + # an archive. Without the pairing the item ships with no description at all. { printf "# What's New in TablePro %s\n\n" "$VERSION" cat release_highlights.md diff --git a/scripts/ci/test_name_deltas.py b/scripts/ci/test_name_deltas.py index dfba876a7e..e92fbd0d37 100755 --- a/scripts/ci/test_name_deltas.py +++ b/scripts/ci/test_name_deltas.py @@ -42,7 +42,7 @@ def generated(deltas, arch="arm64", prefix=PREFIX): " 140\n" " 0.81.0\n" f"{delta_block}" - f' \n' " \n" " \n" diff --git a/scripts/ci/verify-update-archive.sh b/scripts/ci/verify-update-archive.sh new file mode 100755 index 0000000000..33ab4cd93d --- /dev/null +++ b/scripts/ci/verify-update-archive.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Extracts an update archive the way Sparkle's installer does and checks the app inside still +# passes code signing, its stapled ticket and Gatekeeper. +# +# Usage: verify-update-archive.sh + +set -euo pipefail + +ARCHIVE="${1:?Usage: verify-update-archive.sh }" + +if [ ! -f "$ARCHIVE" ]; then + echo "::error::$ARCHIVE does not exist" + exit 1 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# SUPipedUnarchiver pipes a .tar.xz into exactly this command, from Sparkle 2.8.1 through 2.10.0. +/usr/bin/tar -zxC "$WORK" < "$ARCHIVE" + +ENTRIES="$(find "$WORK" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" +APP="$(find "$WORK" -mindepth 1 -maxdepth 1 -name '*.app' -type d)" +if [ "$ENTRIES" -ne 1 ] || [ -z "$APP" ]; then + echo "::error::$ARCHIVE must hold one app bundle and nothing else" + ls -la "$WORK" + exit 1 +fi + +codesign --verify --deep --strict --verbose=2 "$APP" +xcrun stapler validate "$APP" + +if ! ASSESSMENT="$(spctl -a -vvv -t exec "$APP" 2>&1)"; then + echo "::error::Gatekeeper rejects the app extracted from $ARCHIVE" + echo "$ASSESSMENT" + exit 1 +fi +echo "$ASSESSMENT" +echo "✅ $(basename "$ARCHIVE") extracts to an app that passes codesign, stapler and spctl"