Skip to content
Open
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
18 changes: 17 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/run-fast-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
103 changes: 103 additions & 0 deletions scripts/package-release-dmg.sh
Original file line number Diff line number Diff line change
@@ -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-<VERSION_OR_TAG>.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"
93 changes: 93 additions & 0 deletions scripts/test-package-release-dmg.sh
Original file line number Diff line number Diff line change
@@ -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 '<plist version="1.0"><dict></dict></plist>\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"