This is an automated email from the ASF dual-hosted git repository.

jimjag pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/openoffice-devtools.git

commit c7b4dec5c72fae5fbf3edee26918643dbd31615c
Author: Jim Jagielski <[email protected]>
AuthorDate: Thu Sep 24 16:36:48 2026 -0400

    macosx-remote-sign.sh: bundle trunk's signing helpers, sign 4.1.x dmgs
---
 release-scripts/macosx-check-load-commands.sh      |  56 ++++
 release-scripts/macosx-codesign-entitlements.plist |  21 ++
 release-scripts/macosx-codesign.sh                 | 307 +++++++++++++++++++++
 release-scripts/macosx-remote-sign.sh              | 117 ++++++--
 release-scripts/tests/conftest.py                  |  88 +++++-
 release-scripts/tests/test_macosx_remote_sign.py   | 129 ++++++++-
 6 files changed, 699 insertions(+), 19 deletions(-)

diff --git a/release-scripts/macosx-check-load-commands.sh 
b/release-scripts/macosx-check-load-commands.sh
new file mode 100755
index 0000000..5f1f25e
--- /dev/null
+++ b/release-scripts/macosx-check-load-commands.sh
@@ -0,0 +1,56 @@
+#!/bin/bash
+#**************************************************************
+#
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+#
+#**************************************************************
+#
+# Fail when a Mach-O under the given paths still loads from or searches a
+# build-time location: the CPython staging prefix or an unresolved @_______
+# placeholder that macosx-change-install-names.pl should have rewritten.
+#
+#   ./solenv/bin/macosx-check-load-commands.sh <bundle-or-file> ...
+
+set -euo pipefail
+
+[ $# -gt 0 ] || { echo "usage: $(basename "$0") <bundle-or-file> ..." >&2; 
exit 2; }
+
+machos=()
+while IFS= read -r -d '' f && IFS= read -r type; do
+       case "$type" in ": Mach-O"*) machos+=("$f") ;; esac
+done < <(find "$@" -type f -print0 | xargs -0 file --no-pad --print0 -- 
2>/dev/null)
+# Every caller passes a real app bundle or binary that is expected to contain
+# Mach-O content; zero matches means a wrong path or missing build output, not
+# "nothing to check" - fail loudly instead of silently passing.
+if [ ${#machos[@]} -eq 0 ]; then
+       echo "no Mach-O files found under: $*" >&2
+       exit 1
+fi
+
+# A dylib's own ID (LC_ID_DYLIB) is never used to locate it at runtime.
+bad=$(printf '%s\0' "${machos[@]}" | xargs -0 otool -l | awk '
+       /^[^[:space:]].*:$/ { file = $0; next }
+       $1 == "cmd" { cmd = $2; next }
+       cmd != "LC_ID_DYLIB" && ($1 == "name" || $1 == "path") && 
/python-inst|@_______/ {
+               print file " " cmd " " $2
+       }')
+if [ -n "$bad" ]; then
+       echo "unrelocated Mach-O load commands:" >&2
+       echo "$bad" >&2
+       exit 1
+fi
diff --git a/release-scripts/macosx-codesign-entitlements.plist 
b/release-scripts/macosx-codesign-entitlements.plist
new file mode 100644
index 0000000..ca5913f
--- /dev/null
+++ b/release-scripts/macosx-codesign-entitlements.plist
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 
"http://www.apple.com/DTDs/PropertyList-1.0.dtd";>
+<plist version="1.0">
+<dict>
+       <!-- The bundled JVM/Java bridge and Basic runtime generate and run 
code at runtime. -->
+       <key>com.apple.security.cs.allow-jit</key>
+       <true/>
+       <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
+       <true/>
+       <!-- soffice dlopens an external JDK's libjvm.dylib, user-installed UNO 
extensions
+            and Python modules; none of those carry our Team ID. -->
+       <key>com.apple.security.cs.disable-library-validation</key>
+       <true/>
+       <!-- sofficerc / the launcher set DYLD_* to find the bundled URE 
libraries. -->
+       <key>com.apple.security.cs.allow-dyld-environment-variables</key>
+       <true/>
+       <!-- Mail merge, "Send Document as E-Mail" and the installer script 
drive other apps. -->
+       <key>com.apple.security.automation.apple-events</key>
+       <true/>
+</dict>
+</plist>
diff --git a/release-scripts/macosx-codesign.sh 
b/release-scripts/macosx-codesign.sh
new file mode 100755
index 0000000..40c8970
--- /dev/null
+++ b/release-scripts/macosx-codesign.sh
@@ -0,0 +1,307 @@
+#!/bin/bash
+#
+# Code-sign a built Apache OpenOffice .app (or .dmg) on macOS.
+#
+#   ./solenv/bin/macosx-codesign.sh [options] <OpenOffice.app|installer.dmg> 
...
+#
+# Options:
+#   -i, --identity ID   codesign identity; "-" = ad-hoc (default, or
+#                       $MACOSX_CODESIGNING_IDENTITY when set)
+#   -e, --entitlements  entitlements plist (default: 
macosx-codesign-entitlements.plist)
+#   -k, --keychain PATH keychain holding the identity (default: the search 
list)
+#       --hardened      force hardened runtime even for an ad-hoc signature
+#                       (implied by a real identity)
+#       --notarize PROFILE
+#                       after signing, submit to the Apple notary service with
+#                       the "xcrun notarytool store-credentials" keychain
+#                       profile PROFILE and staple the ticket (.dmg, or a .app
+#                       zipped for submission); needs a real identity
+#       --release       fail if the Gatekeeper (spctl) assessment rejects the
+#                       result, instead of only reporting it; ignored for an
+#                       ad-hoc identity, which spctl always rejects regardless
+#                       of notarization
+#       --verify        only report the current signing state, change nothing
+#
+# The linker already ad-hoc-signs each Mach-O it produces, which is why the
+# binaries load at all on arm64. What it does not do is seal the *bundles*:
+# without a _CodeSignature/CodeResources the .app has no identity, Gatekeeper
+# rejects it once it carries a quarantine flag, and it can never be notarized.
+#
+# This signs inside-out -- every Mach-O, then the nested bundles, then the app.
+# It relies on the installation being laid out so that Contents/MacOS holds
+# only the soffice launcher (see scp2/source/ooo/common_brand.scp); codesign
+# refuses to seal a bundle with anything else in there.
+
+set -euo pipefail
+
+SRCDIR=$(cd "$(dirname "$0")" && pwd)
+IDENTITY="${MACOSX_CODESIGNING_IDENTITY:--}"
+ENTITLEMENTS="$SRCDIR/macosx-codesign-entitlements.plist"
+KEYCHAIN="${MACOSX_CODESIGNING_KEYCHAIN:-}"
+NOTARY_PROFILE=""
+HARDENED=no
+RELEASE=no
+VERIFY_ONLY=no
+TARGETS=()
+
+while [ $# -gt 0 ]; do
+       case "$1" in
+               -i|--identity)
+                       [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; 
exit 2; }
+                       IDENTITY="$2"; shift 2 ;;
+               -e|--entitlements)
+                       [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; 
exit 2; }
+                       ENTITLEMENTS="$2"; shift 2 ;;
+               -k|--keychain)
+                       [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; 
exit 2; }
+                       KEYCHAIN="$2"; shift 2 ;;
+               --hardened)        HARDENED=yes; shift ;;
+               --notarize)
+                       [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; 
exit 2; }
+                       NOTARY_PROFILE="$2"; shift 2 ;;
+               --release)         RELEASE=yes; shift ;;
+               --verify)          VERIFY_ONLY=yes; shift ;;
+               -h|--help)         sed -n '2,33p' "$0"; exit 0 ;;
+               -*)                echo "unknown option: $1" >&2; exit 2 ;;
+               *)                 TARGETS+=("$1"); shift ;;
+       esac
+done
+
+[ ${#TARGETS[@]} -gt 0 ] || { echo "usage: $(basename "$0") [options] 
<app-or-dmg> ..." >&2; exit 2; }
+[ "$IDENTITY" = "-" ] || HARDENED=yes
+if [ -n "$NOTARY_PROFILE" ] && [ "$IDENTITY" = "-" ]; then
+       echo "--notarize needs a Developer ID identity, not an ad-hoc 
signature" >&2
+       exit 2
+fi
+
+sign_one() {
+       local path="$1"; shift
+       local args=(--force --sign "$IDENTITY" --timestamp=none)
+       if [ "$IDENTITY" != "-" ]; then
+               args=(--force --sign "$IDENTITY" --timestamp)
+       fi
+       if [ -n "$KEYCHAIN" ]; then
+               args+=(--keychain "$KEYCHAIN")
+       fi
+       codesign "${args[@]}" "$@" "$path"
+}
+
+sign_executable() {
+       local path="$1"; shift
+       if [ "$HARDENED" = yes ]; then
+               sign_one "$path" --options runtime --entitlements 
"$ENTITLEMENTS" "$@"
+       else
+               sign_one "$path" "$@"
+       fi
+}
+
+sign_disk_image() {
+       local path="$1"
+       local args=(--force --sign "$IDENTITY" --timestamp)
+       if [ -n "$KEYCHAIN" ]; then
+               args+=(--keychain "$KEYCHAIN")
+       fi
+       codesign "${args[@]}" "$path"
+}
+
+# notarytool exits 0 even when the notary service rejects the submission, so
+# a successful staple is the only proof the ticket exists. A .app cannot be
+# submitted as-is; it goes up zipped and the ticket is stapled to the bundle.
+notarize() {
+       local target="$1" upload="$1" zip=""
+       case "$target" in
+               *.dmg) ;;
+               *)
+                       zip=$(mktemp -d)/$(basename "$target").zip
+                       ditto -c -k --keepParent "$target" "$zip"
+                       upload="$zip" ;;
+       esac
+       echo "==> notarizing $target  (profile: $NOTARY_PROFILE)"
+       if ! xcrun notarytool submit --wait --keychain-profile 
"$NOTARY_PROFILE" "$upload"; then
+               echo "notarization submission failed" >&2
+               [ -z "$zip" ] || rm -rf "$(dirname "$zip")"
+               return 1
+       fi
+       [ -z "$zip" ] || rm -rf "$(dirname "$zip")"
+       if ! xcrun stapler staple "$target"; then
+               echo "stapling failed: the submission was probably rejected; 
run 'xcrun notarytool log' for details" >&2
+               return 1
+       fi
+       echo "    stapled notarization ticket to $target"
+       if ! xcrun stapler validate "$target"; then
+               echo "stapler validate: FAILED (staple reported success but 
does not validate)" >&2
+               return 1
+       fi
+       echo "    stapled ticket validates"
+}
+
+# hdiutil create/codesign only ever touch the staged tree before it goes into
+# the image; nothing checks the *finished* image's own structure, or that the
+# application actually mounts and verifies from inside it. See the "older
+# hdiutil makehybrid" note above sign_app() for why that distinction matters.
+verify_dmg_contents() {
+       local dmg="$1" mnt rc=0 app
+       if ! hdiutil verify "$dmg" >/dev/null; then
+               echo "hdiutil verify: FAILED" >&2
+               return 1
+       fi
+       echo "hdiutil verify: OK"
+
+       mnt=$(hdiutil attach -readonly -nobrowse "$dmg" | tail -1 | awk -F'\t' 
'{print $NF}')
+       if [ -z "$mnt" ] || [ ! -d "$mnt" ]; then
+               echo "could not mount $dmg to verify its contents" >&2
+               return 1
+       fi
+       for app in "$mnt"/*.app; do
+               [ -d "$app" ] || continue
+               if ! codesign --verify --deep --strict --verbose=2 "$app"; then
+                       echo "enclosed application verify: FAILED ($app)" >&2
+                       rc=1
+               fi
+       done
+       hdiutil detach "$mnt" -quiet
+       if [ "$rc" -eq 0 ]; then
+               echo "    enclosed application verifies from the mounted image"
+       fi
+       return "$rc"
+}
+
+report() {
+       local target="$1" spctl_rc=0 dv
+       echo "--- $target"
+       dv=$(codesign -dv --verbose=2 "$target" 2>&1) || true
+       printf '%s\n' "$dv" | grep -E 
'Identifier|Format|CodeDirectory|Authority|TeamIdentifier|Sealed' || true
+       if ! codesign --verify --deep --strict --verbose=2 "$target"; then
+               echo "verify: FAILED" >&2
+               return 1
+       fi
+       echo "verify: OK"
+       case "$target" in
+               *.dmg)
+                       verify_dmg_contents "$target" || return 1
+                       spctl --assess --type open --context 
context:primary-signature --verbose=4 "$target" || spctl_rc=$? ;;
+               *)     spctl --assess --type exec --verbose=4 "$target" || 
spctl_rc=$? ;;
+       esac
+       # spctl rejects every ad-hoc signature outright, notarized or not, so 
only
+       # a real signature's rejection is meaningful enough to fail the build 
on.
+       # Detect that from the target's own signature (an "Authority=" line 
means a
+       # real CA-chained identity; ad-hoc has none) rather than the -i/env
+       # default, so this is also correct when --verify checks an
+       # already-signed artifact without re-passing -i.
+       if [ "$spctl_rc" -ne 0 ] && [ "$RELEASE" = yes ] && printf '%s\n' "$dv" 
| grep -q 'Authority='; then
+               echo "spctl: FAILED (fatal under --release)" >&2
+               return 1
+       fi
+}
+
+# codesign rewrites every Mach-O it signs and writes _CodeSignature/ into
+# every bundle it seals; the installer stages files read-only. Open just those
+# for writing rather than the whole tree, so the staged UnixRights survive.
+open_for_signing() {
+       local b="$1" d
+       for d in "$b" "$b/Contents" "$b"/Versions/*/; do
+               if [ -d "$d" ]; then chmod u+w "$d"; fi
+       done
+       find "$b" -maxdepth 3 -name _CodeSignature -exec chmod -R u+w {} + 
2>/dev/null || true
+}
+
+sign_app() {
+       local app="$1"
+       [ -d "$app/Contents" ] && [ -f "$app/Contents/Info.plist" ] || {
+               echo "not an application bundle: $app" >&2
+               return 1
+       }
+       echo "==> signing $app  (identity: $IDENTITY, hardened: $HARDENED)"
+
+       # Quarantine and other xattrs make codesign fail or produce an unstable 
seal.
+       xattr -cr "$app" 2>/dev/null || true
+
+       # A bundle's main executable is signed as part of its bundle, not on 
its own:
+       # codesign silently redirects such a path to the enclosing bundle.
+       local bundles=() main_execs=() b exe
+       while IFS= read -r b; do bundles+=("$b"); done < <(find "$app" \
+               \( -name '*.app' -o -name '*.framework' -o -name '*.bundle' \
+               -o -name '*.mdimporter' -o -name '*.plugin' -o -name 
'*.qlgenerator' \) \
+               | awk '{ print gsub(/\//,"/") "\t" $0 }' | sort -rn | cut -f2-)
+       for b in ${bundles[@]+"${bundles[@]}"}; do
+               exe=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' 
"$b/Contents/Info.plist" 2>/dev/null) || continue
+               main_execs+=("$b/Contents/MacOS/$exe")
+       done
+
+       # Find every Mach-O with one batched file(1) run instead of a process 
per
+       # file (an installation holds ~10k). --print0 emits "path\0: type\n".
+       local machos=() executable_machos=() f type
+       while IFS= read -r -d '' f && IFS= read -r type; do
+               case "$type" in
+                       ": Mach-O"*)
+                               machos+=("$f")
+                               case "$type" in *" executable"*) 
executable_machos+=("$f") ;; esac
+                               ;;
+               esac
+       done < <(find "$app" -type f -print0 | xargs -0 file --no-pad --print0 
-- 2>/dev/null)
+       "$SRCDIR/macosx-check-load-commands.sh" "$app"
+       # codesign rewrites a Mach-O through a temporary file beside it, so the
+       # containing directory has to be writable as well.
+       if [ ${#machos[@]} -gt 0 ]; then
+               local dirs=()
+               for f in "${machos[@]}"; do dirs+=("${f%/*}"); done
+               chmod u+w "${machos[@]}"
+               printf '%s\n' "${dirs[@]}" | sort -u | tr '\n' '\0' | xargs -0 
chmod u+w
+       fi
+       for b in ${bundles[@]+"${bundles[@]}"}; do open_for_signing "$b"; done
+
+       # 1. every Mach-O object, deepest path first. Process entitlements 
belong
+       # on executables; dylibs and plug-ins inherit the host process's policy.
+       local count=0
+       if [ ${#machos[@]} -gt 0 ]; then
+               while IFS= read -r f; do
+                       case " ${main_execs[*]-} " in *" $f "*) continue ;; esac
+                       case " ${executable_machos[*]-} " in
+                               *" $f "*) sign_executable "$f" ;;
+                               *)         sign_one "$f" ;;
+                       esac
+                       count=$((count + 1))
+               done < <(printf '%s\n' "${machos[@]}" | awk '{ print 
gsub(/\//,"/") "\t" $0 }' | sort -rn | cut -f2-)
+       fi
+       echo "    signed $count Mach-O objects"
+
+       # 2. nested bundles, deepest first, so each seal covers already-signed 
contents
+       for b in ${bundles[@]+"${bundles[@]}"}; do
+               [ "$b" = "$app" ] && continue
+               case "$b" in
+                       *.app) sign_executable "$b" ;;
+                       *)     sign_one "$b" ;;
+               esac
+               echo "    sealed nested bundle: ${b#"$app"/}"
+       done
+
+       # 3. the app bundle itself
+       sign_executable "$app"
+       echo "    sealed $app"
+}
+
+for target in "${TARGETS[@]}"; do
+       [ -e "$target" ] || { echo "no such path: $target" >&2; exit 1; }
+       case "$target" in
+               *.dmg)
+                       if [ "$VERIFY_ONLY" = yes ]; then report "$target"; 
continue; fi
+                       # A .dmg is signed as a whole; the .app inside must 
already be signed.
+                       if [ "$IDENTITY" = "-" ]; then
+                               echo "refusing to ad-hoc sign a .dmg 
(pointless); pass -i <Developer ID>" >&2
+                               exit 1
+                       fi
+                       sign_disk_image "$target"
+                       [ -z "$NOTARY_PROFILE" ] || notarize "$target"
+                       report "$target"
+                       ;;
+               *)
+                       if [ "$VERIFY_ONLY" = yes ]; then report "$target"; 
continue; fi
+                       sign_app "$target"
+                       [ -z "$NOTARY_PROFILE" ] || notarize "$target"
+                       # spctl rejects a Developer ID app that isn't notarized 
yet, so this
+                       # must run after notarize, not inside sign_app() - see 
the .dmg
+                       # branch above, which already had this order right.
+                       report "$target"
+                       ;;
+       esac
+done
diff --git a/release-scripts/macosx-remote-sign.sh 
b/release-scripts/macosx-remote-sign.sh
index 6d9ac20..1f92b22 100755
--- a/release-scripts/macosx-remote-sign.sh
+++ b/release-scripts/macosx-remote-sign.sh
@@ -15,12 +15,12 @@
 # --with-macosx-codesigning-identity, which already produces an ordinary
 # unsigned .dmg with no build-side changes, and hands it to this script.
 #
