Automatically open a PR to bump the calver (#4167)

* Update our tools package to Swift 6.1

Also improves the package layout with subdirectories 📁

* Update GenerateSDKMocks to be an Async command.

* Add a tool to bump the project CalVer every month.

* Add a workflow to automatically bump the calendar version.

Note: This only does year & month, the patch is handled by the release script.
This commit is contained in:
Doug
2025-06-03 17:52:16 +01:00
committed by GitHub
parent f0c7ad1141
commit cf6afcf647
16 changed files with 154 additions and 82 deletions
@@ -0,0 +1,65 @@
import ArgumentParser
import SwiftUI
struct AppIconBanner: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "A Swift command-line tool to add a banner to an app icons.")
@Argument(help: "Path to the input image.")
var path: String
@Option(help: "Text for the banner.")
var bannerText: String
@MainActor
func run() async throws {
let currentDirectoryURL = URL(filePath: FileManager.default.currentDirectoryPath)
let pathURL = currentDirectoryURL.appending(path: path)
guard let image = NSImage(contentsOf: pathURL) else {
throw ValidationError("Could not load the image at \(pathURL).")
}
let renderer = ImageRenderer(content: BannerImage(image: image,
text: bannerText))
do {
guard let cgImage = renderer.cgImage else {
throw ValidationError("Couldn't generate CG image.")
}
let bitmap = NSBitmapImageRep(cgImage: cgImage)
guard let pngData = bitmap.representation(using: .png, properties: [:]) else {
throw ValidationError("Couldn't create png data from image.")
}
try pngData.write(to: pathURL)
print("Successfully saved the image with a banner to \(pathURL).")
} catch {
throw ValidationError("Failed to save the image: \(error).")
}
}
}
struct BannerImage: View {
let image: NSImage
let text: String
var body: some View {
ZStack(alignment: .bottom) {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
Text(text)
.frame(maxWidth: .infinity)
.padding()
.background(Color.black.opacity(0.5))
.foregroundColor(.white)
.font(.system(size: 140))
.lineLimit(1)
.allowsTightening(true)
}
.frame(width: image.size.width, height: image.size.height)
}
}
+142
View File
@@ -0,0 +1,142 @@
import ArgumentParser
import CommandLineTools
import Foundation
import Yams
enum Profile: String, ExpressibleByArgument {
case debug = "dbg"
case reldbg, release
}
enum Target: String, ExpressibleByArgument, CaseIterable {
case iOS = "aarch64-apple-ios"
case simulatorARM64 = "aarch64-apple-ios-sim"
case simulatorIntel = "x86_64-apple-ios"
case macARM64 = "aarch64-apple-darwin"
case macIntel = "x86_64-apple-darwin"
}
struct BuildSDK: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "A tool to checkout and build MatrixRustSDK locally for development.")
@Argument(help: "An optional argument to specify a branch of the SDK.")
var branch: String?
@Flag(help: "Build the SDK to run on an iPhone or iPad. This flag takes precedence over --simulator and --target.")
var device
@Flag(help: "Build the SDK to run on the simulator. This flag takes precedence over --target.")
var simulator
@Option(help: "The target to build for such as aarch64-apple-ios. Omit this option to build for all targets.")
var target: [Target] = []
@Option(help: "The profile to use when building the SDK. Omit this option to build in debug mode.")
var profile: Profile = .reldbg
enum Error: LocalizedError {
case rustupOutputFailure
case missingRustTargets([String])
case failureParsingProjectYAML
var errorDescription: String? {
switch self {
case .missingRustTargets(let missingTargets):
return """
Rust is missing the necessary targets to build the SDK.
Run the following command to install them:
rustup target add \(missingTargets.joined(separator: " "))
"""
default:
return nil
}
}
}
func run() throws {
try checkRustupTargets()
try cloneSDKIfNeeded()
try checkoutBranchIfSupplied()
try buildFramework()
try updateXcodeProject()
try generateSDKMocks()
}
/// Checks that all of the required targets have been added through rustup
/// but only when the ``target`` option hasn't been supplied.
func checkRustupTargets() throws {
guard target.isEmpty, device == 0, simulator == 0 else { return }
guard let output = try Zsh.run(command: "rustup target list --toolchain stable --installed") else { throw Error.rustupOutputFailure }
var requiredTargets = Target.allCases.reduce(into: [String: Bool]()) { partialResult, target in
partialResult[target.rawValue] = false
}
output.enumerateLines { line, _ in
if requiredTargets.keys.contains(line) {
requiredTargets[line] = true
}
}
let missingTargets = requiredTargets.compactMap { !$0.value ? $0.key : nil }
guard missingTargets.isEmpty else { throw Error.missingRustTargets(missingTargets) }
}
/// Clones the Rust SDK if a copy isn't found in the parent directory.
func cloneSDKIfNeeded() throws {
guard !FileManager.default.fileExists(atPath: URL.sdkDirectory.path) else { return }
try Zsh.run(command: "git clone https://github.com/matrix-org/matrix-rust-sdk", directory: .parentDirectory)
}
/// Checkout the specified branch of the SDK if supplied.
func checkoutBranchIfSupplied() throws {
guard let branch else { return }
try Zsh.run(command: "git checkout \(branch)", directory: .sdkDirectory)
}
/// Build the Rust SDK as an XCFramework with the debug profile.
func buildFramework() throws {
// unset fixes an issue where swift compilation prevents building for targets other than macOS X
var buildCommand = "unset SDKROOT && cargo xtask swift build-framework"
buildCommand.append(" --profile \(profile.rawValue)")
if device > 0 {
buildCommand.append(" --target \(Target.iOS.rawValue)")
} else if simulator > 0 {
let hostArchitecture = try Zsh.run(command: "arch")
if hostArchitecture?.trimmingCharacters(in: .whitespacesAndNewlines) == "arm64" {
buildCommand.append(" --target \(Target.simulatorARM64.rawValue)")
} else {
buildCommand.append(" --target \(Target.simulatorIntel.rawValue)")
}
} else if !target.isEmpty {
target.forEach { buildCommand.append(" --target \($0.rawValue)") }
}
try Zsh.run(command: buildCommand, directory: .sdkDirectory)
}
/// Update the Xcode project to use the build of the SDK.
func updateXcodeProject() throws {
try updateProjectYAML()
try Zsh.run(command: "xcodegen")
}
/// Update project.yml with the local path of the SDK.
func updateProjectYAML() throws {
let yamlURL = URL.projectDirectory.appendingPathComponent("project.yml")
let yamlString = try String(contentsOf: yamlURL)
guard var projectConfig = try Yams.compose(yaml: yamlString) else { throw Error.failureParsingProjectYAML }
projectConfig["packages"]?.mapping?["MatrixRustSDK"]? = ["path": "../matrix-rust-sdk"]
let updatedYAMLString = try Yams.serialize(node: projectConfig)
try updatedYAMLString.write(to: yamlURL, atomically: true, encoding: .utf8)
}
func generateSDKMocks() throws {
var command = GenerateSDKMocks()
command.version = "local"
try command.run()
}
}
@@ -0,0 +1,54 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct BumpCalendarVersion: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "A tool that bumps the CalVer every month (if needed), setting the patch back to 0.",
discussion: "The tool assumes the release will be published in 6-days so bumps early.")
func run() throws {
try updateProjectYAML()
try Zsh.run(command: "xcodegen")
}
/// Updates the project YAML with the new version.
private func updateProjectYAML() throws {
let yamlURL = URL.projectDirectory.appendingPathComponent("project.yml")
let yamlString = try String(contentsOf: yamlURL)
// Use regex instead of Yams to preserve any whitespace, comments etc in the file.
let marketingVersionRegex = /MARKETING_VERSION:\s*([^\s]+)/
var updatedYAMLString = ""
yamlString.enumerateLines { line, _ in
let processedLine = if let match = line.firstMatch(of: marketingVersionRegex),
let newVersion = try? generateNewVersion(from: String(match.1)) {
line.replacingOccurrences(of: match.1, with: newVersion)
} else {
line
}
updatedYAMLString.append(processedLine + "\n")
}
try updatedYAMLString.write(to: yamlURL, atomically: true, encoding: .utf8)
}
/// Returns the new version string if a change is necessary.
///
/// **Note:** This tool does *not* handle patch bumps, those are done automatically in the release script.
private func generateNewVersion(from currentVersion: String) throws -> String? {
let releaseDate = Date.now.addingTimeInterval(6 * 24 * 60 * 60) // Always assume we're building the RC.
let releaseYear = Calendar.current.component(.year, from: releaseDate) % 1000 // We use the short year.
let releaseMonth = Calendar.current.component(.month, from: releaseDate)
let versionComponents = currentVersion.split(separator: ".").compactMap { Int($0) }
guard versionComponents.count == 3 else { fatalError("Unexpected version format: \(currentVersion)") }
if versionComponents[0] != releaseYear || versionComponents[1] != releaseMonth {
return "\(releaseYear).\(String(format: "%02d", releaseMonth)).0"
} else {
return nil
}
}
}
@@ -0,0 +1,24 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct DownloadStrings: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "A tool to download localizable strings from localazy")
@Flag(help: "Use to download translation keys for all languages")
var allLanguages = false
func run() throws {
try localazyDownload()
try swiftgen()
}
private func localazyDownload() throws {
let arguments = allLanguages ? " all" : ""
try Zsh.run(command: "localazy download\(arguments)")
}
private func swiftgen() throws {
try Zsh.run(command: "swiftgen config run --config Tools/SwiftGen/swiftgen-config.yml")
}
}
+105
View File
@@ -0,0 +1,105 @@
import ArgumentParser
import Foundation
struct GenerateSAS: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "A tool to download and generate SAS localization strings")
private static let defaultLanguage = "en"
@Flag(name: .shortAndLong, help: "Increase output verbosity.")
var verbose = false
func run() throws {
let baseURL = URL(string: "https://raw.githubusercontent.com/matrix-org/matrix-spec/main/data-definitions/sas-emoji.json")!
printIfVerbose("Argument:")
printIfVerbose(self)
print("Downloading \(baseURL.absoluteString)…")
guard let data = try? Data(contentsOf: baseURL),
let json = try? JSONDecoder().decode([SASEmoji].self, from: data) else {
print("Failed to download or parse JSON.")
return
}
printIfVerbose("Json data:")
printIfVerbose(json)
var defaultTranslations = [String: String]()
var cumulativeTranslations = [String: [String: String]]()
cumulativeTranslations[Self.defaultLanguage] = [String: String]()
for emoji in json {
let description = emoji.description
printIfVerbose("Description: \(description)")
defaultTranslations[description] = description
let translations = emoji.translatedDescriptions
// en is not included since is the default language so we need to add it ourselves
cumulativeTranslations[Self.defaultLanguage]![description] = description
for (lang, translation) in translations {
printIfVerbose("Lang: \(lang)")
if cumulativeTranslations[lang] == nil {
cumulativeTranslations[lang] = [String: String]()
}
cumulativeTranslations[lang]![description] = translation
}
}
printIfVerbose(defaultTranslations)
printIfVerbose(cumulativeTranslations)
for (lang, translations) in cumulativeTranslations {
let iosLang = lang
.replacingOccurrences(of: "_", with: "-")
.appending(".lproj")
writeToFile(file: "SAS.strings", dict: translations, subdirectory: iosLang)
}
print("Write completed")
}
private func writeToFile(file: String, dict: [String: String], subdirectory: String) {
let fileDirectory = URL.projectDirectory.appendingPathComponent("ElementX/Resources/Localizations").appendingPathComponent(subdirectory)
let filePath = fileDirectory.appendingPathComponent(file)
print("Writing file \(filePath.path)")
printIfVerbose("With")
printIfVerbose(dict)
do {
// This will fail if the .lproj dir does not exist already, which is fine since we don't want to add translations for unsupported languages.
try dict
.sorted(by: { $0.key < $1.key })
.map { "\"\($0.key.lowercased().replacingOccurrences(of: " ", with: "_"))\" = \"\($0.value)\";" }
.joined(separator: "\n")
.write(to: filePath, atomically: true, encoding: .utf8)
} catch {
print("Failed to write file \(filePath.path): \(error)")
}
}
private struct SASEmoji: Codable {
let number: Int
let emoji, description, unicode: String
let translatedDescriptions: [String: String?]
enum CodingKeys: String, CodingKey {
case number, emoji, description, unicode
case translatedDescriptions = "translated_descriptions"
}
}
private func printIfVerbose(_ items: Any...) {
if verbose {
print(items)
}
}
}
@@ -0,0 +1,44 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct GenerateSDKMocks: AsyncParsableCommand {
enum GenerateSDKMocksError: Error {
case invalidFileUrl
}
static let configuration = CommandConfiguration(abstract: "A tool to setup the mocks for the Matrix Rust SDK")
@Argument(help: "The argument to specify a branch of the SDK. Use `local` to use your local version")
var version: String
private var fileURLFormat = "https://raw.githubusercontent.com/element-hq/matrix-rust-components-swift/%@/Sources/MatrixRustSDK/matrix_sdk_ffi.swift"
func run() async throws {
if version == "local" {
try generateSDKMocks(ffiPath: "\(URL.sdkDirectory.path)/bindings/apple/generated/swift")
} else {
let path = try await downloadSDK(version: version)
try generateSDKMocks(ffiPath: path)
try FileManager.default.removeItem(atPath: path)
}
}
/// Generates the SDK mocks using Sourcery.
func generateSDKMocks(ffiPath: String) throws {
try Zsh.run(command: "sourcery --sources \(ffiPath) --templates Tools/Sourcery/SDKAutoMockable.stencil --output ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift")
}
/// Downloads the specified version of the `matrix_sdk_ffi.swift` file and returns the path to the downloaded file.
func downloadSDK(version: String) async throws -> String {
let fileURLString = String(format: fileURLFormat, version)
guard let fileURL = URL(string: fileURLString) else {
throw GenerateSDKMocksError.invalidFileUrl
}
let (tempURL, _) = try await URLSession.shared.download(from: fileURL)
let sdkFilePath = NSTemporaryDirectory().appending("matrix_sdk_ffi.swift")
try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: sdkFilePath))
return sdkFilePath
}
}
+45
View File
@@ -0,0 +1,45 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct Locheck: ParsableCommand {
enum LocheckError: LocalizedError {
case missingMint
case outputError
var errorDescription: String? {
switch self {
case .missingMint:
return "💥 Unable to find mint. Fix by running:\nbrew install mint\n"
case .outputError:
return "💥 Failed to read the output from locheck."
}
}
}
static let configuration = CommandConfiguration(abstract: "A tool that verifies bad strings contained in localization files")
private var stringsDirectoryURL: URL {
.projectDirectory.appendingPathComponent("ElementX/Resources/Localizations")
}
func run() throws {
try checkMint()
try checkStrings()
}
func checkStrings() throws {
guard let output = try Zsh.run(command: "mint run locheck discoverlproj --ignore-missing --ignore lproj_file_missing_from_translation --treat-warnings-as-errors \(stringsDirectoryURL.path)") else {
throw LocheckError.missingMint
}
print(output)
}
private func checkMint() throws {
let result = try Zsh.run(command: "which mint")
if result?.contains("not found") == true {
throw LocheckError.missingMint
}
}
}
@@ -0,0 +1,24 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct OutdatedPackages: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "A tool to check outdated package dependencies. Please make sure you have already run setup-project before using this tool.")
private var projectSwiftPMDirectoryURL: URL { .projectDirectory.appendingPathComponent("ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm") }
func run() throws {
try checkToolsDependencies()
try checkProjectDependencies()
}
func checkToolsDependencies() throws {
guard let output = try Zsh.run(command: "swift outdated"), !output.isEmpty else { return }
print("outdated tools Swift packages:\n\(output)")
}
func checkProjectDependencies() throws {
guard let output = try Zsh.run(command: "swift outdated", directory: projectSwiftPMDirectoryURL), !output.isEmpty else { return }
print("outdated project Swift packages:\n\(output)")
}
}
+30
View File
@@ -0,0 +1,30 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct SetupProject: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "A tool to setup the required components to efficiently run and contribute to Element X iOS")
func run() throws {
try setupGitHooks()
try brewInstall()
try mintPackagesInstall()
try xcodegen()
}
func setupGitHooks() throws {
try Zsh.run(command: "git config core.hooksPath .githooks")
}
func brewInstall() throws {
try Zsh.run(command: "brew install xcodegen swiftgen swiftformat git-lfs sourcery mint pkl kiliankoe/formulae/swift-outdated localazy/tools/localazy peripheryapp/periphery/periphery FelixHerrmann/tap/swift-package-list")
}
func mintPackagesInstall() throws {
try Zsh.run(command: "mint install Asana/locheck")
}
func xcodegen() throws {
try Zsh.run(command: "xcodegen")
}
}
@@ -0,0 +1,34 @@
import ArgumentParser
import CommandLineTools
import Foundation
struct UnusedStrings: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Generates a report showing which strings aren't used in the project.")
@Flag(help: "Save the results to disk instead of printing them.")
var saveToFile = false
func run() throws {
try peripheryScan()
}
func peripheryScan() throws {
print("Analysing project, this may take a while…")
// Uses the existing .periphery.yml with small tweaks to the output.
let command = "periphery scan --quiet --relative-results --report-include ElementX/Sources/Generated/Strings.swift"
let output = try Zsh.run(command: command)
guard let output else {
print("Nothing reported.")
return
}
if saveToFile {
try output.write(to: .projectDirectory.appending(component: "Unused Strings.txt"), atomically: true, encoding: .utf8)
print("Report saved: Unused Strings.txt")
} else {
print(output)
}
}
}