diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -18,7 +18,19 @@ concurrency:
   cancel-in-progress: false
 
 jobs:
+  dmg-test:
+    name: macOS DMG integrity
+    runs-on: macos-15
+    timeout-minutes: 10
+    permissions:
+      contents: read
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+      - name: Test signed fixture and installation copy
+        run: bash packaging/macos/dmg-test.sh
+
   release:
+    needs: dmg-test
     # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
     uses: storytold/craft-common-actions/.github/workflows/release.yml@main
     with:
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (macOS DMG integrity verification updated to current release scripts and the standard macos-15 runner (#387); non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
 
 FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -428,6 +428,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
 | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
 | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
 | 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
+| 2026-10-15 | minor | macOS DMG: replace the HFS hybrid image builder that adds FinderInfo after signing; verify the app in the finished read-only image and a normal installation copy, with notarization checks for signed releases. A separate release-workflow job runs the native ad-hoc regression once before the shared release job, covering signature integrity and cleanup (#387). Numbers unchanged. |
 | 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
 | 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
 | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
diff --git a/docs/releasing.md b/docs/releasing.md
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -135,10 +135,19 @@ embedded font's licence as `OFL-<family>.txt`
 `Info.plist.in`). The `.icns` is generated at packaging time with `iconutil` from the committed
 `assets/app-icon/figcraft-<size>.png` (made by `cargo xtask icon`); it is never committed.
 Signing goes inside-out with the hardened runtime and a timestamp (empty entitlements), then the
-app is notarized and stapled, put on a DMG with an `Applications` link (`hdiutil makehybrid` +
-`convert`), and the DMG is signed, notarized and stapled. The CLI is signed with the same
-identity and notarized as a zip. `packaging/macos/verify.sh` checks the artifacts as shipped
-(strict with secrets, warnings without).
+app is notarized and stapled, put on a DMG with an `Applications` link (`hdiutil create -fs HFS+`,
+with retries for transient device contention), and the DMG is signed, notarized and stapled.
+The CLI is signed with the same identity and notarized as a zip. `packaging/macos/verify.sh` checks the artifacts as shipped
+(signature integrity is always required; Developer ID and notarization checks require secrets).
+Both scripts mount the finished DMG read-only, verify `FigCraft.app` with `codesign --verify
+--deep --strict`, copy it out with `ditto`, and repeat the check. Notarized builds also validate
+the app's stapled ticket and Gatekeeper acceptance in both locations. The image is detached on
+success or failure; neither check repairs metadata. `makehybrid -hfs` is avoided because it adds
+FinderInfo to resources after signing (#387). `bash packaging/macos/dmg-test.sh` exercises this
+round trip with an ad-hoc signed fixture, rejects FinderInfo in either the image or installation
+copy, a missing app and a top-level app symlink, and checks mount cleanup. The release workflow runs it once
+in a separate `macOS DMG integrity` job, without signing secrets, before the shared release job.
+Local packaging runs only the real artifact checks, without repeating the fixture test.
 
 Locally (no certificates: ad-hoc signature, no notarization):
 
diff --git a/packaging/macos/dmg-test.sh b/packaging/macos/dmg-test.sh
new file mode 100755
--- /dev/null
+++ b/packaging/macos/dmg-test.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Native signature round-trip regression for #387. No build, certificates or notarization needed.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TEST_WORK="$(mktemp -d "${TMPDIR:-/tmp}/figcraft-dmg-test.XXXXXX")"
+trap 'rm -rf "$TEST_WORK"' EXIT
+export DIST="$TEST_WORK/dist"
+# shellcheck source=../env.sh
+. "$HERE/../env.sh"
+# shellcheck source=dmg.sh
+. "$HERE/dmg.sh"
+mkdir -p "$TEST_WORK/stage/FigCraft.app/Contents/MacOS" "$TEST_WORK/stage/FigCraft.app/Contents/Resources" "$TEST_WORK/checks"
+APP="$TEST_WORK/stage/FigCraft.app"
+cp /usr/bin/true "$APP/Contents/MacOS/FigCraft"
+sed -e 's/@VERSION@/0.0.0/g' -e 's/@SHORT_VERSION@/0.0.0/g' -e 's/@BUILD_SHA@/test/g' \
+  "$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
+printf 'Test licence\n' >"$APP/Contents/Resources/License.txt"
+ln -s /Applications "$TEST_WORK/stage/Applications"
+codesign --force --sign - --timestamp=none "$APP/Contents/MacOS/FigCraft"
+codesign --force --sign - --timestamp=none "$APP"
+codesign --verify --deep --strict "$APP"
+
+# Invoke in a fresh shell: testing a function directly in `if` would disable its errexit.
+verify_image() {
+  RUNNER_TEMP="$TEST_WORK/checks" bash -c '
+    set -euo pipefail
+    . "$1/../env.sh"
+    . "$1/dmg.sh"
+    verify_dmg_app "$2" 0
+  ' _ "$HERE" "$1"
+}
+assert_detached() {
+  if [[ -n "$(find "$TEST_WORK/checks" -mindepth 1 -print -quit)" ]]; then
+    echo "error: image check did not detach and remove its temporary directory" >&2
+    exit 1
+  fi
+}
+
+# Both the app in the read-only image and a ditto installation copy must remain valid.
+create_dmg "$TEST_WORK/stage" "$TEST_WORK/good.dmg"
+verify_image "$TEST_WORK/good.dmg"
+assert_detached
+
+# A copy can acquire metadata independently of the mounted app. Keep real ditto/codesign,
+# but inject FinderInfo into the installation copy to prove its own check is mandatory.
+mkdir -p "$TEST_WORK/bin"
+cat >"$TEST_WORK/bin/ditto" <<'DITTO'
+#!/usr/bin/env bash
+set -euo pipefail
+/usr/bin/ditto "$@"
+xattr -wx com.apple.FinderInfo 5445585400000000000000000000000000000000000000000000000000000000 \
+  "$2/Contents/Resources/License.txt"
+DITTO
+chmod +x "$TEST_WORK/bin/ditto"
+if PATH="$TEST_WORK/bin:$PATH" verify_image "$TEST_WORK/good.dmg" >"$TEST_WORK/copy.log" 2>&1; then
+  echo "error: verifier accepted invalid Finder data added to the installation copy" >&2
+  exit 1
+fi
+grep -q 'Disallowed xattr com.apple.FinderInfo.*copied/FigCraft.app' "$TEST_WORK/copy.log"
+assert_detached
+
+# Reproduce the old image builder: it adds FinderInfo to License.txt after signing.
+hdiutil makehybrid -hfs -hfs-volume-name FigCraft -hfs-openfolder "$TEST_WORK/stage" \
+  -o "$TEST_WORK/bad.dmg" "$TEST_WORK/stage"
+if verify_image "$TEST_WORK/bad.dmg" >"$TEST_WORK/rejected.log" 2>&1; then
+  echo "error: verifier accepted an app with invalid attached Finder data" >&2
+  exit 1
+fi
+grep -q 'Disallowed xattr com.apple.FinderInfo.*mounted/FigCraft.app' "$TEST_WORK/rejected.log"
+assert_detached
+
+mkdir -p "$TEST_WORK/empty"
+printf 'No application\n' >"$TEST_WORK/empty/README.txt"
+create_dmg "$TEST_WORK/empty" "$TEST_WORK/missing.dmg"
+if verify_image "$TEST_WORK/missing.dmg" >"$TEST_WORK/missing.log" 2>&1; then
+  echo "error: verifier accepted a DMG without FigCraft.app" >&2
+  exit 1
+fi
+grep -q 'DMG has no FigCraft.app' "$TEST_WORK/missing.log"
+assert_detached
+
+# A top-level link can point outside the image at a valid signed app. Verification
+# must reject the link itself rather than validating and copying its external target.
+mkdir -p "$TEST_WORK/linked"
+ln -s "$APP" "$TEST_WORK/linked/FigCraft.app"
+create_dmg "$TEST_WORK/linked" "$TEST_WORK/linked.dmg"
+if verify_image "$TEST_WORK/linked.dmg" >"$TEST_WORK/linked.log" 2>&1; then
+  echo "error: verifier accepted a symlink instead of an app stored in the image" >&2
+  exit 1
+fi
+grep -q 'DMG has no FigCraft.app' "$TEST_WORK/linked.log"
+assert_detached
+
+echo 'DMG round trip: signed app and installation copy pass; FinderInfo in image/copy, missing app and symlink rejected; mounts cleaned up'
diff --git a/packaging/macos/dmg.sh b/packaging/macos/dmg.sh
new file mode 100644
--- /dev/null
+++ b/packaging/macos/dmg.sh
@@ -0,0 +1,58 @@
+# shellcheck shell=bash
+# Shared by package.sh and verify.sh after packaging/env.sh has been sourced.
+
+create_dmg() {
+  local stage="$1" image="$2"
+  # makehybrid -hfs synthesizes FinderInfo on resources (even plain .txt files), invalidating
+  # strict bundle verification. create preserves the signed app's metadata. It attaches a
+  # temporary device internally: retry transient "Resource busy" failures on release runners.
+  retry hdiutil create -ov -fs HFS+ -volname FigCraft -srcfolder "$stage" \
+    -format UDZO -imagekey zlib-level=9 "$image"
+}
+
+# Check the finished, read-only image and a normal installation copy, without repairing either.
+# Integrity is mandatory for ad-hoc builds too; notarization is checked only when requested.
+verify_dmg_app() (
+  set -euo pipefail
+  image="$1"
+  notarized="${2:-0}"
+  check_work="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/figcraft-dmg-check.XXXXXX")"
+  mounted=0
+  # Invoked by the EXIT trap, including failures under errexit. ShellCheck 0.9 calls the
+  # nested callback unreachable (SC2317); 0.11 calls it unused (SC2329).
+  # shellcheck disable=SC2317,SC2329
+  cleanup_dmg_check() {
+    local check_exit=$?
+    trap - EXIT
+    if [[ "$mounted" = 1 ]]; then
+      if ! retry hdiutil detach "$check_work/mounted"; then
+        echo "error: could not detach $check_work/mounted; leaving its directory intact" >&2
+        exit 1
+      fi
+    fi
+    rm -rf "$check_work"
+    exit "$check_exit"
+  }
+  trap cleanup_dmg_check EXIT
+  mkdir -p "$check_work/mounted" "$check_work/copied"
+  hdiutil attach -readonly -nobrowse -noautoopen -mountpoint "$check_work/mounted" "$image"
+  mounted=1
+
+  check_app() {
+    local app="$1"
+    # Require the app directory itself, not a link to a signed app outside the image.
+    if [[ -L "$app" || ! -d "$app" ]]; then
+      echo "error: DMG has no FigCraft.app: $app" >&2
+      return 1
+    fi
+    codesign --verify --deep --strict --verbose=2 "$app"
+    if [[ "$notarized" = 1 ]]; then
+      retry xcrun stapler validate "$app"
+      retry spctl --assess --type execute -vvv "$app"
+    fi
+  }
+
+  check_app "$check_work/mounted/FigCraft.app"
+  ditto "$check_work/mounted/FigCraft.app" "$check_work/copied/FigCraft.app"
+  check_app "$check_work/copied/FigCraft.app"
+)
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -16,6 +16,8 @@ set -euo pipefail
 # shellcheck source=../env.sh
 . "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
 HERE="$ROOT/packaging/macos"
+# shellcheck source=dmg.sh
+. "$HERE/dmg.sh"
 
 ARCH=universal
 SKIP_BUILD=0
@@ -147,12 +149,7 @@ STAGE="$WORK/dmg"
 mkdir -p "$STAGE"
 ditto "$APP" "$STAGE/FigCraft.app"
 ln -s /Applications "$STAGE/Applications"
-rm -f "$DMG" "$WORK/raw.dmg"
-# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
-# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
-hdiutil makehybrid -hfs -hfs-volume-name "FigCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
-hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
-rm -f "$WORK/raw.dmg"
+create_dmg "$STAGE" "$DMG"
 sign "$DMG"
 codesign --verify --strict --verbose=2 "$DMG"
 if [[ "$NOTARIZE" = 1 ]]; then
@@ -162,6 +159,8 @@ if [[ "$NOTARIZE" = 1 ]]; then
   spctl --assess --type open --context context:primary-signature -vvv "$DMG"
 fi
 
+verify_dmg_app "$DMG" "$NOTARIZE"
+
 # ---- CLI ---------------------------------------------------------------------------------------
 if [[ "$HAS_CLI" = 1 ]]; then
   echo "==> building $CLI_ZIP"
diff --git a/packaging/macos/verify.sh b/packaging/macos/verify.sh
--- a/packaging/macos/verify.sh
+++ b/packaging/macos/verify.sh
@@ -21,6 +21,8 @@
 set -euo pipefail
 # shellcheck source=../env.sh
 . "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
+# shellcheck source=dmg.sh
+. "$ROOT/packaging/macos/dmg.sh"
 
 ARCH=universal
 while [[ $# -gt 0 ]]; do
@@ -135,15 +137,17 @@ if [[ "$STRICT" = 1 ]]; then
     || fail "Gatekeeper rejects the DMG"
 fi
 
+verify_dmg_app "$DMG" "$STRICT"
+
 if [[ "$FAILED" != 0 ]]; then
   echo "error: macOS artifacts failed verification" >&2
   exit 1
 fi
 
 if [[ "$STRICT" = 1 ]]; then
-  result="signed (Developer ID, hardened runtime), notarized; DMG stapled"
+  result="signed (Developer ID, hardened runtime), notarized; DMG stapled; enclosed/copied app verified"
 else
-  result="signature intact; Developer ID and notarization not required (no signing secrets)"
+  result="DMG and enclosed/copied app signatures intact; Developer ID and notarization not required (no signing secrets)"
 fi
 echo "==> verified: $result"
 if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