-# Deploy this script to the signing host alongside copies of three files from
-# openoffice/main/solenv/bin/: macosx-codesign.sh,
-# macosx-check-load-commands.sh, macosx-codesign-entitlements.plist. All the
-# actual signing/notarizing/verifying is done by macosx-codesign.sh, resolved
-# here as a sibling of wherever this script itself was invoked from - no
-# OpenOffice source checkout is needed on the signing host.
+# Deploy this script together with its siblings macosx-codesign.sh,
+# macosx-check-load-commands.sh and macosx-codesign-entitlements.plist, which
+# do the actual signing/notarizing/verifying. They are verbatim copies of
+# openoffice trunk's main/solenv/bin/ files as of c42fc0a9ce; keep them in
+# sync from there rather than editing them here. No OpenOffice source
+# checkout is needed on the signing host.
 #
 # Not to be confused with this directory's hash-sign.sh, which GPG-signs
 # release artifacts for Apache distribution integrity (checksums + .asc
@@ -41,6 +41,10 @@
 #       --sha256 HASH    verify <input.dmg> against this checksum before
 #                        doing anything else (a bare digest or a full
 #                        "shasum -a 256" checksum line both work)
+#       --legacy-layout  first move non-code out of Contents/MacOS and loose
+#                        entries out of Contents/ into Contents/Resources,
+#                        leaving symlinks, and drop dangling symlinks: 4.1.x
+#                        bundles cannot be sealed otherwise
 #   -h, --help
 #
 # Known limitation: the dmg is rebuilt with a plain "hdiutil create", without
