Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 12 additions & 9 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand All @@ -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/
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 != ''
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<slug>-v*` tags, where the slug is a key in `.github/plugin-registry.json`.

## Area rules
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion scripts/ci/fetch-delta-bases.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
Expand Down
19 changes: 10 additions & 9 deletions scripts/ci/package-artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,29 @@ echo "✅ DMG installer created successfully: $DMG_FILE"

ls -lh build/Release/*.dmg

# --- Create ZIP ---
echo "Creating ZIP archive..."
# --- Create the Sparkle update archive ---
echo "Creating the update archive..."

cd build/Release

# Use ditto to preserve framework symlinks (zip -r resolves them,
# which breaks code signature validation and Sparkle updates)
if ! ditto -c -k --sequesterRsrc --keepParent "TablePro-${ARCH}.app" "TablePro-${ARCH}.zip"; then
echo "❌ ERROR: Failed to create ZIP archive"
# tar keeps the framework symlinks, which codesign seals. A signed bundle needs no extended
# attributes, and the build machine's owner means nothing on a user's Mac.
if ! /usr/bin/tar --no-mac-metadata --no-xattrs --no-acls --uid 0 --gid 0 \
--options xz:compression-level=9 -cJf "TablePro-${ARCH}.tar.xz" "TablePro-${ARCH}.app"; then
echo "❌ ERROR: Failed to create the update archive"
exit 1
fi

echo "✅ ZIP archive created"
ls -lh "TablePro-${ARCH}.zip"
echo "✅ Update archive created"
ls -lh "TablePro-${ARCH}.tar.xz"

cd - > /dev/null

# --- Stage artifacts (optional, for local/self-hosted use) ---
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
17 changes: 9 additions & 8 deletions scripts/ci/sign-and-appcast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ set -euo pipefail
# architecture gets its own staging directory and its own generate_appcast run.
#
# Usage: sign-and-appcast.sh <version>
# Requires: SPARKLE_PRIVATE_KEY env var, artifacts/ directory with both architectures' ZIPs,
# and $DELTA_BASES/<arch>/ 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/<arch>/ 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}"
Expand Down Expand Up @@ -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/"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci/test_name_deltas.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def generated(deltas, arch="arm64", prefix=PREFIX):
" <sparkle:version>140</sparkle:version>\n"
" <sparkle:shortVersionString>0.81.0</sparkle:shortVersionString>\n"
f"{delta_block}"
f' <enclosure url="{prefix}TablePro-0.81.0-{arch}.zip" length="33851296" '
f' <enclosure url="{prefix}TablePro-0.81.0-{arch}.tar.xz" length="21188768" '
'type="application/octet-stream" sparkle:edSignature="sig=="/>\n'
" </item>\n"
" </channel>\n"
Expand Down
40 changes: 40 additions & 0 deletions scripts/ci/verify-update-archive.sh
Original file line number Diff line number Diff line change
@@ -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 <archive.tar.xz>

set -euo pipefail

ARCHIVE="${1:?Usage: verify-update-archive.sh <archive.tar.xz>}"

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"
Loading