diff --git a/.claude/rules/libs.md b/.claude/rules/libs.md index cc6bcce352..7361d3eaf9 100644 --- a/.claude/rules/libs.md +++ b/.claude/rules/libs.md @@ -3,10 +3,10 @@ paths: - "Libs/**/*" - "scripts/download-libs.sh" - "scripts/publish-libs.sh" - - "scripts/publish-ios-libs.sh" --- # Static libraries -- **`Libs/` is published only through `scripts/publish-libs.sh `** (and `scripts/publish-ios-libs.sh` for the iOS xcframeworks), then the updated `checksums.sha256` is committed. Never regenerate a checksum file by hand: a stale `Libs/` silently reverts other libraries. -- **`scripts/download-libs.sh` verifies every archive against the checksums**; `--force` re-downloads. +- **`Libs/` is published only through `scripts/publish-libs.sh `.** Each run creates the next immutable `libs-` release and writes `Libs/release` and both `checksums.sha256` files, which are committed together. Never edit them by hand: a stale `Libs/` silently reverts other libraries. +- A published `libs-` never changes; a fix is the next ``. `libs-v1` stays frozen for older commits. +- **`scripts/download-libs.sh` fetches the release `Libs/release` names at `HEAD` and verifies every file against the checksums at `HEAD`** on every run; `--force` re-downloads. diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index fb0825d0a1..20c93eeb3e 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -51,6 +51,7 @@ jobs: python3 .github/scripts/test_update_registry.py bash scripts/ci/test_create_dmg_args.sh bash scripts/ci/test_draft_release.sh + bash scripts/ci/test_libs_release.sh # shellcheck cannot tell a library function from a command, so a missing source is exit 127 # at release time (v0.67.1). diff --git a/AGENTS.md b/AGENTS.md index e03a2b327f..1c35aa67aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,7 @@ Claude Code loads a rule when it reads or edits a file the rule covers. Under Co | `data-grid.md` | `TablePro/Views/Results`, `TablePro/Core/DataGrid`, `TableProTests/Views/Results` | | `data-sync-storage.md` | `TablePro/Core/**/*Storage*.swift`, `TablePro/Core/**/*Sync*.swift`, `TablePro/Core/Database`, `TablePro/Core/Plugins/PluginMetadataRegistry*.swift`, `TablePro/Core/Services/Query`, `Packages/TableProCore/Sources/TableProSyncTransport`, `CloudKit` | | `editor.md` | `Packages/TableProEditor`, `TablePro/Theme`, `TablePro/Views/Editor`, `TablePro/Views/RowInspector` | -| `libs.md` | `Libs`, `scripts/download-libs.sh`, `scripts/publish-libs.sh`, `scripts/publish-ios-libs.sh` | +| `libs.md` | `Libs`, `scripts/download-libs.sh`, `scripts/publish-libs.sh` | | `mongodb-driver.md` | `Plugins/MongoDBDriverPlugin`, `Plugins/MQLExportPlugin` | | `mysql-driver.md` | `Plugins/MySQLDriverPlugin`, `Packages/TableProCore/Sources/TableProMySQLCore`, `TableProMobile/**/*MySQL*`, `TableProMobile/**/*MariaDB*` | | `native-bridges.md` | `Native`, `Plugins/DamengDriverPlugin`, `Plugins/HanaDriverPlugin`, `scripts/build-dameng.sh`, `scripts/build-hana.sh` | diff --git a/Libs/release b/Libs/release new file mode 100644 index 0000000000..1931ef161a --- /dev/null +++ b/Libs/release @@ -0,0 +1 @@ +libs-2 diff --git a/scripts/build-duckdb-ios.sh b/scripts/build-duckdb-ios.sh index 553b1f0e10..889b56a1d9 100755 --- a/scripts/build-duckdb-ios.sh +++ b/scripts/build-duckdb-ios.sh @@ -197,9 +197,7 @@ xcodebuild -create-xcframework \ echo echo "Done: $OUT_DIR/DuckDB.xcframework" echo -echo "Next steps (these modify the libs-v1 release; run them yourself):" -echo " scripts/publish-ios-libs.sh" -echo " git add Libs/ios/checksums.sha256 && git commit -m 'build: update iOS xcframework checksums'" +echo "Build TableProMobile in Xcode, then publish: scripts/publish-libs.sh DuckDB.xcframework" echo -echo "Then build TableProMobile in Xcode. Cleaning up $WORK_DIR" +echo "Cleaning up $WORK_DIR" rm -rf "$WORK_DIR" diff --git a/scripts/build-freetds.sh b/scripts/build-freetds.sh index 051eb78443..4b7271477a 100755 --- a/scripts/build-freetds.sh +++ b/scripts/build-freetds.sh @@ -22,15 +22,13 @@ IOS_LIBS_DIR="$LIBS_DIR/ios" FREETDS_VERSION="1.4.22" FREETDS_SHA256="6acb9086350425f5178e544bbe2d54a001097e8e20277a2b766ad0799a2e7d87" FREETDS_URL="https://www.freetds.org/files/stable/freetds-${FREETDS_VERSION}.tar.gz" -# A private directory with a cleanup trap. This was a fixed /tmp path, which is world-writable and -# therefore something another local user can own first, and it collided between two concurrent -# runs. The four mktemp dirs below were also never removed, so a run leaked all five. +# Not a fixed /tmp path: another local user could own it first, and two runs would collide. BUILD_DIR="$(mktemp -d)" TEMP_DIRS=("$BUILD_DIR") cleanup_freetds() { rm -rf "${TEMP_DIRS[@]}"; } trap cleanup_freetds EXIT -# Keeps the downloaded tarballs across runs, which the fixed build root used to provide for free. +# Outside BUILD_DIR so the downloaded tarballs survive across runs. CACHE_DIR="${TMPDIR:-/tmp}/tablepro-freetds-cache" mkdir -p "$CACHE_DIR" SOURCE_DIR="$BUILD_DIR/freetds-${FREETDS_VERSION}" @@ -265,6 +263,5 @@ find "$XCFRAMEWORK_OUT" -mindepth 1 -maxdepth 1 ! -name Info.plist -exec basenam echo "" echo "NEXT STEPS:" echo " 1. Inspect: xcodebuild -checkFirstLaunchStatus; file ${XCFRAMEWORK_OUT}/*/libsybdb.a" -echo " 2. Publish the iOS libs and refresh their integrity baseline:" -echo " scripts/publish-ios-libs.sh" -echo " git add Libs/ios/checksums.sha256 && git commit -m 'build: update iOS xcframework checksums'" +echo " 2. Publish:" +echo " scripts/publish-libs.sh libsybdb_arm64.a libsybdb_x86_64.a libsybdb_universal.a libsybdb.a FreeTDS.xcframework" diff --git a/scripts/build-mariadb.sh b/scripts/build-mariadb.sh index cade8c9e1a..8e908498d6 100755 --- a/scripts/build-mariadb.sh +++ b/scripts/build-mariadb.sh @@ -4,16 +4,13 @@ set -eo pipefail # Build static MariaDB Connector/C for macOS (arm64 + x86_64 + universal). # # Includes the mysql_clear_password client plugin (STATIC), required for AWS RDS -# IAM authentication. The previous Libs/libmariadb*.a were built without it, so -# IAM connections failed with "Plugin mysql_clear_password could not be loaded". +# IAM authentication. # # Output (overwrites, since Libs/*.a are not in git): # Libs/libmariadb_arm64.a Libs/libmariadb_x86_64.a # Libs/libmariadb_universal.a Libs/libmariadb.a (= universal) # # Requires: cmake, OpenSSL 3 (defaults to Homebrew openssl@3; override OPENSSL_ROOT). -# After running: regenerate Libs/checksums.sha256 and re-upload to the libs-v1 -# release (see CLAUDE.md "Updating Static Libraries"). # # Usage: ./scripts/build-mariadb.sh @@ -111,4 +108,3 @@ echo "" echo "Done. Libs/libmariadb*.a rebuilt with the cleartext plugin." echo "Next: rebuild the app and test MySQL IAM. When confirmed working, publish the libs:" echo " scripts/publish-libs.sh libmariadb_arm64.a libmariadb_x86_64.a libmariadb_universal.a libmariadb.a" -echo " git add Libs/checksums.sha256 && git commit -m 'build: update static library checksums'" diff --git a/scripts/ci/test_libs_release.sh b/scripts/ci/test_libs_release.sh new file mode 100755 index 0000000000..646a505b9c --- /dev/null +++ b/scripts/ci/test_libs_release.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# +# Runs publish-libs.sh and download-libs.sh in a scratch repository, against a stub gh that keeps +# the releases, tags and assets in a directory. +# +# Usage: test_libs_release.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +command -v jq > /dev/null || { echo "test_libs_release.sh needs jq" >&2; exit 1; } + +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT + +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com + +mkdir -p "$SCRATCH/bin" +cat > "$SCRATCH/bin/gh" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$STATE/log" +API="repos/TableProApp/TablePro" + +set_field() { + jq --arg t "$1" --argjson v "$3" "map(if .tag_name == \$t then .$2 = \$v else . end)" \ + "$STATE/releases.json" > "$STATE/releases.tmp" + mv "$STATE/releases.tmp" "$STATE/releases.json" +} + +if [ "$1" = "api" ]; then + endpoint="$2" + filter="." + shift 2 + while [ $# -gt 0 ]; do + case "$1" in + --jq) filter="$2"; shift 2 ;; + --paginate) shift ;; + *) echo "stub gh: unexpected api flag $1" >&2; exit 99 ;; + esac + done + case "$endpoint" in + "$API/immutable-releases") + if [ -f "$STATE/immutable-enabled" ]; then echo '{"enabled":true}'; else echo '{"enabled":false}'; fi ;; + "$API/releases?per_page=100") cat "$STATE/releases.json" ;; + "$API/releases/tags/"*) jq --arg t "${endpoint##*/}" '.[] | select(.tag_name == $t)' "$STATE/releases.json" ;; + "$API/git/matching-refs/tags/libs-") jq -R '{ref: ("refs/tags/" + .)}' "$STATE/tags" | jq -s . ;; + *) echo "stub gh: unexpected endpoint $endpoint" >&2; exit 99 ;; + esac | jq -r "$filter" + exit 0 +fi + +[ "$1" = "release" ] || { echo "stub gh: unexpected call: $*" >&2; exit 99; } +sub="$2" +tag="$3" +shift 3 +case "$sub" in + create) + draft=false + mkdir -p "$STATE/assets/$tag" + for arg in "$@"; do + case "$arg" in + --draft) draft=true ;; + *.tar.gz) cp "$arg" "$STATE/assets/$tag/" ;; + esac + done + jq --arg t "$tag" --argjson d "$draft" '. + [{tag_name: $t, draft: $d, immutable: false}]' \ + "$STATE/releases.json" > "$STATE/releases.tmp" + mv "$STATE/releases.tmp" "$STATE/releases.json" + ;; + edit) + set_field "$tag" draft false + if [ -f "$STATE/immutable-enabled" ] && [ ! -f "$STATE/publish-mutable" ]; then + set_field "$tag" immutable true + fi + echo "$tag" >> "$STATE/tags" + ;; + download) + pattern="" + dir="" + while [ $# -gt 0 ]; do + case "$1" in + --pattern) pattern="$2"; shift 2 ;; + --dir) dir="$2"; shift 2 ;; + *) shift ;; + esac + done + cp "$STATE/assets/$tag/$pattern" "$dir/$pattern" + ;; + *) + echo "stub gh: unexpected call: release $sub" >&2 + exit 99 + ;; +esac +STUB +chmod +x "$SCRATCH/bin/gh" + +FAILURES=0 +STATUS=0 +OUTPUT="" +STATE="" +TREE="" + +new_case() { + STATE="$SCRATCH/$1/state" + TREE="$SCRATCH/$1/tree" + mkdir -p "$STATE/assets" "$TREE/scripts" "$TREE/Libs/ios/A.xcframework/ios-arm64" "$TREE/Libs/ios/B.xcframework/ios-arm64" + cp "$REPO_ROOT/scripts/publish-libs.sh" "$REPO_ROOT/scripts/download-libs.sh" "$TREE/scripts/" + printf 'a\n' > "$TREE/Libs/liba.a" + printf 'b\n' > "$TREE/Libs/libb.a" + ln -s liba.a "$TREE/Libs/libc.a" + printf 'plist a\n' > "$TREE/Libs/ios/A.xcframework/Info.plist" + printf 'device a\n' > "$TREE/Libs/ios/A.xcframework/ios-arm64/liba.a" + printf 'plist b\n' > "$TREE/Libs/ios/B.xcframework/Info.plist" + printf 'device b\n' > "$TREE/Libs/ios/B.xcframework/ios-arm64/libb.a" + echo libs-v1 > "$TREE/Libs/release" + (cd "$TREE" && shasum -a 256 Libs/*.a > Libs/checksums.sha256) + (cd "$TREE/Libs/ios" && find ./*.xcframework -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256 > checksums.sha256) + git -C "$TREE" init -q + commit + + echo '[{"tag_name":"v0.81.0","draft":false,"immutable":true},{"tag_name":"libs-v1","draft":false,"immutable":false}]' > "$STATE/releases.json" + echo libs-v1 > "$STATE/tags" + touch "$STATE/immutable-enabled" + : > "$STATE/log" +} + +commit() { + git -C "$TREE" add Libs/release Libs/checksums.sha256 Libs/ios/checksums.sha256 + git -C "$TREE" commit -qm pin +} + +serve_legacy_v1() { + mkdir -p "$STATE/assets/libs-v1" + tar -czf "$STATE/assets/libs-v1/tablepro-libs-v1.tar.gz" -C "$TREE/Libs" liba.a libb.a libc.a checksums.sha256 + tar -czf "$STATE/assets/libs-v1/tablepro-libs-ios-v1.tar.gz" -C "$TREE/Libs/ios" . +} + +clear_libs() { + rm -rf "$TREE"/Libs/*.a "$TREE"/Libs/ios/*.xcframework "$TREE/Libs/.downloaded" "$TREE/Libs/ios/.downloaded" +} + +run() { + STATUS=0 + OUTPUT="$(cd "$TREE" && export STATE && PATH="$SCRATCH/bin:$PATH" "$@" 2>&1)" || STATUS=$? +} + +fail() { + echo "FAIL: $1" >&2 + printf ' %s\n' "${OUTPUT//$'\n'/$'\n '}" >&2 + FAILURES=$((FAILURES + 1)) +} + +expect_status() { + [ "$STATUS" -eq "$2" ] || fail "$1: exited $STATUS, expected $2" +} + +expect_output() { + case "$OUTPUT" in + *"$2"*) ;; + *) fail "$1: output lacks '$2'" ;; + esac +} + +expect_log() { + grep -qF -- "$2" "$STATE/log" || fail "$1: no gh call matching '$2'" +} + +refute_log() { + if grep -qF -- "$2" "$STATE/log"; then + fail "$1: unexpected gh call matching '$2'" + fi +} + +expect_file() { + [ "$(cat "$TREE/$2" 2> /dev/null)" = "$3" ] || fail "$1: $2 holds '$(cat "$TREE/$2" 2> /dev/null)', expected '$3'" +} + +expect_unchanged_at_head() { + git -C "$TREE" diff --quiet -- Libs/release Libs/checksums.sha256 Libs/ios/checksums.sha256 \ + || fail "$1: the pin or a checksum file changed" +} + +members() { + tar -tzf "$1" | sed 's|^\./||; s|/$||' | grep -v '^$' | LC_ALL=C sort | paste -sd ' ' - +} + +new_case usage +run scripts/publish-libs.sh +expect_status usage 2 +expect_output usage "Usage:" +run scripts/publish-libs.sh notes.txt +expect_status usage-type 2 + +new_case migrate +run scripts/publish-libs.sh Libs/liba.a Libs/libb.a Libs/libc.a Libs/ios/A.xcframework Libs/ios/B.xcframework +expect_status migrate 0 +expect_log migrate "release create libs-2 --repo TableProApp/TablePro --draft --latest=false" +expect_log migrate "release edit libs-2 --repo TableProApp/TablePro --draft=false --latest=false" +expect_file migrate Libs/release libs-2 +expect_file migrate Libs/.downloaded libs-2 +expect_file migrate Libs/ios/.downloaded libs-2 +git -C "$TREE" diff --quiet -- Libs/checksums.sha256 Libs/ios/checksums.sha256 || fail "migrate: a checksum file changed" +[ "$(members "$STATE/assets/libs-2/tablepro-libs-2.tar.gz")" = "liba.a libb.a libc.a" ] \ + || fail "migrate: macOS archive holds $(members "$STATE/assets/libs-2/tablepro-libs-2.tar.gz")" +[ "$(members "$STATE/assets/libs-2/tablepro-libs-ios-2.tar.gz" | tr ' ' '\n' | grep -v / | paste -sd ' ' -)" = "A.xcframework B.xcframework" ] \ + || fail "migrate: iOS archive holds $(members "$STATE/assets/libs-2/tablepro-libs-ios-2.tar.gz")" +tar -tvzf "$STATE/assets/libs-2/tablepro-libs-2.tar.gz" | grep -q 'libc.a -> liba.a' \ + || fail "migrate: libc.a is not archived as a link to liba.a" + +commit +: > "$STATE/log" +run scripts/publish-libs.sh liba.a +expect_status unchanged 1 +expect_output unchanged "nothing to publish: every named library matches libs-2" +refute_log unchanged "release create" + +clear_libs +run scripts/download-libs.sh +expect_status download 0 +expect_log download "release download libs-2 --repo TableProApp/TablePro --pattern tablepro-libs-2.tar.gz" +expect_log download "release download libs-2 --repo TableProApp/TablePro --pattern tablepro-libs-ios-2.tar.gz" +expect_file download Libs/.downloaded libs-2 +expect_file download Libs/ios/.downloaded libs-2 +expect_file download Libs/ios/B.xcframework/ios-arm64/libb.a "device b" +expect_unchanged_at_head download + +: > "$STATE/log" +run scripts/download-libs.sh +expect_status present 0 +refute_log present "release download" + +: > "$STATE/log" +echo libs-v1 > "$TREE/Libs/.downloaded" +printf 'stale\n' > "$TREE/Libs/libstale.a" +run scripts/download-libs.sh +expect_status new-pin 0 +expect_log new-pin "--pattern tablepro-libs-2.tar.gz" +refute_log new-pin "--pattern tablepro-libs-ios-2.tar.gz" +[ ! -e "$TREE/Libs/libstale.a" ] || fail "new-pin: a library the release lacks survived the download" + +: > "$STATE/log" +run scripts/download-libs.sh --force +expect_status force 0 +expect_log force "--pattern tablepro-libs-2.tar.gz" +expect_log force "--pattern tablepro-libs-ios-2.tar.gz" + +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +run scripts/download-libs.sh +expect_status rebuilt-check 1 +expect_output rebuilt-check "Libs does not match the checksums committed in git." + +: > "$STATE/log" +run scripts/publish-libs.sh --dry-run libb.a +expect_status dry-run 0 +expect_output dry-run "libs-3" +refute_log dry-run "release create" +expect_unchanged_at_head dry-run + +: > "$STATE/log" +run scripts/publish-libs.sh libb.a +expect_status rebuilt 0 +expect_output rebuilt "Rebuilt since libs-2: libb.a." +expect_log rebuilt "release create libs-3 --repo TableProApp/TablePro --draft --latest=false" +expect_file rebuilt Libs/release libs-3 +[ "$(git -C "$TREE" diff --numstat -- Libs/checksums.sha256)" = "$(printf '1\t1\tLibs/checksums.sha256')" ] \ + || fail "rebuilt: Libs/checksums.sha256 moved more than the libb.a line" +git -C "$TREE" diff --quiet -- Libs/ios/checksums.sha256 || fail "rebuilt: the iOS checksums moved" + +commit +rm "$TREE/Libs/libb.a" +run scripts/publish-libs.sh libb.a +expect_status removed 0 +grep -q libb.a "$TREE/Libs/checksums.sha256" && fail "removed: libb.a is still in the checksums" +[ "$(members "$STATE/assets/libs-4/tablepro-libs-4.tar.gz")" = "liba.a libc.a" ] || fail "removed: macOS archive still holds libb.a" + +new_case stale +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +printf 'plist b2\n' > "$TREE/Libs/ios/B.xcframework/Info.plist" +printf 'd\n' > "$TREE/Libs/libd.a" +rm "$TREE/Libs/ios/A.xcframework/ios-arm64/liba.a" +printf 'rebuilt a\n' > "$TREE/Libs/liba.a" +run scripts/publish-libs.sh liba.a libc.a +expect_status stale 1 +expect_output stale "libb.a (differs from HEAD)" +expect_output stale "libd.a (not at HEAD)" +expect_output stale "B.xcframework (differs from HEAD)" +expect_output stale "A.xcframework (differs from HEAD)" +refute_log stale "api" + +new_case typo +run scripts/publish-libs.sh libz.a +expect_status typo 1 +expect_output typo "neither Libs/ nor HEAD has libz.a." + +new_case link +mv "$TREE/Libs/liba.a" "$SCRATCH/link/liba.a" +ln -s "$SCRATCH/link/liba.a" "$TREE/Libs/liba.a" +run scripts/publish-libs.sh libb.a +expect_status link 1 +expect_output link "Libs/liba.a links outside Libs." + +new_case draft +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +jq '. + [{tag_name: "libs-2", draft: true, immutable: false}]' "$STATE/releases.json" > "$STATE/r" && mv "$STATE/r" "$STATE/releases.json" +run scripts/publish-libs.sh libb.a +expect_status draft 1 +expect_output draft "a libs release is still a draft: libs-2." +refute_log draft "release create" + +new_case behind +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +jq '. + [{tag_name: "libs-2", draft: false, immutable: true}]' "$STATE/releases.json" > "$STATE/r" && mv "$STATE/r" "$STATE/releases.json" +echo libs-2 >> "$STATE/tags" +run scripts/publish-libs.sh libb.a +expect_status behind 1 +expect_output behind "HEAD pins libs-v1, but libs-2 is newer." +refute_log behind "release create" + +new_case numbering +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +echo libs-5 >> "$STATE/tags" +run scripts/publish-libs.sh libb.a +expect_status numbering 0 +expect_log numbering "release create libs-6 " + +new_case immutable-off +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +rm "$STATE/immutable-enabled" +run scripts/publish-libs.sh libb.a +expect_status immutable-off 1 +expect_output immutable-off "immutable releases are off" +refute_log immutable-off "release create" + +new_case published-mutable +printf 'rebuilt b\n' > "$TREE/Libs/libb.a" +touch "$STATE/publish-mutable" +run scripts/publish-libs.sh libb.a +expect_status published-mutable 1 +expect_output published-mutable "libs-2 was published, but GitHub does not report it immutable" +expect_unchanged_at_head published-mutable + +new_case legacy +serve_legacy_v1 +clear_libs +run scripts/download-libs.sh +expect_status legacy 0 +expect_log legacy "release download libs-v1 --repo TableProApp/TablePro --pattern tablepro-libs-v1.tar.gz" +expect_file legacy Libs/.downloaded libs-v1 +expect_unchanged_at_head legacy + +new_case tampered +serve_legacy_v1 +clear_libs +printf 'tampered\n' > "$SCRATCH/tampered/liba.a" +tar -czf "$STATE/assets/libs-v1/tablepro-libs-v1.tar.gz" -C "$SCRATCH/tampered" liba.a +run scripts/download-libs.sh +expect_status tampered 1 +expect_output tampered "Libs does not match the checksums committed in git." +[ ! -e "$TREE/Libs/.downloaded" ] || fail "tampered: the marker was written" + +new_case bad-pin +echo 'libs-2 extra' > "$TREE/Libs/release" +commit +run scripts/download-libs.sh +expect_status bad-pin 1 +expect_output bad-pin "not a libs- tag" +run scripts/download-libs.sh --frce +expect_status bad-flag 2 + +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES check(s) failed" >&2 + exit 1 +fi +echo "test_libs_release.sh: all checks passed" diff --git a/scripts/download-libs.sh b/scripts/download-libs.sh index b9e11e8e6b..7c8147748b 100755 --- a/scripts/download-libs.sh +++ b/scripts/download-libs.sh @@ -1,109 +1,88 @@ #!/usr/bin/env bash set -euo pipefail -# Download the pre-built static libraries from GitHub Releases. -# Usage: scripts/download-libs.sh [--force] -# -# Libraries are hosted as tarballs on the "libs-v1" release tag rather than in git, to avoid -# Git LFS bandwidth limits. Two archives: the macOS .a files, and the iOS xcframeworks. +# Downloads the libs- release that Libs/release names, then verifies Libs/ against the checksums +# committed at HEAD. The check runs on every run, including one that downloads nothing: these +# libraries are force_loaded into a signed and notarized app. # -# Whatever ends up in Libs/ is verified against the baseline committed in git, on every run, -# including runs that download nothing. These libraries are force_loaded into a signed and -# notarized app, so "we already have some files, skip the check" is not a safe shortcut. +# Usage: scripts/download-libs.sh [--force] REPO="TableProApp/TablePro" -LIBS_TAG="libs-v1" -LIBS_ARCHIVE="tablepro-libs-v1.tar.gz" -IOS_ARCHIVE="tablepro-libs-ios-v1.tar.gz" LIBS_DIR="Libs" IOS_DIR="$LIBS_DIR/ios" -MARKER="$LIBS_DIR/.downloaded" -IOS_MARKER="$IOS_DIR/.downloaded" FORCE="${1:-}" +if [[ -n "$FORCE" && "$FORCE" != "--force" ]]; then + echo "Usage: scripts/download-libs.sh [--force]" >&2 + exit 2 +fi WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT -# The baseline comes from git, never from the extracted copy. Each archive bundles its own -# checksums file and extraction overwrites the working one, so verifying against that is -# self-referential: a tampered release ships matching checksums and passes. Captured up front, -# before anything is extracted, for the same reason. -capture_baseline() { - local tracked="$1" out="$2" - git rev-parse --is-inside-work-tree &> /dev/null || return 0 - git cat-file -e "HEAD:$tracked" 2> /dev/null || return 0 - git show "HEAD:$tracked" > "$out" +# The pin and the baselines come from git, read before anything is extracted: a libs-v1 archive +# carries its own checksums file, and checking against that would pass a tampered archive. +tracked() { + if git rev-parse --is-inside-work-tree &> /dev/null; then + git show "HEAD:$1" + else + cat "$1" + fi } -capture_baseline "$LIBS_DIR/checksums.sha256" "$WORK/macos-baseline" -capture_baseline "$IOS_DIR/checksums.sha256" "$WORK/ios-baseline" +LIBS_TAG="$(tracked "$LIBS_DIR/release")" +if [[ ! "$LIBS_TAG" =~ ^libs-v?[0-9]+$ ]]; then + echo "ERROR: $LIBS_DIR/release holds '$LIBS_TAG', not a libs- tag." >&2 + exit 1 +fi +tracked "$LIBS_DIR/checksums.sha256" > "$WORK/macos-baseline" +tracked "$IOS_DIR/checksums.sha256" > "$WORK/ios-baseline" fetch() { - local archive="$1" dest="$2" + local archive="$1" if command -v gh &> /dev/null; then - gh release download "$LIBS_TAG" --repo "$REPO" --pattern "$archive" --dir "$dest" --clobber + gh release download "$LIBS_TAG" --repo "$REPO" --pattern "$archive" --dir "$WORK" --clobber else - curl -fSL -o "$dest/$archive" "https://github.com/$REPO/releases/download/$LIBS_TAG/$archive" + curl -fSL -o "$WORK/$archive" "https://github.com/$REPO/releases/download/$LIBS_TAG/$archive" fi } -# The two baselines use different path conventions, so each names the directory to run from: -# Libs/checksums.sha256 holds "Libs/..." paths and is checked from the repo root, while +# Libs/checksums.sha256 holds "Libs/..." paths and is checked from the repo root; # Libs/ios/checksums.sha256 holds "./..." paths and is checked from inside Libs/ios. verify() { local run_from="$1" dir="$2" baseline="$3" label="$4" - if [[ ! -s "$baseline" ]]; then - echo "WARNING: no trusted $label baseline at HEAD; skipping integrity verification." >&2 - return 0 - fi - echo "Verifying $label against the baseline committed in git..." + echo "Verifying $label against the checksums committed in git..." if (cd "$run_from" && shasum -a 256 -c "$baseline" --quiet 2> /dev/null); then echo " $label OK" return 0 fi echo "ERROR: $dir does not match the checksums committed in git." >&2 - echo " The archive may be corrupt or tampered with." >&2 - echo " If you rebuilt a library on purpose, publish it so the baseline moves with it:" >&2 - echo " scripts/publish-libs.sh .a for the macOS libraries" >&2 - echo " scripts/publish-ios-libs.sh for the iOS xcframeworks" >&2 + echo " Run scripts/download-libs.sh --force first. A library rebuilt on purpose" >&2 + echo " is published with scripts/publish-libs.sh as the next libs- release." >&2 return 1 } -# --- macOS static libraries --- - -if [[ -f "$MARKER" && "$FORCE" != "--force" ]]; then - echo "macOS libraries already present." -elif [[ "$(find "$LIBS_DIR" -maxdepth 1 -name '*.a' 2> /dev/null | wc -l | tr -d ' ')" -gt 0 && "$FORCE" != "--force" ]]; then - echo "Found existing .a files in $LIBS_DIR." -else - echo "Downloading macOS static libraries from $REPO@$LIBS_TAG..." - fetch "$LIBS_ARCHIVE" "$WORK" - mkdir -p "$LIBS_DIR" - tar xzf "$WORK/$LIBS_ARCHIVE" -C "$LIBS_DIR" - touch "$MARKER" - echo "Downloaded $(find "$LIBS_DIR" -maxdepth 1 -name '*.a' | wc -l | tr -d ' ') static libraries." -fi - -verify "." "$LIBS_DIR" "$WORK/macos-baseline" "macOS libraries" - -# --- iOS xcframeworks --- - -if [[ -f "$IOS_MARKER" && "$FORCE" != "--force" ]]; then - echo "iOS xcframeworks already present." -elif [[ "$(find "$IOS_DIR" -maxdepth 1 -name '*.xcframework' 2> /dev/null | wc -l | tr -d ' ')" -gt 0 && "$FORCE" != "--force" ]]; then - echo "Found existing xcframeworks in $IOS_DIR." -else - echo "Downloading iOS xcframeworks..." - fetch "$IOS_ARCHIVE" "$WORK" - mkdir -p "$IOS_DIR" - tar xzf "$WORK/$IOS_ARCHIVE" -C "$IOS_DIR" - touch "$IOS_MARKER" - echo "Downloaded $(find "$IOS_DIR" -maxdepth 1 -name '*.xcframework' | wc -l | tr -d ' ') iOS xcframeworks." -fi - -verify "$IOS_DIR" "$IOS_DIR" "$WORK/ios-baseline" "iOS xcframeworks" +# The marker names the release Libs/ came from, so a new pin downloads again. +sync_libs() { + local label="$1" dir="$2" archive="$3" unit="$4" run_from="$5" baseline="$6" + local marker="$dir/.downloaded" + if [[ "$FORCE" != "--force" && "$(cat "$marker" 2> /dev/null)" == "$LIBS_TAG" ]]; then + echo "$label from $LIBS_TAG already present." + verify "$run_from" "$dir" "$baseline" "$label" + return + fi + echo "Downloading $label from $REPO@$LIBS_TAG..." + fetch "$archive" + mkdir -p "$dir" + rm -rf "$marker" "${dir:?}"/*"$unit" + tar xzf "$WORK/$archive" -C "$dir" + verify "$run_from" "$dir" "$baseline" "$label" + echo "$LIBS_TAG" > "$marker" +} -# --- OpenSSL shared dylibs, built from the verified static libraries --- +sync_libs "macOS libraries" "$LIBS_DIR" "tablepro-libs-${LIBS_TAG#libs-}.tar.gz" ".a" \ + "." "$WORK/macos-baseline" +sync_libs "iOS xcframeworks" "$IOS_DIR" "tablepro-libs-ios-${LIBS_TAG#libs-}.tar.gz" ".xcframework" \ + "$IOS_DIR" "$WORK/ios-baseline" if [[ -f "$LIBS_DIR/libcrypto_arm64.a" || -f "$LIBS_DIR/libcrypto.a" ]]; then echo "Creating OpenSSL shared dylibs for local development..." diff --git a/scripts/publish-ios-libs.sh b/scripts/publish-ios-libs.sh deleted file mode 100755 index dff2478957..0000000000 --- a/scripts/publish-ios-libs.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Publishes Libs/ios to the libs-v1 release and regenerates the integrity baseline. -# -# Usage: scripts/publish-ios-libs.sh [--dry-run] -# -# CLAUDE.md used to document this as a bare `tar czf ... && gh release upload`, which published -# 299 MB of xcframeworks that nothing verified and left no baseline behind. download-libs.sh now -# checks Libs/ios against Libs/ios/checksums.sha256 on every run, so the archive and the baseline -# have to move together or every developer and every CI job fails on the next pull. -# -# Run this after rebuilding with scripts/ios/build-*.sh, then commit the refreshed baseline. - -REPO="TableProApp/TablePro" -LIBS_TAG="libs-v1" -IOS_ARCHIVE="tablepro-libs-ios-v1.tar.gz" -IOS_DIR="Libs/ios" -CHECKSUMS="$IOS_DIR/checksums.sha256" -DRY_RUN=0 -[ "${1:-}" = "--dry-run" ] && DRY_RUN=1 - -[ -d "$IOS_DIR" ] || { echo "โŒ $IOS_DIR does not exist." >&2; exit 1; } - -frameworks=() -while IFS= read -r fw; do - frameworks+=("$(basename "$fw")") -done < <(find "$IOS_DIR" -maxdepth 1 -name '*.xcframework' | LC_ALL=C sort) - -if [ "${#frameworks[@]}" -eq 0 ]; then - echo "โŒ No xcframeworks in $IOS_DIR. Build them first with scripts/ios/build-*.sh." >&2 - exit 1 -fi - -echo "๐Ÿ“ฆ ${#frameworks[@]} xcframeworks: ${frameworks[*]}" - -WORK="$(mktemp -d)" -trap 'rm -rf "$WORK"' EXIT - -# LC_ALL=C so the committed file is byte-stable across machines and locales. -generate_baseline() { - (cd "$IOS_DIR" && find . -type f ! -name '.downloaded' ! -name 'checksums.sha256' -print0 | - LC_ALL=C sort -z | xargs -0 shasum -a 256) -} - -generate_baseline > "$WORK/new-baseline" -echo "๐Ÿ”ข $(wc -l < "$WORK/new-baseline" | tr -d ' ') files hashed" - -# Report what moved, so an accidental rebuild of everything is visible before it is published. -if git cat-file -e "HEAD:$CHECKSUMS" 2> /dev/null; then - git show "HEAD:$CHECKSUMS" > "$WORK/old-baseline" - added=$(comm -13 <(awk '{print $2}' "$WORK/old-baseline" | LC_ALL=C sort) \ - <(awk '{print $2}' "$WORK/new-baseline" | LC_ALL=C sort) | wc -l | tr -d ' ') - removed=$(comm -23 <(awk '{print $2}' "$WORK/old-baseline" | LC_ALL=C sort) \ - <(awk '{print $2}' "$WORK/new-baseline" | LC_ALL=C sort) | wc -l | tr -d ' ') - changed=$(comm -13 <(LC_ALL=C sort "$WORK/old-baseline") <(LC_ALL=C sort "$WORK/new-baseline") | wc -l | tr -d ' ') - changed=$((changed - added)) - echo " vs HEAD: $changed changed, $added added, $removed removed" - if [ "$changed" -eq 0 ] && [ "$added" -eq 0 ] && [ "$removed" -eq 0 ]; then - echo "โŒ Nothing to publish: $IOS_DIR is identical to the baseline at HEAD." >&2 - exit 1 - fi -else - echo " no baseline at HEAD yet; this publish establishes it" -fi - -if [ "$DRY_RUN" -eq 1 ]; then - echo "๐Ÿงช --dry-run: not writing $CHECKSUMS and not uploading." - exit 0 -fi - -# libs-v1 predates release immutability, so it stays mutable. Stop before the baseline moves if not. -immutable="$(gh api "repos/$REPO/releases/tags/$LIBS_TAG" --jq .immutable)" -if [ "$immutable" != "false" ]; then - echo "error: $REPO@$LIBS_TAG is an immutable release, so --clobber cannot replace its archive." >&2 - echo " Publish to a new release tag and point scripts/download-libs.sh at it." >&2 - exit 1 -fi - -cp "$WORK/new-baseline" "$CHECKSUMS" -echo "๐Ÿ“ Wrote $CHECKSUMS" - -# An allowlist, so the archive holds exactly what the baseline covers and no stray markers. -tar czf "$WORK/$IOS_ARCHIVE" -C "$IOS_DIR" "${frameworks[@]}" "$(basename "$CHECKSUMS")" -echo "๐Ÿ“ฆ $IOS_ARCHIVE is $(du -h "$WORK/$IOS_ARCHIVE" | cut -f1)" - -echo "โ˜๏ธ Uploading to $REPO@$LIBS_TAG..." -gh release upload "$LIBS_TAG" "$WORK/$IOS_ARCHIVE" --clobber --repo "$REPO" - -echo "" -echo "๐ŸŽ‰ Published. Now commit the baseline so download-libs.sh accepts it:" -echo " git add $CHECKSUMS && git commit -m 'build: update iOS xcframework checksums'" diff --git a/scripts/publish-libs.sh b/scripts/publish-libs.sh index c4538a71c5..18b46541bb 100755 --- a/scripts/publish-libs.sh +++ b/scripts/publish-libs.sh @@ -1,163 +1,191 @@ #!/usr/bin/env bash set -euo pipefail -# Publish updated static libraries to the libs-v1 GitHub Release. +# Publishes Libs/*.a and Libs/ios/*.xcframework as the next immutable libs- release, then +# writes Libs/release and both checksum files for you to commit together. # -# Guards against publishing from a stale checkout: every library you did -# NOT name must still match the checksums committed at HEAD. Regenerating -# checksums.sha256 from a stale Libs/ has silently reverted previously -# published libraries before (#1401 reverted the OpenSSL libmongoc from -# #1251 and the DuckDB 1.5.2 bump from #1106). +# Usage: scripts/publish-libs.sh [--dry-run] ... # -# Usage: -# scripts/publish-libs.sh [ ...] -# -# Example (after rebuilding libmongoc/libbson): -# scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a \ -# libmongoc_universal.a libmongoc.a libbson_arm64.a libbson_x86_64.a \ -# libbson_universal.a libbson.a -# -# Steps: -# 1. Verify all unnamed libs against git HEAD checksums -# 2. Regenerate Libs/checksums.sha256 -# 3. Create and upload tablepro-libs-v1.tar.gz (--clobber) -# 4. Remind you to commit checksums.sha256 +# Name every library you rebuilt, added or removed. Every other one must match the checksums +# committed at HEAD, so a stale Libs/ cannot revert a library someone else published. REPO="TableProApp/TablePro" -LIBS_TAG="libs-v1" -LIBS_ARCHIVE="tablepro-libs-v1.tar.gz" -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -CHECKSUMS_FILE="Libs/checksums.sha256" - -cd "$PROJECT_DIR" +MAC_DIR="Libs" +IOS_DIR="Libs/ios" +MAC_SUMS="$MAC_DIR/checksums.sha256" +IOS_SUMS="$IOS_DIR/checksums.sha256" +PIN="Libs/release" + +cd "$(dirname "$0")/.." + +usage() { + echo "Usage: scripts/publish-libs.sh [--dry-run] ..." >&2 + echo "Name every library you rebuilt; every other one is verified against HEAD." >&2 + exit 2 +} -if [ "$#" -eq 0 ]; then - echo "Usage: $0 [ ...]" - echo "Name every library you rebuilt; all others are verified against HEAD." +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do + echo " $line" >&2 + done exit 1 -fi +} -declare -a UPDATED=() +DRY_RUN=0 +NAMED=() for arg in "$@"; do - UPDATED+=("$(basename "$arg")") + case "$arg" in + --dry-run) DRY_RUN=1 ;; + -*) usage ;; + *.a | *.xcframework) NAMED+=("$(basename "$arg")") ;; + *) usage ;; + esac done - -is_updated() { - local name=$1 - for u in "${UPDATED[@]}"; do - [ "$u" = "$name" ] && return 0 - done - return 1 -} - -for name in "${UPDATED[@]}"; do - if [ ! -f "Libs/$name" ]; then - echo "โŒ Updated library Libs/$name does not exist." - exit 1 +[ "${#NAMED[@]}" -gt 0 ] || usage + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +PINNED="$(git show "HEAD:$PIN")" +git show "HEAD:$MAC_SUMS" > "$WORK/mac.old" +git show "HEAD:$IOS_SUMS" > "$WORK/ios.old" + +MAC_MEMBERS=() +for lib in "$MAC_DIR"/*.a; do + [ -e "$lib" ] || [ -L "$lib" ] || continue + # A worktree links each library to another checkout, and tar would archive the link. + if [ -L "$lib" ]; then + case "$(readlink "$lib")" in + */*) fail "$lib links outside $MAC_DIR." "Publish from a checkout that holds the files; scripts/download-libs.sh --force replaces the links." ;; + esac fi + MAC_MEMBERS+=("$(basename "$lib")") done +IOS_MEMBERS=() +for framework in "$IOS_DIR"/*.xcframework; do + [ -e "$framework" ] || [ -L "$framework" ] || continue + IOS_MEMBERS+=("$(basename "$framework")") +done +[ "${#MAC_MEMBERS[@]}" -gt 0 ] || fail "no $MAC_DIR/*.a to publish." +[ "${#IOS_MEMBERS[@]}" -gt 0 ] || fail "no $IOS_DIR/*.xcframework to publish." +if [ -n "$(find "$IOS_DIR" -type l)" ]; then + fail "$IOS_DIR holds symlinks, which its checksums cannot cover." "Publish from a checkout that holds the files; scripts/download-libs.sh --force replaces the links." +fi -committed_checksums=$(git show "HEAD:$CHECKSUMS_FILE") +shasum -a 256 "$MAC_DIR"/*.a > "$WORK/mac.new" +(cd "$IOS_DIR" && find ./*.xcframework -type f -print0 | LC_ALL=C sort -z | xargs -0 shasum -a 256) > "$WORK/ios.new" -echo "๐Ÿ” Verifying unchanged libraries against HEAD checksums..." -stale=() -while read -r expected path; do - [ -n "$path" ] || continue - name=$(basename "$path") - is_updated "$name" && continue - if [ ! -f "$path" ]; then - stale+=("$path (missing locally)") - continue - fi - actual=$(shasum -a 256 "$path" | awk '{print $1}') - if [ "$actual" != "$expected" ]; then - stale+=("$path") - fi -done <<< "$committed_checksums" +# A unit is one .a or one .xcframework: the second path component of a checksum line. +units_of() { + awk '{ split($2, part, "/"); print part[2] }' "$@" | LC_ALL=C sort -u +} -if [ "${#stale[@]}" -gt 0 ]; then - echo "โŒ These libraries differ from the checksums committed at HEAD," - echo " but were not named as updated:" - for s in "${stale[@]}"; do - echo " - $s" - done - echo "" - echo "Your Libs/ checkout is stale or carries unintended changes." - echo "Run scripts/download-libs.sh --force, re-apply only the libraries" - echo "you rebuilt, then retry. If a listed library was rebuilt on" - echo "purpose, pass it as an argument." - exit 1 +cat "$WORK/mac.old" "$WORK/ios.old" | LC_ALL=C sort > "$WORK/old" +cat "$WORK/mac.new" "$WORK/ios.new" | LC_ALL=C sort > "$WORK/new" +units_of "$WORK/old" > "$WORK/old.units" +units_of "$WORK/new" > "$WORK/new.units" +LC_ALL=C comm -3 "$WORK/old" "$WORK/new" | tr -d '\t' | units_of > "$WORK/changed" +printf '%s\n' "${NAMED[@]}" | LC_ALL=C sort -u > "$WORK/named" +LC_ALL=C sort -u "$WORK/old.units" "$WORK/new.units" | LC_ALL=C comm -13 - "$WORK/named" > "$WORK/unknown" +LC_ALL=C comm -23 "$WORK/changed" "$WORK/named" > "$WORK/unnamed" + +if [ -s "$WORK/unknown" ]; then + fail "neither Libs/ nor HEAD has $(paste -sd ' ' "$WORK/unknown")." fi -new_libs=() -for lib in Libs/*.a; do - name=$(basename "$lib") - known=$(awk -v p="Libs/$name" '$2 == p {print $1}' <<< "$committed_checksums") - if [ -z "$known" ] && ! is_updated "$name"; then - new_libs+=("$lib") +stale=() +while IFS= read -r unit; do + if ! grep -qxF "$unit" "$WORK/new.units"; then + stale+=("$unit (missing locally)") + elif ! grep -qxF "$unit" "$WORK/old.units"; then + stale+=("$unit (not at HEAD)") + else + stale+=("$unit (differs from HEAD)") fi -done -if [ "${#new_libs[@]}" -gt 0 ]; then - echo "โŒ These libraries are not in the committed checksums and were not" - echo " named as updated:" - for n in "${new_libs[@]}"; do - echo " - $n" - done - echo "Name them explicitly if they are meant to be published." - exit 1 +done < "$WORK/unnamed" +if [ "${#stale[@]}" -gt 0 ]; then + fail "these differ from the checksums committed at HEAD but were not named:" "${stale[@]}" \ + "Run scripts/download-libs.sh --force, re-apply only what you rebuilt, then retry." \ + "Name a library here if it changed on purpose." fi -unchanged=0 -for name in "${UPDATED[@]}"; do - expected=$(awk -v p="Libs/$name" '$2 == p {print $1}' <<< "$committed_checksums") - [ -n "$expected" ] || continue - actual=$(shasum -a 256 "Libs/$name" | awk '{print $1}') - if [ "$actual" = "$expected" ]; then - echo " โ„น๏ธ Libs/$name is identical to the version at HEAD" - unchanged=$((unchanged + 1)) - fi -done -if [ "$unchanged" -eq "${#UPDATED[@]}" ]; then - echo "โŒ Nothing to publish: every named library matches HEAD." - exit 1 -fi +REBUILT="$(LC_ALL=C comm -12 "$WORK/changed" "$WORK/named")" +identical="$(LC_ALL=C comm -23 "$WORK/named" "$WORK/changed" | paste -sd ' ' -)" +[ -z "$identical" ] || echo "note: identical to HEAD: $identical" -echo "โœ… All unchanged libraries match HEAD" +[ "$(gh api "repos/$REPO/immutable-releases" --jq .enabled)" = "true" ] \ + || fail "immutable releases are off for $REPO." "Turn them on in the repository settings, then retry." -# libs-v1 predates release immutability, so it stays mutable. Stop before the checksums move if not. -immutable="$(gh api "repos/$REPO/releases/tags/$LIBS_TAG" --jq .immutable)" -if [ "$immutable" != "false" ]; then - echo "error: $REPO@$LIBS_TAG is an immutable release, so --clobber cannot replace its archive." >&2 - echo " Publish to a new release tag and point scripts/download-libs.sh at it." >&2 - exit 1 +gh api "repos/$REPO/releases?per_page=100" --paginate \ + --jq '.[] | select(.tag_name | startswith("libs-")) | [.tag_name, .draft, .immutable] | @tsv' > "$WORK/releases" +gh api "repos/$REPO/git/matching-refs/tags/libs-" --paginate --jq '.[].ref | ltrimstr("refs/tags/")' > "$WORK/tags" + +draft="$(awk -F'\t' '$2 == "true" { print $1 }' "$WORK/releases" | paste -sd ' ' -)" +[ -z "$draft" ] || fail "a libs release is still a draft: $draft." \ + "Delete each with: gh release delete --repo $REPO --yes" + +number_of() { + sed -n 's/^libs-v\{0,1\}\([0-9][0-9]*\)$/\1/p' | sort -n | tail -1 +} +newest_release="$(awk -F'\t' '$2 == "false" { print $1 }' "$WORK/releases" | number_of)" +[ "$(echo "$PINNED" | number_of)" = "$newest_release" ] \ + || fail "HEAD pins $PINNED, but libs-$newest_release is newer." "Merge origin/main first, so this release builds on it." + +if [ -z "$REBUILT" ]; then + # Moving identical libraries is only worth a release number when it leaves a mutable release. + pinned_immutable="$(awk -F'\t' -v tag="$PINNED" '$1 == tag { print $3 }' "$WORK/releases")" + [ "$pinned_immutable" = "false" ] || fail "nothing to publish: every named library matches $PINNED." fi -echo "๐Ÿ“ Regenerating $CHECKSUMS_FILE..." -shasum -a 256 Libs/*.a > "$CHECKSUMS_FILE" +last="$( (cut -f1 "$WORK/releases"; cat "$WORK/tags") | number_of)" +TAG="libs-$((${last:-0} + 1))" +MAC_ARCHIVE="tablepro-libs-${TAG#libs-}.tar.gz" +IOS_ARCHIVE="tablepro-libs-ios-${TAG#libs-}.tar.gz" -# An allowlist of exactly what the checksum guard above covers, not `-C Libs .`. The dot swept in -# Libs/ios and Libs/dylibs, so every download of the macOS archive also carried about 78 MB of iOS -# xcframeworks that nothing here verified, and the marker files with them. -echo "๐Ÿ“ฆ Creating $LIBS_ARCHIVE..." -archive_members=() -for lib in Libs/*.a; do - archive_members+=("$(basename "$lib")") -done -if [ "${#archive_members[@]}" -eq 0 ]; then - echo "โŒ No Libs/*.a to publish." - exit 1 +# Only the files the checksums cover: macOS tar would add AppleDouble entries and xattrs. +COPYFILE_DISABLE=1 tar --no-xattrs -czf "$WORK/$MAC_ARCHIVE" -C "$MAC_DIR" "${MAC_MEMBERS[@]}" +COPYFILE_DISABLE=1 tar --no-xattrs -czf "$WORK/$IOS_ARCHIVE" -C "$IOS_DIR" "${IOS_MEMBERS[@]}" + +if [ -z "$REBUILT" ]; then + NOTES="The libraries of $PINNED, unchanged." +else + NOTES="Rebuilt since $PINNED: $(echo "$REBUILT" | paste -sd ' ' -)." fi -# A private directory rather than a predictable /tmp name: this archive is uploaded publicly, and -# /tmp is world-writable, so a fixed path is something another local user can replace first. -staging="$(mktemp -d)" -trap 'rm -rf "$staging"' EXIT -tar czf "$staging/$LIBS_ARCHIVE" -C Libs "${archive_members[@]}" "$(basename "$CHECKSUMS_FILE")" +echo "$TAG: ${#MAC_MEMBERS[@]} macOS libraries, ${#IOS_MEMBERS[@]} iOS xcframeworks. $NOTES" +for archive in "$MAC_ARCHIVE" "$IOS_ARCHIVE"; do + echo " $archive $(du -h "$WORK/$archive" | cut -f1)" +done + +if [ "$DRY_RUN" -eq 1 ]; then + echo "--dry-run: nothing created or written." + exit 0 +fi -echo "โ˜๏ธ Uploading to $REPO@$LIBS_TAG..." -gh release upload "$LIBS_TAG" "$staging/$LIBS_ARCHIVE" --clobber --repo "$REPO" +# Assets cannot be added once an immutable release is published, so it goes up as a draft first. +gh release create "$TAG" --repo "$REPO" --draft --latest=false --title "$TAG" --notes "$NOTES" \ + "$WORK/$MAC_ARCHIVE" "$WORK/$IOS_ARCHIVE" +gh release edit "$TAG" --repo "$REPO" --draft=false --latest=false -echo "" -echo "๐ŸŽ‰ Published. Now commit the checksum update:" -echo " git add $CHECKSUMS_FILE && git commit -m \"build: update static library checksums\"" +immutable="" +for attempt in 1 2 3; do + immutable="$(gh api "repos/$REPO/releases/tags/$TAG" --jq .immutable || true)" + if [ "$immutable" = "true" ] || [ "$attempt" -eq 3 ]; then + break + fi + sleep 3 +done +[ "$immutable" = "true" ] \ + || fail "$TAG was published, but GitHub does not report it immutable, so nothing was written." \ + "Check with: gh release view $TAG --repo $REPO --json isImmutable" + +cp "$WORK/mac.new" "$MAC_SUMS" +cp "$WORK/ios.new" "$IOS_SUMS" +echo "$TAG" > "$PIN" +echo "$TAG" > "$MAC_DIR/.downloaded" +echo "$TAG" > "$IOS_DIR/.downloaded" + +echo "Published $TAG. Commit the pin and the checksums together:" +echo " git add $PIN $MAC_SUMS $IOS_SUMS && git commit -m 'build(libs): publish $TAG'"