@@ -61,6 +65,7 @@ KEYCHAIN=""
 ENTITLEMENTS=""
 NOTARY_PROFILE=""
 RELEASE=no
+LEGACY_LAYOUT=no
 NON_RELEASE=no
 EXPECT_SHA256=""
 SRC_DMG=""
@@ -82,10 +87,11 @@ while [ $# -gt 0 ]; do
                        NOTARY_PROFILE="$2"; shift 2 ;;
                --release)         RELEASE=yes; shift ;;
                --non-release)     NON_RELEASE=yes; shift ;;
+               --legacy-layout)   LEGACY_LAYOUT=yes; shift ;;
                --sha256)
                        [ $# -ge 2 ] || { echo "$1 requires an argument" >&2; 
exit 2; }
                        EXPECT_SHA256="$2"; shift 2 ;;
-               -h|--help)         sed -n '2,52p' "$0"; exit 0 ;;
+               -h|--help)         sed -n '2,56p' "$0"; exit 0 ;;
                -*)                echo "unknown option: $1" >&2; exit 2 ;;
                *)
                        if [ -z "$SRC_DMG" ]; then SRC_DMG="$1"
@@ -131,14 +137,14 @@ fi
 
 MOUNT_POINT=""
 STAGING_DIR=""
-TEMP_DMG=""
+TEMP_DIR=""
 cleanup() {
        if [ -n "$MOUNT_POINT" ]; then
                hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || true
                rmdir "$MOUNT_POINT" 2>/dev/null || true
        fi
        [ -z "$STAGING_DIR" ] || rm -rf "$STAGING_DIR"
-       [ -z "$TEMP_DMG" ] || rm -f "$TEMP_DMG"
+       [ -z "$TEMP_DIR" ] || rm -rf "$TEMP_DIR"
 }
 trap cleanup EXIT
 
