Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,38 @@ jobs:
fi
done

validate_base64_secret() {
local name="$1"
local expected_byte_lengths="$2"

POWERLENS_SECRET_NAME="$name" \
POWERLENS_SECRET_VALUE="${!name}" \
POWERLENS_EXPECTED_BYTE_LENGTHS="$expected_byte_lengths" \
python3 - <<'PY'
import base64
import os

name = os.environ["POWERLENS_SECRET_NAME"]
value = os.environ["POWERLENS_SECRET_VALUE"].strip()
expected_byte_lengths = {
int(length)
for length in os.environ["POWERLENS_EXPECTED_BYTE_LENGTHS"].replace(",", " ").split()
}

try:
decoded = base64.b64decode(value, validate=True)
except Exception as error:
raise SystemExit(f"invalid {name}: {error}")

if len(decoded) not in expected_byte_lengths:
expected = " or ".join(str(length) for length in sorted(expected_byte_lengths))
raise SystemExit(f"{name} must decode to {expected} bytes, got {len(decoded)} bytes")
PY
}

validate_base64_secret POWERLENS_SPARKLE_PUBLIC_ED_KEY 32
validate_base64_secret POWERLENS_SPARKLE_PRIVATE_ED_KEY "32 96"

- name: Import Developer ID certificate
env:
CERTIFICATE_P12_BASE64: ${{ secrets.POWERLENS_DEVELOPER_ID_APPLICATION_P12_BASE64 }}
Expand Down Expand Up @@ -220,14 +252,12 @@ jobs:
cp "${{ steps.meta.outputs.appcast_path }}" "$RUNNER_TEMP/powerlens-appcast/appcast.xml"

- name: Create release notes
env:
VERSION: ${{ steps.meta.outputs.version }}
run: |
cat > "$RUNNER_TEMP/powerlens-release-notes.md" <<EOF
PowerLens ${{ steps.meta.outputs.version }}

Channel: ${{ steps.meta.outputs.channel }}
set -euo pipefail

This release was built, signed, notarized, and packaged by GitHub Actions.
EOF
printf 'Release %s\n' "$VERSION" > "$RUNNER_TEMP/powerlens-release-notes.md"

- name: Publish GitHub Release
env:
Expand Down
27 changes: 19 additions & 8 deletions Packaging/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,22 @@ Generate a Sparkle EdDSA key once on a maintainer machine:
.build/artifacts/sparkle/Sparkle/bin/generate_keys --account powerlens
```

Put only the printed public key in `POWERLENS_SPARKLE_PUBLIC_ED_KEY`. The
private key stays in Keychain or another local secret store.
Put only the printed public key in `POWERLENS_SPARKLE_PUBLIC_ED_KEY`, including
any trailing `=` padding. The key must be valid base64 that decodes to 32 bytes.
Leading or trailing whitespace is trimmed by the packaging script, but the key
should still be stored as a single line. The private key stays in Keychain or
another local secret store.

To generate an appcast while packaging, set the optional appcast variables from
`.env.example`, then run the release script. The script copies the signed app
ZIP into a temporary appcast directory and invokes Sparkle's `generate_appcast`
tool. Set `POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast.xml"` for
stable releases or `POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast-alpha.xml"`
`.env.example`, then run the release script. When `SUPublicEDKey` is embedded in
the app, the script fails if the generated appcast does not contain
`sparkle:edSignature` for the current archive. Local maintainers can let
Sparkle sign through Keychain with `--account powerlens`; CI passes an exported
private EdDSA key through `POWERLENS_SPARKLE_PRIVATE_ED_KEY`. The script copies
the signed app ZIP into a temporary appcast directory and invokes Sparkle's
`generate_appcast` tool. Set
`POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast.xml"` for stable
releases or `POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast-alpha.xml"`
for alpha releases to copy the generated feed into the local `docs/` directory
without committing the ZIP archive.

