13 Commits

Author SHA1 Message Date
e0fb84ccb9 chore(release): prepare 0.2.3 2026-07-30 22:22:11 +02:00
30025a4ebf fix(release): download Play APK media 2026-07-30 22:19:40 +02:00
50e9a6c1cc chore(release): prepare 0.2.2 2026-07-30 21:54:54 +02:00
224a8e0e7a fix(release): enforce Apple hardened runtime 2026-07-30 21:27:34 +02:00
efacfab213 fix(release): expose Apple notarization failures 2026-07-30 21:03:30 +02:00
Hammed Abass
0ec7618ce8 Merge pull request #39 from sudosylabs/feat/release-pipeline-fixes
fix(release): repair Apple and Android builds
2026-07-30 20:30:28 +02:00
8b75423b7a fix(release): repair Apple and Android builds 2026-07-30 20:26:27 +02:00
Hammed Abass
7236933b76 Merge pull request #38 from sudosylabs/feat/release-0.2.1
chore(release): prepare 0.2.1
2026-07-30 19:43:19 +02:00
fc732e1b77 chore(release): prepare 0.2.1 2026-07-30 19:27:57 +02:00
Hammed Abass
d097c82f6a Merge pull request #37 from sudosylabs/feat/microsoft-store-publishing
ci: automate Microsoft Store updates
2026-07-30 19:25:15 +02:00
caaa9a472d ci: automate Microsoft Store updates 2026-07-30 19:12:50 +02:00
Hammed Abass
4ce124da7c Merge pull request #36 from sudosylabs/feat/automated-release-versions
feat(release): automate derived store versions
2026-07-30 17:58:18 +02:00
94a8b3481b feat(release): automate derived store versions 2026-07-30 17:40:00 +02:00
23 changed files with 865 additions and 128 deletions

View File