@@ -170,6 +176,56 @@ require_developer_id_signature() {
        }
 }
 
+is_macho() {
+       [ -f "$1" ] && [ ! -L "$1" ] && file -b "$1" | grep '^Mach-O' >/dev/null
+}
+
+# NUL-separated: what codesign refuses to seal in Contents/MacOS, i.e. every
+# entry other than Mach-O files, symlinks and the bundle's main executable.
+non_code_in_macos() {
+       local app="$1" exe e
+       [ -d "$app/Contents/MacOS" ] || return 0
+       exe=$(/usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' 
"$app/Contents/Info.plist" 2>/dev/null) || exe=""
+       while IFS= read -r -d '' e; do
+               [ ! -L "$e" ] && [ "${e##*/}" != "$exe" ] && ! is_macho "$e" || 
continue
+               printf '%s\0' "$e"
+       done < <(find "$app/Contents/MacOS" -mindepth 1 -maxdepth 1 -print0)
+}
+
+# Relative symlinks keep every path the 4.1.x runtime uses resolving to the
+# same file, while codesign only sees code in Contents/MacOS.
+relayout_legacy_app() {
+       local app="$1" contents="$1/Contents" e n
+       /usr/libexec/PlistBuddy -c 'Print CFBundleExecutable' 
"$contents/Info.plist" >/dev/null || {
+               echo "no CFBundleExecutable in $contents/Info.plist" >&2
+               return 1
+       }
+       # codesign --strict rejects them, and they resolve to nothing anyway.
+       find "$app" -type l ! -exec test -e {} \; -print -delete | sed 's/^/    
removed dangling symlink: /'
+       chmod u+w "$contents" "$contents/MacOS" "$contents/Resources"
+       mkdir -p "$contents/Resources/ooo-program" 
"$contents/Resources/ooo-contents"
+       while IFS= read -r -d '' e; do
+               n="${e##*/}"
+               if [ -d "$e" ] && find "$e" -type f -print0 | xargs -0 file 
--no-pad -- 2>/dev/null | grep ': Mach-O' >/dev/null; then
+                       echo "legacy layout: $e holds code; refusing to move it 
into Resources" >&2
+                       return 1
+               fi
+               [ ! -e "$contents/Resources/ooo-program/$n" ] || { echo "legacy 
layout: $n already in Resources/ooo-program" >&2; return 1; }
+               mv "$e" "$contents/Resources/ooo-program/$n"
+               ln -s "../Resources/ooo-program/$n" "$e"
+       done < <(non_code_in_macos "$app")
+       while IFS= read -r -d '' e; do
+               n="${e##*/}"
+               [ ! -L "$e" ] || continue
+               case "$n" in
+                       
Info.plist|PkgInfo|MacOS|Resources|Frameworks|PlugIns|Library|SharedSupport|_CodeSignature)
 continue ;;
+               esac
+               [ ! -e "$contents/Resources/ooo-contents/$n" ] || { echo 
"legacy layout: $n already in Resources/ooo-contents" >&2; return 1; }
+               mv "$e" "$contents/Resources/ooo-contents/$n"
+               ln -s "Resources/ooo-contents/$n" "$e"
+       done < <(find "$contents" -mindepth 1 -maxdepth 1 -print0)
+}
+
 if [ -n "$EXPECT_SHA256" ]; then
        echo "==> verifying checksum of $SRC_DMG"
        # Accept either a bare digest or a full checksum line as written by