Expand Down Expand Up @@ -153,7 +161,8 @@ The release workflow requires these GitHub Secrets:
- `POWERLENS_NOTARY_PASSWORD`
- Apple app-specific password for notarization
- `POWERLENS_SPARKLE_PUBLIC_ED_KEY`
- Sparkle public EdDSA key embedded in the app
- Sparkle public EdDSA key embedded in the app; keep the trailing `=` padding
from `generate_keys`
- `POWERLENS_SPARKLE_PRIVATE_ED_KEY`
- exported Sparkle private EdDSA key used only to sign appcasts

Expand All @@ -164,7 +173,9 @@ The private Sparkle key can be exported on a maintainer Mac with:
```

Store the file contents in the `POWERLENS_SPARKLE_PRIVATE_ED_KEY` secret, then
delete the exported local file.
delete the exported local file. The exported key should be a single-line base64
value. Current Sparkle keys decode to 32 bytes; older Sparkle exports can decode
to 96 bytes and are still accepted by the release tooling.

## Release Checklist

Expand Down
149 changes: 147 additions & 2 deletions script/package_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,147 @@ SPARKLE_KEY_ACCOUNT="${POWERLENS_SPARKLE_KEY_ACCOUNT:-powerlens}"
SPARKLE_PRIVATE_ED_KEY="${POWERLENS_SPARKLE_PRIVATE_ED_KEY:-}"
SPARKLE_ED_KEY_FILE="${POWERLENS_SPARKLE_ED_KEY_FILE:-}"

normalize_base64_key() {
local key_name="$1"
local key_value="$2"
local expected_byte_lengths="$3"

POWERLENS_KEY_NAME="$key_name" \
POWERLENS_KEY_VALUE="$key_value" \
POWERLENS_EXPECTED_BYTE_LENGTHS="$expected_byte_lengths" \
python3 - <<'PY'
import base64
import os

key_name = os.environ["POWERLENS_KEY_NAME"]
key = os.environ["POWERLENS_KEY_VALUE"].strip()
expected_byte_lengths = {
int(length)
for length in os.environ["POWERLENS_EXPECTED_BYTE_LENGTHS"].replace(",", " ").split()
}

try:
decoded = base64.b64decode(key, validate=True)
except Exception as error:
raise SystemExit(f"sparkle: {key_name} is not valid base64: {error}")

if len(decoded) not in expected_byte_lengths:
expected = " or ".join(str(length) for length in sorted(expected_byte_lengths))
raise SystemExit(f"sparkle: {key_name} must decode to {expected} bytes, got {len(decoded)} bytes")

print(key, end="")
PY
}

normalize_sparkle_keys() {
if [[ -n "$SPARKLE_PUBLIC_ED_KEY" ]]; then
SPARKLE_PUBLIC_ED_KEY="$(normalize_base64_key "SUPublicEDKey" "$SPARKLE_PUBLIC_ED_KEY" 32)"
fi

if [[ -n "$SPARKLE_PRIVATE_ED_KEY" ]]; then
SPARKLE_PRIVATE_ED_KEY="$(normalize_base64_key "Sparkle private EdDSA key" "$SPARKLE_PRIVATE_ED_KEY" "32 96")"
fi
}

derive_sparkle_public_key() {
local private_key="$1"

POWERLENS_SPARKLE_PRIVATE_ED_KEY="$private_key" swift - <<'SWIFT'
import CryptoKit
import Foundation

let keyString = ProcessInfo.processInfo.environment["POWERLENS_SPARKLE_PRIVATE_ED_KEY"]?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""

guard let seed = Data(base64Encoded: keyString) else {
fputs("sparkle: private EdDSA key is not valid base64\n", stderr)
exit(2)
}

do {
switch seed.count {
case 32:
let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed)
print(privateKey.publicKey.rawRepresentation.base64EncodedString(), terminator: "")
case 96:
print(seed.suffix(32).base64EncodedString(), terminator: "")
default:
fputs("sparkle: private EdDSA key must decode to 32 or 96 bytes\n", stderr)
exit(2)
}
} catch {
fputs("sparkle: private EdDSA key cannot derive a public key\n", stderr)
exit(2)
}
SWIFT
}

validate_sparkle_key_pair() {
if [[ -z "$SPARKLE_PUBLIC_ED_KEY" ]]; then
return
fi

local private_key=""
if [[ -n "$SPARKLE_PRIVATE_ED_KEY" ]]; then
private_key="$SPARKLE_PRIVATE_ED_KEY"
elif [[ -n "$SPARKLE_ED_KEY_FILE" ]]; then
powerlens_require_file "$SPARKLE_ED_KEY_FILE"
private_key="$(< "$SPARKLE_ED_KEY_FILE")"
else
return
fi

local derived_public_key
derived_public_key="$(derive_sparkle_public_key "$private_key")"
if [[ "$derived_public_key" != "$SPARKLE_PUBLIC_ED_KEY" ]]; then
echo "sparkle: public and private EdDSA keys do not match" >&2
exit 2
fi
}

validate_generated_appcast_signature() {
local appcast="$1"
local expected_archive="$2"
local requires_signature=0
if [[ -n "$SPARKLE_PUBLIC_ED_KEY" ]]; then
requires_signature=1
fi

python3 - "$appcast" "$expected_archive" "$requires_signature" <<'PY'
import sys
import xml.etree.ElementTree as ET

appcast_path = sys.argv[1]
expected_archive = sys.argv[2]
requires_signature = sys.argv[3] == "1"
signature_key = "{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature"

tree = ET.parse(appcast_path)
matches = []

for enclosure in tree.findall(".//enclosure"):
url = enclosure.attrib.get("url", "")
if url == expected_archive or url.endswith("/" + expected_archive) or url.endswith(expected_archive):
matches.append(enclosure)

if not matches:
raise SystemExit(f"sparkle: generated appcast has no enclosure for {expected_archive}")

has_missing_signature = any(not enclosure.attrib.get(signature_key) for enclosure in matches)
if has_missing_signature and requires_signature:
raise SystemExit(
f"sparkle: generated appcast enclosure for {expected_archive} is missing "
"sparkle:edSignature; check the Sparkle private key or Keychain account"
)

if has_missing_signature:
print(
"sparkle: appcast signature omitted; generated appcast will not support secure updates",
file=sys.stderr,
)
PY
}

prepare_release_dir() {
rm -rf "$STAGE_DIR" "$DMG_STAGE_DIR" "$APP_ZIP" "$DMG_PATH" "$CHECKSUMS_PATH"
mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$APP_FRAMEWORKS" "$DMG_STAGE_DIR"
Expand Down Expand Up @@ -146,9 +287,11 @@ generate_appcast_if_configured() {
"${args[@]}"
fi

local generated_appcast="$SPARKLE_APPCAST_DIR/appcast.xml"
powerlens_require_file "$generated_appcast"
validate_generated_appcast_signature "$generated_appcast" "$(basename "$APP_ZIP")"

if [[ -n "$SPARKLE_APPCAST_OUTPUT_PATH" ]]; then
local generated_appcast="$SPARKLE_APPCAST_DIR/appcast.xml"
powerlens_require_file "$generated_appcast"
mkdir -p "$(dirname "$SPARKLE_APPCAST_OUTPUT_PATH")"
cp "$generated_appcast" "$SPARKLE_APPCAST_OUTPUT_PATH"
echo "sparkle: copied appcast to $SPARKLE_APPCAST_OUTPUT_PATH"
Expand Down Expand Up @@ -237,6 +380,8 @@ print_summary() {

powerlens_require_file "$SOURCE_INFO_PLIST"
powerlens_require_file "$ENTITLEMENTS"
normalize_sparkle_keys
validate_sparkle_key_pair
prepare_release_dir
build_app_bundle
sign_app_if_configured
Expand Down
Loading