Skip to content
Merged
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
88 changes: 88 additions & 0 deletions .github/scripts/check-versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Check that SDK versions and documented installation versions agree."""

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


POD_NAMES = (
"SimlaCore",
"SimlaOnlineConsultant",
"SimlaPushNotificationExtension",
"SimlaPushService",
"SimlaRecommendations",
"SimlaSDKConfiguration",
"SimlaSQLiteStorage",
"SimlaTracker",
)

NUMBER = r"(?:0|[1-9][0-9]*)"
PRERELEASE = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
SEMVER = (
rf"{NUMBER}\.{NUMBER}\.{NUMBER}"
rf"(?:-{PRERELEASE}(?:\.{PRERELEASE})*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
)


def validate(root: Path, tag: Optional[str] = None) -> list[str]:
if tag is not None and re.fullmatch(SEMVER, tag) is None:
return [f"Release tag must be a semantic version without a 'v' prefix; got {tag!r}."]
podspecs = {f"{name}.podspec" for name in POD_NAMES}
podspecs.update(path.name for path in root.glob("*.podspec"))
fields = [
(name, "podspec version", r"^\s*s\.version\s*=\s*['\"]([^'\"]+)['\"]", False)
for name in sorted(podspecs)
]
for name, label in (
("Sources/Core/Session/SessionRuntime.swift", "SessionRuntime.version"),
("Sources/PushService/PushService+Version.swift", "PushService.version"),
):
fields.append((name, label, r'\bstatic\s+let\s+version\s*=\s*"([^"]+)"', False))
for language in ("en", "ru"):
name = f"docs/{language}/sdk.md"
fields.extend([
(name, "SPM example", r"Exact Version\s*→\s*([^*\r\n]+)", True),
(name, "CocoaPods example", r":tag\s*=>\s*['\"]([^'\"]+)['\"]", True),
])

errors = []
versions = []
for name, label, pattern, multiple in fields:
path = root / name
if not path.is_file():
errors.append(f"{name}: missing file ({label}).")
continue
content = path.read_text(encoding="utf-8")
matches = list(re.finditer(pattern, content, re.MULTILINE))
if not matches or (not multiple and len(matches) != 1):
expected = "at least one" if multiple else "exactly one"
errors.append(f"{name}: expected {expected} {label}, found {len(matches)}.")
for match in matches:
version = match.group(1).strip()
line = content.count("\n", 0, match.start(1)) + 1
versions.append((name, line, label, version))

if tag is not None:
errors.extend(
f"{name}:{line}: {label} = {version!r}, expected release tag {tag!r}."
for name, line, label, version in versions if version != tag
)
elif len({version for _, _, _, version in versions}) > 1:
errors.append("SDK versions do not match:")
errors.extend(
f"{name}:{line}: {label} = {version!r}"
for name, line, label, version in versions
)
return errors


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--tag', help='Also require every version to match this release tag.')
args = parser.parse_args()
errors = validate(Path(__file__).resolve().parents[2], args.tag)
if errors:
raise SystemExit("Version check failed:\n" + "\n".join(errors))
print("All podspec, Swift and documented installation versions match.")
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
require 'fileutils'
require 'optparse'
require 'pathname'
require 'xcodeproj'

options = { manager: 'pod' }
OptionParser.new do |parser|
parser.on('--manager MANAGER', %w[pod spm]) { |value| options[:manager] = value }
parser.on('--git URL') { |value| options[:git] = value }
parser.on('--tag VERSION') { |value| options[:tag] = value }
end.parse!
abort 'Pass --git and --tag together.' if options.key?(:git) != options.key?(:tag)
if options[:manager] == 'spm' && !options.key?(:git)
abort 'SPM integration builds require --git and --tag.'
end

sdk_path = Pathname.new(ARGV.fetch(0)).expand_path
build_path = Pathname.new(ARGV.fetch(1)).expand_path
FileUtils.mkdir_p(build_path)
Expand Down Expand Up @@ -64,31 +76,60 @@
SWIFT
app.add_file_references([project.main_group.new_file('App.swift')])
notification_extension.add_file_references([project.main_group.new_file('NotificationService.swift')])

if options[:manager] == 'spm'
package = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
package.repositoryURL = options.fetch(:git)
package.requirement = { 'kind' => 'exactVersion', 'version' => options.fetch(:tag) }
project.root_object.package_references << package
{
app => %w[Core OnlineConsultant Tracker Recommendations PushService PushNotificationExtension],
notification_extension => %w[PushNotificationExtension]
}.each do |target, products|
products.each do |name|
product = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
product.package = package
product.product_name = name
target.package_product_dependencies << product
link = project.new(Xcodeproj::Project::Object::PBXBuildFile)
link.product_ref = product
target.frameworks_build_phase.files << link
end
end
end

project.save
scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(app)
scheme.set_launch_target(app)
scheme.save_as(project.path, 'PodBuild', true)

File.write(build_path.join('Podfile'), <<~PODS)
source 'https://cdn.cocoapods.org/'
platform :ios, '15.0'
use_frameworks! :linkage => :static
if options[:manager] == 'pod'
sdk_source = if options.key?(:git)
{ git: options.fetch(:git), tag: options.fetch(:tag) }
else
{ path: sdk_path.to_s }
end
File.write(build_path.join('Podfile'), <<~PODS)
source 'https://cdn.cocoapods.org/'
platform :ios, '15.0'
use_frameworks! :linkage => :static

sdk_path = #{sdk_path.to_s.dump}
target 'PodBuild' do
%w[
SimlaCore SimlaOnlineConsultant SimlaTracker SimlaRecommendations
SimlaPushService SimlaPushNotificationExtension SimlaSDKConfiguration SimlaSQLiteStorage
].each do |name|
pod name, :path => sdk_path
sdk_source = #{sdk_source.inspect}
target 'PodBuild' do
%w[
SimlaCore SimlaOnlineConsultant SimlaTracker SimlaRecommendations
SimlaPushService SimlaPushNotificationExtension SimlaSDKConfiguration SimlaSQLiteStorage
].each do |name|
pod name, sdk_source
end
end
end

target 'NotificationService' do
pod 'SimlaSDKConfiguration', :path => sdk_path
pod 'SimlaSQLiteStorage', :path => sdk_path
pod 'SimlaPushNotificationExtension', :path => sdk_path
end
PODS
puts "CocoaPods build project: #{build_path}"
target 'NotificationService' do
pod 'SimlaSDKConfiguration', sdk_source
pod 'SimlaSQLiteStorage', sdk_source
pod 'SimlaPushNotificationExtension', sdk_source
end
PODS
end
puts "#{options[:manager]} integration build project: #{build_path}"
24 changes: 24 additions & 0 deletions .github/scripts/publish-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail

: "${RELEASE_TAG:?Missing release tag}"
: "${EXPECTED_COMMIT:?Missing verified commit}"
: "${SDK_REPOSITORY_URL:?Missing SDK repository URL}"
: "${GITHUB_REPOSITORY:?Missing GitHub repository}"

# An annotated tag has its own object ID; compare its peeled commit instead.
remote_commit="$(git ls-remote --exit-code "$SDK_REPOSITORY_URL" \
"refs/tags/$RELEASE_TAG" "refs/tags/$RELEASE_TAG^{}" |
awk '$2 ~ /\^\{\}$/ { peeled = $1; next } { direct = $1 } END { print peeled ? peeled : direct }')"
if [[ "$remote_commit" != "$EXPECTED_COMMIT" ]]; then
echo "Release tag $RELEASE_TAG no longer points to verified commit $EXPECTED_COMMIT (found $remote_commit)." >&2
exit 1
fi

release_args=(--verify-tag --generate-notes --title "Release $RELEASE_TAG")
version_without_metadata="${RELEASE_TAG%%+*}"
if [[ "$version_without_metadata" == *-* ]]; then
release_args+=(--prerelease --latest=false)
fi

gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" "${release_args[@]}"
138 changes: 138 additions & 0 deletions .github/scripts/test_check_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest


spec = importlib.util.spec_from_file_location(
"check_versions", Path(__file__).with_name("check-versions.py")
)
check_versions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check_versions)


class VersionCheckTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.files = {
f"{name}.podspec": "Pod::Spec.new do |s|\n s.version = '1.2.3'\nend\n"
for name in (
"SimlaCore", "SimlaOnlineConsultant", "SimlaPushNotificationExtension",
"SimlaPushService", "SimlaRecommendations", "SimlaSDKConfiguration",
"SimlaSQLiteStorage", "SimlaTracker",
)
}
self.files.update({
"Sources/Core/Session/SessionRuntime.swift":
'nonisolated package static let version = "1.2.3"\n',
"Sources/PushService/PushService+Version.swift":
'static let version = "1.2.3"\n',
"docs/en/sdk.md": "**Exact Version → 1.2.3**\n:tag => '1.2.3'\n",
"docs/ru/sdk.md": "**Exact Version → 1.2.3**\n:tag => '1.2.3'\n",
})
for name, content in self.files.items():
self.write(name, content)

def write(self, name, content):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")

def test_matching_versions_pass(self):
self.assertEqual(check_versions.validate(self.root), [])

def test_changing_all_versions_together_passes(self):
for name, content in self.files.items():
self.write(name, content.replace("1.2.3", "2.0.0"))
self.assertEqual(check_versions.validate(self.root), [])

def test_each_of_the_fourteen_version_fields_can_fail_independently(self):
for name, content in self.files.items():
parts = content.split("1.2.3")
for index in range(len(parts) - 1):
with self.subTest(file=name, occurrence=index):
changed = parts[0]
for occurrence, part in enumerate(parts[1:]):
changed += ("1.2.2" if occurrence == index else "1.2.3") + part
self.write(name, changed)
errors = check_versions.validate(self.root)
self.assertIn("SDK versions do not match:", errors)
line = content.count("\n", 0, content.index("1.2.3")) + 1
if name.startswith("docs/") and index == 1:
line = 2
self.assertTrue(any(
error.startswith(f"{name}:{line}:") and "'1.2.2'" in error
for error in errors
), errors)
self.write(name, content)

def test_missing_required_files_fail(self):
for name, content in self.files.items():
with self.subTest(file=name):
(self.root / name).unlink()
errors = check_versions.validate(self.root)
self.assertTrue(any(
error.startswith(f"{name}: missing file") for error in errors
), errors)
self.write(name, content)

def test_missing_each_documentation_example_fails(self):
for language in ("en", "ru"):
name = f"docs/{language}/sdk.md"
for retained_line in self.files[name].splitlines(keepends=True):
with self.subTest(file=name, retained=retained_line):
self.write(name, retained_line)
errors = check_versions.validate(self.root)
self.assertTrue(any(
error.startswith(name + ":") and "found 0" in error
for error in errors
), errors)
self.write(name, self.files[name])

def test_missing_and_duplicate_constant_fail(self):
name = "Sources/PushService/PushService+Version.swift"
for content, count in (("", 0), (self.files[name] * 2, 2)):
with self.subTest(count=count):
self.write(name, content)
self.assertTrue(any(
error.startswith(name + ":") and f"found {count}" in error
for error in check_versions.validate(self.root)
))

def test_additional_examples_are_checked(self):
name = "docs/en/sdk.md"
self.write(name, self.files[name] + ":tag => '1.2.2'\n")
errors = check_versions.validate(self.root)
self.assertIn(f"{name}:3: CocoaPods example = '1.2.2'", errors)

def test_additional_podspecs_are_checked(self):
self.write("SimlaNewModule.podspec", "s.version = '1.2.2'\n")
self.assertIn(
"SimlaNewModule.podspec:1: podspec version = '1.2.2'",
check_versions.validate(self.root),
)

def test_matching_release_tag_passes(self):
self.assertEqual(check_versions.validate(self.root, "1.2.3"), [])

def test_versions_can_agree_but_mismatch_the_release_tag(self):
errors = check_versions.validate(self.root, "1.2.4")
self.assertEqual(len(errors), 14)
self.assertTrue(all("expected release tag '1.2.4'" in error for error in errors))

def test_invalid_release_tag_names_fail(self):
for tag in ("v1.2.3", "1.2", "01.2.3", "1.2.3-01", "release/1.2.3"):
with self.subTest(tag=tag):
self.assertIn("semantic version", check_versions.validate(self.root, tag)[0])

def test_prerelease_tag_is_supported(self):
tag = "1.2.3-rc.1"
for name, content in self.files.items():
self.write(name, content.replace("1.2.3", tag))
self.assertEqual(check_versions.validate(self.root, tag), [])


if __name__ == "__main__":
unittest.main()
Loading
Loading