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
47 changes: 44 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# Security-update metadata is stored in the annotated release tag.
fetch-depth: 0

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -178,19 +181,57 @@ jobs:

app_zip="dist/APW.app-${GITHUB_REF_NAME}.zip"
release_notes="dist/APW.app-${GITHUB_REF_NAME}.md"
release_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
download_url_prefix="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}/"
tag_ref="refs/tags/${GITHUB_REF_NAME}"
tag_object_type="$(git for-each-ref "$tag_ref" --format='%(objecttype)')"
critical_update_metadata="$(git for-each-ref "$tag_ref" --format='%(contents)' | sed -n 's/^APW-Sparkle-Critical-Update-Version:[[:space:]]*//p')"
critical_update_metadata_count="$(printf '%s\n' "$critical_update_metadata" | sed '/^$/d' | wc -l | tr -d ' ')"
if [ "$critical_update_metadata_count" -gt 1 ]; then
echo "Release tag contains more than one APW-Sparkle-Critical-Update-Version line." >&2
exit 1
fi
critical_update_version="$critical_update_metadata"
critical_update_args=()

if [ -n "$critical_update_version" ]; then
if [ "$tag_object_type" != "tag" ]; then
echo "Critical Sparkle updates require annotated release tags." >&2
exit 1
fi
if [ "$critical_update_version" = "all" ]; then
critical_update_args=(--critical-update-version "")
else
critical_update_args=(--critical-update-version "$critical_update_version")
fi
update_title="APW ${GITHUB_REF_NAME} Security Update"
else
update_title="APW ${GITHUB_REF_NAME} Update"
fi

ditto -c -k --keepParent native-app/dist/APW.app "$app_zip"
{
echo "# APW ${GITHUB_REF_NAME}"
echo "# $update_title"
echo
echo "See the GitHub release notes for this tag."
if [ -n "$critical_update_version" ]; then
echo "## Security"
echo
echo "See the GitHub release notes for this security update."
else
echo "## Changes"
echo
echo "See the GitHub release notes for this update."
fi
} > "$release_notes"

./scripts/prepare-sparkle-appcast.sh \
--archive "$app_zip" \
--release-notes "$release_notes" \
--updates-dir dist/sparkle-updates \
--generate-appcast "$SPARKLE_GENERATE_APPCAST"
--generate-appcast "$SPARKLE_GENERATE_APPCAST" \
--download-url-prefix "$download_url_prefix" \
--release-url "$release_url" \
"${critical_update_args[@]}"

cp dist/sparkle-updates/appcast.xml dist/appcast.xml
echo "available=true" >> "$GITHUB_OUTPUT"
Expand Down
17 changes: 17 additions & 0 deletions docs/IN_APP_UPDATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,23 @@ Critical update status is reserved for credential-broker security fixes,
signing/notarization failures, or vulnerabilities that can affect credential
confidentiality, integrity, or update trust.

### Release classification

Normal tags create ordinary updates. To mark a tagged release as a critical
security update, create an annotated tag with exactly one metadata line:

```text
APW-Sparkle-Critical-Update-Version: all
```

Use a specific Sparkle build version instead of `all` when only installations
at or below that version should see the update as critical. The release workflow
passes this metadata to Sparkle's `generate_appcast` tool, adds the required
`Security` release-notes section, and links the generated item to the exact
GitHub release tag. Because the metadata lives in the immutable annotated tag,
a critical classification cannot leak into a later ordinary release through a
stale repository variable.

## Validation