@@ -132,7 +132,16 @@ jobs:
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV" echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
- name: Build, sign & notarize DMG - name: Build, sign & notarize DMG
run: apple/scripts/build-dmg.sh run: make build-apple-dmg
- name: Upload notarization diagnostics
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
path: apple/dist/*.notary-log.json
if-no-files-found: ignore
retention-days: 14
- name: Generate appcast - name: Generate appcast
env: env:

View File

@@ -185,37 +185,19 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
apksigner_path="$(
find "$ANDROID_SDK_ROOT/build-tools" -type f -name apksigner -perm -111 |
sort -r |
head -1
)"
apkanalyzer_path="$( apkanalyzer_path="$(
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 | find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
sort -r | sort -r |
head -1 head -1
)" )"
if [ -z "$apksigner_path" ] || [ -z "$apkanalyzer_path" ]; then if [ -z "$apkanalyzer_path" ]; then
echo "Android SDK verification tools were not found" >&2 echo "apkanalyzer was not found" >&2
exit 1
fi
"$apksigner_path" verify --verbose --print-certs "$apk" \
> build/release/play/apksigner-report.txt
actual="$(
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}' \
build/release/play/apksigner-report.txt |
tr -d '[:space:]:' |
tr '[:upper:]' '[:lower:]'
)"
expected="$(
printf '%s' "$EXPECTED_CERT_SHA256" |
tr -d '[:space:]:' |
tr '[:upper:]' '[:lower:]'
)"
if [ -z "$actual" ] || [ "$actual" != "$expected" ]; then
echo "Play APK signing certificate mismatch" >&2
exit 1 exit 1
fi fi
packaging/android/verify-apk-signature.sh \
"$apk" \
"$EXPECTED_CERT_SHA256" \
>/dev/null
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
echo "Play APK package name mismatch" >&2 echo "Play APK package name mismatch" >&2
exit 1 exit 1
@@ -228,7 +210,6 @@ jobs:
echo "Play APK version code mismatch" >&2 echo "Play APK version code mismatch" >&2
exit 1 exit 1
fi fi
rm build/release/play/apksigner-report.txt
( (
cd build/release/play cd build/release/play
sha256sum \ sha256sum \
@@ -246,6 +227,114 @@ jobs:
retention-days: 90 retention-days: 90
compression-level: 0 compression-level: 0
publish-microsoft-store:
name: Submit Microsoft Store update
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
runs-on: windows-2025
timeout-minutes: 30
environment: microsoft-store
permissions:
contents: read
steps:
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/windows
- name: Validate Microsoft Store configuration
id: store-package
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
$configuration = @{
AZURE_AD_TENANT_ID = $env:AZURE_AD_TENANT_ID
AZURE_AD_APPLICATION_CLIENT_ID = $env:AZURE_AD_APPLICATION_CLIENT_ID
AZURE_AD_APPLICATION_SECRET = $env:AZURE_AD_APPLICATION_SECRET
SELLER_ID = $env:SELLER_ID
MICROSOFT_STORE_PRODUCT_ID = $env:MICROSOFT_STORE_PRODUCT_ID
}
foreach ($entry in $configuration.GetEnumerator()) {
if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -eq "REPLACE_ME") {
throw "Missing Microsoft Store configuration: $($entry.Key)"
}
}
if ($env:MICROSOFT_STORE_PRODUCT_ID -ne "9NJ5Q0FG7TGL") {
throw "Unexpected Microsoft Store product ID: $env:MICROSOFT_STORE_PRODUCT_ID"
}
$packages = @(
Get-ChildItem build/release/windows -File -Filter *.msixupload -Recurse
)
if ($packages.Count -ne 1) {
throw "Expected exactly one msixupload package, found $($packages.Count)"
}
"path=$($packages[0].FullName)" >> $env:GITHUB_OUTPUT
- name: Set up Microsoft Store Developer CLI
uses: microsoft/microsoft-store-apppublisher@15abd1c50fcc164b19cb240fb04ef3c49bf715a2 # v1.1
with:
version: v0.3.9
- name: Authenticate and verify Store access
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
msstore settings --enableTelemetry false
if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Microsoft Store CLI telemetry"
}
msstore reconfigure `
--tenantId "$env:AZURE_AD_TENANT_ID" `
--sellerId "$env:SELLER_ID" `
--clientId "$env:AZURE_AD_APPLICATION_CLIENT_ID" `
--clientSecret "$env:AZURE_AD_APPLICATION_SECRET"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store authentication failed"
}
msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "The Microsoft Store application is not accessible"
}
- name: Publish package to Microsoft Store
shell: pwsh
env:
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
STORE_PACKAGE: ${{ steps.store-package.outputs.path }}
run: |
msstore publish "$env:STORE_PACKAGE" `
--appId "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store package publication failed"
}
- name: Summarize Store submission
shell: pwsh
env:
VERSION: ${{ needs.preflight.outputs.version }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
"### Microsoft Store submission" >> $env:GITHUB_STEP_SUMMARY
"- App version: $env:VERSION" >> $env:GITHUB_STEP_SUMMARY
"- Product ID: $env:MICROSOFT_STORE_PRODUCT_ID" >> $env:GITHUB_STEP_SUMMARY
"- Package submitted for certification" >> $env:GITHUB_STEP_SUMMARY
publish-github: publish-github:
name: Publish coordinated GitHub Release name: Publish coordinated GitHub Release
needs: needs:
@@ -254,6 +343,7 @@ jobs:
- windows - windows
- macos - macos
- play-closed-testing - play-closed-testing
- publish-microsoft-store
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 20 timeout-minutes: 20
permissions: permissions:

View File

@@ -13,7 +13,7 @@ include $(ROOT)/make/release.mk
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink .PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop .PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
.PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple .PHONY: apple-core apple-version-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple
.PHONY: check-version check-release check-localization localization localization-migrate .PHONY: prepare-release check-version check-release check-localization localization localization-migrate
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics .PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
help: ## Show available commands and common configuration variables. help: ## Show available commands and common configuration variables.
@@ -61,14 +61,22 @@ test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification. check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
prepare-release: ## Update PRODUCT_VERSION and show its derived store versions (RELEASE_VERSION=x.y.z).
@test -n "$(RELEASE_VERSION)" || { printf 'Usage: make prepare-release RELEASE_VERSION=x.y.z\n' >&2; exit 1; }
cd $(ROOT) && packaging/version/prepare-release.sh "$(RELEASE_VERSION)"
check-version: ## Validate the canonical version and its platform mappings. check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && packaging/version/test-version.sh cd $(ROOT) && packaging/version/test-version.sh
cd $(ROOT) && packaging/version/resolve-version.sh verify cd $(ROOT) && packaging/version/resolve-version.sh verify
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS) cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-release: ## Validate coordinated release scripts and workflow YAML. check-release: ## Validate coordinated release scripts and workflow YAML.
cd $(ROOT) && bash -n packaging/android/build-release.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
cd $(ROOT) && packaging/release/test-assemble-release.sh cd $(ROOT) && packaging/release/test-assemble-release.sh
cd $(ROOT) && packaging/release/test-release-config.sh
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
@@ -139,7 +147,7 @@ build-apple-macos: apple-project ## Build the native macOS app (unsigned by defa
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check. build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
build-apple-dmg: ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env). build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh cd $(ROOT) && apple/scripts/build-dmg.sh
open-apple: build-apple-macos ## Build and launch the native macOS app. open-apple: build-apple-macos ## Build and launch the native macOS app.

View File

@@ -105,6 +105,12 @@ ACTUAL_BUILD="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' \
exit 1 exit 1
} }
echo "==> Enforcing hardened-runtime signature"
"$SCRIPT_DIR/sign-exported-app.sh" \
"$APP" \
"$DEVELOPER_ID_APP" \
"$APPLE_DIR/VniDrop/Resources/VniDropDirect.entitlements"
# --- Build the DMG ----------------------------------------------------------- # --- Build the DMG -----------------------------------------------------------
DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg" DMG="$DIST_DIR/$APP_NAME-$VERSION.dmg"
rm -f "$DMG" rm -f "$DMG"
@@ -137,7 +143,8 @@ codesign --force --sign "$DEVELOPER_ID_APP" --timestamp "$DMG"
# --- Notarize + staple ------------------------------------------------------- # --- Notarize + staple -------------------------------------------------------
if [ -n "${NOTARY_PROFILE:-}" ]; then if [ -n "${NOTARY_PROFILE:-}" ]; then
echo "==> Notarizing (profile: $NOTARY_PROFILE)" echo "==> Notarizing (profile: $NOTARY_PROFILE)"
xcrun notarytool submit "$DMG" --keychain-profile "$NOTARY_PROFILE" --wait NOTARY_LOG="$DIST_DIR/$APP_NAME-$VERSION.notary-log.json"
"$SCRIPT_DIR/notarize.sh" "$DMG" "$NOTARY_PROFILE" "$NOTARY_LOG"
echo "==> Stapling" echo "==> Stapling"
xcrun stapler staple "$DMG" xcrun stapler staple "$DMG"
xcrun stapler validate "$DMG" xcrun stapler validate "$DMG"

67
apple/scripts/notarize.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
printf 'Usage: %s <artifact> <keychain-profile> <log-output>\n' "$0" >&2
exit 2
fi
artifact=$1
keychain_profile=$2
log_output=$3
[[ -s $artifact ]] || {
printf 'error: notarization artifact is missing or empty: %s\n' "$artifact" >&2
exit 1
}
[[ -n $keychain_profile ]] || {
printf 'error: notarization keychain profile is empty\n' >&2
exit 1
}
[[ -n $log_output ]] || {
printf 'error: notarization log output path is empty\n' >&2
exit 1
}
rm -f "$log_output"
set +e
response="$(
xcrun notarytool submit "$artifact" \
--keychain-profile "$keychain_profile" \
--wait \
--output-format json
)"
submit_exit=$?
set -e
printf '%s\n' "$response"
submission_id="$(
printf '%s\n' "$response" |
jq -r '.id // empty' 2>/dev/null ||
true
)"
status="$(
printf '%s\n' "$response" |
jq -r '.status // empty' 2>/dev/null ||
true
)"
if [[ $submit_exit -eq 0 && $status == Accepted && -n $submission_id ]]; then
printf 'Notarization accepted (submission %s)\n' "$submission_id"
exit 0
fi
printf 'error: notarization was not accepted (status: %s, submission: %s)\n' \
"${status:-unknown}" "${submission_id:-unknown}" >&2
if [[ -n $submission_id ]]; then
mkdir -p "$(dirname "$log_output")"
if xcrun notarytool log "$submission_id" "$log_output" \
--keychain-profile "$keychain_profile"; then
printf '%s\n' 'Apple notarization log:' >&2
cat "$log_output" >&2
else
printf 'error: could not retrieve the Apple notarization log\n' >&2
fi
fi
exit 1

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 3 ]]; then
printf 'Usage: %s <app-bundle> <signing-identity> <entitlements>\n' "$0" >&2
exit 2
fi
app=$1
signing_identity=$2
entitlements=$3
[[ -d $app ]] || {
printf 'error: exported app bundle does not exist: %s\n' "$app" >&2
exit 1
}
[[ -n $signing_identity ]] || {
printf 'error: signing identity is empty\n' >&2
exit 1
}
[[ -f $entitlements ]] || {
printf 'error: entitlements file does not exist: %s\n' "$entitlements" >&2
exit 1
}
codesign \
--force \
--sign "$signing_identity" \
--options runtime \
--timestamp \
--entitlements "$entitlements" \
"$app"
codesign --verify --deep --strict --verbose=2 "$app"
signature_details="$(codesign --display --verbose=4 "$app" 2>&1)"
printf '%s\n' "$signature_details"
printf '%s\n' "$signature_details" |
grep -Eq 'flags=.*\(runtime([^)]*)?\)' || {
printf 'error: exported app signature does not enable the hardened runtime\n' >&2
exit 1
}

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
notarize="$script_dir/../notarize.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-notarize-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin"
artifact="$scratch/VniDrop.dmg"
calls="$scratch/calls.txt"
log_output="$scratch/notary/notary-log.json"
printf 'dmg\n' > "$artifact"
cat > "$scratch/bin/xcrun" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$FAKE_NOTARY_CALLS"
if [[ $1 == notarytool && $2 == submit ]]; then
case "${FAKE_NOTARY_MODE:-accepted}" in
accepted)
printf '%s\n' \
'{"id":"11111111-1111-1111-1111-111111111111","status":"Accepted"}'
;;
invalid)
printf '%s\n' \
'{"id":"22222222-2222-2222-2222-222222222222","status":"Invalid"}'
;;
transport-error)
printf '%s\n' 'notary service unavailable' >&2
exit 1
;;
esac
elif [[ $1 == notarytool && $2 == log ]]; then
mkdir -p "$(dirname "$4")"
printf '%s\n' \
'{"status":"Invalid","issues":[{"message":"The signature is invalid."}]}' \
> "$4"
else
printf 'unexpected xcrun invocation: %s\n' "$*" >&2
exit 1
fi
SCRIPT
chmod +x "$scratch/bin/xcrun"
PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=accepted \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null
[[ ! -e $log_output ]]
[[ $(grep -c '^notarytool submit ' "$calls") -eq 1 ]]
if grep -q '^notarytool log ' "$calls"; then
printf 'Accepted submissions must not request a rejection log\n' >&2
exit 1
fi
: > "$calls"
if PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=invalid \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
printf 'Invalid notarization must fail\n' >&2
exit 1
fi
grep -F '"The signature is invalid."' "$log_output" >/dev/null
grep -F \
'notarytool log 22222222-2222-2222-2222-222222222222' \
"$calls" >/dev/null
: > "$calls"
rm -f "$log_output"
if PATH="$scratch/bin:$PATH" \
FAKE_NOTARY_CALLS="$calls" \
FAKE_NOTARY_MODE=transport-error \
"$notarize" "$artifact" test-profile "$log_output" >/dev/null 2>&1; then
printf 'Notary transport errors must fail\n' >&2
exit 1
fi
[[ ! -e $log_output ]]
if grep -q '^notarytool log ' "$calls"; then
printf 'A submission without an ID cannot request a rejection log\n' >&2
exit 1
fi
printf 'Notarization helper tests passed.\n'

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sign_exported_app="$script_dir/../sign-exported-app.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-codesign-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
mkdir -p "$scratch/bin" "$scratch/VniDrop.app/Contents/MacOS"
app="$scratch/VniDrop.app"
entitlements="$scratch/VniDropDirect.entitlements"
calls="$scratch/calls.txt"
printf '<plist><dict/></plist>\n' > "$entitlements"
printf 'binary\n' > "$app/Contents/MacOS/VniDrop"
cat > "$scratch/bin/codesign" <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$FAKE_CODESIGN_CALLS"
case " $* " in
*" --display "*)
if [[ ${FAKE_CODESIGN_MODE:-runtime} == missing-runtime ]]; then
printf '%s\n' \
'CodeDirectory v=20500 size=123 flags=0x0(none) hashes=1+0 location=embedded' \
>&2
else
printf '%s\n' \
'CodeDirectory v=20500 size=123 flags=0x10000(runtime) hashes=1+0 location=embedded' \
>&2
fi
;;
*" --verify "*)
if [[ ${FAKE_CODESIGN_MODE:-runtime} == verify-error ]]; then
printf '%s\n' 'invalid signature' >&2
exit 1
fi
;;
esac
SCRIPT
chmod +x "$scratch/bin/codesign"
PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null
grep -F -- \
'--force --sign Developer ID Application: Example (ABCDEFGHIJ) --options runtime --timestamp --entitlements' \
"$calls" >/dev/null
grep -F -- '--verify --deep --strict --verbose=2' "$calls" >/dev/null
grep -F -- '--display --verbose=4' "$calls" >/dev/null
if PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
FAKE_CODESIGN_MODE=missing-runtime \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null 2>&1; then
printf 'A signature without the hardened runtime must fail\n' >&2
exit 1
fi
if PATH="$scratch/bin:$PATH" \
FAKE_CODESIGN_CALLS="$calls" \
FAKE_CODESIGN_MODE=verify-error \
"$sign_exported_app" \
"$app" \
'Developer ID Application: Example (ABCDEFGHIJ)' \
"$entitlements" >/dev/null 2>&1; then
printf 'Signature verification errors must fail\n' >&2
exit 1
fi
printf 'Exported app signing tests passed.\n'

View File

@@ -41,19 +41,18 @@ val productVersionMatch = Regex("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-
.matchEntire(productVersion) .matchEntire(productVersion)
?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers") ?: error("PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers")
val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong) val productVersionParts = productVersionMatch.groupValues.drop(1).map(String::toLong)
require(productVersionParts[0] <= 65534 && productVersionParts.drop(1).all { it <= 65535 }) { require(productVersionParts[0] <= 2099 && productVersionParts.drop(1).all { it <= 999 }) {
"PRODUCT_VERSION components exceed the supported store ranges" "PRODUCT_VERSION must use a major no greater than 2099 and minor/patch no greater than 999"
} }
val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL") val releaseChannel = requiredVersionProperty("RELEASE_CHANNEL")
require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) { require(releaseChannel.matches(Regex("[a-z][a-z0-9-]*"))) {
"RELEASE_CHANNEL contains unsupported characters" "RELEASE_CHANNEL contains unsupported characters"
} }
val androidVersionCode = canonicalInteger( val androidVersionCode =
"ANDROID_VERSION_CODE", (productVersionParts[0] * 1_000_000L + productVersionParts[1] * 1_000L + productVersionParts[2])
requiredVersionProperty("ANDROID_VERSION_CODE"), .also { require(it in 1L..2_100_000_000L) { "Derived Android version code is out of range" } }
1L..2_100_000_000L, .toInt()
).toInt()
val windowsVersionEpoch = canonicalInteger( val windowsVersionEpoch = canonicalInteger(
"WINDOWS_VERSION_EPOCH", "WINDOWS_VERSION_EPOCH",
requiredVersionProperty("WINDOWS_VERSION_EPOCH"), requiredVersionProperty("WINDOWS_VERSION_EPOCH"),

View File

@@ -53,18 +53,6 @@ verify_archive_entries() {
done done
} }
find_apksigner() {
if command -v apksigner >/dev/null 2>&1; then
command -v apksigner
return
fi
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
[[ -n $sdk_root ]] || return 1
find "$sdk_root/build-tools" -type f -name apksigner -perm -111 2>/dev/null |
sort -r |
head -1
}
for name in \ for name in \
VNIDROP_ANDROID_KEYSTORE_PATH \ VNIDROP_ANDROID_KEYSTORE_PATH \
VNIDROP_ANDROID_KEYSTORE_PASSWORD \ VNIDROP_ANDROID_KEYSTORE_PASSWORD \
@@ -119,26 +107,12 @@ grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || {
} }
verify_archive_entries "$source_apk" "${required_apk_libraries[@]}" verify_archive_entries "$source_apk" "${required_apk_libraries[@]}"
verify_archive_entries "$source_aab" "${required_aab_libraries[@]}" verify_archive_entries "$source_aab" "${required_aab_libraries[@]}"
apksigner_path="$(find_apksigner)" || {
printf 'apksigner was not found in PATH or the Android SDK\n' >&2
exit 1
}
signature_report="$("$apksigner_path" verify --verbose --print-certs "$source_apk")"
actual_fingerprint="$( actual_fingerprint="$(
printf '%s\n' "$signature_report" | "$script_dir/verify-apk-signature.sh" \
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; exit}' "$source_apk" \
"$VNIDROP_ANDROID_UPLOAD_CERT_SHA256"
)" )"
[[ -n $actual_fingerprint ]] || {
printf 'Could not read the APK signing certificate fingerprint\n' >&2
exit 1
}
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")" expected_fingerprint="$(normalize_fingerprint "$VNIDROP_ANDROID_UPLOAD_CERT_SHA256")"
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
"$expected_fingerprint" "$actual_fingerprint" >&2
exit 1
}
aab_fingerprint="$( aab_fingerprint="$(
keytool -printcert -jarfile "$source_aab" | keytool -printcert -jarfile "$source_aab" |
awk -F': ' '/SHA256:/ {print $2; exit}' awk -F': ' '/SHA256:/ {print $2; exit}'

View File

@@ -184,6 +184,19 @@ def generated_apks_url(package_name: str, version_code: int) -> str:
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}" return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
def generated_apk_download_url(
package_name: str,
version_code: int,
download_id: str,
) -> str:
package = urllib.parse.quote(package_name, safe="")
download = urllib.parse.quote(download_id, safe="")
return (
f"{API_ROOT}/applications/{package}/generatedApks/"
f"{version_code}/downloads/{download}:download?alt=media"
)
def get_generated_apks( def get_generated_apks(
client: PlayClient, client: PlayClient,
package_name: str, package_name: str,
@@ -216,22 +229,23 @@ def download_universal_apk(
selected = find_universal_apk(response, expected_fingerprint) selected = find_universal_apk(response, expected_fingerprint)
if selected is not None: if selected is not None:
fingerprint, download_id = selected fingerprint, download_id = selected
package = urllib.parse.quote(package_name, safe="") apk = client.request(
download = urllib.parse.quote(download_id, safe="") "GET",
url = ( generated_apk_download_url(
f"{API_ROOT}/applications/{package}/generatedApks/" package_name,
f"{version_code}/downloads/{download}:download" version_code,
download_id,
),
) )
if apk:
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(client.request("GET", url)) output.write_bytes(apk)
if output.stat().st_size == 0:
raise RuntimeError("Google Play returned an empty universal APK")
return fingerprint return fingerprint
if attempt < attempts: if attempt < attempts:
time.sleep(interval_seconds) time.sleep(interval_seconds)
raise RuntimeError( raise RuntimeError(
"Google Play did not provide a universal APK signed with the expected " "Google Play did not provide a non-empty universal APK signed with the "
f"certificate after {attempts} attempts" f"expected certificate after {attempts} attempts"
) )

View File

@@ -1,4 +1,5 @@
import importlib.util import importlib.util
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -44,6 +45,69 @@ class PublishPlayTests(unittest.TestCase):
("aabb", "correct"), ("aabb", "correct"),
) )
def test_downloads_generated_apk_as_media(self):
class FakePlayClient:
def __init__(self):
self.download_urls = []
self.media_attempts = 0
def request_json(self, method, url):
self.assert_request(method, url)
return {
"generatedApks": [
{
"certificateSha256Hash": "AA:BB",
"generatedUniversalApk": {
"downloadId": "download/id+=",
},
}
]
}
def request(self, method, url):
self.assert_request(method, url)
self.download_urls.append(url)
if not url.endswith("?alt=media"):
return b""
self.media_attempts += 1
return b"apk" if self.media_attempts == 2 else b""
@staticmethod
def assert_request(method, url):
if method != "GET" or not url.startswith(publish_play.API_ROOT):
raise AssertionError(f"unexpected request: {method} {url}")
client = FakePlayClient()
with tempfile.TemporaryDirectory() as scratch:
output = Path(scratch) / "universal.apk"
fingerprint = publish_play.download_universal_apk(
client,
"com.example app",
2002,
"aa:bb",
output,
attempts=2,
interval_seconds=0,
)
self.assertEqual(fingerprint, "aabb")
self.assertEqual(output.read_bytes(), b"apk")
self.assertEqual(
client.download_urls,
[
(
f"{publish_play.API_ROOT}/applications/com.example%20app/"
"generatedApks/2002/downloads/"
"download%2Fid%2B%3D:download?alt=media"
),
(
f"{publish_play.API_ROOT}/applications/com.example%20app/"
"generatedApks/2002/downloads/"
"download%2Fid%2B%3D:download?alt=media"
),
],
)
def test_track_update_preserves_existing_releases_and_adds_draft(self): def test_track_update_preserves_existing_releases_and_adds_draft(self):
track = { track = {
"track": "closed-beta", "track": "closed-beta",

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
verifier="$script_dir/../verify-apk-signature.sh"
scratch="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-apksigner-test.XXXXXX")"
trap 'rm -rf "$scratch"' EXIT
apk="$scratch/app.apk"
fake_apksigner="$scratch/apksigner"
printf 'apk\n' > "$apk"
cat > "$fake_apksigner" <<'SCRIPT'
#!/usr/bin/env bash
case "${FAKE_APKSIGNER_MODE:-success}" in
success)
printf '%s\n' \
'Verifies' \
'Signer #1 certificate SHA-256 digest: AA:BB:CC:DD' >&2
;;
missing)
printf '%s\n' 'Verifies' >&2
;;
failure)
printf '%s\n' 'invalid APK signature' >&2
exit 1
;;
esac
SCRIPT
chmod +x "$fake_apksigner"
actual="$(
APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" "aa bb cc dd"
)"
[[ $actual == aabbccdd ]]
if APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" deadbeef >/dev/null 2>&1; then
printf 'Expected a certificate mismatch to fail\n' >&2
exit 1
fi
if FAKE_APKSIGNER_MODE=missing APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
printf 'Expected missing certificate output to fail\n' >&2
exit 1
fi
if FAKE_APKSIGNER_MODE=failure APKSIGNER="$fake_apksigner" \
"$verifier" "$apk" aabbccdd >/dev/null 2>&1; then
printf 'Expected signature verification failure to propagate\n' >&2
exit 1
fi
printf 'APK signature verifier tests passed.\n'

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
printf 'Usage: %s <apk> <expected-certificate-sha256>\n' "$0" >&2
exit 2
fi
apk=$1
expected_fingerprint=$2
build_tools_version=${ANDROID_BUILD_TOOLS_VERSION:-36.0.0}
normalize_fingerprint() {
printf '%s' "$1" |
tr -d '[:space:]:' |
tr '[:upper:]' '[:lower:]'
}
find_apksigner() {
if [[ -n ${APKSIGNER:-} ]]; then
[[ -x $APKSIGNER ]] || {
printf 'Configured apksigner is not executable: %s\n' "$APKSIGNER" >&2
return 1
}
printf '%s\n' "$APKSIGNER"
return
fi
local sdk_root=${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}
if [[ -n $sdk_root ]]; then
local pinned="$sdk_root/build-tools/$build_tools_version/apksigner"
if [[ -x $pinned ]]; then
printf '%s\n' "$pinned"
return
fi
fi
if command -v apksigner >/dev/null 2>&1; then
command -v apksigner
return
fi
printf 'apksigner %s was not found in the Android SDK or PATH\n' \
"$build_tools_version" >&2
return 1
}
[[ -s $apk ]] || {
printf 'APK is missing or empty: %s\n' "$apk" >&2
exit 1
}
apksigner_path="$(find_apksigner)" || exit 1
if ! signature_report="$(
"$apksigner_path" verify --verbose --print-certs "$apk" 2>&1
)"; then
printf 'APK signature verification failed:\n%s\n' "$signature_report" >&2
exit 1
fi
actual_fingerprint="$(
printf '%s\n' "$signature_report" |
awk '
tolower($0) ~ /^signer #1 certificate sha-256 digest:[[:space:]]*/ {
line = $0
sub(/^[^:]*:[[:space:]]*/, "", line)
print line
exit
}
'
)"
[[ -n $actual_fingerprint ]] || {
printf 'Could not read the APK signing certificate fingerprint\n' >&2
exit 1
}
actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")"
expected_fingerprint="$(normalize_fingerprint "$expected_fingerprint")"
[[ $actual_fingerprint == "$expected_fingerprint" ]] || {
printf 'APK signing certificate mismatch: expected %s, got %s\n' \
"$expected_fingerprint" "$actual_fingerprint" >&2
exit 1
}
printf '%s\n' "$actual_fingerprint"

