diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62a6bd6..8a043d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -149,6 +149,9 @@ jobs: - name: Package release archive run: ./scripts/package-release-archive.sh "${GITHUB_REF_NAME}" + - name: Package release DMG + run: ./scripts/package-release-dmg.sh "${GITHUB_REF_NAME}" + - name: Validate release archive contents run: | archive="dist/apw-macos-${GITHUB_REF_NAME}.tar.gz" @@ -160,10 +163,23 @@ jobs: - name: Validate universal release binaries run: ./scripts/verify-universal-binaries.sh + - name: Validate release DMG checksum + run: | + dmg="dist/apw-macos-${GITHUB_REF_NAME}.dmg" + test -f "$dmg" + test -f "$dmg.sha256" + ( + cd dist + shasum -a 256 -c "apw-macos-${GITHUB_REF_NAME}.dmg.sha256" + ) + - name: Publish release assets uses: softprops/action-gh-release@v2 with: - files: dist/apw-macos-${{ github.ref_name }}.tar.gz + files: | + dist/apw-macos-${{ github.ref_name }}.tar.gz + dist/apw-macos-${{ github.ref_name }}.dmg + dist/apw-macos-${{ github.ref_name }}.dmg.sha256 publish-homebrew-tap: name: Publish Homebrew tap PR diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 75bb39b..17b9963 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -91,6 +91,43 @@ listed in `docs/bootstrap/onboarding.md` are configured. If those optional secrets are absent, the workflow emits a warning and publishes an unstapled archive rather than failing unrelated release automation. +## Install from a release DMG + +Release DMGs are named `apw-macos-vX.Y.Z.dmg`. Each release also publishes a +matching `apw-macos-vX.Y.Z.dmg.sha256` file for checksum verification. + +Download both files from the GitHub release, then verify the DMG before opening +it: + +```bash +shasum -a 256 -c apw-macos-vX.Y.Z.dmg.sha256 +``` + +Open the DMG and install the app bundle: + +```bash +MOUNT="/Volumes/APW vX.Y.Z" +hdiutil attach apw-macos-vX.Y.Z.dmg +install -m 0755 "$MOUNT/bin/apw" /usr/local/bin/apw +(cd "$MOUNT" && ./bin/apw app install) +cp -R "$MOUNT/APW.app" /Applications/APW.app +hdiutil detach "$MOUNT" +``` + +Keep the DMG mounted until `apw app install` completes; the installer discovers +`APW.app` from the mounted volume before copying it into the per-user runtime +directory. Then run the first-use launch check: + +```bash +apw --version +apw status --json +apw app launch +``` + +Gatekeeper should accept release DMGs built by the release workflow. If macOS +blocks the app, stop the install and verify that the release asset was signed +and notarized before use. + ## Homebrew APW uses a **formula-plus-app-install** Homebrew model for the v2 line. The diff --git a/scripts/ci/run-fast-checks.sh b/scripts/ci/run-fast-checks.sh index 3d84d49..3d6f6a1 100755 --- a/scripts/ci/run-fast-checks.sh +++ b/scripts/ci/run-fast-checks.sh @@ -45,5 +45,6 @@ done < <(find .github/scripts scripts -type f -name '*.sh' -print0) ./scripts/test-verify-universal-binaries.sh ./scripts/test-universal-release-config.sh ./scripts/test-notarize-native-app.sh +./scripts/test-package-release-dmg.sh echo "APW fast checks passed." diff --git a/scripts/package-release-dmg.sh b/scripts/package-release-dmg.sh new file mode 100755 index 0000000..b0f367e --- /dev/null +++ b/scripts/package-release-dmg.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +print_help() { + cat <<'HELP' +Usage: ./scripts/package-release-dmg.sh VERSION_OR_TAG + +Create dist/apw-macos-.dmg and a matching .sha256 file. + +The DMG contains: + - APW.app/ + - bin/apw + - Applications -> /Applications + +Prerequisites: + - cargo release binary at rust/target/release/apw + - native app bundle at native-app/dist/APW.app + - hdiutil on macOS +HELP +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + print_help + exit 0 +fi + +if [[ $# -ne 1 || -z "${1:-}" ]]; then + echo "VERSION_OR_TAG is required." >&2 + print_help >&2 + exit 1 +fi + +ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION_OR_TAG="$1" +BIN_PATH="$ROOT_DIR/rust/target/release/apw" +APP_DIR="$ROOT_DIR/native-app/dist/APW.app" +DIST_DIR="$ROOT_DIR/dist" +STAGING_DIR="$DIST_DIR/dmg-staging" +DMG_PATH="$DIST_DIR/apw-macos-${VERSION_OR_TAG}.dmg" +CHECKSUM_PATH="$DMG_PATH.sha256" +MOUNT_DIR="$DIST_DIR/dmg-mount" +HDIUTIL_BIN="${HDIUTIL_BIN:-hdiutil}" + +if ! command -v "$HDIUTIL_BIN" >/dev/null 2>&1; then + echo "hdiutil not found. DMG packaging must run on macOS." >&2 + exit 1 +fi + +if [[ ! -x "$BIN_PATH" ]]; then + echo "Missing executable release binary: $BIN_PATH" >&2 + echo "Build it first with: cargo build --manifest-path rust/Cargo.toml --release" >&2 + exit 1 +fi + +if [[ ! -d "$APP_DIR" ]]; then + echo "Missing native app bundle: $APP_DIR" >&2 + echo "Build it first with: ./scripts/build-native-app.sh" >&2 + exit 1 +fi + +if [[ ! -f "$APP_DIR/Contents/Info.plist" || ! -x "$APP_DIR/Contents/MacOS/APW" ]]; then + echo "Native app bundle is incomplete: $APP_DIR" >&2 + exit 1 +fi + +cleanup() { + if mount | grep -q " on $MOUNT_DIR "; then + "$HDIUTIL_BIN" detach "$MOUNT_DIR" >/dev/null 2>&1 || true + fi + rm -rf "$STAGING_DIR" "$MOUNT_DIR" +} +trap cleanup EXIT + +rm -rf "$STAGING_DIR" "$MOUNT_DIR" "$DMG_PATH" "$CHECKSUM_PATH" +mkdir -p "$STAGING_DIR/bin" "$DIST_DIR" + +cp "$BIN_PATH" "$STAGING_DIR/bin/apw" +cp -R "$APP_DIR" "$STAGING_DIR/APW.app" +ln -s /Applications "$STAGING_DIR/Applications" + +"$HDIUTIL_BIN" create \ + -volname "APW ${VERSION_OR_TAG}" \ + -srcfolder "$STAGING_DIR" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +if [[ "${APW_SKIP_DMG_MOUNT_SMOKE:-0}" != "1" ]]; then + mkdir -p "$MOUNT_DIR" + "$HDIUTIL_BIN" attach -nobrowse -readonly -mountpoint "$MOUNT_DIR" "$DMG_PATH" >/dev/null + test -x "$MOUNT_DIR/bin/apw" + test -f "$MOUNT_DIR/APW.app/Contents/Info.plist" + test -x "$MOUNT_DIR/APW.app/Contents/MacOS/APW" + "$HDIUTIL_BIN" detach "$MOUNT_DIR" >/dev/null +fi + +( + cd "$DIST_DIR" + shasum -a 256 "$(basename "$DMG_PATH")" > "$(basename "$CHECKSUM_PATH")" +) + +echo "$DMG_PATH" +echo "$CHECKSUM_PATH" diff --git a/scripts/test-package-release-dmg.sh b/scripts/test-package-release-dmg.sh new file mode 100755 index 0000000..800d615 --- /dev/null +++ b/scripts/test-package-release-dmg.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK_DIR="$(mktemp -d)" +BIN_PATH="$ROOT_DIR/rust/target/release/apw" +APP_PATH="$ROOT_DIR/native-app/dist/APW.app" +DMG_PATH="$ROOT_DIR/dist/apw-macos-v9.9.9.dmg" +CHECKSUM_PATH="$DMG_PATH.sha256" +cleanup() { + rm -f "$DMG_PATH" "$CHECKSUM_PATH" + rm -rf "$APP_PATH" + rm -f "$BIN_PATH" + if [[ -f "$WORK_DIR/apw.backup" ]]; then + mkdir -p "$(dirname "$BIN_PATH")" + mv "$WORK_DIR/apw.backup" "$BIN_PATH" + fi + if [[ -d "$WORK_DIR/APW.app.backup" ]]; then + mkdir -p "$(dirname "$APP_PATH")" + mv "$WORK_DIR/APW.app.backup" "$APP_PATH" + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +fake_hdiutil="$WORK_DIR/hdiutil" +fake_log="$WORK_DIR/hdiutil.log" + +cat > "$fake_hdiutil" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> "$FAKE_HDIUTIL_LOG" +if [[ "${1:-}" == "create" ]]; then + srcfolder="" + for ((i = 1; i <= $#; i++)); do + if [[ "${!i}" == "-srcfolder" ]]; then + next=$((i + 1)) + srcfolder="${!next}" + break + fi + done + if [[ -z "$srcfolder" ]]; then + echo "missing -srcfolder" >&2 + exit 1 + fi + test -x "$srcfolder/bin/apw" + test -f "$srcfolder/APW.app/Contents/Info.plist" + test -x "$srcfolder/APW.app/Contents/MacOS/APW" + test -L "$srcfolder/Applications" + [[ "$(readlink "$srcfolder/Applications")" == "/Applications" ]] + output="${@: -1}" + mkdir -p "$(dirname "$output")" + printf 'fake dmg\n' > "$output" +fi +EOF +chmod +x "$fake_hdiutil" + +if [[ -f "$BIN_PATH" ]]; then + mv "$BIN_PATH" "$WORK_DIR/apw.backup" +fi +if [[ -d "$APP_PATH" ]]; then + mv "$APP_PATH" "$WORK_DIR/APW.app.backup" +fi + +mkdir -p \ + "$(dirname "$BIN_PATH")" \ + "$APP_PATH/Contents/MacOS" + +printf '#!/usr/bin/env bash\necho apw\n' > "$BIN_PATH" +chmod +x "$BIN_PATH" +printf '#!/usr/bin/env bash\necho APW\n' > "$APP_PATH/Contents/MacOS/APW" +chmod +x "$APP_PATH/Contents/MacOS/APW" +printf '\n' > "$APP_PATH/Contents/Info.plist" + +rm -f "$DMG_PATH" "$CHECKSUM_PATH" + +FAKE_HDIUTIL_LOG="$fake_log" \ +HDIUTIL_BIN="$fake_hdiutil" \ +APW_SKIP_DMG_MOUNT_SMOKE=1 \ + "$ROOT_DIR/scripts/package-release-dmg.sh" v9.9.9 >"$WORK_DIR/package.out" + +test -f "$DMG_PATH" +test -f "$CHECKSUM_PATH" +grep -q "create" "$fake_log" +grep -Eq "^[0-9a-f]{64} apw-macos-v9.9.9.dmg$" "$CHECKSUM_PATH" + +awk ' + /^## Install from a release DMG/ { inside = 1 } + /^## Homebrew/ { inside = 0 } + inside && /apw app install/ && !install_line { install_line = NR } + inside && /hdiutil detach/ { detach_line = NR } + END { exit !(install_line && detach_line && install_line < detach_line) } +' "$ROOT_DIR/docs/INSTALLATION.md"