Run the contract check with:
Expand Down
12 changes: 11 additions & 1 deletion scripts/ci/validate-appcast-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ require_pattern() {
file="$1"
pattern="$2"
description="$3"
if ! grep -Eq "$pattern" "$file"; then
if ! grep -Eq -- "$pattern" "$file"; then
echo "Missing appcast contract requirement in $file: $description" >&2
exit 1
fi
Expand Down Expand Up @@ -64,6 +64,7 @@ require_pattern "$DOC_PATH" "SPARKLE_GENERATE_APPCAST" "release runner generate_
require_pattern "$DOC_PATH" "APW_SPARKLE_PUBLIC_ED_KEY" "release runner Sparkle public key configuration"
require_pattern "$DOC_PATH" "fails before publishing" "tagged release fails without Sparkle config"
require_pattern "$DOC_PATH" "SPUStandardUpdaterController" "runtime Sparkle updater controller"
require_pattern "$DOC_PATH" "APW-Sparkle-Critical-Update-Version" "annotated-tag security update metadata"

require_pattern "$TEMPLATE_PATH" "xmlns:sparkle=\"http://www\\.andymatuschak\\.org/xml-namespaces/sparkle\"" "Sparkle namespace"
require_pattern "$TEMPLATE_PATH" "<title>APW [0-9]+\\.[0-9]+\\.[0-9]+ Security Update</title>" "security update title"
Expand Down Expand Up @@ -99,6 +100,9 @@ PY

require_pattern "$PREPARE_SCRIPT" "generate_appcast" "Sparkle appcast generation invocation"
require_pattern "$PREPARE_SCRIPT" "sparkle:edSignature=" "signed appcast output enforcement"
require_pattern "$PREPARE_SCRIPT" "critical-update-version" "critical update generator metadata"
require_pattern "$PREPARE_SCRIPT" "download-url-prefix" "published archive URL generator metadata"
require_pattern "$PREPARE_SCRIPT" "release-url" "release link generator metadata"
require_pattern "$PREPARE_SCRIPT" "Do not pass private keys" "private key handling guardrail"
require_pattern "$PREPARE_TEST" "Sparkle appcast preparation test passed" "helper regression test"
require_pattern "$PLIST_RENDERER" "SUFeedURL" "native app Sparkle feed plist key"
Expand All @@ -121,6 +125,12 @@ require_pattern "$BUILD_NATIVE_APP" "@loader_path/\\.\\./Frameworks" "native app
require_pattern "$RELEASE_WORKFLOW" "prepare-sparkle-appcast\\.sh" "release appcast preparation step"
require_pattern "$RELEASE_WORKFLOW" "SPARKLE_GENERATE_APPCAST" "release appcast generator variable"
require_pattern "$RELEASE_WORKFLOW" "APW_SPARKLE_PUBLIC_ED_KEY" "release Sparkle public key variable"
require_pattern "$RELEASE_WORKFLOW" "APW-Sparkle-Critical-Update-Version" "annotated-tag security update metadata"
require_pattern "$RELEASE_WORKFLOW" "objecttype" "annotated critical-update tag validation"
require_pattern "$RELEASE_WORKFLOW" "Critical Sparkle updates require annotated release tags" "annotated critical-update tag failure"
require_pattern "$RELEASE_WORKFLOW" "--critical-update-version" "Sparkle critical update generator invocation"
require_pattern "$RELEASE_WORKFLOW" "--download-url-prefix" "Sparkle archive download URL generator invocation"
require_pattern "$RELEASE_WORKFLOW" "--release-url" "Sparkle release link generator invocation"
require_pattern "$RELEASE_WORKFLOW" "Sparkle release configuration is incomplete" "tagged release Sparkle config fail-closed gate"
require_pattern "$RELEASE_WORKFLOW" "dist/appcast\\.xml" "release appcast asset upload"
require_pattern "$RELEASE_WORKFLOW" "APW\\.app-\\$\\{\\{ github\\.ref_name \\}\\}\\.zip" "release Sparkle app archive upload"
Expand Down
63 changes: 61 additions & 2 deletions scripts/prepare-sparkle-appcast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ set -euo pipefail

usage() {
cat <<'USAGE'
Usage: ./scripts/prepare-sparkle-appcast.sh --archive PATH --release-notes PATH --updates-dir DIR --generate-appcast PATH [--feed-url URL]
Usage: ./scripts/prepare-sparkle-appcast.sh --archive PATH --release-notes PATH --updates-dir DIR --generate-appcast PATH [options]

Prepare a Sparkle updates directory and run Sparkle's generate_appcast tool.
The tool is expected to sign archives, release notes, and the appcast using
Expand All @@ -16,6 +16,11 @@ Options:
--updates-dir DIR Directory holding Sparkle update archives.
--generate-appcast PATH Path to Sparkle's generate_appcast executable.
--feed-url URL Feed URL; default is APW's production appcast URL.
--download-url-prefix URL HTTPS prefix for published update archives.
--release-url URL HTTPS GitHub release URL linked by the appcast item.
--critical-update-version VERSION
Mark the item as critical for versions through VERSION.
Pass an empty value to mark it critical for all versions.
-h, --help Show this help.
USAGE
}
Expand All @@ -25,6 +30,10 @@ ARCHIVE_PATH=""
RELEASE_NOTES_PATH=""
UPDATES_DIR=""
GENERATE_APPCAST=""
DOWNLOAD_URL_PREFIX=""
RELEASE_URL=""
CRITICAL_UPDATE_VERSION=""
CRITICAL_UPDATE_VERSION_SET=0

while [ "$#" -gt 0 ]; do
case "$1" in
Expand All @@ -48,6 +57,19 @@ while [ "$#" -gt 0 ]; do
FEED_URL="${2:-}"
shift 2
;;
--download-url-prefix)
DOWNLOAD_URL_PREFIX="${2:-}"
shift 2
;;
--release-url)
RELEASE_URL="${2:-}"
shift 2
;;
--critical-update-version)
CRITICAL_UPDATE_VERSION="${2:-}"
CRITICAL_UPDATE_VERSION_SET=1
shift 2
;;
-h|--help)
usage
exit 0
Expand Down Expand Up @@ -75,6 +97,20 @@ case "$FEED_URL" in
*) fail "--feed-url must be an https URL" ;;
esac

