diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index cfaf411..0264389 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -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 diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000..9754133 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -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 diff --git a/scripts/verify_swiftpm_binary_artifact.py b/scripts/verify_swiftpm_binary_artifact.py new file mode 100644 index 0000000..4955395 --- /dev/null +++ b/scripts/verify_swiftpm_binary_artifact.py @@ -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() diff --git a/update_package.py b/update_package.py index bca686b..9831d06 100755 --- a/update_package.py +++ b/update_package.py @@ -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 = [ @@ -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)