ci: add coordinated release pipeline

This commit is contained in:
2026-07-28 11:09:53 +02:00
parent 52d4102308
commit 2d7982bbb9
17 changed files with 1678 additions and 165 deletions

View File

@@ -0,0 +1,54 @@
# Android release pipeline
Android releases use two independent credentials:
- the upload keystore signs the APK and AAB;
- a short-lived Google access token publishes the AAB through the Play
Developer API.
The GitHub release workflow expects these encrypted secrets:
- `ANDROID_UPLOAD_KEYSTORE_BASE64`
- `ANDROID_UPLOAD_KEYSTORE_PASSWORD`
- `ANDROID_UPLOAD_KEY_ALIAS`
- `ANDROID_UPLOAD_KEY_PASSWORD`
It also expects this repository variable:
- `ANDROID_UPLOAD_CERT_SHA256`
The protected `play-closed-testing` GitHub Environment supplies:
- `PLAY_APP_SIGNING_CERT_SHA256`
- `GCP_WORKLOAD_IDENTITY_PROVIDER`
- `GCP_PLAY_SERVICE_ACCOUNT`
- `PLAY_PACKAGE_NAME` (`com.vnidrop.app`)
- `PLAY_CLOSED_TRACK` (the existing closed-test track identifier)
The upload and app-signing certificate fingerprints are public identifiers from
Play Console's App signing page. Do not store a private key in a repository
variable.
`packaging/android/build-release.sh` creates an upload-signed AAB and APK,
verifies their canonical version and upload certificate, and writes checksums.
The release workflow uploads only the AAB to Play. It then downloads the
universal APK generated and signed by Play for the public GitHub Release.
Play publishing is deliberately restricted to `draft` releases on
`PLAY_CLOSED_TRACK`. Production promotion is not part of this pipeline.
## One-time setup
1. In Play Console, link a Google Cloud project and grant the deployment
service account permission to manage releases for VniDrop.
2. In Google Cloud, enable the Google Play Android Developer API and configure
a Workload Identity Federation provider that trusts this repository's
GitHub Actions identity. Permit the service account to receive federated
tokens from that provider.
3. Create the `play-closed-testing` GitHub Environment. Add the five variables
listed above and restrict deployment branches/tags to the release policy.
4. Add the four upload-keystore secrets and
`ANDROID_UPLOAD_CERT_SHA256` in the repository settings.
No Google service-account JSON key is stored in GitHub. The workflow exchanges
GitHub's OIDC identity for a short-lived Google access token.

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$repo_root/packaging/version/resolve-version.sh"
output_dir="$repo_root/build/release/android"
required_apk_libraries=(
"lib/arm64-v8a/libvnidrop.so"
"lib/x86_64/libvnidrop.so"
)
required_aab_libraries=(
"base/lib/arm64-v8a/libvnidrop.so"
"base/lib/x86_64/libvnidrop.so"
)
require_environment() {
local name=$1
[[ -n ${!name:-} ]] || {
printf 'Missing required environment variable: %s\n' "$name" >&2
exit 1
}
}
normalize_fingerprint() {
printf '%s' "$1" | tr -d '[:space:]:' | tr '[:upper:]' '[:lower:]'
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
shasum -a 256 "$1" | awk '{print $1}'
fi
}
verify_archive_entries() {
local archive=$1
shift
local entry
local size
for entry in "$@"; do
size="$(
unzip -l "$archive" "$entry" |
awk -v expected="$entry" '$4 == expected {print $1; exit}'
)"
[[ -n $size && $size -gt 0 ]] || {
printf 'Missing or empty Android native library %s in %s\n' \
"$entry" "$archive" >&2
exit 1
}
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 \
VNIDROP_ANDROID_KEYSTORE_PATH \
VNIDROP_ANDROID_KEYSTORE_PASSWORD \
VNIDROP_ANDROID_KEY_ALIAS \
VNIDROP_ANDROID_KEY_PASSWORD \
VNIDROP_ANDROID_UPLOAD_CERT_SHA256; do
require_environment "$name"
done
[[ -r $VNIDROP_ANDROID_KEYSTORE_PATH ]] || {
printf 'Android upload keystore is not readable: %s\n' "$VNIDROP_ANDROID_KEYSTORE_PATH" >&2
exit 1
}
version="$("$resolver" product)"
version_code="$("$resolver" android-code)"
"$resolver" verify >/dev/null
cd "$repo_root"
./gradlew \
:androidApp:check \
:androidApp:assembleRelease \
:androidApp:bundleRelease \
-Pvnidrop.diagnostics.included=false \
--no-daemon \
--no-configuration-cache \
--stacktrace
source_apk="$repo_root/androidApp/build/outputs/apk/release/androidApp-release.apk"
source_aab="$repo_root/androidApp/build/outputs/bundle/release/androidApp-release.aab"
metadata="$repo_root/androidApp/build/intermediates/merged_manifests/release/processReleaseManifest/output-metadata.json"
[[ -s $source_apk && -s $source_aab && -s $metadata ]] || {
printf 'Android release outputs are missing or empty\n' >&2
exit 1
}
actual_version="$(jq -r '.elements[0].versionName' "$metadata")"
actual_version_code="$(jq -r '.elements[0].versionCode' "$metadata")"
[[ $actual_version == "$version" && $actual_version_code == "$version_code" ]] || {
printf 'Android artifact version mismatch: expected %s (%s), got %s (%s)\n' \
"$version" "$version_code" "$actual_version" "$actual_version_code" >&2
exit 1
}
jarsigner_report="$(jarsigner -verify "$source_aab" 2>&1)" || {
printf 'AAB signature verification failed:\n%s\n' "$jarsigner_report" >&2
exit 1
}
grep -F 'jar verified.' <<< "$jarsigner_report" >/dev/null || {
printf 'jarsigner did not confirm the AAB signature\n' >&2
exit 1
}
verify_archive_entries "$source_apk" "${required_apk_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="$(
printf '%s\n' "$signature_report" |
awk -F': ' '/Signer #1 certificate SHA-256 digest:/ {print $2; 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 "$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="$(
keytool -printcert -jarfile "$source_aab" |
awk -F': ' '/SHA256:/ {print $2; exit}'
)"
aab_fingerprint="$(normalize_fingerprint "$aab_fingerprint")"
[[ $aab_fingerprint == "$expected_fingerprint" ]] || {
printf 'AAB signing certificate mismatch: expected %s, got %s\n' \
"$expected_fingerprint" "$aab_fingerprint" >&2
exit 1
}
mkdir -p "$output_dir"
rm -f \
"$output_dir"/VniDrop-*-upload-signed.apk \
"$output_dir"/VniDrop-*.aab \
"$output_dir"/SHA256SUMS
apk_name="VniDrop-${version}-${version_code}-upload-signed.apk"
aab_name="VniDrop-${version}-${version_code}.aab"
cp "$source_apk" "$output_dir/$apk_name"
cp "$source_aab" "$output_dir/$aab_name"
{
printf '%s %s\n' "$(sha256_file "$output_dir/$apk_name")" "$apk_name"
printf '%s %s\n' "$(sha256_file "$output_dir/$aab_name")" "$aab_name"
} > "$output_dir/SHA256SUMS"
printf 'Created signed Android release artifacts:\n'
printf ' %s\n' "$output_dir/$aab_name"
printf ' %s\n' "$output_dir/$apk_name"
printf ' upload certificate SHA-256: %s\n' "$actual_fingerprint"