View File

@@ -13,27 +13,37 @@ Platform workflows upload private workflow artifacts. After every native build
passes, the release pipeline: passes, the release pipeline:
1. stages the signed AAB as a draft on the configured Play closed-test track; 1. stages the signed AAB as a draft on the configured Play closed-test track;
2. downloads the universal APK signed by Play; 2. submits the unsigned `.msixupload` package to Microsoft Store certification;
3. verifies and assembles the public artifacts; 3. downloads the universal APK signed by Play;
4. generates checksums and GitHub build-provenance attestations; 4. verifies and assembles the public artifacts;
5. creates exactly one GitHub Release; 5. generates checksums and GitHub build-provenance attestations;
6. updates the Homebrew cask. 6. creates exactly one GitHub Release;
7. updates the Homebrew cask.
Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast, Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast,
Play-signed universal APK, checksum file, and release manifest. Play-signed universal APK, checksum file, and release manifest.
The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain
private workflow artifacts. Partner Center submission stays manual until the private workflow artifacts. The protected `microsoft-store` GitHub Environment
first Microsoft Store release is certified. The Play release remains a draft supplies the Partner Center credentials and Store product ID used to submit the
on a closed-testing track; this pipeline cannot publish to production. Windows package. Microsoft publishes the update after certification; the job
does not change Store listings, pricing, or availability. The Play release
remains a draft on a closed-testing track; this pipeline cannot publish it to
production.
To release, first update and merge `version.properties`, including a monotonic To release, prepare and merge the new product version. Android, Microsoft Store,
Android version code. Apple Store and Direct build numbers are derived and Apple build/package versions are derived automatically:
independently at build time. Then create and push the matching tag:
```bash ```bash
git tag -s v0.2.0 -m "VniDrop 0.2.0" make prepare-release RELEASE_VERSION=0.2.1
git push origin v0.2.0 make check-version
```
Then create and push the matching tag:
```bash
git tag -s v0.2.1 -m "VniDrop 0.2.1"
git push origin v0.2.1
``` ```
The tag must point at the current `origin/master` commit. A failed run creates The tag must point at the current `origin/master` commit. A failed run creates

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
dry_run="$(make -n -C "$repo_root" build-apple-dmg)"
localization_line="$(
printf '%s\n' "$dry_run" |
awk '/bun run generate/ {print NR; exit}'
)"
build_line="$(
printf '%s\n' "$dry_run" |
awk '/apple\/scripts\/build-dmg\.sh/ {print NR; exit}'
)"
[[ -n $localization_line && -n $build_line && $localization_line -lt $build_line ]] || {
printf 'build-apple-dmg must generate localization before building the DMG\n' >&2
exit 1
}
grep -F 'run: make build-apple-dmg' \
"$repo_root/.github/workflows/apple-release.yml" >/dev/null || {
printf 'Apple release workflow must use the generated-input-aware Make target\n' >&2
exit 1
}
signing_line="$(
awk '/sign-exported-app\.sh/ {print NR; exit}' \
"$repo_root/apple/scripts/build-dmg.sh"
)"
dmg_line="$(
awk '/echo "==> Building DMG"/ {print NR; exit}' \
"$repo_root/apple/scripts/build-dmg.sh"
)"
[[ -n $signing_line && -n $dmg_line && $signing_line -lt $dmg_line ]] || {
printf 'The exported app must enforce hardened-runtime signing before DMG creation\n' >&2
exit 1
}
printf 'Release configuration tests passed.\n'