if [ -n "$DOWNLOAD_URL_PREFIX" ]; then
case "$DOWNLOAD_URL_PREFIX" in
https://*/) ;;
*) fail "--download-url-prefix must be an https URL ending in /" ;;
esac
fi

if [ -n "$RELEASE_URL" ]; then
case "$RELEASE_URL" in
https://*) ;;
*) fail "--release-url must be an https URL" ;;
esac
fi

[ -f "$ARCHIVE_PATH" ] || fail "archive not found: $ARCHIVE_PATH"
[ -f "$RELEASE_NOTES_PATH" ] || fail "release notes not found: $RELEASE_NOTES_PATH"
[ -x "$GENERATE_APPCAST" ] || fail "generate_appcast is not executable: $GENERATE_APPCAST"
Expand All @@ -92,7 +128,22 @@ mkdir -p "$UPDATES_DIR"
cp "$ARCHIVE_PATH" "$UPDATES_DIR/$archive_name"
cp "$RELEASE_NOTES_PATH" "$UPDATES_DIR/$archive_name.md"

"$GENERATE_APPCAST" "$UPDATES_DIR"
generate_args=()
if [ -n "$DOWNLOAD_URL_PREFIX" ]; then
generate_args+=(--download-url-prefix "$DOWNLOAD_URL_PREFIX")
fi
if [ -n "$RELEASE_URL" ]; then
generate_args+=(--link "$RELEASE_URL")
fi
if [ "$CRITICAL_UPDATE_VERSION_SET" -eq 1 ]; then
generate_args+=(--critical-update-version "$CRITICAL_UPDATE_VERSION")
fi

if [ "${#generate_args[@]}" -gt 0 ]; then
"$GENERATE_APPCAST" "${generate_args[@]}" "$UPDATES_DIR"
else
"$GENERATE_APPCAST" "$UPDATES_DIR"
fi

appcast_path="$UPDATES_DIR/$feed_file"
[ -f "$appcast_path" ] || fail "generate_appcast did not create $appcast_path"
Expand All @@ -115,4 +166,12 @@ if ! grep -q "$archive_name" "$appcast_path"; then
fail "$appcast_path does not reference $archive_name"
fi

if [ -n "$DOWNLOAD_URL_PREFIX" ] && ! grep -Fq "$DOWNLOAD_URL_PREFIX$archive_name" "$appcast_path"; then
fail "$appcast_path does not reference the configured download URL prefix"
fi

if [ -n "$RELEASE_URL" ] && ! grep -Fq "$RELEASE_URL" "$appcast_path"; then
fail "$appcast_path does not link to the configured release URL"
fi

