mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-11-07 08:04:04 +01:00
ci(macos): build Developer ID and Mac App Store lanes, signed by xcodebuild
The macOS lane has never gone green. It built with the project's Release signing, which was still Cake's (automatic Apple Development, team 32J6BB6VUS), and failed at "No profiles for 'com.suchsoftware...'". Past that it would have re-signed the finished app by hand, shipping keychain-access-groups as a literal "$(AppIdentifierPrefix)" with no profile to authorise it, and read notary secrets that do not exist. Now, on the shape Vegan IQ proved for its store build: - Runner's Release config signs manually under D8PL9F7X33 and takes its profile name from HASH_BAGS_MACOS_PROFILE, so only the app carries one. - scripts/macos/build_signed.sh decodes a profile, picks the keychain identity it was issued for (check_macos_profile.py, from vegan-IQ), and signs inside xcodebuild; it refuses an app whose entitlements keep an unexpanded variable or that embeds no profile. - developer-id lane: hardened runtime, notarize, staple, spctl assess, zip to the release. app-store lane: productbuild with the installer identity, pkgutil check, altool upload to App Store Connect. - The first step refuses a selected lane missing any of its secrets and names each one. The API key lives under the job's temp dir, not the runner home. Not run: no Developer ID or Mac App Store signing material is in the repository's secrets yet, so the guard is the only part exercised.
This commit is contained in:
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and sign Hash Bags for macOS against one provisioning profile.
|
||||
#
|
||||
# Usage: build_signed.sh <profile.provisionprofile> <keychain> <derived-data-dir> [setting=value ...]
|
||||
# Run from the repository root after `flutter build macos --release --config-only`.
|
||||
# Prints the built .app's path as the last line of stdout; build output goes to stderr.
|
||||
#
|
||||
# Signing happens inside xcodebuild, never afterwards. Xcode then expands the
|
||||
# entitlements' $(AppIdentifierPrefix), embeds the profile, and signs every nested
|
||||
# dylib and framework with the same identity. Re-signing a finished .app by hand
|
||||
# did none of that: the keychain group shipped as a literal "$(AppIdentifierPrefix)"
|
||||
# string and the app had no profile to authorise it.
|
||||
#
|
||||
# The profile decides the identity. check_macos_profile.py reads the certificates it
|
||||
# was issued for and picks the one the keychain holds, so a Developer ID profile
|
||||
# cannot be paired with the App Store certificate or the other way round.
|
||||
set -euo pipefail
|
||||
|
||||
[ $# -ge 3 ] || { echo "usage: $0 <profile> <keychain> <derived-data-dir> [setting=value ...]" >&2; exit 64; }
|
||||
profile=$1 keychain=$2 derived=$3
|
||||
shift 3
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
security cms -D -i "$profile" > "$work/profile.plist"
|
||||
name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$work/profile.plist")"
|
||||
uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$work/profile.plist")"
|
||||
team="$(/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' "$work/profile.plist")"
|
||||
echo "profile: $name ($uuid), team $team" >&2
|
||||
|
||||
# Xcode 16 reads UserData; older Xcode reads MobileDevice. Both, by UUID, which is
|
||||
# what Xcode matches on after resolving the specifier.
|
||||
for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" \
|
||||
"$HOME/Library/MobileDevice/Provisioning Profiles"; do
|
||||
mkdir -p "$dir"
|
||||
cp "$profile" "$dir/$uuid.provisionprofile"
|
||||
done
|
||||
|
||||
security find-identity -v -p codesigning "$keychain" > "$work/identities.txt"
|
||||
identity="$(python3 scripts/macos/check_macos_profile.py \
|
||||
--profile "$work/profile.plist" --identities "$work/identities.txt" --expect-name "$name")"
|
||||
if [ -z "$identity" ]; then
|
||||
# The checker declines only when the profile lists no readable certificate. With
|
||||
# two lanes in one keychain, guessing could sign with the other lane's identity.
|
||||
echo "FATAL: profile '$name' names no certificate this build can match; refusing to guess." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "signing identity: $identity" >&2
|
||||
|
||||
# The profile specifier is a Runner-only setting (project.pbxproj reads it from
|
||||
# HASH_BAGS_MACOS_PROFILE), so the pods never see a profile they would refuse.
|
||||
xcodebuild -workspace macos/Runner.xcworkspace -scheme Runner \
|
||||
-configuration Release -derivedDataPath "$derived" \
|
||||
CODE_SIGN_IDENTITY="$identity" \
|
||||
DEVELOPMENT_TEAM="$team" \
|
||||
HASH_BAGS_MACOS_PROFILE="$name" \
|
||||
OTHER_CODE_SIGN_FLAGS="--keychain $keychain --timestamp" \
|
||||
"$@" build >&2
|
||||
|
||||
app="$(find "$derived/Build/Products/Release" -maxdepth 1 -name '*.app' | head -1)"
|
||||
[ -n "$app" ] || { echo "FATAL: xcodebuild produced no .app under $derived" >&2; exit 1; }
|
||||
codesign --verify --deep --strict --verbose=2 "$app" >&2
|
||||
|
||||
codesign -d --entitlements - --xml "$app" > "$work/entitlements.plist" 2>/dev/null
|
||||
if grep -q '\$(' "$work/entitlements.plist"; then
|
||||
echo "FATAL: the signed entitlements still hold an unexpanded build variable:" >&2
|
||||
grep '\$(' "$work/entitlements.plist" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$app/Contents/embedded.provisionprofile" ] || {
|
||||
echo "FATAL: $app carries no embedded provisioning profile" >&2; exit 1; }
|
||||
|
||||
echo "$app"
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Choose the signing identity a provisioning profile will actually accept.
|
||||
|
||||
Copied from vegan-IQ (scripts/check_macos_profile.py), where it carried the first
|
||||
Mac App Store build through; Hash Bags uses it for both the Developer ID and the
|
||||
App Store lanes.
|
||||
|
||||
The profile is the constraint, not the certificate. A Mac App Store profile is
|
||||
built around specific certificates, and signing with any other one fails late,
|
||||
in a message that blames the certificate for being absent rather than the
|
||||
profile for having been built without it. So read the profile first and pick
|
||||
the keychain identity it endorses.
|
||||
|
||||
Prints the chosen identity on stdout. Diagnostics go to stderr so the caller
|
||||
can capture stdout directly.
|
||||
|
||||
Exits 0 when the profile cannot be parsed or lists no certificates: those are
|
||||
different failures, and xcodebuild is entitled to be the one that reports
|
||||
them. In that case nothing is printed and the caller falls back.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import plistlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
IDENTITY_RE = re.compile(r'"([^"]+)"')
|
||||
# "subject=UID=X, CN=Apple Distribution: Org (TEAM), OU=..." -> the CN value
|
||||
CN_RE = re.compile(r"CN\s*=\s*([^,/]+)")
|
||||
|
||||
|
||||
def log(message):
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def profile_common_names(profile):
|
||||
names = []
|
||||
for der in profile.get("DeveloperCertificates", []):
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-inform", "DER", "-noout", "-subject"],
|
||||
input=der,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
continue
|
||||
subject = result.stdout.decode("utf-8", "replace").strip()
|
||||
match = CN_RE.search(subject)
|
||||
if match:
|
||||
names.append(match.group(1).strip())
|
||||
return names
|
||||
|
||||
|
||||
def keychain_identities(path):
|
||||
with open(path) as handle:
|
||||
return [
|
||||
match.group(1)
|
||||
for line in handle
|
||||
for match in [IDENTITY_RE.search(line)]
|
||||
if match
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--profile", required=True)
|
||||
parser.add_argument("--identities", required=True, help="security find-identity output")
|
||||
parser.add_argument("--expect-name", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
with open(args.profile, "rb") as handle:
|
||||
profile = plistlib.load(handle)
|
||||
except Exception as exc:
|
||||
log("check_macos_profile: cannot parse profile (%s); leaving it to xcodebuild" % exc)
|
||||
return 0
|
||||
|
||||
name = profile.get("Name", "")
|
||||
if name != args.expect_name:
|
||||
log("FATAL: provisioning profile name mismatch.")
|
||||
log(" the secret contains: %s" % (name or "<unnamed>"))
|
||||
log(" Runner.xcodeproj asks for: %s" % args.expect_name)
|
||||
log("Rename it in the portal, or change PROVISIONING_PROFILE_SPECIFIER.")
|
||||
return 1
|
||||
|
||||
wanted = profile_common_names(profile)
|
||||
if not wanted:
|
||||
log("check_macos_profile: profile lists no certificates; leaving it to xcodebuild")
|
||||
return 0
|
||||
|
||||
available = keychain_identities(args.identities)
|
||||
for candidate in wanted:
|
||||
if candidate in available:
|
||||
log("profile %r endorses %r, which is in the keychain" % (name, candidate))
|
||||
print(candidate)
|
||||
return 0
|
||||
|
||||
log("FATAL: no keychain identity matches this provisioning profile.")
|
||||
log(" profile %r was built around:" % name)
|
||||
for candidate in wanted:
|
||||
log(" %s" % candidate)
|
||||
log(" the keychain holds:")
|
||||
for identity in available:
|
||||
log(" %s" % identity)
|
||||
log("")
|
||||
log("Either put one of the profile's certificates in this lane's certificate")
|
||||
log("secret, or regenerate the profile in the portal around a certificate")
|
||||
log("listed above and update this lane's profile secret.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user