View File

@@ -1,9 +1,9 @@
# Application versioning # Application versioning
`version.properties` at the repository root is the single source of truth for `version.properties` at the repository root is the single source of truth for
the VniDrop product version and persistent store counters. Platform projects the VniDrop product version and the permanent Windows version epoch. Platform
and release workflows use the version resolver rather than accepting projects and release workflows derive their versions from it rather than
independent version overrides. accepting independent release counters.
Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell, Keep it as plain `KEY=VALUE` assignments: the same file is parsed by shell,
PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files. PowerShell, Gradle, and Rust. Xcode receives resolver-generated xcconfig files.
@@ -15,7 +15,7 @@ the patch component (`0.2.1`). Release channels belong in
| Platform | Product version | Platform build/package version | | Platform | Product version | Platform build/package version |
| --- | --- | --- | | --- | --- | --- |
| Android | `PRODUCT_VERSION` | `ANDROID_VERSION_CODE` | | Android | `PRODUCT_VERSION` | Derived monotonic integer |
| Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` | | Apple Store | `PRODUCT_VERSION` | Derived UTC `YYYYMMDD.HHMM.SS` |
| Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` | | Direct macOS | `PRODUCT_VERSION` | Independently derived UTC `YYYYMMDD.HHMM.SS` |
| Linux | `PRODUCT_VERSION` | Native package revision | | Linux | `PRODUCT_VERSION` | Native package revision |
@@ -32,12 +32,23 @@ the Store. Its version is:
With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.0` With epoch `1`, product `0.2.0` maps to MSIX `1.2.0.0`, while product `1.0.0`
maps to `2.0.0.0`. Do not change the epoch after publishing. maps to `2.0.0.0`. Do not change the epoch after publishing.
Every Android upload must increment `ANDROID_VERSION_CODE`. Apple build numbers Android derives its version code as:
are derived at build time by `apple-store-build` and `apple-direct-build`; they
are kept as separate resolver outputs so App Store and Sparkle releases do not ```text
consume each other's cadence. Every changed Windows Store package must product major * 1,000,000 + product minor * 1,000 + product patch
increment the product version because the Store-reserved fourth component ```
cannot carry a rebuild number.
For example, `0.2.0` maps to Android code `2000`, `0.2.1` to `2001`, and
`1.0.0` to `1000000`. To keep that mapping unique and within store limits,
the product major may not exceed `2099`, and minor and patch may not exceed
`999`. A rejected store build must use a new patch version rather than
rebuilding a previously uploaded product version.
Apple build numbers are derived at build time by `apple-store-build` and
`apple-direct-build`; they are kept as separate resolver outputs so App Store
and Sparkle releases do not consume each other's cadence. Every changed Windows
Store package must increment the product version because the Store-reserved
fourth component cannot carry a rebuild number.
Apple projects read generated build settings rather than `version.properties` Apple projects read generated build settings rather than `version.properties`
directly: directly:
@@ -50,7 +61,14 @@ The generated files under `apple/Generated/` are intentionally ignored.
`VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for `VNIDROP_BUILD_TIME_UTC=YYYYMMDDHHMMSS` provides a deterministic clock for
tests; distribution builds normally use the current UTC time. tests; distribution builds normally use the current UTC time.
Before releasing: Prepare the next release by changing only the product version:
```bash
make prepare-release RELEASE_VERSION=0.2.1
```
The command refuses non-increasing versions, updates `PRODUCT_VERSION`, and
prints the derived Android and Microsoft Store versions. Then verify:
```bash ```bash
make check-version make check-version

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$script_dir/resolve-version.sh"
version_file="${VNIDROP_VERSION_FILE:-$repo_root/version.properties}"
next_version="${1:-}"
fail() {
printf '%s\n' "$*" >&2
exit 1
}
[[ -n $next_version ]] ||
fail "Usage: $0 MAJOR.MINOR.PATCH"
[[ -f $version_file ]] ||
fail "Version file not found: $version_file"
[[ $(grep -c '^PRODUCT_VERSION=' "$version_file") == 1 ]] ||
fail "Expected exactly one PRODUCT_VERSION entry in $version_file"
current_version="$(
VNIDROP_VERSION_FILE="$version_file" "$resolver" product
)"
current_android_code="$(
VNIDROP_VERSION_FILE="$version_file" "$resolver" android-code
)"
temporary="$(mktemp "$(dirname "$version_file")/.version.properties.XXXXXX")"
trap 'rm -f "$temporary"' EXIT
sed "s/^PRODUCT_VERSION=.*/PRODUCT_VERSION=$next_version/" \
"$version_file" > "$temporary"
next_android_code="$(
VNIDROP_VERSION_FILE="$temporary" "$resolver" android-code
)"
next_windows_package="$(
VNIDROP_VERSION_FILE="$temporary" "$resolver" windows-package
)"
VNIDROP_VERSION_FILE="$temporary" "$resolver" verify >/dev/null
(( next_android_code > current_android_code )) ||
fail "New version must be greater than $current_version"
chmod 644 "$temporary"
mv "$temporary" "$version_file"
trap - EXIT
printf 'Prepared VniDrop %s\n' "$next_version"
printf ' Android version code: %s\n' "$next_android_code"
printf ' Microsoft Store package: %s\n' "$next_windows_package"
printf 'Next: make check-version\n'

