Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/gradle-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
uses: gradle/actions/setup-gradle@v4

- name: Install native build dependencies
run: sudo apt-get update && sudo apt-get install -y pkg-config protobuf-compiler
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config protobuf-compiler zip unzip

- name: Set up Android SDK
uses: android-actions/setup-android@v3
Expand Down
79 changes: 79 additions & 0 deletions .github/workflows/ios-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: iOS Release Artifact

on:
workflow_dispatch:
inputs:
version:
description: "Version to prepare (e.g., 0.5.23)"
required: true

permissions:
contents: read

jobs:
ios-release-artifact:
name: iOS Release Candidate Artifact
runs-on: macos-latest

steps:
- name: Normalize version
id: version
shell: bash
run: |
VERSION="${{ github.event.inputs.version }}"
TAG="$VERSION"
if [[ "$TAG" != v* ]]; then
TAG="v$TAG"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"

- name: Checkout source
uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Set SwiftPM release tag
run: python3 ./update_package.py --tag "${{ steps.version.outputs.tag }}"

- name: Generate iOS release artifact
run: ./build_ios.sh

- name: Read generated manifest
id: manifest
run: |
python3 scripts/verify_swiftpm_binary_artifact.py manifest \
--release-tag "${{ steps.version.outputs.tag }}" \
--github-output "$GITHUB_OUTPUT"

- name: Verify iOS release artifact
id: artifact
run: |
python3 scripts/verify_swiftpm_binary_artifact.py verify \
--release-tag "${{ steps.version.outputs.tag }}" \
--expected-checksum "${{ steps.manifest.outputs.checksum }}" \
--github-output "$GITHUB_OUTPUT" \
--github-summary "$GITHUB_STEP_SUMMARY"

{
echo ""
echo "## Release preparation"
echo ""
echo "1. Download the workflow artifact \`vss-ios-release-candidate-${{ steps.version.outputs.version }}\`."
echo "2. Commit the generated \`Package.swift\` and binding changes needed for the release."
echo "3. Tag that commit as \`${{ steps.version.outputs.tag }}\`."
echo "4. Upload \`VssRustClientFfi.xcframework.zip\` from this workflow artifact to the GitHub release."
} >> "$GITHUB_STEP_SUMMARY"

- name: Upload iOS artifact
uses: actions/upload-artifact@v4
with:
name: vss-ios-release-candidate-${{ steps.version.outputs.version }}
path: |
bindings/ios/VssRustClientFfi.xcframework.zip
bindings/ios/module.modulemap
bindings/ios/vss_rust_client_ffi.swift
bindings/ios/vss_rust_client_ffiFFI.h
Package.swift
if-no-files-found: error
116 changes: 116 additions & 0 deletions scripts/verify_swiftpm_binary_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3

import argparse
import re
import subprocess
import sys
from pathlib import Path
from typing import Optional


def normalize_tag(tag: str) -> str:
tag = tag.strip()
if not tag:
raise ValueError("Release tag must not be empty")
return tag if tag.startswith("v") else f"v{tag}"


def read_manifest(package_path: Path) -> tuple[str, str]:
package = package_path.read_text()

tag_match = re.search(r'^let\s+tag\s*=\s*"([^"]+)"', package, re.MULTILINE)
checksum_match = re.search(r'^let\s+checksum\s*=\s*"([^"]+)"', package, re.MULTILINE)

if tag_match is None or checksum_match is None:
raise ValueError(f"Failed to read tag/checksum from {package_path}")

return tag_match.group(1), checksum_match.group(1)


def write_output(path: Optional[str], name: str, value: str) -> None:
if path is None:
print(f"{name}={value}")
return

with open(path, "a") as output:
output.write(f"{name}={value}\n")


