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
The following commit(s) were added to refs/heads/main by this push:
new 6431a99 Provide func tests for our DMG signer
6431a99 is described below
commit 6431a9919782f9041c09b0fa89be0e03cdafdd1b
Author: Jim Jagielski <[email protected]>
AuthorDate: Sat Sep 12 15:21:55 2026 -0400
Provide func tests for our DMG signer
---
release-scripts/macosx-remote-sign.sh | 48 ++++-
release-scripts/pyproject.toml | 17 ++
release-scripts/tests/conftest.py | 169 +++++++++++++++
release-scripts/tests/test_macosx_remote_sign.py | 257 +++++++++++++++++++++++
4 files changed, 484 insertions(+), 7 deletions(-)
diff --git a/release-scripts/macosx-remote-sign.sh
b/release-scripts/macosx-remote-sign.sh
index 93fe957..0af7dc4 100755
--- a/release-scripts/macosx-remote-sign.sh
+++ b/release-scripts/macosx-remote-sign.sh
@@ -37,7 +37,8 @@
# extracted app and the rebuilt dmg (see its --help)
# --release passed through: fail if spctl rejects the result
# --sha256 HASH verify <unsigned.dmg> against this checksum before
-# doing anything else
+# doing anything else (a bare digest or a full
+# "shasum -a 256" checksum line both work)
# -h, --help
#
# Known limitation: the dmg is rebuilt with a plain "hdiutil create", without
@@ -80,7 +81,7 @@ while [ $# -gt 0 ]; do
--sha256)
[ $# -ge 2 ] || { echo "$1 requires an argument" >&2;
exit 2; }
EXPECT_SHA256="$2"; shift 2 ;;
- -h|--help) sed -n '2,49p' "$0"; exit 0 ;;
+ -h|--help) sed -n '2,50p' "$0"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*)
if [ -z "$SRC_DMG" ]; then SRC_DMG="$1"
@@ -104,17 +105,32 @@ done
exit 2
}
[ -f "$SRC_DMG" ] || { echo "no such file: $SRC_DMG" >&2; exit 1; }
+[ -d "$OUT_DMG" ] && { echo "output path is a directory: $OUT_DMG" >&2; exit
2; }
+case "$OUT_DMG" in
+ *.dmg) ;;
+ *) OUT_DMG="$OUT_DMG.dmg" ;;
+esac
+if [ "$SRC_DMG" -ef "$OUT_DMG" ]; then
+ echo "output path is the input dmg: $OUT_DMG (write to a different
file)" >&2
+ exit 2
+fi
MOUNT_POINT=""
STAGING_DIR=""
+TEMP_DMG=""
cleanup() {
[ -z "$MOUNT_POINT" ] || hdiutil detach "$MOUNT_POINT" -quiet
2>/dev/null || true
[ -z "$STAGING_DIR" ] || rm -rf "$STAGING_DIR"
+ [ -z "$TEMP_DMG" ] || rm -f "$TEMP_DMG"
}
trap cleanup EXIT
if [ -n "$EXPECT_SHA256" ]; then
echo "==> verifying checksum of $SRC_DMG"
+ # Accept either a bare digest or a full checksum line as written by
+ # shasum/hash-sign.sh ("<hash> <file>" / "<hash> *<file>"): compare
+ # against the first field.
+ EXPECT_SHA256="${EXPECT_SHA256%%[[:space:]]*}"
actual=$(shasum -a 256 "$SRC_DMG" | awk '{print $1}')
if [ "$actual" != "$EXPECT_SHA256" ]; then
echo "checksum mismatch: expected $EXPECT_SHA256, got $actual"
>&2
@@ -124,12 +140,23 @@ if [ -n "$EXPECT_SHA256" ]; then
fi
echo "==> mounting $SRC_DMG"
-MOUNT_POINT=$(hdiutil attach -readonly -nobrowse "$SRC_DMG" | tail -1 | awk
-F'\t' '{print $NF}')
+# Parse the -plist form rather than the human-readable table: the mount point
+# is whichever system-entity has one (the first often does not), and its
+# tab-column position is not a documented guarantee.
+ATTACH_PLIST=$(hdiutil attach -readonly -nobrowse -plist "$SRC_DMG")
+MOUNT_POINT=""
+ATTACH_COUNT=$(printf '%s' "$ATTACH_PLIST" | plutil -extract system-entities
raw -o - -)
+i=0
+while [ "$i" -lt "$ATTACH_COUNT" ]; do
+ mp=$(printf '%s' "$ATTACH_PLIST" | plutil -extract
"system-entities.$i.mount-point" raw -o - - 2>/dev/null || true)
+ [ -z "$mp" ] || MOUNT_POINT="$mp"
+ i=$((i + 1))
+done
[ -n "$MOUNT_POINT" ] && [ -d "$MOUNT_POINT" ] || {
echo "could not mount $SRC_DMG" >&2
exit 1
}
-VOLUME_NAME=$(diskutil info "$MOUNT_POINT" | awk -F': +' '/Volume Name/{print
$2; exit}')
+VOLUME_NAME=$(diskutil info "$MOUNT_POINT" | awk '/Volume Name/{sub(/^[^:]*:
+/, ""); print; exit}')
[ -n "$VOLUME_NAME" ] || VOLUME_NAME=$(basename "$MOUNT_POINT")
STAGING_DIR=$(mktemp -d)
@@ -162,9 +189,16 @@ echo "==> signing $APP"
echo "==> building $OUT_DMG (volume: $VOLUME_NAME)"
mkdir -p "$(dirname "$OUT_DMG")"
-hdiutil create -srcfolder "$STAGING_DIR" -volname "$VOLUME_NAME" -fs HFS+
-format UDZO -ov "$OUT_DMG"
+# Build and sign the image under a temp name in the output directory (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"
+
+echo "==> signing $TEMP_DMG"
+"$CODESIGN" "${sign_args[@]}" "$TEMP_DMG"
-echo "==> signing $OUT_DMG"
-"$CODESIGN" "${sign_args[@]}" "$OUT_DMG"
+mv -f "$TEMP_DMG" "$OUT_DMG"
+TEMP_DMG=""
echo "==> done: $OUT_DMG"
diff --git a/release-scripts/pyproject.toml b/release-scripts/pyproject.toml
new file mode 100644
index 0000000..a80130d
--- /dev/null
+++ b/release-scripts/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "openoffice-devtools-release-scripts-tests"
+version = "0.1.0"
+description = "Tests for release-scripts/macosx-remote-sign.sh"
+requires-python = ">=3.12"
+
+[dependency-groups]
+test = ["pytest>=8.0"]
+
+[tool.uv]
+package = false
+default-groups = ["test"]
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
diff --git a/release-scripts/tests/conftest.py
b/release-scripts/tests/conftest.py
new file mode 100644
index 0000000..3c0f37c
--- /dev/null
+++ b/release-scripts/tests/conftest.py
@@ -0,0 +1,169 @@
+################################################################
+#
+# 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.
+#
+################################################################
+
+"""Shared fixtures for the macosx-remote-sign.sh tests.
+
+No Developer ID key is available here, so the wrapper is exercised with a stub
+macosx-codesign.sh. That covers the wrapper's
mount/copy/rebuild/publish/cleanup
+logic only; the signature itself is the delegate's responsibility.
+"""
+
+import os
+import shutil
+import stat
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT_DIR = Path(__file__).resolve().parent.parent
+WRAPPER = SCRIPT_DIR / "macosx-remote-sign.sh"
+
+IDENTITY = "Developer ID Application: Test"
+
+requires_macos = pytest.mark.skipif(
+ os.uname().sysname != "Darwin", reason="requires macOS (hdiutil, diskutil,
plutil)"
+)
+
+
+def _run(args, **kwargs):
+ return subprocess.run(args, capture_output=True, text=True, **kwargs)
+
+
+def make_dmg(src: Path, volname: str, out: Path) -> None:
+ result = _run(
+ [
+ "hdiutil", "create",
+ "-srcfolder", str(src),
+ "-volname", volname,
+ "-fs", "HFS+",
+ "-format", "UDZO",
+ "-ov", str(out),
+ ]
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def fixture_dmg(root: Path, name: str, app_count: int = 1, extras: bool =
True) -> Path:
+ """Build an install-dmg-like fixture and return the path to its in.dmg."""
+ src = root / name / "src"
+ src.mkdir(parents=True)
+ for i in range(1, app_count + 1):
+ app = src / f"App{i}.app" / "Contents"
+ app.mkdir(parents=True)
+ (app / "Info.plist").write_text("fixture\n")
+ if extras:
+ (src / "READMEs").mkdir()
+ (src / "READMEs" / "readme.txt").write_text("readme\n")
+ (src / "Applications").symlink_to("/Applications")
+ # 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")
+ out = root / name / "in.dmg"
+ make_dmg(src, f"remote-sign-{name}", out)
+ return out
+
+
+def _stub_log(root: Path) -> Path:
+ log = root / "stub.log"
+ log.write_text("")
+ return log
+
+
+class Signer:
+ """A scratch dir holding the wrapper plus a controllable stub delegate."""
+
+ def __init__(self, root: Path, name: str, fail: str = "none"):
+ self.root = root
+ self.path = root / name
+ self.path.mkdir(parents=True)
+ self.log = _stub_log(self.path)
+ self.fail = fail
+ shutil.copy(WRAPPER, self.path / "macosx-remote-sign.sh")
+ self._write_stub()
+
+ def _write_stub(self):
+ stub = self.path / "macosx-codesign.sh"
+ body = [
+ "#!/bin/bash",
+ 'printf "STUB: %s\\n" "$*" >> "$STUB_LOG"',
+ ]
+ if self.fail == "dmg":
+ body += [
+ 'case "$*" in',
+ ' *.dmg) echo "STUB refusing dmg sign" >&2; exit 1 ;;',
+ "esac",
+ ]
+ body.append("exit 0")
+ stub.write_text("\n".join(body) + "\n")
+ stub.chmod(stub.stat().st_mode | stat.S_IXUSR)
+
+ def run(self, *args, env=None):
+ run_env = os.environ.copy()
+ run_env["STUB_LOG"] = str(self.log)
+ if env:
+ run_env.update(env)
+ return subprocess.run(
+ [str(self.path / "macosx-remote-sign.sh"), *args],
+ capture_output=True,
+ text=True,
+ env=run_env,
+ )
+
+
[email protected]
+def workdir(tmp_path):
+ return tmp_path
+
+
[email protected]
+def signer(tmp_path):
+ return Signer(tmp_path, "w-main")
+
+
+def mount_point(dmg: Path):
+ """Context manager mounting dmg read-only, yielding the mount point."""
+ import contextlib
+
+ @contextlib.contextmanager
+ def _mount():
+ mp = Path(_run(["mktemp", "-d"]).stdout.strip())
+ attach = _run(
+ ["hdiutil", "attach", "-readonly", "-nobrowse", "-mountpoint",
str(mp), str(dmg)]
+ )
+ assert attach.returncode == 0, attach.stderr
+ try:
+ yield mp
+ finally:
+ _run(["hdiutil", "detach", str(mp), "-quiet"])
+ shutil.rmtree(mp, ignore_errors=True)
+
+ return _mount()
+
+
+def volume_name(dmg: Path) -> str:
+ with mount_point(dmg) as mp:
+ info = _run(["diskutil", "info", str(mp)])
+ assert info.returncode == 0, info.stderr
+ for line in info.stdout.splitlines():
+ if line.strip().startswith("Volume Name:"):
+ return line.split(":", 1)[1].strip()
+ raise AssertionError(f"no volume name for {dmg}")
diff --git a/release-scripts/tests/test_macosx_remote_sign.py
b/release-scripts/tests/test_macosx_remote_sign.py
new file mode 100644
index 0000000..6534765
--- /dev/null
+++ b/release-scripts/tests/test_macosx_remote_sign.py
@@ -0,0 +1,257 @@
+################################################################
+#
+# 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.
+#
+################################################################
+
+"""Tests for release-scripts/macosx-remote-sign.sh."""
+
+import hashlib
+import os
+import subprocess
+
+import pytest
+
+from conftest import IDENTITY, Signer, fixture_dmg, mount_point,
requires_macos, volume_name
+
+pytestmark = requires_macos
+
+
+def test_help_exits_zero(signer):
+ r = signer.run("--help")
+ assert r.returncode == 0
+ assert "unsigned.dmg" in r.stdout
+
+
+def test_no_arguments_exits_two(signer):
+ assert signer.run().returncode == 2
+
+
+def test_unknown_option_exits_two(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ assert signer.run("--bogus", src, signer.path / "out.dmg").returncode == 2
+
+
+def test_missing_option_argument_exits_two(signer):
+ assert signer.run("-i").returncode == 2
+
+
+def test_third_positional_exits_two(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ r = signer.run("-i", IDENTITY, src, signer.path / "out.dmg", "extra")
+ assert r.returncode == 2
+
+
+def test_missing_identity_exits_two(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ assert signer.run(src, signer.path / "out.dmg").returncode == 2
+
+
+def test_adhoc_identity_exits_two(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ assert signer.run("-i", "-", src, signer.path / "out.dmg").returncode == 2
+
+
+def test_missing_source_exits_one(signer, workdir):
+ r = signer.run("-i", IDENTITY, workdir / "nope.dmg", signer.path /
"out.dmg")
+ assert r.returncode == 1
+
+
+def test_missing_sibling_signer_exits_two(workdir, tmp_path):
+ import shutil
+
+ from conftest import WRAPPER
+
+ d = tmp_path / "no-signer"
+ d.mkdir()
+ shutil.copy(WRAPPER, d / "macosx-remote-sign.sh")
+ src = fixture_dmg(workdir, "args")
+ r = subprocess.run(
+ [str(d / "macosx-remote-sign.sh"), "-i", IDENTITY, str(src), str(d /
"out.dmg")],
+ capture_output=True,
+ text=True,
+ )
+ assert r.returncode == 2
+
+
+# ------------------------------------------------------------ output guards
+
+
+def test_directory_output_exits_two(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "isadir"
+ out.mkdir()
+ r = signer.run("-i", IDENTITY, src, out)
+ assert r.returncode == 2
+ assert "directory" in r.stderr
+
+
+def test_missing_dmg_suffix_is_normalized(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ r = signer.run("-i", IDENTITY, src, signer.path / "suffixed")
+ assert r.returncode == 0
+ assert (signer.path / "suffixed.dmg").exists()
+ assert not (signer.path / "suffixed").exists()
+
+
+def test_output_equal_to_input_exits_two(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ r = signer.run("-i", IDENTITY, src, src)
+ assert r.returncode == 2
+ assert "input" in r.stderr
+
+
+# ------------------------------------------------------------- checksum (F2)
+
+
+def _digest(path):
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
[email protected](
+ "fmt",
+ [
+ pytest.param(lambda p, d: d, id="bare-digest"),
+ pytest.param(lambda p, d: f"{d} {p.name}", id="shasum-line"),
+ pytest.param(lambda p, d: f"{d} *{p.name}", id="hash-sign-line"),
+ ],
+)
+def test_sha256_accepts_digest_and_lines(signer, workdir, fmt):
+ src = fixture_dmg(workdir, "args")
+ value = fmt(src, _digest(src))
+ out = signer.path / f"sum-{abs(hash(value))}.dmg"
+ r = signer.run("-i", IDENTITY, "--sha256", value, src, out)
+ assert r.returncode == 0, r.stderr
+
+
+def test_sha256_mismatch_exits_one_and_writes_nothing(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "sum-bad.dmg"
+ r = signer.run("-i", IDENTITY, "--sha256", "0" * 64, src, out)
+ assert r.returncode == 1
+ assert not out.exists()
+
+
+# ---------------------------------------------------------- .app discovery
+
+
+def test_no_app_exits_one(signer, workdir):
+ src = fixture_dmg(workdir, "zeroapp", app_count=0, extras=False)
+ r = signer.run("-i", IDENTITY, src, signer.path / "zero.dmg")
+ assert r.returncode == 1
+ assert "no .app" in r.stderr
+
+
+def test_multiple_app_exits_one(signer, workdir):
+ src = fixture_dmg(workdir, "twoapp", app_count=2, extras=False)
+ r = signer.run("-i", IDENTITY, src, signer.path / "two.dmg")
+ assert r.returncode == 1
+ assert "exactly one" in r.stderr
+
+
+# --------------------------------------------------------------- happy path
+
+
+def test_normal_run_publishes_output_and_no_temp(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "out.dmg"
+ r = signer.run("-i", IDENTITY, src, out)
+ assert r.returncode == 0, r.stderr
+ assert out.exists()
+ assert list(signer.path.glob("out.dmg.tmp.*")) == []
+
+
+def test_whole_volume_survives(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "out.dmg"
+ assert signer.run("-i", IDENTITY, src, out).returncode == 0
+ with mount_point(out) as mp:
+ assert (mp / "READMEs").is_dir()
+ assert (mp / "Applications").is_symlink()
+ assert (mp / "READMEs" / ".DS_Store").is_file()
+ assert (mp / "App1.app").is_dir()
+
+
+def test_original_volume_name_reused(signer, workdir):
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "out.dmg"
+ assert signer.run("-i", IDENTITY, src, out).returncode == 0
+ assert volume_name(out) == "remote-sign-args"
+
+
+def test_volume_name_parse_keeps_colons(signer, workdir):
+ """The wrapper reads the volume name from `diskutil info` output without
+ splitting on a colon that is part of the name. HFS+ cannot store ':' in a
+ volume name (hdiutil rewrites it to '/'), so the value cannot be produced
+ end-to-end; a stub `diskutil` on PATH drives the script's own parse."""
+ stub_bin = signer.path / "bin"
+ stub_bin.mkdir()
+ stub = stub_bin / "diskutil"
+ stub.write_text(
+ "#!/bin/bash\n"
+ "printf ' Volume Name: Foo: Bar\\n'\n"
+ )
+ stub.chmod(0o755)
+ src = fixture_dmg(workdir, "colon", extras=False)
+ out = signer.path / "colon.dmg"
+ r = signer.run(
+ "-i", IDENTITY, src, out, env={"PATH":
f"{stub_bin}:{os.environ['PATH']}"}
+ )
+ assert r.returncode == 0, r.stderr
+ assert "volume: Foo: Bar" in r.stdout
+
+
+# ----------------------------------------------- F1: no half-published output
+
+
+def test_dmg_sign_failure_writes_nothing(tmp_path, workdir):
+ signer = Signer(tmp_path, "w-dmgfail", fail="dmg")
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "final.dmg"
+ r = signer.run("-i", IDENTITY, src, out)
+ assert r.returncode != 0
+ assert not out.exists()
+ assert list(signer.path.glob("final.dmg.tmp.*")) == []
+
+
+def test_app_sign_failure_writes_nothing(tmp_path, workdir):
+ signer = Signer(tmp_path, "w-appfail")
+ stub = signer.path / "macosx-codesign.sh"
+ stub.write_text("#!/bin/bash\nexit 3\n")
+ stub.chmod(0o755)
+ src = fixture_dmg(workdir, "args")
+ out = signer.path / "final.dmg"
+ r = signer.run("-i", IDENTITY, src, out)
+ assert r.returncode != 0
+ assert not out.exists()
+
+
+# ------------------------------------------------------------ paths with
spaces
+
+
+def test_spaces_in_paths(tmp_path, workdir):
+ signer = Signer(tmp_path, "sp ace")
+ src = fixture_dmg(workdir, "args")
+ spaced_in = signer.path / "in sp.dmg"
+ import shutil
+
+ shutil.copy(src, spaced_in)
+ out = signer.path / "out dir" / "signed out.dmg"
+ r = signer.run("-i", IDENTITY, spaced_in, out)
+ assert r.returncode == 0, r.stderr
+ assert out.exists()