echo "Prepared signed Sparkle appcast: $appcast_path"
43 changes: 38 additions & 5 deletions scripts/test-prepare-sparkle-appcast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ archive="$WORK_DIR/APW.app.zip"
notes="$WORK_DIR/APW.app.release.md"
updates="$WORK_DIR/updates"
fake_generate="$WORK_DIR/generate_appcast"
download_prefix="https://github.com/OMT-Global/apw-cli/releases/download/v2.0.0/"
release_url="https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0"

printf 'fake notarized archive\n' >"$archive"
cat >"$notes" <<'NOTES'
Expand All @@ -23,15 +25,30 @@ cat >"$fake_generate" <<'FAKE'
#!/usr/bin/env bash
set -euo pipefail

updates_dir="$1"
updates_dir=""
critical_update=false
for argument in "$@"; do
updates_dir="$argument"
if [ "$argument" = "--critical-update-version" ]; then
critical_update=true
fi
done
printf '%s\n' "$@" >"$updates_dir/generate_appcast.args"

critical_element=""
if [ "$critical_update" = true ]; then
critical_element=' <sparkle:criticalUpdate />'
fi

cat >"$updates_dir/appcast.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<item>
<title>APW 2.0.0 Security Update</title>
<sparkle:releaseNotesLink sparkle:edSignature="notes-signed">https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0</sparkle:releaseNotesLink>
<sparkle:criticalUpdate />
<link>https://github.com/OMT-Global/apw-cli/releases/tag/v2.0.0</link>
$critical_element
<enclosure url="https://github.com/OMT-Global/apw-cli/releases/download/v2.0.0/APW.app.zip" sparkle:edSignature="signed" length="22" type="application/octet-stream" />
</item>
</channel>
Expand All @@ -44,20 +61,32 @@ chmod +x "$fake_generate"
--archive "$archive" \
--release-notes "$notes" \
--updates-dir "$updates" \
--generate-appcast "$fake_generate"
--generate-appcast "$fake_generate" \
--download-url-prefix "$download_prefix" \
--release-url "$release_url" \
--critical-update-version "1.9.9"

[ -f "$updates/APW.app.zip" ]
[ -f "$updates/APW.app.zip.md" ]
[ -f "$updates/appcast.xml" ]
grep -q 'sparkle:edSignature="signed"' "$updates/appcast.xml"
grep -q 'sparkle:edSignature="notes-signed"' "$updates/appcast.xml"
grep -Fx -- '--download-url-prefix' "$updates/generate_appcast.args" >/dev/null
grep -Fx -- "$download_prefix" "$updates/generate_appcast.args" >/dev/null
grep -Fx -- '--link' "$updates/generate_appcast.args" >/dev/null
grep -Fx -- "$release_url" "$updates/generate_appcast.args" >/dev/null
grep -Fx -- '--critical-update-version' "$updates/generate_appcast.args" >/dev/null
grep -Fx -- '1.9.9' "$updates/generate_appcast.args" >/dev/null

unsigned_notes_generate="$WORK_DIR/generate_unsigned_notes_appcast"
cat >"$unsigned_notes_generate" <<'FAKE'
#!/usr/bin/env bash
set -euo pipefail

updates_dir="$1"
updates_dir=""
for argument in "$@"; do
updates_dir="$argument"
done
cat >"$updates_dir/appcast.xml" <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
Expand All @@ -82,7 +111,10 @@ if "$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \
echo "prepare-sparkle-appcast accepted unsigned release notes." >&2
exit 1
fi
grep -q "unsigned Sparkle release notes" "$WORK_DIR/unsigned-notes.err"
if ! grep -q "unsigned Sparkle release notes" "$WORK_DIR/unsigned-notes.err"; then
cat "$WORK_DIR/unsigned-notes.err" >&2
exit 1
fi

missing_security_notes="$WORK_DIR/APW.app.no-security.md"
cat >"$missing_security_notes" <<'NOTES'
Expand All @@ -98,6 +130,7 @@ if "$ROOT_DIR/scripts/prepare-sparkle-appcast.sh" \
--release-notes "$missing_security_notes" \
--updates-dir "$WORK_DIR/missing-security" \
--generate-appcast "$fake_generate" \
--critical-update-version "1.9.9" \
>"$WORK_DIR/missing-security.out" 2>"$WORK_DIR/missing-security.err"; then
echo "prepare-sparkle-appcast accepted critical update notes without a Security section." >&2
exit 1
Expand Down
Loading