def compute_checksum(artifact_path: Path) -> str:
result = subprocess.run(
["swift", "package", "compute-checksum", str(artifact_path)],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()


def capture_manifest(args: argparse.Namespace) -> None:
release_tag = normalize_tag(args.release_tag)
manifest_tag, manifest_checksum = read_manifest(args.package)

if manifest_tag != release_tag:
raise ValueError(f"Package.swift tag ({manifest_tag}) does not match release tag ({release_tag})")

write_output(args.github_output, "tag", manifest_tag)
write_output(args.github_output, "checksum", manifest_checksum)


def verify_artifact(args: argparse.Namespace) -> None:
release_tag = normalize_tag(args.release_tag)
manifest_tag, _ = read_manifest(args.package)

if manifest_tag != release_tag:
raise ValueError(f"Package.swift tag ({manifest_tag}) does not match release tag ({release_tag})")

if not args.artifact.is_file():
raise ValueError(f"Missing artifact: {args.artifact}")

checksum = compute_checksum(args.artifact)
if checksum != args.expected_checksum:
raise ValueError(
"Generated artifact checksum does not match Package.swift\n"
f"Package.swift checksum: {args.expected_checksum}\n"
f"Generated checksum: {checksum}"
)

write_output(args.github_output, "checksum", checksum)
print(f"SwiftPM checksum: {checksum}")

if args.github_summary is not None:
with open(args.github_summary, "a") as summary:
summary.write("## iOS release artifact\n\n")
summary.write(f"- Tag: {release_tag}\n")
summary.write(f"- SwiftPM checksum: `{checksum}`\n")


def main() -> None:
parser = argparse.ArgumentParser(description="Validate iOS SwiftPM release artifacts")
parser.add_argument("--package", type=Path, default=Path("Package.swift"), help="Path to Package.swift")
subparsers = parser.add_subparsers(dest="command", required=True)

manifest = subparsers.add_parser("manifest", help="Read and validate the committed SwiftPM release manifest")
manifest.add_argument("--release-tag", required=True)
manifest.add_argument("--github-output", default=None)
manifest.set_defaults(func=capture_manifest)

verify = subparsers.add_parser("verify", help="Verify the generated SwiftPM binary artifact")
verify.add_argument("--release-tag", required=True)
verify.add_argument("--expected-checksum", required=True)
verify.add_argument("--artifact", type=Path, default=Path("bindings/ios/VssRustClientFfi.xcframework.zip"))
verify.add_argument("--github-output", default=None)
verify.add_argument("--github-summary", default=None)
verify.set_defaults(func=verify_artifact)

args = parser.parse_args()

try:
args.func(args)
except Exception as error:
print(error, file=sys.stderr)
sys.exit(1)


if __name__ == "__main__":
main()
8 changes: 4 additions & 4 deletions update_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ def run(new_checksum: str = None, new_tag: str = None):
print('Tag must not contain any whitespace.', file=sys.stderr)
sys.exit(1)

# Support both v0.3.1 and 0.3.1 formats
tag_regex = re.compile("^v?\d+[.]\d+[.]\d+$")
# Support SemVer tags with an optional leading v, prerelease, and build metadata.
tag_regex = re.compile(r"^v?\d+[.]\d+[.]\d+(?:-[0-9A-Za-z.-]+)?(?:[+][0-9A-Za-z.-]+)?$")
tag_match = tag_regex.match(new_tag)
if tag_match is None:
print('Tag must adhere to x.x.x or vx.x.x major/minor/patch format.', file=sys.stderr)
print('Tag must adhere to SemVer format, with an optional leading v.', file=sys.stderr)
sys.exit(1)

settings = [
Expand Down Expand Up @@ -68,7 +68,7 @@ def run(new_checksum: str = None, new_tag: str = None):
print(f'Setting {current_variable_name}: {new_value}')

# Create regex pattern to match the let declaration
regex = re.compile(f'(let[\s]+{current_variable_name}[\s]*=[\s]*)"([^"]*)"')
regex = re.compile(rf'(let\s+{current_variable_name}\s*=\s*)"([^"]*)"')

# Find and replace the value
match = regex.search(package_file)
Expand Down
Loading