@@ -190,15 +246,25 @@ VOLUME_NAME=$(diskutil info "$MOUNT_POINT" | awk '/Volume 
Name/{sub(/^[^:]*: +/,
 [ -n "$VOLUME_NAME" ] || VOLUME_NAME=$(basename "$MOUNT_POINT")
 
 STAGING_DIR=$(mktemp -d)
-echo "==> copying volume contents to $STAGING_DIR"
+# ditto into a not-yet-existing directory so it takes the volume root's mode;
+# the 0700 mktemp directory would otherwise become the rebuilt volume's root.
+VOLUME_DIR="$STAGING_DIR/volume"
+echo "==> copying volume contents to $VOLUME_DIR"
 # Copy everything on the volume, not just the .app: an install DMG normally
 # also carries an Applications symlink, license/readme folders, and a
 # background image, all of which the rebuilt dmg below should keep too.
-ditto "$MOUNT_POINT" "$STAGING_DIR"
+ditto "$MOUNT_POINT" "$VOLUME_DIR"
+# Shipped dmgs put FinderInfo on read-only files, which macosx-codesign.sh's
+# xattr -cr cannot strip and codesign rejects; recopy the app without it.
+for app in "$VOLUME_DIR"/*.app; do
+       [ -d "$app" ] || continue
+       rm -rf "$app"
+       ditto --norsrc --noextattr --noqtn "$MOUNT_POINT/${app##*/}" "$app"
+done
 
 detach_mounted
 
-apps=("$STAGING_DIR"/*.app)
+apps=("$VOLUME_DIR"/*.app)
 [ -d "${apps[0]}" ] || { echo "no .app bundle found in $SRC_DMG" >&2; exit 1; }
 [ ${#apps[@]} -eq 1 ] || {
        echo "expected exactly one .app in $SRC_DMG, found ${#apps[@]}:" >&2
@@ -207,6 +273,20 @@ apps=("$STAGING_DIR"/*.app)
 }
 APP="${apps[0]}"
 
+if [ "$LEGACY_LAYOUT" = yes ]; then
+       echo "==> moving non-code out of $APP/Contents/MacOS"
+       relayout_legacy_app "$APP"
+else
+       non_code=()
+       while IFS= read -r -d '' e; do non_code+=("${e#"$APP"/}"); done < 
<(non_code_in_macos "$APP")
+       [ ${#non_code[@]} -eq 0 ] || {
+               echo "$APP/Contents/MacOS holds ${#non_code[@]} non-code 
entries, which codesign will not seal:" >&2
+               printf '  %s\n' "${non_code[@]:0:10}" >&2
+               echo "this is the layout of 4.1.x and 4.2.0 dev builds; rerun 
with --legacy-layout" >&2
+               exit 1
+       }
+fi
+
 sign_args=(-i "$IDENTITY")
 [ -z "$KEYCHAIN" ] || sign_args+=(-k "$KEYCHAIN")
 [ -z "$ENTITLEMENTS" ] || sign_args+=(-e "$ENTITLEMENTS")
@@ -219,11 +299,13 @@ require_developer_id_signature "$APP"
 
 echo "==> building $OUT_DMG  (volume: $VOLUME_NAME)"
 mkdir -p "$(dirname "$OUT_DMG")"
-# Build and sign the image under a temp name in the output directory (same
+# Build and sign the image in a temp directory beside the output (same
 # filesystem, so the final mv is atomic) and only publish it once the signing
-# succeeds: a failure here must not leave an unsigned image at $OUT_DMG.
-TEMP_DMG="$OUT_DMG.tmp.$$.dmg"
-hdiutil create -srcfolder "$STAGING_DIR" -volname "$VOLUME_NAME" -fs HFS+ 
-format UDZO -ov "$TEMP_DMG"
+# succeeds: a failure here must not leave an unsigned image at $OUT_DMG. It
+# keeps the final file name because codesign takes the dmg's identifier from 
it.
+TEMP_DIR=$(mktemp -d "$(dirname "$OUT_DMG")/.macosx-remote-sign.XXXXXX")
+TEMP_DMG="$TEMP_DIR/$(basename "$OUT_DMG")"
+hdiutil create -srcfolder "$VOLUME_DIR" -volname "$VOLUME_NAME" -fs HFS+ 
-format UDZO -ov "$TEMP_DMG"
 
 echo "==> signing $TEMP_DMG"
 "$CODESIGN" "${sign_args[@]}" "$TEMP_DMG"
@@ -242,6 +324,7 @@ if [ -n "$NOTARY_PROFILE" ]; then
 fi
 
 mv -f "$TEMP_DMG" "$OUT_DMG"
-TEMP_DMG=""
+rmdir "$TEMP_DIR"
+TEMP_DIR=""
 
 echo "==> done: $OUT_DMG"
diff --git a/release-scripts/tests/conftest.py 
b/release-scripts/tests/conftest.py
index fd71d16..55771db 100644
--- a/release-scripts/tests/conftest.py
+++ b/release-scripts/tests/conftest.py
@@ -62,10 +62,27 @@ def make_dmg(src: Path, volname: str, out: Path) -> None:
     assert result.returncode == 0, result.stderr
 
 
-def fixture_dmg(root: Path, name: str, app_count: int = 1, extras: bool = 
True) -> Path:
+FINDER_INFO_HEX = "00" * 10 + "FFFFFFFF" + "00" * 18
+
+
+def set_finder_info(path: Path) -> None:
+    r = _run(["xattr", "-wx", "com.apple.FinderInfo", FINDER_INFO_HEX, 
str(path)])
+    assert r.returncode == 0, r.stderr
+
+
+def fixture_dmg(
+    root: Path,
+    name: str,
+    app_count: int = 1,
+    extras: bool = True,
+    root_mode: int | None = None,
+    finder_info: bool = False,
+) -> Path:
     """Build an install-dmg-like fixture and return the path to its in.dmg."""
     src = root / name / "src"
     src.mkdir(parents=True)
+    if root_mode is not None:
+        src.chmod(root_mode)
     for i in range(1, app_count + 1):
         app = src / f"App{i}.app" / "Contents"
         app.mkdir(parents=True)
@@ -77,6 +94,14 @@ def fixture_dmg(root: Path, name: str, app_count: int = 1, 
extras: bool = True)
         # A nested .DS_Store survives dmg (re)creation; a root one written as a
         # plain file does not (hdiutil create drops it). See REVIEW F7.
         (src / "READMEs" / ".DS_Store").write_text("finder-layout\n")
+    if finder_info:
+        # Shipped AOO dmgs carry FinderInfo on read-only files, which xattr -c
+        # cannot strip and codesign rejects as "detritus".
+        lib = src / "App1.app" / "Contents" / "lib.dylib"
+        lib.write_text("not really a dylib\n")
+        set_finder_info(lib)
+        lib.chmod(0o444)
+        set_finder_info(src / "READMEs")
     out = root / name / "in.dmg"
     make_dmg(src, f"remote-sign-{name}", out)
     return out
@@ -208,3 +233,64 @@ def volume_name(dmg: Path) -> str:
             if line.strip().startswith("Volume Name:"):
                 return line.split(":", 1)[1].strip()
     raise AssertionError(f"no volume name for {dmg}")
+
+
+def xattrs(path: Path) -> list[str]:
+    r = _run(["xattr", str(path)])
+    assert r.returncode == 0, r.stderr
+    return r.stdout.split()
+
+
+INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 
"http://www.apple.com/DTDs/PropertyList-1.0.dtd";>
+<plist 
version="1.0"><dict><key>CFBundleExecutable</key><string>soffice</string></dict></plist>
+"""
+
+
+def legacy_fixture_dmg(root: Path, name: str, code_in_subdir: bool = False) -> 
Path:
+    """An install dmg whose .app has the 4.1.x layout: data files and folders
+    in Contents/MacOS and loose files directly in Contents/."""
+    src = root / name / "src"
+    contents = src / "OpenOffice.app" / "Contents"
+    macos = contents / "MacOS"
+    macos.mkdir(parents=True)
+    (contents / "Info.plist").write_text(INFO_PLIST)
+    (contents / "NOTICE").write_text("notice\n")
+    (contents / "share").mkdir()
+    (contents / "share" / "x.xcd").write_text("share\n")
+    (contents / "Library").mkdir()
+    (contents / "Resources").mkdir()
+    (contents / "program").symlink_to("MacOS")
+    # The launcher is the bundle's main executable; stays put whatever it is.
+    (macos / "soffice").write_text("#!/bin/sh\n")
+    shutil.copy("/usr/bin/true", macos / "libcode.dylib")
+    (macos / "unorc").write_text("unorc\n")
+    (macos / "unorc").chmod(0o444)
+    (macos / "startup.sh").write_text("#!/bin/sh\n")
+    (macos / "regcomp").symlink_to("startup.sh")
+    (macos / "urelibs").symlink_to("../basis-link/ure-link/lib")
+    (macos / "addin").mkdir()
+    (macos / "addin" / "a.rdb").write_text("addin\n")
+    if code_in_subdir:
+        shutil.copy("/usr/bin/true", macos / "addin" / "helper")
+    (src / "Applications").symlink_to("/Applications")
+    out = root / name / "in.dmg"
+    make_dmg(src, f"remote-sign-{name}", out)
+    return out
+
+
+def modern_fixture_dmg(root: Path, name: str) -> Path:
+    """An install dmg whose .app has trunk's layout: only code in 
Contents/MacOS."""
+    src = root / name / "src"
+    contents = src / "OpenOffice.app" / "Contents"
+    macos = contents / "MacOS"
+    macos.mkdir(parents=True)
+    (contents / "Info.plist").write_text(INFO_PLIST)
+    (contents / "Resources").mkdir()
+    (contents / "Resources" / "unorc").write_text("unorc\n")
+    (macos / "soffice").write_text("#!/bin/sh\n")
+    shutil.copy("/usr/bin/true", macos / "libcode.dylib")
+    (macos / "libcode.1.dylib").symlink_to("libcode.dylib")
+    out = root / name / "in.dmg"
+    make_dmg(src, f"remote-sign-{name}", out)
+    return out
diff --git a/release-scripts/tests/test_macosx_remote_sign.py 
b/release-scripts/tests/test_macosx_remote_sign.py
index d8ad1f3..b210fca 100644
--- a/release-scripts/tests/test_macosx_remote_sign.py
+++ b/release-scripts/tests/test_macosx_remote_sign.py
@@ -23,11 +23,12 @@
 
 import hashlib
 import os
+import stat
 import subprocess
 
 import pytest
 
-from conftest import IDENTITY, Signer, fixture_dmg, mount_point, 
requires_macos, volume_name
+from conftest import IDENTITY, SCRIPT_DIR, Signer, legacy_fixture_dmg, 
modern_fixture_dmg, xattrs, fixture_dmg, mount_point, requires_macos, 
volume_name
 
 pytestmark = requires_macos
 
@@ -233,6 +234,17 @@ def test_normal_run_publishes_output_and_no_temp(signer, 
workdir):
     assert r.returncode == 0, r.stderr
     assert out.exists()
     assert list(signer.path.glob("out.dmg.tmp.*")) == []
+    assert list(signer.path.glob(".macosx-remote-sign.*")) == []
+
+
+def test_dmg_signed_under_final_name(signer, workdir):
+    """codesign takes a dmg's signing identifier from its file name."""
+    src = fixture_dmg(workdir, "args")
+    out = signer.path / "final-name.dmg"
+    assert signer.run_non_release("-i", IDENTITY, src, out).returncode == 0
+    dmg_signs = [l for l in signer.log.read_text().splitlines() if 
l.endswith(".dmg")]
+    assert len(dmg_signs) == 1
+    assert dmg_signs[0].endswith("/final-name.dmg")
 
 
 def test_release_forwards_options_and_validates_enclosed_staple(signer, 
workdir):
@@ -291,6 +303,7 @@ def 
test_non_developer_id_signature_is_not_published(signer, workdir, env):
     assert "not signed with a Developer ID Application certificate" in r.stderr
     assert not out.exists()
     assert list(signer.path.glob("wrong-identity.dmg.tmp.*")) == []
+    assert list(signer.path.glob(".macosx-remote-sign.*")) == []
 
 
 def test_enclosed_staple_failure_is_not_published(signer, workdir):
@@ -309,6 +322,7 @@ def test_enclosed_staple_failure_is_not_published(signer, 
workdir):
     assert r.returncode != 0
     assert not out.exists()
     assert list(signer.path.glob("bad-staple.dmg.tmp.*")) == []
+    assert list(signer.path.glob(".macosx-remote-sign.*")) == []
 
 
 def test_whole_volume_survives(signer, workdir):
@@ -322,6 +336,32 @@ def test_whole_volume_survives(signer, workdir):
         assert (mp / "App1.app").is_dir()
 
 
[email protected]("mode", [0o775, 0o755])
+def test_volume_root_mode_preserved(signer, workdir, mode):
+    src = fixture_dmg(workdir, "args", root_mode=mode)
+    out = signer.path / "out.dmg"
+    assert signer.run_non_release("-i", IDENTITY, src, out).returncode == 0
+    with mount_point(src) as mp:
+        assert stat.S_IMODE(mp.stat().st_mode) == mode
+    with mount_point(out) as mp:
+        assert stat.S_IMODE(mp.stat().st_mode) == mode
+
+
+def test_app_finder_info_stripped_rest_kept(signer, workdir):
+    src = fixture_dmg(workdir, "args", finder_info=True)
+    out = signer.path / "out.dmg"
+    with mount_point(src) as mp:
+        lib = mp / "App1.app" / "Contents" / "lib.dylib"
+        assert "com.apple.FinderInfo" in xattrs(lib)
+    r = signer.run_non_release("-i", IDENTITY, src, out)
+    assert r.returncode == 0, r.stderr
+    with mount_point(out) as mp:
+        lib = mp / "App1.app" / "Contents" / "lib.dylib"
+        assert xattrs(lib) == []
+        assert stat.S_IMODE(lib.stat().st_mode) == 0o444
+        assert "com.apple.FinderInfo" in xattrs(mp / "READMEs")
+
+
 def test_original_volume_name_reused(signer, workdir):
     src = fixture_dmg(workdir, "args")
     out = signer.path / "out.dmg"
@@ -362,6 +402,7 @@ def test_dmg_sign_failure_writes_nothing(tmp_path, workdir):
     assert r.returncode != 0
     assert not out.exists()
     assert list(signer.path.glob("final.dmg.tmp.*")) == []
+    assert list(signer.path.glob(".macosx-remote-sign.*")) == []
 
 
 def test_app_sign_failure_writes_nothing(tmp_path, workdir):
@@ -390,3 +431,89 @@ def test_spaces_in_paths(tmp_path, workdir):
     r = signer.run_non_release("-i", IDENTITY, spaced_in, out)
     assert r.returncode == 0, r.stderr
     assert out.exists()
+
+
+# ---------------------------------------------------------- legacy layout
+
+
+def test_legacy_layout_moves_non_code_out_of_macos(signer, workdir):
+    src = legacy_fixture_dmg(workdir, "legacy")
+    out = signer.path / "out.dmg"
+    r = signer.run_non_release("--legacy-layout", "-i", IDENTITY, src, out)
+    assert r.returncode == 0, r.stderr
+    with mount_point(out) as mp:
+        contents = mp / "OpenOffice.app" / "Contents"
+        macos = contents / "MacOS"
+        assert os.readlink(macos / "unorc") == "../Resources/ooo-program/unorc"
+        assert (macos / "unorc").read_text() == "unorc\n"
+        assert stat.S_IMODE((contents / "Resources" / "ooo-program" / 
"unorc").stat().st_mode) == 0o444
+        assert os.readlink(macos / "addin") == "../Resources/ooo-program/addin"
+        assert (macos / "addin" / "a.rdb").read_text() == "addin\n"
+        assert os.readlink(macos / "startup.sh") == 
"../Resources/ooo-program/startup.sh"
+        assert os.readlink(macos / "regcomp") == "startup.sh"
+        assert (macos / "regcomp").is_file()
+        assert not (macos / "urelibs").is_symlink()
+        for kept in ("soffice", "libcode.dylib"):
+            assert (macos / kept).is_file() and not (macos / 
kept).is_symlink(), kept
+        assert os.readlink(contents / "NOTICE") == 
"Resources/ooo-contents/NOTICE"
+        assert (contents / "share" / "x.xcd").read_text() == "share\n"
+        assert os.readlink(contents / "program") == "MacOS"
+        for kept in ("Info.plist", "Library", "Resources"):
+            assert not (contents / kept).is_symlink(), kept
+
+
+def test_legacy_layout_without_flag_stops_before_signing(signer, workdir):
+    src = legacy_fixture_dmg(workdir, "legacy")
+    out = signer.path / "out.dmg"
+    r = signer.run_non_release("-i", IDENTITY, src, out)
+    assert r.returncode == 1
+    assert "--legacy-layout" in r.stderr
+    assert "unorc" in r.stderr and "addin" in r.stderr
+    assert "libcode.dylib" not in r.stderr and "soffice" not in r.stderr
+    assert not out.exists()
+    assert signer.events.read_text() == ""
+
+
+def test_modern_layout_signs_untouched_without_flag(signer, workdir):
+    src = modern_fixture_dmg(workdir, "modern")
+    out = signer.path / "out.dmg"
+    r = signer.run_non_release("-i", IDENTITY, src, out)
+    assert r.returncode == 0, r.stderr
+    with mount_point(out) as mp:
+        contents = mp / "OpenOffice.app" / "Contents"
+        assert not (contents / "Resources" / "ooo-program").exists()
+        assert (contents / "MacOS" / "soffice").is_file()
+        assert os.readlink(contents / "MacOS" / "libcode.1.dylib") == 
"libcode.dylib"
+
+
+def test_legacy_layout_refuses_code_in_moved_folder(signer, workdir):
+    src = legacy_fixture_dmg(workdir, "legacy", code_in_subdir=True)
+    out = signer.path / "out.dmg"
+    r = signer.run_non_release("--legacy-layout", "-i", IDENTITY, src, out)
+    assert r.returncode == 1
+    assert "addin" in r.stderr
+    assert not out.exists()
+    assert "sign-app" not in signer.events.read_text()
+
+
+# ------------------------------------------------------- bundled delegate
+
+
+def test_bundled_delegate_accepts_forwarded_options(workdir):
+    """The real sibling macosx-codesign.sh must parse every option the wrapper
+    forwards; a missing path makes it stop right after option parsing."""
+    delegate = SCRIPT_DIR / "macosx-codesign.sh"
+    missing = workdir / "missing.dmg"
+    r = subprocess.run(
+        [str(delegate), "-i", IDENTITY, "-k", "k", "-e", "e",
+         "--notarize", "p", "--release", str(missing)],
+        capture_output=True, text=True,
+    )
+    assert r.returncode == 1, r.stderr
+    assert f"no such path: {missing}" in r.stderr
+
+
+def test_bundled_delegate_siblings_present():
+    for name in ("macosx-codesign.sh", "macosx-check-load-commands.sh"):
+        assert os.access(SCRIPT_DIR / name, os.X_OK), name
+    assert (SCRIPT_DIR / "macosx-codesign-entitlements.plist").is_file()

Reply via email to