mirror of
https://github.com/wownero/wowlet.git
synced 2026-10-20 16:48:39 +02:00
ci(release): bind private Wowlet candidates to reviewed source
This commit is contained in:
@@ -11,7 +11,16 @@ name: linux appimage (gitea self-hosted)
|
||||
#
|
||||
# Dispatch-only. The runner is capacity 1 and shared with the fleet gates.
|
||||
|
||||
on: [workflow_dispatch]
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
description: Exact reviewed Builds commit
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
|
||||
jobs:
|
||||
appimage:
|
||||
@@ -39,6 +48,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Verify exact reviewed source
|
||||
run: python3 -B contrib/release/check_dispatch.py
|
||||
|
||||
- name: fetch wownerod to embed
|
||||
run: |
|
||||
@@ -154,41 +167,3 @@ jobs:
|
||||
name: wowlet-x86_64-linux-AppImage
|
||||
path: wowlet-x86_64.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
- name: publish the AppImage to the tag's release
|
||||
# The artifact above uploads fine but this Gitea's REST listing reports
|
||||
# zero artifacts for every run, so a tag build's output cannot be fetched
|
||||
# back through the API. Release assets can, so publish there as well.
|
||||
# Fail-safe by design: with no usable token this says why and exits 0,
|
||||
# it never turns a green build red.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
FILE=wowlet-x86_64.AppImage
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
API="${GITHUB_SERVER_URL:-https://git.such.software}/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "no token available to this runner, skipping the release upload" >&2
|
||||
exit 0
|
||||
fi
|
||||
H="Authorization: token $RELEASE_TOKEN"
|
||||
# Single-line python on purpose: a continuation line at column 0 would
|
||||
# end this block scalar and break the workflow's YAML. `|| true` keeps
|
||||
# a malformed response from aborting the step under `set -e`.
|
||||
rid=$(curl -sS -H "$H" "$API/releases/tags/$TAG" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$rid" ]; then
|
||||
rid=$(curl -sS -H "$H" -H "Content-Type: application/json" -X POST \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"wowlet $TAG\"}" "$API/releases" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$rid" ]; then
|
||||
echo "could not resolve or create a release for $TAG, skipping the upload" >&2
|
||||
exit 0
|
||||
fi
|
||||
code=$(curl -sS -H "$H" -o /dev/null -w '%{http_code}' -X POST \
|
||||
-F "attachment=@$FILE" "$API/releases/$rid/assets?name=$(basename "$FILE")")
|
||||
echo "release asset upload returned HTTP $code"
|
||||
if [ "$code" != "201" ]; then
|
||||
echo "the release upload did not return 201; the run artifact still holds the build" >&2
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,16 @@ name: macos build (gitea self-hosted)
|
||||
# APPLE_API_ISSUER, APPLE_API_KEY, APPLE_API_KEY_P8_BASE64 (notarytool).
|
||||
# If APPLE_CERTIFICATE is unset the build ships unsigned instead of failing.
|
||||
|
||||
on: [workflow_dispatch]
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
description: Exact reviewed Builds commit
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
@@ -31,13 +40,21 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: install dependencies (brew)
|
||||
- name: Verify exact reviewed source
|
||||
run: python3 -B contrib/release/check_dispatch.py
|
||||
|
||||
- name: Verify Fleet-managed build dependencies
|
||||
run: |
|
||||
set -e
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
set +o pipefail # yes(1) takes SIGPIPE when brew has nothing to ask
|
||||
export NONINTERACTIVE=1 HOMEBREW_NO_ENV_HINTS=1 HOMEBREW_NO_INSTALL_UPGRADE=1
|
||||
yes | brew install --quiet qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf pkgconfig zxing-cpp
|
||||
for formula in qt libsodium libzip qrencode unbound cmake boost hidapi openssl expat protobuf zxing-cpp; do
|
||||
brew --prefix "$formula" >/dev/null || {
|
||||
echo "Missing Fleet-managed build dependency: $formula" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: fetch wownerod to embed
|
||||
run: |
|
||||
@@ -184,47 +201,8 @@ jobs:
|
||||
ls -la "$FW" | grep -iE "webp|sharpyuv|brotli" || true
|
||||
ls -la dist/
|
||||
|
||||
- name: sign and notarize (skipped when secrets absent)
|
||||
run: |
|
||||
set -e
|
||||
cd build
|
||||
if [ -z "$APPLE_CERTIFICATE" ]; then
|
||||
echo "APPLE_CERTIFICATE not set — shipping UNSIGNED build"
|
||||
exit 0
|
||||
fi
|
||||
KC=/tmp/wowlet-ci.keychain-db
|
||||
KCPW=$(/usr/bin/uuidgen)
|
||||
echo "$KCPW" > /tmp/wowlet-ci-kcpw # for the cleanup step only
|
||||
security create-keychain -p "$KCPW" "$KC"
|
||||
security set-keychain-settings -lut 3600 "$KC"
|
||||
security unlock-keychain -p "$KCPW" "$KC"
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 -d > /tmp/dev_id.p12
|
||||
security import /tmp/dev_id.p12 -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||
-T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KCPW" "$KC" >/dev/null
|
||||
security list-keychains -d user -s "$KC" login.keychain-db
|
||||
|
||||
# Sign inside-out: every Mach-O NOT inside a .framework (embedded
|
||||
# wownerod/tor, the bundled webp/sharpyuv/brotli dylibs, and the
|
||||
# image-format plugins whose signatures install_name_tool just
|
||||
# invalidated — keyed on `file`, not exec bit, since plugins/dylibs
|
||||
# aren't +x), then each Qt framework as a unit, then the bundle.
|
||||
SIGN() { codesign --force --options runtime --timestamp --keychain "$KC" \
|
||||
--sign "$APPLE_SIGNING_IDENTITY" "$@"; }
|
||||
find dist/Wowlet.app -type f ! -path "*/*.framework/*" \
|
||||
| while read -r f; do file "$f" | grep -q Mach-O && SIGN "$f" || true; done
|
||||
find dist/Wowlet.app/Contents/Frameworks -maxdepth 1 -name "*.framework" 2>/dev/null \
|
||||
| while read -r fw; do SIGN "$fw"; done
|
||||
SIGN dist/Wowlet.app
|
||||
codesign --verify --deep --strict dist/Wowlet.app
|
||||
|
||||
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -d > /tmp/notary.p8
|
||||
ditto -c -k --keepParent dist/Wowlet.app /tmp/wowlet-notarize.zip
|
||||
xcrun notarytool submit /tmp/wowlet-notarize.zip \
|
||||
--key /tmp/notary.p8 --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \
|
||||
--wait --timeout 30m
|
||||
xcrun stapler staple dist/Wowlet.app
|
||||
echo "signed + notarized + stapled"
|
||||
- name: Sign and notarize the private candidate
|
||||
run: python3 -B contrib/release/sign_macos.py
|
||||
|
||||
- name: zip artifact
|
||||
run: |
|
||||
@@ -233,14 +211,6 @@ jobs:
|
||||
ditto -c -k --keepParent Wowlet.app ../wowlet-macos-$(uname -m).zip
|
||||
ls -la ../wowlet-macos-*.zip
|
||||
|
||||
- name: cleanup signing material
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/wowlet-ci-kcpw ]; then
|
||||
security delete-keychain /tmp/wowlet-ci.keychain-db || true
|
||||
fi
|
||||
rm -f /tmp/dev_id.p12 /tmp/notary.p8 /tmp/wowlet-ci-kcpw /tmp/wowlet-notarize.zip
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
@@ -250,44 +220,3 @@ jobs:
|
||||
# Without this an empty upload passes silently, which is how a signed
|
||||
# build can appear to publish while shipping nothing.
|
||||
if-no-files-found: error
|
||||
|
||||
- name: publish the signed zip to the tag's release
|
||||
# Same reason as the Linux job: run artifacts upload but this Gitea's
|
||||
# REST listing returns zero for every run, so the notarized zip cannot
|
||||
# be fetched back through the API. Release assets can be. Fail-safe:
|
||||
# with no usable token this says why and exits 0.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
FILE=$(ls build/wowlet-macos-*.zip 2>/dev/null | head -1)
|
||||
if [ -z "$FILE" ]; then
|
||||
echo "no macOS zip to publish; the packaging step should have failed first" >&2
|
||||
exit 0
|
||||
fi
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
API="${GITHUB_SERVER_URL:-https://git.such.software}/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "no token available to this runner, skipping the release upload" >&2
|
||||
exit 0
|
||||
fi
|
||||
H="Authorization: token $RELEASE_TOKEN"
|
||||
# Single-line python on purpose: a continuation line at column 0 would
|
||||
# end this block scalar and break the workflow's YAML. `|| true` keeps
|
||||
# a malformed response from aborting the step under `set -e`.
|
||||
rid=$(curl -sS -H "$H" "$API/releases/tags/$TAG" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
if [ -z "$rid" ]; then
|
||||
rid=$(curl -sS -H "$H" -H "Content-Type: application/json" -X POST \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"wowlet $TAG\"}" "$API/releases" | python3 -c "import json,sys;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$rid" ]; then
|
||||
echo "could not resolve or create a release for $TAG, skipping the upload" >&2
|
||||
exit 0
|
||||
fi
|
||||
code=$(curl -sS -H "$H" -o /dev/null -w '%{http_code}' -X POST \
|
||||
-F "attachment=@$FILE" "$API/releases/$rid/assets?name=$(basename "$FILE")")
|
||||
echo "release asset upload returned HTTP $code"
|
||||
if [ "$code" != "201" ]; then
|
||||
echo "the release upload did not return 201; the run artifact still holds the build" >&2
|
||||
fi
|
||||
|
||||
@@ -20,9 +20,16 @@ name: windows build + sign (gitea self-hosted)
|
||||
#
|
||||
# Dispatch-only. Both runners are capacity 1 and shared with other lanes.
|
||||
|
||||
on: [workflow_dispatch]
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expected_sha:
|
||||
description: Exact reviewed Builds commit
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
HOST: x86_64-w64-mingw32
|
||||
|
||||
jobs:
|
||||
@@ -52,6 +59,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Verify exact reviewed source
|
||||
run: python3 -B contrib/release/check_dispatch.py
|
||||
|
||||
# depends is the slow part (static Qt6/boost/openssl/tor from source).
|
||||
# Cache the source tarballs and the compiled packages so only a cold run
|
||||
@@ -270,47 +281,3 @@ jobs:
|
||||
wowlet-x86_64-windows.zip
|
||||
wowlet-x86_64-windows-unsigned.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: publish the Windows zips to the tag's release
|
||||
# Same reason as the Linux and macOS jobs: artifacts upload fine, but
|
||||
# this Gitea's REST listing reports zero for every run, so the signed exe
|
||||
# cannot be fetched back through the API. Release assets can be.
|
||||
#
|
||||
# This runs after the Authenticode, timestamp and signer checks above, so
|
||||
# an unsigned or untimestamped binary never reaches a release. curl.exe,
|
||||
# not Invoke-WebRequest: `shell: powershell` is Windows PowerShell 5.1,
|
||||
# whose Invoke-WebRequest has no -Form for multipart uploads.
|
||||
#
|
||||
# Fail-safe: a build that produced a correctly signed binary must not go
|
||||
# red because publishing failed, so every bad path reports and exits 0.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: powershell
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Continue'
|
||||
if (-not $env:RELEASE_TOKEN) {
|
||||
Write-Host "no token available to this runner, skipping the release upload"
|
||||
exit 0
|
||||
}
|
||||
$tag = $env:GITHUB_REF -replace '^refs/tags/', ''
|
||||
$server = if ($env:GITHUB_SERVER_URL) { $env:GITHUB_SERVER_URL } else { 'https://git.such.software' }
|
||||
$api = "$server/api/v1/repos/$env:GITHUB_REPOSITORY"
|
||||
$auth = "Authorization: token $env:RELEASE_TOKEN"
|
||||
$rid = ''
|
||||
try { $rid = ((curl.exe -sS -H $auth "$api/releases/tags/$tag") | ConvertFrom-Json).id } catch { $rid = '' }
|
||||
if (-not $rid) {
|
||||
try {
|
||||
$body = "{""tag_name"":""$tag"",""name"":""wowlet $tag""}"
|
||||
$rid = ((curl.exe -sS -H $auth -H "Content-Type: application/json" -X POST -d $body "$api/releases") | ConvertFrom-Json).id
|
||||
} catch { $rid = '' }
|
||||
}
|
||||
if (-not $rid) {
|
||||
Write-Host "could not resolve or create a release for $tag, skipping the upload"
|
||||
exit 0
|
||||
}
|
||||
foreach ($f in @('wowlet-x86_64-windows.zip', 'wowlet-x86_64-windows-unsigned.zip')) {
|
||||
if (-not (Test-Path $f)) { Write-Host "missing $f, nothing to publish for it"; continue }
|
||||
$code = curl.exe -sS -o NUL -w '%{http_code}' -H $auth -F "attachment=@$f" "$api/releases/$rid/assets?name=$f"
|
||||
Write-Host "release asset upload for $f returned HTTP $code"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Wowlet verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check support guides and release source admission
|
||||
run: make check
|
||||
@@ -15,5 +15,6 @@ default: build
|
||||
|
||||
check:
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/docs/tests -v
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s contrib/release/tests -v
|
||||
|
||||
.PHONY: default build attest verify clean check
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Refuse a build whose resolved source differs from its reviewed request."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def check(expected, reported, checked_out):
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", expected or ""):
|
||||
raise ValueError("expected_sha must identify the exact reviewed commit")
|
||||
if reported != expected:
|
||||
raise ValueError("provider run head differs from expected_sha")
|
||||
if checked_out != expected:
|
||||
raise ValueError("checked-out source differs from expected_sha")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
head = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||
try:
|
||||
check(os.environ.get("EXPECTED_SHA"), os.environ.get("GITHUB_SHA"), head)
|
||||
except ValueError as error:
|
||||
raise SystemExit(str(error)) from None
|
||||
print("Exact requested build source verified")
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Sign one private candidate without placing passwords in process arguments.
|
||||
|
||||
Apple SecurityTool reads one quoted operation from stdin in interactive mode:
|
||||
https://github.com/apple-oss-distributions/SecurityTool/blob/main/security.c
|
||||
Output stays captured because native diagnostics may repeat supplied input.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
FIELDS = (
|
||||
"APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY",
|
||||
"APPLE_API_ISSUER", "APPLE_API_KEY", "APPLE_API_KEY_P8_BASE64",
|
||||
)
|
||||
|
||||
|
||||
def argument(value):
|
||||
if any(ord(char) < 32 or ord(char) == 127 for char in value):
|
||||
raise ValueError("Keychain input contains an unsupported control character")
|
||||
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def child_environment():
|
||||
return {key: os.environ[key] for key in ("HOME", "PATH", "TMPDIR", "LANG", "DEVELOPER_DIR") if key in os.environ}
|
||||
|
||||
|
||||
def security(operation, *values):
|
||||
if operation not in {
|
||||
"create-keychain", "set-keychain-settings", "unlock-keychain", "import",
|
||||
"set-key-partition-list", "delete-keychain",
|
||||
}:
|
||||
raise ValueError("Keychain operation is outside candidate signing")
|
||||
command = " ".join(argument(value) for value in (operation, *values)) + "\n"
|
||||
if len(command.encode()) > 1024:
|
||||
raise ValueError("Keychain input exceeds the bounded operation size")
|
||||
result = subprocess.run(
|
||||
["/usr/bin/security", "-i", "-q"], input=command,
|
||||
text=True, capture_output=True, env=child_environment(), timeout=90,
|
||||
)
|
||||
if result.returncode:
|
||||
# Never forward diagnostics or operation data. Report recognized causes.
|
||||
causes = {
|
||||
"MAC verification failed": "PKCS12 encoding or password verification failed",
|
||||
"User interaction is not allowed": "keychain requires interactive access",
|
||||
"already exists": "ephemeral keychain or imported identity already exists",
|
||||
"could not be found": "requested keychain item was not found",
|
||||
}
|
||||
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
|
||||
"native keychain operation returned an error")
|
||||
raise RuntimeError(f"{operation}: {cause} (exit {result.returncode}); raw diagnostics withheld")
|
||||
|
||||
|
||||
def run(arguments, *, timeout=120):
|
||||
result = subprocess.run(arguments, text=True, capture_output=True,
|
||||
env=child_environment(), timeout=timeout)
|
||||
if result.returncode:
|
||||
causes = {
|
||||
"no identity found": "expected signing identity was not found",
|
||||
"User interaction is not allowed": "signing key requires interactive access",
|
||||
"errSecInternalComponent": "keychain denied access to the signing identity",
|
||||
"not signed at all": "candidate contains an unsigned component",
|
||||
}
|
||||
cause = next((cause for marker, cause in causes.items() if marker in result.stderr),
|
||||
"native signing operation returned an error")
|
||||
raise RuntimeError(f"{Path(arguments[0]).name}: {cause} (exit {result.returncode}); raw diagnostics withheld")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def private_file(path, content):
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(content)
|
||||
|
||||
|
||||
def main():
|
||||
credentials = {name: os.environ.get(name, "") for name in FIELDS}
|
||||
missing = [name for name, value in credentials.items() if not value]
|
||||
if missing:
|
||||
raise ValueError("Signing input is missing: " + ", ".join(missing))
|
||||
if "Such Software" not in credentials["APPLE_SIGNING_IDENTITY"]:
|
||||
raise ValueError("Developer ID identity does not name the expected publisher")
|
||||
app = Path("build/dist/Wowlet.app").resolve()
|
||||
if not app.is_dir():
|
||||
raise ValueError("Packaged Wowlet.app is missing")
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix=".wowlet-signing-", dir=app.parent) as temporary:
|
||||
workspace = Path(temporary)
|
||||
keychain = workspace / "candidate.keychain-db"
|
||||
certificate = workspace / "identity.p12"
|
||||
notary_key = workspace / "notary.p8"
|
||||
private_file(certificate, base64.b64decode(credentials["APPLE_CERTIFICATE"]))
|
||||
private_file(notary_key, base64.b64decode(credentials["APPLE_API_KEY_P8_BASE64"]))
|
||||
password = secrets.token_hex(32)
|
||||
created = False
|
||||
try:
|
||||
security("create-keychain", "-p", password, str(keychain))
|
||||
created = True
|
||||
security("set-keychain-settings", "-lut", "3600", str(keychain))
|
||||
security("unlock-keychain", "-p", password, str(keychain))
|
||||
security("import", str(certificate), "-k", str(keychain), "-P",
|
||||
credentials["APPLE_CERTIFICATE_PASSWORD"], "-T", "/usr/bin/codesign")
|
||||
security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s",
|
||||
"-k", password, str(keychain))
|
||||
|
||||
def sign(path):
|
||||
run(["/usr/bin/codesign", "--force", "--options", "runtime", "--timestamp",
|
||||
"--keychain", str(keychain), "--sign",
|
||||
credentials["APPLE_SIGNING_IDENTITY"], str(path)])
|
||||
|
||||
for path in sorted(app.rglob("*")):
|
||||
if path.is_file() and not any(part.endswith(".framework") for part in path.parts):
|
||||
if "Mach-O" in run(["/usr/bin/file", "-b", str(path)]):
|
||||
sign(path)
|
||||
frameworks = app / "Contents/Frameworks"
|
||||
for path in sorted(frameworks.glob("*.framework")):
|
||||
sign(path)
|
||||
sign(app)
|
||||
run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)])
|
||||
archive = workspace / "candidate.zip"
|
||||
run(["/usr/bin/ditto", "-c", "-k", "--keepParent", str(app), str(archive)])
|
||||
raw = run(["/usr/bin/xcrun", "notarytool", "submit", str(archive),
|
||||
"--key", str(notary_key), "--key-id", credentials["APPLE_API_KEY"],
|
||||
"--issuer", credentials["APPLE_API_ISSUER"], "--wait", "--timeout", "30m",
|
||||
"--output-format", "json"], timeout=1900)
|
||||
response = json.loads(raw)
|
||||
if response.get("status") != "Accepted":
|
||||
raise RuntimeError("Apple notarization did not return Accepted")
|
||||
run(["/usr/bin/xcrun", "stapler", "staple", str(app)])
|
||||
run(["/usr/bin/xcrun", "stapler", "validate", str(app)])
|
||||
print("Developer ID signature verified; notarization accepted; staple validated")
|
||||
finally:
|
||||
if created:
|
||||
security("delete-keychain", str(keychain))
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
|
||||
# These exceptions contain logical fields, native result classes or
|
||||
# public file paths. Captured child output is never included.
|
||||
raise SystemExit(str(error)) from None
|
||||
@@ -0,0 +1,20 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location("dispatch", Path(__file__).parents[1] / "check_dispatch.py")
|
||||
dispatch = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(dispatch)
|
||||
|
||||
|
||||
class DispatchTests(unittest.TestCase):
|
||||
def test_exact_source_is_required_before_building(self):
|
||||
sha = "a" * 40
|
||||
dispatch.check(sha, sha, sha)
|
||||
for expected, reported, checked_out in (
|
||||
(None, sha, sha), ("master", sha, sha),
|
||||
(sha, "b" * 40, sha), (sha, sha, "b" * 40),
|
||||
):
|
||||
with self.subTest(expected=expected, reported=reported, checked_out=checked_out):
|
||||
with self.assertRaises(ValueError):
|
||||
dispatch.check(expected, reported, checked_out)
|
||||
@@ -0,0 +1,39 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("sign_macos", Path(__file__).parents[1] / "sign_macos.py")
|
||||
signing = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(signing)
|
||||
|
||||
|
||||
class MacSigningTests(unittest.TestCase):
|
||||
def test_password_uses_private_input_and_diagnostics_never_echo_it(self):
|
||||
synthetic = 'synthetic "quoted" value \\ with spaces'
|
||||
captured = []
|
||||
|
||||
def execute(arguments, **options):
|
||||
captured.append((arguments, options))
|
||||
return SimpleNamespace(returncode=1, stdout=synthetic,
|
||||
stderr="MAC verification failed " + synthetic)
|
||||
|
||||
with patch.object(signing.subprocess, "run", execute):
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
signing.security("import", "candidate.p12", "-P", synthetic)
|
||||
self.assertNotIn(synthetic, str(caught.exception))
|
||||
arguments, options = captured[0]
|
||||
self.assertNotIn(synthetic, " ".join(arguments))
|
||||
self.assertIn("quoted", options["input"])
|
||||
self.assertTrue(options["capture_output"])
|
||||
self.assertNotIn("APPLE_CERTIFICATE_PASSWORD", options["env"])
|
||||
|
||||
def test_control_characters_and_unrelated_keychain_operations_refuse(self):
|
||||
with patch.object(signing.subprocess, "run") as execute:
|
||||
for value in ("line\nbreak", "nul\x00byte", "carriage\rreturn"):
|
||||
with self.assertRaises(ValueError):
|
||||
signing.security("unlock-keychain", "-p", value, "candidate.keychain")
|
||||
with self.assertRaises(ValueError):
|
||||
signing.security("dump-keychain", "login.keychain")
|
||||
execute.assert_not_called()
|
||||
Reference in New Issue
Block a user