Skip to content

Commit ad7a8c6

Browse files
Version script (#715)
- Keeps the old nanpa format and parses that (moves to the `.changes` directory) - Replaces the old bash script for versioning - Everything else is opinionated, so feel free to comment/change if needed IMO, it's _good enough_ for our simple use case --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent f6f2978 commit ad7a8c6

File tree

4 files changed

+376
-40
lines changed

4 files changed

+376
-40
lines changed

.github/workflows/ci.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,31 @@ jobs:
170170
name: docs
171171
path: docs.zip
172172
retention-days: 1
173+
check-changes:
174+
name: Check Changes
175+
if: github.event_name == 'pull_request'
176+
runs-on: ubuntu-latest
177+
permissions:
178+
contents: read
179+
pull-requests: write
180+
steps:
181+
- uses: actions/checkout@v4
182+
with:
183+
fetch-depth: 0
184+
185+
- name: Check for .changes files
186+
id: check-changes
187+
run: |
188+
if [ -z "$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} | grep '^\.changes/')" ]; then
189+
echo "has_changes=false" >> $GITHUB_OUTPUT
190+
else
191+
echo "has_changes=true" >> $GITHUB_OUTPUT
192+
fi
193+
194+
- name: Comment on PR
195+
if: steps.check-changes.outputs.has_changes == 'false'
196+
uses: mshick/add-pr-comment@v2
197+
with:
198+
message: |
199+
⚠️ This PR does not contain any files in the `.changes` directory.
200+
repo-token: ${{ secrets.GITHUB_TOKEN }}

.version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
2.6.0

scripts/create_version.swift

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
#!/usr/bin/env swift
2+
3+
/*
4+
* Copyright 2025 LiveKit
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import Foundation
20+
21+
/// Change file format:
22+
/// Each line in a change file should follow the format:
23+
/// `level type="kind" "description"`
24+
///
25+
/// Where:
26+
/// - level: One of [patch, minor, major] indicating the version bump level
27+
/// - kind: One of [added, changed, fixed] indicating the type of change
28+
/// - description: A detailed description of the change
29+
///
30+
/// Examples:
31+
/// ```
32+
/// patch type="fixed" "Fix audio frame generation when publishing"
33+
/// minor type="added" "Add support for custom audio processing"
34+
/// major type="changed" "Breaking: Rename Room.connect() to Room.join()"
35+
/// ```
36+
///
37+
/// The script will:
38+
/// 1. Parse all change files in the .changes directory
39+
/// 2. Determine the highest level change (major > minor > patch)
40+
/// 3. Bump the version accordingly
41+
/// 4. Generate a changelog entry
42+
/// 5. Update version numbers in all relevant files
43+
/// 6. Clean up the change files
44+
45+
enum Path {
46+
static let changes = ".changes"
47+
static let version = ".version"
48+
static let changelog = "CHANGELOG.md"
49+
static let podspec = "LiveKitClient.podspec"
50+
static let readme = "README.md"
51+
static let livekitVersion = "Sources/LiveKit/LiveKit.swift"
52+
}
53+
54+
// ANSI color codes
55+
enum Color {
56+
static let reset = "\u{001B}[0m"
57+
static let green = "\u{001B}[32m"
58+
static let bold = "\u{001B}[1m"
59+
}
60+
61+
// Regex patterns
62+
enum VersionPattern {
63+
static let podspecVersion = #"spec\.version\s*=\s*"[^"]*""#
64+
static let readmeVersion = #"upToNextMajor\("[^"]*""#
65+
static let livekitVersion = #"static let version = "[^"]*""#
66+
}
67+
68+
// File operations
69+
func readFile(_ path: String) throws -> String {
70+
try String(contentsOfFile: path, encoding: .utf8)
71+
}
72+
73+
func writeFile(_ path: String, content: String) throws {
74+
try content.write(toFile: path, atomically: true, encoding: .utf8)
75+
}
76+
77+
struct SemanticVersion: CustomStringConvertible {
78+
let major: Int
79+
let minor: Int
80+
let patch: Int
81+
82+
init(major: Int, minor: Int, patch: Int) {
83+
self.major = major
84+
self.minor = minor
85+
self.patch = patch
86+
}
87+
88+
init?(string: String) {
89+
let components = string.split(separator: ".").compactMap { Int($0) }
90+
guard components.count == 3 else { return nil }
91+
major = components[0]
92+
minor = components[1]
93+
patch = components[2]
94+
}
95+
96+
var description: String {
97+
"\(major).\(minor).\(patch)"
98+
}
99+
100+
func bumpMajor() -> SemanticVersion {
101+
SemanticVersion(major: major + 1, minor: 0, patch: 0)
102+
}
103+
104+
func bumpMinor() -> SemanticVersion {
105+
SemanticVersion(major: major, minor: minor + 1, patch: 0)
106+
}
107+
108+
func bumpPatch() -> SemanticVersion {
109+
SemanticVersion(major: major, minor: minor, patch: patch + 1)
110+
}
111+
}
112+
113+
struct Change {
114+
enum Kind: String {
115+
case added
116+
case fixed
117+
case changed
118+
}
119+
120+
enum Level: String, Comparable {
121+
case patch
122+
case minor
123+
case major
124+
125+
static func < (lhs: Level, rhs: Level) -> Bool {
126+
lhs.priority < rhs.priority
127+
}
128+
129+
private var priority: Int {
130+
switch self {
131+
case .patch: return 0
132+
case .minor: return 1
133+
case .major: return 2
134+
}
135+
}
136+
}
137+
138+
let level: Level
139+
let kind: Kind
140+
let description: String
141+
}
142+
143+
func getCurrentVersion() -> SemanticVersion {
144+
do {
145+
let content = try readFile(Path.version)
146+
let versionString = content.trimmingCharacters(in: .whitespacesAndNewlines)
147+
guard let version = SemanticVersion(string: versionString) else {
148+
fatalError("Invalid version format in \(Path.version): \(versionString)")
149+
}
150+
return version
151+
} catch {
152+
fatalError("Failed to read \(Path.version): \(error)")
153+
}
154+
}
155+
156+
func parseChanges() -> [Change] {
157+
let fileManager = FileManager.default
158+
159+
guard let files = try? fileManager.contentsOfDirectory(atPath: Path.changes) else {
160+
return []
161+
}
162+
163+
var changes: [Change] = []
164+
165+
for file in files {
166+
let filePath = (Path.changes as NSString).appendingPathComponent(file)
167+
guard let content = try? readFile(filePath) else { continue }
168+
169+
let lines = content.components(separatedBy: .newlines)
170+
for line in lines {
171+
// Skip empty lines
172+
guard !line.isEmpty else { continue }
173+
174+
// Parse format: level type="kind" "description"
175+
let components = line.components(separatedBy: .whitespaces)
176+
guard components.count >= 3 else { continue }
177+
178+
// Extract level
179+
guard let level = Change.Level(rawValue: components[0]) else { continue }
180+
181+
// Extract type from type="kind" format
182+
let typeComponent = components[1]
183+
guard typeComponent.hasPrefix("type="),
184+
let typeStart = typeComponent.firstIndex(of: "\""),
185+
let typeEnd = typeComponent.lastIndex(of: "\""),
186+
typeStart < typeEnd else { continue }
187+
188+
let typeString = String(typeComponent[typeComponent.index(after: typeStart) ..< typeEnd])
189+
guard let kind = Change.Kind(rawValue: typeString) else { continue }
190+
191+
// Extract description from the last quoted string
192+
if let lastQuoteStart = line.lastIndex(of: "\""),
193+
let lastQuoteEnd = line[..<lastQuoteStart].lastIndex(of: "\"")
194+
{
195+
let description = String(line[line.index(after: lastQuoteEnd) ..< lastQuoteStart])
196+
guard !description.isEmpty else { continue }
197+
changes.append(Change(level: level, kind: kind, description: description))
198+
}
199+
}
200+
}
201+
202+
guard !changes.isEmpty else {
203+
fatalError("No changes found in \(Path.changes)")
204+
}
205+
206+
return changes
207+
}
208+
209+
func calculateNewVersion(currentVersion: SemanticVersion, changes: [Change]) -> SemanticVersion {
210+
let highestLevel = changes.map(\.level).max() ?? .patch
211+
212+
switch highestLevel {
213+
case .major: return currentVersion.bumpMajor()
214+
case .minor: return currentVersion.bumpMinor()
215+
case .patch: return currentVersion.bumpPatch()
216+
}
217+
}
218+
219+
func generateChangelogEntry(version: SemanticVersion, changes: [Change]) -> String {
220+
let dateFormatter = DateFormatter()
221+
dateFormatter.dateFormat = "yyyy-MM-dd"
222+
let today = dateFormatter.string(from: Date())
223+
224+
var entry = "## [\(version)] - \(today)\n\n"
225+
226+
// Group changes by kind
227+
let added = changes.filter { $0.kind == .added }
228+
let changed = changes.filter { $0.kind == .changed }
229+
let fixed = changes.filter { $0.kind == .fixed }
230+
231+
if !added.isEmpty {
232+
entry += "### Added\n\n"
233+
for change in added {
234+
entry += "- \(change.description)\n"
235+
}
236+
entry += "\n"
237+
}
238+
239+
if !changed.isEmpty {
240+
entry += "### Changed\n\n"
241+
for change in changed {
242+
entry += "- \(change.description)\n"
243+
}
244+
entry += "\n"
245+
}
246+
247+
if !fixed.isEmpty {
248+
entry += "### Fixed\n\n"
249+
for change in fixed {
250+
entry += "- \(change.description)\n"
251+
}
252+
entry += "\n"
253+
}
254+
255+
return entry
256+
}
257+
258+
func appendToChangelog(entry: String) {
259+
do {
260+
let content = try readFile(Path.changelog)
261+
262+
// Find the position after the header
263+
guard let headerRange = content.range(of: "# Changelog\n\n") else {
264+
fatalError("Could not find Changelog header")
265+
}
266+
267+
// Create new content with the entry inserted after the header
268+
let newContent = String(content[..<headerRange.upperBound]) + entry + String(content[headerRange.upperBound...])
269+
270+
try writeFile(Path.changelog, content: newContent)
271+
} catch {
272+
fatalError("Failed to update \(Path.changelog): \(error)")
273+
}
274+
}
275+
276+
func replaceVersionInFile(_ filePath: String, pattern: String, replacement: String) {
277+
do {
278+
let content = try readFile(filePath)
279+
let regex = try Regex(pattern)
280+
let newContent = content.replacing(regex, with: replacement)
281+
try writeFile(filePath, content: newContent)
282+
} catch {
283+
fatalError("Failed to update \(filePath): \(error)")
284+
}
285+
}
286+
287+
func updateVersionFiles(version: SemanticVersion) {
288+
do {
289+
try writeFile(Path.version, content: version.description)
290+
291+
replaceVersionInFile(
292+
Path.podspec,
293+
pattern: VersionPattern.podspecVersion,
294+
replacement: "spec.version = \"\(version)\""
295+
)
296+
297+
replaceVersionInFile(
298+
Path.readme,
299+
pattern: VersionPattern.readmeVersion,
300+
replacement: "upToNextMajor(\"\(version)\""
301+
)
302+
303+
replaceVersionInFile(
304+
Path.livekitVersion,
305+
pattern: VersionPattern.livekitVersion,
306+
replacement: "static let version = \"\(version)\""
307+
)
308+
} catch {
309+
fatalError("Failed to update version files: \(error)")
310+
}
311+
}
312+
313+
func cleanupChangesDirectory() {
314+
let fileManager = FileManager.default
315+
316+
guard let files = try? fileManager.contentsOfDirectory(atPath: Path.changes) else {
317+
return
318+
}
319+
320+
for file in files {
321+
// Skip files that start with a dot
322+
guard !file.hasPrefix(".") else { continue }
323+
324+
let filePath = (Path.changes as NSString).appendingPathComponent(file)
325+
try? fileManager.removeItem(atPath: filePath)
326+
}
327+
}
328+
329+
let currentVersion = getCurrentVersion()
330+
let changes = parseChanges()
331+
let newVersion = calculateNewVersion(currentVersion: currentVersion, changes: changes)
332+
333+
print("Current version: \(currentVersion)")
334+
print("Changes detected:")
335+
for change in changes {
336+
print("- [\(change.kind.rawValue)] \(change.description)")
337+
}
338+
339+
print("New version: \(Color.bold)\(Color.green)\(newVersion)\(Color.reset) 🎉")
340+
341+
let changelogEntry = generateChangelogEntry(version: newVersion, changes: changes)
342+
appendToChangelog(entry: changelogEntry)
343+
print("Changelog entry added 📝")
344+
updateVersionFiles(version: newVersion)
345+
print("Version files updated 📦")
346+
cleanupChangesDirectory()
347+
print("Changes directory cleaned up 🧹")

0 commit comments

Comments
 (0)