410
packaging/android/publish_play.py Executable file
View File

@@ -0,0 +1,410 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
API_ROOT = "https://androidpublisher.googleapis.com/androidpublisher/v3"
UPLOAD_ROOT = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3"
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
class PlayApiError(RuntimeError):
def __init__(self, status: int | None, message: str) -> None:
super().__init__(message)
self.status = status
class PlayClient:
def __init__(self, token: str) -> None:
if not token:
raise ValueError("Google Play access token is required")
self.token = token
def request(
self,
method: str,
url: str,
*,
body: bytes | None = None,
content_type: str | None = None,
timeout: int = 180,
attempts: int = 5,
) -> bytes:
headers = {
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
}
if content_type is not None:
headers["Content-Type"] = content_type
for attempt in range(1, attempts + 1):
request = urllib.request.Request(
url,
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
except urllib.error.HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code not in RETRYABLE_STATUS or attempt == attempts:
raise PlayApiError(
error.code,
f"Google Play API returned HTTP {error.code}: {error_body}",
) from error
except urllib.error.URLError as error:
if attempt == attempts:
raise PlayApiError(
None,
f"Google Play API request failed: {error.reason}",
) from error
time.sleep(2 ** (attempt - 1))
raise AssertionError("request retry loop exited unexpectedly")
def request_json(
self,
method: str,
url: str,
*,
value: Any | None = None,
timeout: int = 180,
) -> dict[str, Any]:
body = None
content_type = None
if value is not None:
body = json.dumps(value, separators=(",", ":")).encode()
content_type = "application/json"
response = self.request(
method,
url,
body=body,
content_type=content_type,
timeout=timeout,
)
return json.loads(response) if response else {}
def normalize_fingerprint(value: str) -> str:
return "".join(character for character in value.lower() if character.isalnum())
def validate_closed_track(track: str) -> None:
normalized = track.strip().casefold()
if not normalized:
raise ValueError("Play track is required")
if normalized == "production" or normalized.endswith(":production"):
raise ValueError(
"production tracks are forbidden by this closed-testing pipeline"
)
def find_universal_apk(
response: dict[str, Any],
expected_fingerprint: str,
) -> tuple[str, str] | None:
expected = normalize_fingerprint(expected_fingerprint)
for signing_key in response.get("generatedApks", []):
fingerprint = normalize_fingerprint(
str(signing_key.get("certificateSha256Hash", ""))
)
if fingerprint != expected:
continue
universal = signing_key.get("generatedUniversalApk") or {}
download_id = universal.get("downloadId")
if download_id:
return fingerprint, str(download_id)
return None
def build_track_payload(
track: dict[str, Any],
version_code: int,
release_name: str,
) -> dict[str, Any]:
releases = list(track.get("releases") or [])
expected_code = str(version_code)
if any(
expected_code in [str(code) for code in release.get("versionCodes", [])]
for release in releases
):
raise ValueError(
f"version code {version_code} is already present in track "
f"{track.get('track', '<unknown>')}"
)
releases.append(
{
"name": release_name,
"versionCodes": [expected_code],
"status": "draft",
}
)
return {"track": track["track"], "releases": releases}
def find_track_release(
track: dict[str, Any],
version_code: int,
) -> dict[str, Any] | None:
expected_code = str(version_code)
return next(
(
release
for release in track.get("releases") or []
if expected_code
in [str(code) for code in release.get("versionCodes", [])]
),
None,
)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def generated_apks_url(package_name: str, version_code: int) -> str:
package = urllib.parse.quote(package_name, safe="")
return f"{API_ROOT}/applications/{package}/generatedApks/{version_code}"
def get_generated_apks(
client: PlayClient,
package_name: str,
version_code: int,
) -> dict[str, Any] | None:
try:
return client.request_json(
"GET",
generated_apks_url(package_name, version_code),
)
except PlayApiError as error:
if error.status == 404:
return None
raise
def download_universal_apk(
client: PlayClient,
package_name: str,
version_code: int,
expected_fingerprint: str,
output: Path,
*,
attempts: int,
interval_seconds: int,
) -> str:
for attempt in range(1, attempts + 1):
response = get_generated_apks(client, package_name, version_code)
if response is not None:
selected = find_universal_apk(response, expected_fingerprint)
if selected is not None:
fingerprint, download_id = selected
package = urllib.parse.quote(package_name, safe="")
download = urllib.parse.quote(download_id, safe="")
url = (
f"{API_ROOT}/applications/{package}/generatedApks/"
f"{version_code}/downloads/{download}:download"
)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(client.request("GET", url))
if output.stat().st_size == 0:
raise RuntimeError("Google Play returned an empty universal APK")
return fingerprint
if attempt < attempts:
time.sleep(interval_seconds)
raise RuntimeError(
"Google Play did not provide a universal APK signed with the expected "
f"certificate after {attempts} attempts"
)
def publish_bundle(args: argparse.Namespace) -> dict[str, Any]:
validate_closed_track(args.track)
if args.version_code < 1:
raise ValueError("version code must be positive")
if not args.bundle.is_file() or args.bundle.stat().st_size == 0:
raise ValueError(f"AAB is missing or empty: {args.bundle}")
client = PlayClient(args.access_token)
existing = get_generated_apks(client, args.package_name, args.version_code)
if existing is not None:
selected = find_universal_apk(existing, args.expected_app_certificate)
if selected is None:
raise RuntimeError(
"version code already exists in Play, but no universal APK matches "
"the expected app-signing certificate"
)
package = urllib.parse.quote(args.package_name, safe="")
edit = client.request_json(
"POST",
f"{API_ROOT}/applications/{package}/edits",
value={},
)
edit_id = str(edit["id"])
edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}"
try:
track_id = urllib.parse.quote(args.track, safe="")
track = client.request_json(
"GET",
f"{edit_base}/tracks/{track_id}",
)
release = find_track_release(track, args.version_code)
if release is None or release.get("status") != "draft":
raise RuntimeError(
"version code already exists in Play but is not a draft on "
f"the configured track {args.track}"
)
finally:
try:
client.request("DELETE", edit_base, attempts=1)
except PlayApiError:
pass
source = "existing"
else:
package = urllib.parse.quote(args.package_name, safe="")
edit = client.request_json(
"POST",
f"{API_ROOT}/applications/{package}/edits",
value={},
)
edit_id = str(edit["id"])
committed = False
edit_base = f"{API_ROOT}/applications/{package}/edits/{edit_id}"
try:
upload_url = (
f"{UPLOAD_ROOT}/applications/{package}/edits/{edit_id}/bundles"
"?uploadType=media"
)
try:
uploaded = json.loads(
client.request(
"POST",
upload_url,
body=args.bundle.read_bytes(),
content_type="application/octet-stream",
attempts=1,
)
)
except PlayApiError:
bundles = client.request_json("GET", f"{edit_base}/bundles")
matches = [
bundle
for bundle in bundles.get("bundles", [])
if int(bundle.get("versionCode", 0)) == args.version_code
]
if len(matches) != 1:
raise
uploaded = matches[0]
uploaded_code = int(uploaded["versionCode"])
if uploaded_code != args.version_code:
raise RuntimeError(
f"Play accepted version code {uploaded_code}, expected "
f"{args.version_code}"
)
track_id = urllib.parse.quote(args.track, safe="")
track_url = f"{edit_base}/tracks/{track_id}"
track = client.request_json("GET", track_url)
payload = build_track_payload(track, args.version_code, args.release_name)
client.request_json("PUT", track_url, value=payload)
commit_url = (
f"{edit_base}:commit"
"?changesInReviewBehavior=ERROR_IF_IN_REVIEW"
)
client.request("POST", commit_url, body=b"")
committed = True
source = "uploaded"
finally:
if not committed:
try:
client.request("DELETE", edit_base, attempts=1)
except PlayApiError:
pass
fingerprint = download_universal_apk(
client,
args.package_name,
args.version_code,
args.expected_app_certificate,
args.apk_output,
attempts=args.poll_attempts,
interval_seconds=args.poll_interval,
)
return {
"packageName": args.package_name,
"track": args.track,
"releaseStatus": "draft",
"releaseName": args.release_name,
"versionCode": args.version_code,
"bundleSha256": sha256_file(args.bundle),
"universalApk": args.apk_output.name,
"universalApkSha256": sha256_file(args.apk_output),
"appSigningCertificateSha256": fingerprint,
"source": source,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Stage a signed AAB on a closed Play track and download Play's "
"app-signed universal APK"
)
)
parser.add_argument(
"--access-token",
default=os.environ.get("GOOGLE_PLAY_ACCESS_TOKEN"),
)
parser.add_argument("--bundle", type=Path, required=True)
parser.add_argument("--package-name", required=True)
parser.add_argument("--track", required=True)
parser.add_argument("--version-code", type=int, required=True)
parser.add_argument("--release-name", required=True)
parser.add_argument("--expected-app-certificate", required=True)
parser.add_argument("--apk-output", type=Path, required=True)
parser.add_argument("--metadata-output", type=Path, required=True)
parser.add_argument("--poll-attempts", type=int, default=18)
parser.add_argument("--poll-interval", type=int, default=10)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
metadata = publish_bundle(args)
except (KeyError, ValueError, RuntimeError, PlayApiError) as error:
print(f"Play closed-testing publication failed: {error}", file=sys.stderr)
return 1
args.metadata_output.parent.mkdir(parents=True, exist_ok=True)
args.metadata_output.write_text(
json.dumps(metadata, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(
f"Staged {args.release_name} ({args.version_code}) as a draft on "
f"{args.track}"
)
print(f"Downloaded Play-signed APK: {args.apk_output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,100 @@
import importlib.util
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "publish_play.py"
SPEC = importlib.util.spec_from_file_location("publish_play", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
publish_play = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(publish_play)
class PublishPlayTests(unittest.TestCase):
def test_rejects_phone_and_form_factor_production_tracks(self):
for track in ("production", "wear:production", " Production "):
with self.subTest(track=track):
with self.assertRaisesRegex(ValueError, "production"):
publish_play.validate_closed_track(track)
def test_accepts_custom_closed_track(self):
publish_play.validate_closed_track("closed-beta")
def test_normalizes_certificate_fingerprint(self):
self.assertEqual(
publish_play.normalize_fingerprint("AA:bb 01"),
"aabb01",
)
def test_selects_universal_apk_for_expected_signing_key(self):
response = {
"generatedApks": [
{
"certificateSha256Hash": "11:22",
"generatedUniversalApk": {"downloadId": "wrong"},
},
{
"certificateSha256Hash": "AA:BB",
"generatedUniversalApk": {"downloadId": "correct"},
},
]
}
self.assertEqual(
publish_play.find_universal_apk(response, "aa:bb"),
("aabb", "correct"),
)
def test_track_update_preserves_existing_releases_and_adds_draft(self):
track = {
"track": "closed-beta",
"releases": [
{
"name": "0.1.0",
"versionCodes": ["1"],
"status": "completed",
}
],
}
updated = publish_play.build_track_payload(track, 2, "0.2.0")
self.assertEqual(updated["releases"][0], track["releases"][0])
self.assertEqual(
updated["releases"][1],
{
"name": "0.2.0",
"versionCodes": ["2"],
"status": "draft",
},
)
def test_track_update_rejects_duplicate_version_code(self):
track = {
"track": "closed-beta",
"releases": [{"versionCodes": ["2"], "status": "draft"}],
}
with self.assertRaisesRegex(ValueError, "already present"):
publish_play.build_track_payload(track, 2, "0.2.0")
def test_finds_existing_release_by_version_code(self):
expected = {"versionCodes": ["2"], "status": "draft"}
track = {
"track": "closed-beta",
"releases": [
{"versionCodes": ["1"], "status": "completed"},
expected,
],
}
self.assertIs(
publish_play.find_track_release(track, 2),
expected,
)
def test_returns_none_when_version_is_not_on_track(self):
track = {
"track": "closed-beta",
"releases": [{"versionCodes": ["1"], "status": "completed"}],
}
self.assertIsNone(publish_play.find_track_release(track, 2))
if __name__ == "__main__":
unittest.main()

View File

@@ -13,9 +13,10 @@ repository should add repository metadata signing and its own update channel.
## GitHub Actions
The Linux packages workflow runs for relevant pull requests, release tags
matching `vMAJOR.MINOR.PATCH`, and manual dispatches. Each native package is
built and validated on its matching distribution family:
The Linux packages workflow runs for relevant pull requests and manual
dispatches. The coordinated release workflow also calls it for a canonical
`vMAJOR.MINOR.PATCH` tag. Each native package is built and validated on its
matching distribution family:
- `.deb` on Ubuntu 22.04 for a conservative glibc baseline
- `.rpm` inside Fedora 43 so `jpackage` can discover normal RPM dependencies
@@ -23,9 +24,9 @@ built and validated on its matching distribution family:
The shared JVM suite runs inside the Debian build job. Package construction and
payload validation happen in both build jobs, so there is no separate test
runner. Pull requests build and verify both packages but do not retain
artifacts. Manual runs retain build artifacts for 14 days. A pushed version tag
whose commit is on `master` creates the matching GitHub Release with the `.deb`,
`.rpm`, and a combined `SHA256SUMS`.
artifacts. Manual and coordinated-release runs retain build artifacts. The
central release workflow creates the single GitHub Release only after every
platform build and Play closed-testing stage succeeds.
The legacy `v1.0.0` tag predates canonical versioning and does not define the
current product version. New release tags must match `version.properties`.

View File

@@ -0,0 +1,41 @@
# Coordinated releases
Only `.github/workflows/release.yml` responds to version tags. It verifies that
the tag matches `version.properties` and points at the current `master`, then
calls the native platform workflows in parallel.
The tag workflow runs only when the repository variable
`RELEASE_PIPELINE_ENABLED` is exactly `true`. Leave it unset or set it to
`false` to disable all coordinated releases, including Play uploads, without
disabling release validation on pull requests.
Platform workflows upload private workflow artifacts. After every native build
passes, the release pipeline:
1. stages the signed AAB as a draft on the configured Play closed-test track;
2. downloads the universal APK signed by Play;
3. verifies and assembles the public artifacts;
4. generates checksums and GitHub build-provenance attestations;
5. creates exactly one GitHub Release;
6. updates the Homebrew cask.
Public GitHub Release assets are the DEB, RPM, notarized DMG, Sparkle appcast,
Play-signed universal APK, checksum file, and release manifest.
The unsigned Microsoft `.msixupload` and upload-signed Android AAB remain
private workflow artifacts. Partner Center submission stays manual until the
first Microsoft Store release is certified. The Play release remains a draft
on a closed-testing track; this pipeline cannot publish to production.
To release, first update and merge `version.properties`, including monotonic
Android and Apple build numbers. Then create and push the matching tag:
```bash
git tag -s v0.2.0 -m "VniDrop 0.2.0"
git push origin v0.2.0
```
The tag must point at the current `origin/master` commit. A failed run creates
no GitHub Release; a rerun safely reuses an already-staged Play draft only when
the version, configured track, draft status, and app-signing certificate all
match.

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
resolver="$repo_root/packaging/version/resolve-version.sh"
input_dir="${VNIDROP_RELEASE_INPUT_DIR:-$repo_root/build/release/downloads}"
output_dir="${VNIDROP_RELEASE_OUTPUT_DIR:-$repo_root/build/release/final}"
source_commit="${GITHUB_SHA:-local}"
source_tag="${GITHUB_REF_NAME:-v$("$resolver" product)}"
find_single() {
local directory=$1
local pattern=$2
local label=$3
local matches=()
local match
while IFS= read -r match; do
matches+=("$match")
done < <(find "$directory" -type f -name "$pattern" -print)
[[ ${#matches[@]} == 1 ]] || {
printf 'Expected exactly one %s under %s, found %s\n' \
"$label" "$directory" "${#matches[@]}" >&2
exit 1
}
printf '%s' "${matches[0]}"
}
file_size() {
if stat -c '%s' "$1" >/dev/null 2>&1; then
stat -c '%s' "$1"
else
stat -f '%z' "$1"
fi
}
verify_checksum_file() {
local checksum_file=$1
(
cd "$(dirname "$checksum_file")"
sha256sum --check "$(basename "$checksum_file")"
)
}
version="$("$resolver" product)"
android_code="$("$resolver" android-code)"
apple_build="$("$resolver" apple-build)"
windows_package="$("$resolver" windows-package)"
"$resolver" verify >/dev/null
[[ $source_tag == "v$version" ]] || {
printf 'Release tag %s does not match canonical version v%s\n' \
"$source_tag" "$version" >&2
exit 1
}
deb="$(find_single "$input_dir/deb" '*.deb' 'Debian package')"
rpm="$(find_single "$input_dir/rpm" '*.rpm' 'RPM package')"
dmg="$(find_single "$input_dir/macos" '*.dmg' 'macOS DMG')"
appcast="$(find_single "$input_dir/macos" 'appcast.xml' 'Sparkle appcast')"
play_apk="$(find_single "$input_dir/play" '*-play-universal.apk' 'Play-signed APK')"
play_metadata="$(find_single "$input_dir/play" 'play-release.json' 'Play release metadata')"
msix="$(find_single "$input_dir/windows" '*.msix' 'Windows MSIX')"
msixupload="$(find_single "$input_dir/windows" '*.msixupload' 'Windows MSIX upload')"
windows_metadata="$(find_single "$input_dir/windows" '*.build-info.json' 'Windows build metadata')"
[[ $(basename "$deb") == "vnidrop_${version}-1_amd64.deb" ]]
[[ $(basename "$rpm") == "vnidrop-${version}-1.x86_64.rpm" ]]
[[ $(basename "$dmg") == "VniDrop-${version}.dmg" ]]
[[ $(basename "$play_apk") == "VniDrop-${version}-${android_code}-play-universal.apk" ]]
[[ $(basename "$msix") == "VniDrop_${version}_x64.msix" ]]
[[ $(basename "$msixupload") == "VniDrop_${version}_x64.msixupload" ]]
deb_checksum="$(find_single "$input_dir/deb" '*.sha256' 'Debian checksum')"
rpm_checksum="$(find_single "$input_dir/rpm" '*.sha256' 'RPM checksum')"
windows_checksums="$(find_single "$input_dir/windows" 'SHA256SUMS' 'Windows checksums')"
play_checksums="$(find_single "$input_dir/play" 'SHA256SUMS' 'Play APK checksums')"
verify_checksum_file "$deb_checksum"
verify_checksum_file "$rpm_checksum"
verify_checksum_file "$windows_checksums"
verify_checksum_file "$play_checksums"
[[ $(jq -r '.releaseStatus' "$play_metadata") == draft ]]
[[ $(jq -r '.releaseName' "$play_metadata") == "$version" ]]
[[ $(jq -r '.versionCode' "$play_metadata") == "$android_code" ]]
play_track="$(jq -r '.track' "$play_metadata")"
normalized_play_track="$(printf '%s' "$play_track" | tr '[:upper:]' '[:lower:]')"
[[ $normalized_play_track != production && $normalized_play_track != *:production ]]
[[ $(jq -r '.appVersion' "$windows_metadata") == "$version" ]]
[[ $(jq -r '.packageVersion' "$windows_metadata") == "$windows_package" ]]
grep -F "VniDrop-${version}.dmg" "$appcast" >/dev/null
mkdir -p "$output_dir"
[[ -z $(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit) ]] || {
printf 'Release output directory must be empty: %s\n' "$output_dir" >&2
exit 1
}
cp "$deb" "$rpm" "$dmg" "$appcast" "$play_apk" "$output_dir/"
payloads=(
"$output_dir/$(basename "$deb")"
"$output_dir/$(basename "$rpm")"
"$output_dir/$(basename "$dmg")"
"$output_dir/$(basename "$appcast")"
"$output_dir/$(basename "$play_apk")"
)
files_json="$(
for file in "${payloads[@]}"; do
jq -n \
--arg name "$(basename "$file")" \
--arg sha256 "$(sha256sum "$file" | awk '{print $1}')" \
--argjson bytes "$(file_size "$file")" \
'{name: $name, sha256: $sha256, bytes: $bytes}'
done | jq -s .
)"
jq -n \
--arg productVersion "$version" \
--arg releaseChannel "$("$resolver" channel)" \
--arg tag "$source_tag" \
--arg commit "$source_commit" \
--arg androidVersionCode "$android_code" \
--arg appleBuildNumber "$apple_build" \
--arg windowsPackageVersion "$windows_package" \
--arg windowsMsixUpload "$(basename "$msixupload")" \
--arg windowsMsixUploadSha256 "$(sha256sum "$msixupload" | awk '{print $1}')" \
--arg playTrack "$play_track" \
--arg playBundleSha256 "$(jq -r '.bundleSha256' "$play_metadata")" \
--arg playCertificateSha256 "$(jq -r '.appSigningCertificateSha256' "$play_metadata")" \
--argjson files "$files_json" \
'{
productVersion: $productVersion,
releaseChannel: $releaseChannel,
tag: $tag,
sourceCommit: $commit,
platformVersions: {
androidVersionCode: ($androidVersionCode | tonumber),
appleBuildNumber: $appleBuildNumber,
windowsPackageVersion: $windowsPackageVersion
},
play: {
track: $playTrack,
status: "draft",
bundleSha256: $playBundleSha256,
appSigningCertificateSha256: $playCertificateSha256
},
windowsStore: {
publicReleaseAsset: false,
msixUpload: $windowsMsixUpload,
sha256: $windowsMsixUploadSha256
},
files: $files
}' > "$output_dir/release-manifest.json"
(
cd "$output_dir"
sha256sum \
"$(basename "$deb")" \
"$(basename "$rpm")" \
"$(basename "$dmg")" \
"$(basename "$appcast")" \
"$(basename "$play_apk")" \
release-manifest.json \
> SHA256SUMS
)
printf 'Assembled public release assets in %s\n' "$output_dir"
printf 'Windows Store submission retained as workflow artifact: %s\n' \
"$(basename "$msixupload")"

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/vnidrop-release-test.XXXXXX")"
trap 'rm -rf "$fixture_root"' EXIT
input_dir="$fixture_root/input"
output_dir="$fixture_root/output"
mkdir -p \
"$input_dir/deb" \
"$input_dir/rpm" \
"$input_dir/macos" \
"$input_dir/play" \
"$input_dir/windows"
version="$("$repo_root/packaging/version/resolve-version.sh" product)"
android_code="$("$repo_root/packaging/version/resolve-version.sh" android-code)"
windows_package="$("$repo_root/packaging/version/resolve-version.sh" windows-package)"
printf 'deb\n' > "$input_dir/deb/vnidrop_${version}-1_amd64.deb"
printf 'rpm\n' > "$input_dir/rpm/vnidrop-${version}-1.x86_64.rpm"
printf 'dmg\n' > "$input_dir/macos/VniDrop-${version}.dmg"
printf '<url>VniDrop-%s.dmg</url>\n' "$version" > "$input_dir/macos/appcast.xml"
printf 'apk\n' > "$input_dir/play/VniDrop-${version}-${android_code}-play-universal.apk"
printf 'msix\n' > "$input_dir/windows/VniDrop_${version}_x64.msix"
printf 'msixupload\n' > "$input_dir/windows/VniDrop_${version}_x64.msixupload"
jq -n \
--arg releaseName "$version" \
--argjson versionCode "$android_code" \
'{
releaseStatus: "draft",
releaseName: $releaseName,
versionCode: $versionCode,
track: "closed-beta",
bundleSha256: "bundle-sha",
appSigningCertificateSha256: "certificate-sha"
}' > "$input_dir/play/play-release.json"
jq -n \
--arg appVersion "$version" \
--arg packageVersion "$windows_package" \
'{appVersion: $appVersion, packageVersion: $packageVersion}' \
> "$input_dir/windows/VniDrop_${version}_x64.build-info.json"
(
cd "$input_dir/deb"
sha256sum "vnidrop_${version}-1_amd64.deb" \
> "vnidrop_${version}-1_amd64.deb.sha256"
)
(
cd "$input_dir/rpm"
sha256sum "vnidrop-${version}-1.x86_64.rpm" \
> "vnidrop-${version}-1.x86_64.rpm.sha256"
)
(
cd "$input_dir/play"
sha256sum \
"VniDrop-${version}-${android_code}-play-universal.apk" \
play-release.json \
> SHA256SUMS
)
(
cd "$input_dir/windows"
sha256sum \
"VniDrop_${version}_x64.msix" \
"VniDrop_${version}_x64.msixupload" \
"VniDrop_${version}_x64.build-info.json" \
> SHA256SUMS
)
GITHUB_REF_NAME="v$version" \
GITHUB_SHA=fixture-commit \
VNIDROP_RELEASE_INPUT_DIR="$input_dir" \
VNIDROP_RELEASE_OUTPUT_DIR="$output_dir" \
"$script_dir/assemble-release.sh" >/dev/null
expected_public_files=(
"SHA256SUMS"
"VniDrop-${version}-${android_code}-play-universal.apk"
"VniDrop-${version}.dmg"
"appcast.xml"
"release-manifest.json"
"vnidrop-${version}-1.x86_64.rpm"
"vnidrop_${version}-1_amd64.deb"
)
actual_public_files=()
while IFS= read -r file; do
actual_public_files+=("$(basename "$file")")
done < <(find "$output_dir" -maxdepth 1 -type f -print | sort)
[[ ${actual_public_files[*]} == "${expected_public_files[*]}" ]]
[[ $(jq -r '.productVersion' "$output_dir/release-manifest.json") == "$version" ]]
[[ $(jq -r '.play.status' "$output_dir/release-manifest.json") == draft ]]
[[ $(jq -r '.windowsStore.publicReleaseAsset' "$output_dir/release-manifest.json") == false ]]
(
cd "$output_dir"
sha256sum --check SHA256SUMS >/dev/null
)

View File

@@ -32,9 +32,9 @@ package version adds `WINDOWS_VERSION_EPOCH` to the product major. With epoch
## GitHub Actions
The Windows Store package workflow runs automatically for relevant pull
requests, for release tags matching vMAJOR.MINOR.PATCH, and by manual dispatch.
Pull requests build and validate without retaining an artifact. Tags and manual
runs retain:
requests and by manual dispatch. The coordinated release workflow also calls
it for a canonical `vMAJOR.MINOR.PATCH` tag. Pull requests build and validate
without retaining an artifact. Manual and coordinated-release runs retain:
- VniDrop_VERSION_x64.msix
- VniDrop_VERSION_x64.msixupload
@@ -56,7 +56,8 @@ MakeAppx unpacks the finished package.
Microsoft's current GitHub Actions publishing flow is for updates to an
already-live free product. For the first release:
1. Run this workflow from a release tag or by manual dispatch.
1. Push a canonical release tag to run the coordinated workflow, or run the
Windows package workflow manually.
2. Download the retained artifact.
3. Test that exact build on an interactive Windows VM. Local installation needs
an ephemeral development signature trusted only by that VM; this is not a