diff --git a/.github/workflows/cron-update-spm-dependencies.yml b/.github/workflows/cron-update-spm-dependencies.yml new file mode 100644 index 0000000000..0e140a9e9d --- /dev/null +++ b/.github/workflows/cron-update-spm-dependencies.yml @@ -0,0 +1,67 @@ +name: Cron / Update SPM dependencies +on: + schedule: + # Run weekly on Monday at 00:00 UTC + - cron: '0 0 * * 1' + workflow_dispatch: + +jobs: + update-spm-dependencies: + name: Update SPM dependencies + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + + - name: Run update script + run: | + ./Scripts/update-dependencies.py + + - name: Check for changes + id: check-changes + run: | + if git diff --quiet -- $PSL_FILE; then + echo "✅ No changes detected, skipping..." + echo "has_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "has_changes=true" >> $GITHUB_OUTPUT + echo "👀 Changes detected" + + - name: Create branch and commit + if: steps.check-changes.outputs.has_changes == 'true' + run: | + echo "📋 Committing project file updates..." + + BRANCH_NAME="cron-update-spm-dependencies/$GITHUB_RUN_NUMBER-spm-update" + git config user.name "GitHub Actions Bot" + git config user.email "actions@github.com" + git checkout -b $BRANCH_NAME + git add $PSL_FILE + git commit -m "Update SPM dependencies" + git push origin $BRANCH_NAME + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + echo "🌱 Branch created: $BRANCH_NAME" + + - name: Create Pull Request + if: steps.check-changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DUPLICATES_FOUND: ${{ steps.check-changes.outputs.duplicates_found }} + BASE_PR_URL: ${{ github.server_url }}/${{ github.repository }}/pull/ + run: | + PR_BODY="Updates SPM dependencies" + + # Use echo -e to interpret escape sequences and pipe to gh pr create + PR_URL=$(echo -e "$PR_BODY" | gh pr create \ + --title "Update SPM dependencies" \ + --body-file - \ + --base main \ + --head $BRANCH_NAME \ + --label "automated-pr" \ + --label "t:ci") diff --git a/.gitignore b/.gitignore index e62bc83b13..ea28264c5d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,7 @@ playground.xcworkspace # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins -# Package.resolved +Package.resolved # *.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata diff --git a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 013b51070b..0000000000 --- a/Bitwarden.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,194 +0,0 @@ -{ - "originHash" : "93ec2eb77f36826178e4c8677e95e70ec909a1b40aa8e2cb1ccaa066a778be58", - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "45d327fcbe7793747295346c2209ad419bdead74", - "version" : "11.14.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "70a7857886f065a40486a7607268781c49db04ae", - "version" : "2.0.0" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "406f72d0d5e9445fd1cf782db3e9e338cee2bed4", - "version" : "11.14.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71", - "version" : "1.69.0" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, - { - "identity" : "sdk-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/bitwarden/sdk-swift", - "state" : { - "revision" : "ad0761937f454e2785c50a9b8dc9be12f26af830" - } - }, - { - "identity" : "swift-custom-dump", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-custom-dump", - "state" : { - "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", - "version" : "1.3.3" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", - "version" : "1.29.0" - } - }, - { - "identity" : "swift-snapshot-testing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", - "state" : { - "revision" : "37230a37e83f1b7023be08e1b1a2603fcb1567fb", - "version" : "1.18.4" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" - } - }, - { - "identity" : "swiftui-introspect", - "kind" : "remoteSourceControl", - "location" : "https://github.com/siteline/SwiftUI-Introspect", - "state" : { - "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", - "version" : "1.3.0" - } - }, - { - "identity" : "viewinspector", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nalexn/ViewInspector", - "state" : { - "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", - "version" : "0.10.1" - } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", - "version" : "1.5.2" - } - } - ], - "version" : 3 -} diff --git a/Scripts/update-dependencies.py b/Scripts/update-dependencies.py new file mode 100755 index 0000000000..ef99261533 --- /dev/null +++ b/Scripts/update-dependencies.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Dict, List, Optional + +import yaml +from packaging import version + + +PROJECT_FILES = ["project-bwk.yml", "project-bwa.yml", "project-pm.yml"] + + +def run_gh_api(endpoint: str) -> Dict: + """Run gh api command and return JSON response.""" + try: + result = subprocess.run( + ["gh", "api", endpoint], + capture_output=True, + text=True, + check=True + ) + return json.loads(result.stdout) + except subprocess.CalledProcessError as e: + print(f"Error calling GitHub API: {e}") + return {} + except json.JSONDecodeError as e: + print(f"Error parsing JSON response: {e}") + return {} + + +def get_latest_release(repo_url: str) -> Optional[str]: + """Get the latest stable release tag for a GitHub repository.""" + repo_path = repo_url.replace("https://github.com/", "") + + # First try to get the latest stable release (non-prerelease) + releases = run_gh_api(f"repos/{repo_path}/releases") + if releases: + for release in releases: + if not release.get("prerelease", False): + tag_name = release.get("tag_name", "") + if not re.search(r'(beta|alpha|rc|pre|dev|snapshot)', tag_name, re.IGNORECASE): + return tag_name + + # If no stable releases found, fall back to tags but filter out beta/alpha/rc versions + tags = run_gh_api(f"repos/{repo_path}/tags") + if tags: + for tag in tags: + tag_name = tag.get("name", "") + if not re.search(r'(beta|alpha|rc|pre|dev|snapshot)', tag_name, re.IGNORECASE): + return tag_name + + return None + + +def get_latest_commit(repo_url: str, branch: str) -> Optional[str]: + """Get the latest commit SHA for a specific branch.""" + repo_path = repo_url.replace("https://github.com/", "") + + commit_data = run_gh_api(f"repos/{repo_path}/commits/{branch}") + return commit_data.get("sha") + + +def version_compare(current: str, latest: str) -> bool: + """Compare versions and return True if current is older than latest.""" + current_clean = current.lstrip("v") + latest_clean = latest.lstrip("v") + + if current_clean == latest_clean: + return False + + try: + return version.parse(current_clean) < version.parse(latest_clean) + except version.InvalidVersion: + # Fallback to string comparison if version parsing fails + return current_clean != latest_clean + + +def process_project_file(project_file: str) -> None: + """Process a single project file to update dependencies.""" + if not os.path.isfile(project_file): + print(f"Warning: {project_file} not found, skipping...") + return + + print(f"Processing {project_file}...") + + # Read the YAML file + try: + with open(project_file, 'r') as f: + data = yaml.safe_load(f) + except yaml.YAMLError as e: + print(f"Error reading {project_file}: {e}") + return + + packages = data.get("packages", {}) + if not packages: + print(f" No packages found in {project_file}") + return + + updated = False + + for package_name, package_info in packages.items(): + print(f" Processing package: {package_name}") + + url = package_info.get("url", "") + exact_version = package_info.get("exactVersion") + revision = package_info.get("revision") + branch = package_info.get("branch") + + # Only process GitHub URLs + if url and url.startswith("https://github.com/"): + if exact_version: + latest_version = get_latest_release(url) + + if latest_version and version_compare(exact_version, latest_version): + print(f" Updating {package_name} from {exact_version} to {latest_version}") + package_info["exactVersion"] = latest_version + updated = True + else: + print(f" {package_name} is up to date ({exact_version})") + + elif revision and branch: + latest_commit = get_latest_commit(url, branch) + + if latest_commit and revision != latest_commit: + print(f" Updating {package_name} revision from {revision} to {latest_commit}") + package_info["revision"] = latest_commit + updated = True + else: + print(f" {package_name} revision is up to date ({revision})") + else: + print(f" {package_name}: No version or revision info found, skipping...") + else: + print(f" {package_name}: Not a GitHub URL or no URL found, skipping...") + + # Write back to file if updated + if updated: + print(f" Updates found. Applying changes to {project_file}...") + try: + with open(project_file, 'w') as f: + yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False) + print(" File updated successfully!") + except Exception as e: + print(f" Error writing {project_file}: {e}") + else: + print(f" No updates needed for {project_file}.") + + +def main(): + """Main function to process all project files.""" + print("Checking for dependency updates...") + + for project_file in PROJECT_FILES: + process_project_file(project_file) + + print("All project files processed!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Scripts/update-dependencies.sh b/Scripts/update-dependencies.sh new file mode 100755 index 0000000000..bf1fa4363c --- /dev/null +++ b/Scripts/update-dependencies.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +set -e + +PROJECT_FILES=("project-bwk.yml" "project-bwa.yml" "project-pm.yml") + +echo "Checking for dependency updates..." + +function get_latest_release() { + local repo_url="$1" + local repo_path=$(echo "$repo_url" | sed 's|https://github.com/||') + + # First try to get the latest stable release (non-prerelease) + local latest_tag=$(gh api "repos/$repo_path/releases" | jq -r '.[] | select(.prerelease == false or .prerelease == null) | .tag_name' | grep -viE '(beta|alpha|rc|pre|dev|snapshot)' | head -1) + + # If no stable releases found, fall back to tags but filter out beta/alpha/rc versions + if [[ -z "$latest_tag" ]]; then + local latest_tag=$(gh api "repos/$repo_path/tags" | jq -r '.[] | select(.prerelease == false or .prerelease == null) | .tag_name' | grep -viE '(beta|alpha|rc|pre|dev|snapshot)' | head -1) + fi + + echo "$latest_tag" +} + +function get_latest_commit() { + local repo_url="$1" + local branch="$2" + local repo_path=$(echo "$repo_url" | sed 's|https://github.com/||') + + local latest_commit=$(gh api "repos/$repo_path/commits/$branch" | jq -r '.sha') + + echo "$latest_commit" +} + +function version_compare() { + local current="$1" + local latest="$2" + + current_clean=$(echo "$current" | sed 's/^v//') + latest_clean=$(echo "$latest" | sed 's/^v//') + + if [[ "$current_clean" == "$latest_clean" ]]; then + return 1 + fi + + printf '%s\n%s\n' "$current_clean" "$latest_clean" | sort -V | head -1 | grep -q "^$current_clean$" +} + +function process_project_file() { + local PROJECT_FILE="$1" + local TEMP_FILE=$(mktemp) + + if [[ ! -f "$PROJECT_FILE" ]]; then + echo "Warning: $PROJECT_FILE not found, skipping..." + return + fi + + echo "Processing $PROJECT_FILE..." + + cp "$PROJECT_FILE" "$TEMP_FILE" + + # Extract all package names using yq + package_names=$(yq eval '.packages | keys | .[]' "$PROJECT_FILE" 2>/dev/null || echo "") + + if [[ -z "$package_names" ]]; then + echo " No packages found in $PROJECT_FILE" + return + fi + + for package_name in $package_names; do + echo " Processing package: $package_name" + + # Extract package info using yq + url=$(yq eval ".packages.$package_name.url" "$PROJECT_FILE" 2>/dev/null) + version=$(yq eval ".packages.$package_name.exactVersion" "$PROJECT_FILE" 2>/dev/null) + revision=$(yq eval ".packages.$package_name.revision" "$PROJECT_FILE" 2>/dev/null) + branch=$(yq eval ".packages.$package_name.branch" "$PROJECT_FILE" 2>/dev/null) + + # Clean up null values from yq + [[ "$url" == "null" ]] && url="" + [[ "$version" == "null" ]] && version="" + [[ "$revision" == "null" ]] && revision="" + [[ "$branch" == "null" ]] && branch="" + + # Only process GitHub URLs + if [[ -n "$url" ]] && [[ "$url" =~ ^https://github\.com/ ]]; then + if [[ -n "$version" ]]; then + latest_version=$(get_latest_release "$url") + + if [[ -n "$latest_version" ]] && version_compare "$version" "$latest_version"; then + echo " Updating $package_name from $version to $latest_version" + yq eval ".packages.$package_name.exactVersion = \"$latest_version\"" -i "$TEMP_FILE" + else + echo " $package_name is up to date ($version)" + fi + + elif [[ -n "$revision" ]] && [[ -n "$branch" ]]; then + latest_commit=$(get_latest_commit "$url" "$branch") + + if [[ -n "$latest_commit" ]] && [[ "$revision" != "$latest_commit" ]]; then + echo " Updating $package_name revision from $revision to $latest_commit" + yq eval ".packages.$package_name.revision = \"$latest_commit\"" -i "$TEMP_FILE" + else + echo " $package_name revision is up to date ($revision)" + fi + else + echo " $package_name: No version or revision info found, skipping..." + fi + else + echo " $package_name: Not a GitHub URL or no URL found, skipping..." + fi + + # Clear variables for next iteration + unset url version revision branch + done + + if ! diff -q "$PROJECT_FILE" "$TEMP_FILE" > /dev/null; then + echo " Updates found. Applying changes to $PROJECT_FILE..." + mv "$TEMP_FILE" "$PROJECT_FILE" + echo " File updated successfully!" + else + echo " No updates needed for $PROJECT_FILE." + rm "$TEMP_FILE" + fi + + if [[ -f "${PROJECT_FILE}.bak" ]]; then + rm "${PROJECT_FILE}.bak" + fi +} + +for project_file in "${PROJECT_FILES[@]}"; do + process_project_file "$project_file" +done + +echo "All project files processed!" \ No newline at end of file