View File

@@ -47,7 +47,6 @@ function Convert-CanonicalInteger {
$productVersion = Read-VersionProperty "PRODUCT_VERSION" $productVersion = Read-VersionProperty "PRODUCT_VERSION"
$releaseChannel = Read-VersionProperty "RELEASE_CHANNEL" $releaseChannel = Read-VersionProperty "RELEASE_CHANNEL"
$androidVersionCodeText = Read-VersionProperty "ANDROID_VERSION_CODE"
$windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText = Read-VersionProperty "WINDOWS_VERSION_EPOCH"
$buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC $buildTimeUtc = $env:VNIDROP_BUILD_TIME_UTC
if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) { if ([string]::IsNullOrWhiteSpace($buildTimeUtc)) {
@@ -78,13 +77,16 @@ if ($productVersion -notmatch "^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*
throw "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers" throw "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
} }
$productParts = $productVersion.Split(".") $productParts = $productVersion.Split(".")
$productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 65534 $productMajor = Convert-CanonicalInteger "PRODUCT_VERSION major" $productParts[0] 0 2099
$null = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 65535 $productMinor = Convert-CanonicalInteger "PRODUCT_VERSION minor" $productParts[1] 0 999
$null = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 65535 $productPatch = Convert-CanonicalInteger "PRODUCT_VERSION patch" $productParts[2] 0 999
if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") { if ($releaseChannel -notmatch "^[a-z][a-z0-9-]*$") {
throw "RELEASE_CHANNEL contains unsupported characters" throw "RELEASE_CHANNEL contains unsupported characters"
} }
$androidVersionCode = Convert-CanonicalInteger "ANDROID_VERSION_CODE" $androidVersionCodeText 1 2100000000 $androidVersionCode = $productMajor * 1000000L + $productMinor * 1000L + $productPatch
if ($androidVersionCode -lt 1 -or $androidVersionCode -gt 2100000000L) {
throw "Derived Android version code must be between 1 and 2100000000"
}
$windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535 $windowsVersionEpoch = Convert-CanonicalInteger "WINDOWS_VERSION_EPOCH" $windowsVersionEpochText 1 65535
$windowsMajor = $productMajor + $windowsVersionEpoch $windowsMajor = $productMajor + $windowsVersionEpoch
if ($windowsMajor -gt 65535) { if ($windowsMajor -gt 65535) {

View File

@@ -34,23 +34,29 @@ validate_canonical_integer() {
product_version="$(read_property PRODUCT_VERSION)" product_version="$(read_property PRODUCT_VERSION)"
release_channel="$(read_property RELEASE_CHANNEL)" release_channel="$(read_property RELEASE_CHANNEL)"
android_version_code="$(read_property ANDROID_VERSION_CODE)"
windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)" windows_version_epoch="$(read_property WINDOWS_VERSION_EPOCH)"
build_time_utc="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}" build_time_utc="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
[[ $product_version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || [[ $product_version =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
fail "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers" fail "PRODUCT_VERSION must use canonical MAJOR.MINOR.PATCH integers"
IFS=. read -r product_major product_minor product_patch <<< "$product_version" IFS=. read -r product_major product_minor product_patch <<< "$product_version"
validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 65534 validate_canonical_integer "PRODUCT_VERSION major" "$product_major" 0 2099
validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 65535 validate_canonical_integer "PRODUCT_VERSION minor" "$product_minor" 0 999
validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 65535 validate_canonical_integer "PRODUCT_VERSION patch" "$product_patch" 0 999
[[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] || [[ $release_channel =~ ^[a-z][a-z0-9-]*$ ]] ||
fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens" fail "RELEASE_CHANNEL must start with a lowercase letter and contain only lowercase letters, digits, and hyphens"
validate_canonical_integer "ANDROID_VERSION_CODE" "$android_version_code" 1 2100000000
validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535 validate_canonical_integer "WINDOWS_VERSION_EPOCH" "$windows_version_epoch" 1 65535
[[ $build_time_utc =~ ^[0-9]{14}$ ]] || [[ $build_time_utc =~ ^[0-9]{14}$ ]] ||
fail "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS" fail "VNIDROP_BUILD_TIME_UTC must use YYYYMMDDHHMMSS"
android_version_code=$((
(10#$product_major * 1000000) +
(10#$product_minor * 1000) +
10#$product_patch
))
(( android_version_code >= 1 && android_version_code <= 2100000000 )) ||
fail "Derived Android version code must be between 1 and 2100000000"
build_month="${build_time_utc:4:2}" build_month="${build_time_utc:4:2}"
build_day="${build_time_utc:6:2}" build_day="${build_time_utc:6:2}"
build_hour="${build_time_utc:8:2}" build_hour="${build_time_utc:8:2}"

View File

@@ -4,6 +4,7 @@ set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolver="$script_dir/resolve-version.sh" resolver="$script_dir/resolve-version.sh"
prepare_release="$script_dir/prepare-release.sh"
scratch="$(mktemp -d)" scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT trap 'rm -rf "$scratch"' EXIT
@@ -11,8 +12,7 @@ write_version() {
printf '%s\n' \ printf '%s\n' \
"PRODUCT_VERSION=$1" \ "PRODUCT_VERSION=$1" \
"RELEASE_CHANNEL=$2" \ "RELEASE_CHANNEL=$2" \
"ANDROID_VERSION_CODE=$3" \ "WINDOWS_VERSION_EPOCH=$3" \
"WINDOWS_VERSION_EPOCH=$4" \
> "$scratch/version.properties" > "$scratch/version.properties"
} }
@@ -29,34 +29,45 @@ expect_failure() {
export VNIDROP_BUILD_TIME_UTC=20260728143217 export VNIDROP_BUILD_TIME_UTC=20260728143217
write_version 0.2.0 beta 2 1 write_version 0.2.0 beta 1
[[ $(resolve product) == 0.2.0 ]] [[ $(resolve product) == 0.2.0 ]]
[[ $(resolve android-code) == 2 ]] [[ $(resolve android-code) == 2000 ]]
[[ $(resolve apple-store-build) == 20260728.1432.17 ]] [[ $(resolve apple-store-build) == 20260728.1432.17 ]]
[[ $(resolve apple-direct-build) == 20260728.1432.17 ]] [[ $(resolve apple-direct-build) == 20260728.1432.17 ]]
[[ $(resolve windows-package) == 1.2.0.0 ]] [[ $(resolve windows-package) == 1.2.0.0 ]]
resolve verify-tag v0.2.0 resolve verify-tag v0.2.0
expect_failure resolve verify-tag v1.0.0 expect_failure resolve verify-tag v1.0.0
write_version 1.0.0 stable 42 1 write_version 1.0.0 stable 1
[[ $(resolve android-code) == 1000000 ]]
[[ $(resolve windows-package) == 2.0.0.0 ]] [[ $(resolve windows-package) == 2.0.0.0 ]]
write_version 01.0.0 beta 2 1 write_version 2099.999.999 stable 1
[[ $(resolve android-code) == 2099999999 ]]
write_version 01.0.0 beta 1
expect_failure resolve verify expect_failure resolve verify
write_version 0.2.0 beta 0 1 write_version 0.0.0 beta 1
expect_failure resolve verify expect_failure resolve verify
write_version 65535.0.0 stable 2 1 write_version 2100.0.0 stable 1
expect_failure resolve verify
write_version 0.1000.0 stable 1
expect_failure resolve verify
write_version 0.0.1000 stable 1
expect_failure resolve verify expect_failure resolve verify
VNIDROP_BUILD_TIME_UTC=20260728146000 expect_failure resolve verify VNIDROP_BUILD_TIME_UTC=20260728146000 expect_failure resolve verify
VNIDROP_BUILD_TIME_UTC=2026-07-28 expect_failure resolve verify VNIDROP_BUILD_TIME_UTC=2026-07-28 expect_failure resolve verify
VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify VNIDROP_BUILD_TIME_UTC=20260229080000 expect_failure resolve verify
write_version 0.2.0 beta 2 1 write_version 0.2.0 beta 1
config_dir="$scratch/xcconfig" config_dir="$scratch/xcconfig"
VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \ VNIDROP_VERSION_FILE="$scratch/version.properties" \
VNIDROP_APPLE_XCCONFIG_DIR="$config_dir" \
"$script_dir/generate-apple-xcconfig.sh" all "$script_dir/generate-apple-xcconfig.sh" all
grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null grep -Fx "PRODUCT_VERSION = 0.2.0" "$config_dir/StoreVersion.xcconfig" >/dev/null
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \ grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
@@ -64,4 +75,14 @@ grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \ grep -Fx "CURRENT_PROJECT_VERSION = 20260728.1432.17" \
"$config_dir/DirectVersion.xcconfig" >/dev/null "$config_dir/DirectVersion.xcconfig" >/dev/null
VNIDROP_VERSION_FILE="$scratch/version.properties" \
"$prepare_release" 0.2.1 >/dev/null
[[ $(resolve product) == 0.2.1 ]]
[[ $(resolve android-code) == 2001 ]]
[[ $(resolve windows-package) == 1.2.1.0 ]]
expect_failure env VNIDROP_VERSION_FILE="$scratch/version.properties" \
"$prepare_release" 0.2.1
expect_failure env VNIDROP_VERSION_FILE="$scratch/version.properties" \
"$prepare_release" 0.1.999
printf 'Version resolver tests passed.\n' printf 'Version resolver tests passed.\n'

View File

@@ -74,16 +74,21 @@ Use this restricted-capability justification in Submission options:
> Rust and JVM libraries and needs normal user-level filesystem and network > Rust and JVM libraries and needs normal user-level filesystem and network
> access to transfer user-selected files directly between devices. > access to transfer user-selected files directly between devices.
After the first release is certified and live, Store publication can be added After the first release is certified and live, the coordinated release
as a separate protected job. Keep its Partner Center credentials in a GitHub workflow submits the generated `.msixupload` from a separate protected job.
Environment, not in this build job: Keep its Partner Center credentials in the `microsoft-store` GitHub
Environment, not in the build job:
- AZURE_AD_TENANT_ID - AZURE_AD_TENANT_ID
- AZURE_AD_APPLICATION_CLIENT_ID - AZURE_AD_APPLICATION_CLIENT_ID
- AZURE_AD_APPLICATION_SECRET - AZURE_AD_APPLICATION_SECRET
- SELLER_ID - SELLER_ID
The Store ID is a non-secret variable. Set `MICROSOFT_STORE_PRODUCT_ID` to `9NJ5Q0FG7TGL` as a non-secret variable in
the same environment. The publishing job validates the product ID,
authenticates with the pinned Microsoft Store Developer CLI, verifies access to
the product, and submits only the package for certification. Existing listings,
pricing, and availability are preserved.
## Manual build on Windows ## Manual build on Windows

View File

@@ -1,4 +1,3 @@
PRODUCT_VERSION=0.2.0 PRODUCT_VERSION=0.2.3
RELEASE_CHANNEL=beta RELEASE_CHANNEL=beta
ANDROID_VERSION_CODE=2
WINDOWS_VERSION_EPOCH=1 WINDOWS_VERSION_EPOCH=1