Skip to content

Instantly share code, notes, and snippets.

@ipedro
Last active March 20, 2025 15:12
Show Gist options
  • Save ipedro/b189821f4ad4e1278d81e1b4f4063c7a to your computer and use it in GitHub Desktop.
Save ipedro/b189821f4ad4e1278d81e1b4f4063c7a to your computer and use it in GitHub Desktop.
Token Diff GitHub Workflow

🚨 Breaking changes!

🚨 Deleted Tokens

  • components/accordion.json
    • component.accordion.border-width.focused
    • component.accordion.border-width.icon.outlined
    • component.accordion.color.background.hover
    • component.accordion.color.background.pressed
    • component.accordion.color.border.focused
    • component.accordion.color.foreground.default
    • component.accordion.color.icon.background.default
    • component.accordion.color.icon.border.default
    • component.accordion.color.icon.default
    • component.accordion.color.icon.filled.default
    • component.accordion.corner-radius.default
    • component.accordion.spacing.header.gap
    • component.accordion.spacing.header.nested.gap
    • component.accordion.spacing.header.nested.padding
    • component.accordion.spacing.header.padding
    • component.accordion.spacing.panel.nested.padding-chevron
    • component.accordion.spacing.panel.nested.padding-opposite
    • component.accordion.spacing.panel.nested.padding-y
    • component.accordion.spacing.panel.padding-chevron
    • component.accordion.spacing.panel.padding-opposite
    • component.accordion.spacing.panel.padding-y

Token Changes

brand/chefsplate.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/everyplate.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/factor.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/factorform.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/goodchop.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/greenchef.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/hellofresh.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/thepetstable.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/vds.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.circle}
❇️ spacing.menu-item - {spacing.xs}

brand/youfoodz.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

brand/zest.json (+2)

Token Path Old Value New Value
❇️ corner-radius.navigation-menu - {corner-radius.none}
❇️ spacing.menu-item - {spacing.none}

components/accordion.json (-21)

Token Path Old Value New Value
❌ component.accordion.border-width.focused {border-width.focused} -
❌ component.accordion.border-width.icon.outlined {border-width.default} -
❌ component.accordion.color.background.hover {color.accent.alternative.hover} -
❌ component.accordion.color.background.pressed {color.accent.alternative.pressed} -
❌ component.accordion.color.border.focused {color.interactive.border.focused} -
❌ component.accordion.color.foreground.default {color.neutral.foreground.default} -
❌ component.accordion.color.icon.background.default {color.interactive.background.default} -
❌ component.accordion.color.icon.border.default {color.interactive.border.default} -
❌ component.accordion.color.icon.default {color.interactive.border.default} -
❌ component.accordion.color.icon.filled.default {color.neutral.foreground.inverse} -
❌ component.accordion.corner-radius.default {corner-radius.none} -
❌ component.accordion.spacing.header.gap {spacing.xs} -
❌ component.accordion.spacing.header.nested.gap {spacing.sm-1} -
❌ component.accordion.spacing.header.nested.padding {spacing.xxs} -
❌ component.accordion.spacing.header.padding {spacing.xs} -
❌ component.accordion.spacing.panel.nested.padding-chevron {spacing.md-3} -
❌ component.accordion.spacing.panel.nested.padding-opposite {spacing.xxs} -
❌ component.accordion.spacing.panel.nested.padding-y {spacing.xxs} -
❌ component.accordion.spacing.panel.padding-chevron {spacing.lg-1} -
❌ component.accordion.spacing.panel.padding-opposite {spacing.xs} -
❌ component.accordion.spacing.panel.padding-y {spacing.xs} -

components/navigation-menu.json (+3)

Token Path Old Value New Value
πŸ”„ component.navigation-menu.color.group.container.background.default {color.elevation.base} {color.elevation.background.level-1.default}
❇️ component.navigation-menu.color.item.background.default - {color.elevation.background.transparent}
πŸ”„ component.navigation-menu.color.item.background.hover {color.accent.alternative.hover} {color.elevation.background.level-1.hover}
πŸ”„ component.navigation-menu.color.item.background.pressed {color.accent.alternative.pressed} {color.elevation.background.level-1.press}
πŸ”„ component.navigation-menu.color.item.background.selected.default {color.accent.alternative.selected} {color.selected.background.secondary.default}
πŸ”„ component.navigation-menu.color.item.background.selected.hover {color.interactive.background.lightest} {color.selected.background.secondary.hover}
❇️ component.navigation-menu.color.item.background.selected.press - {color.selected.background.secondary.press}
πŸ”„ component.navigation-menu.color.item.border.selected.default {color.interactive.border.default} {color.interactive.border.default-alternative}
πŸ”„ component.navigation-menu.corner-radius.item {corner-radius.none} {corner-radius.navigation-menu}
❇️ component.navigation-menu.spacing.item.inline.padding-main - {spacing.menu-item}
import Foundation
// MARK: - Models
struct TokenDiff {
let oldValue: String
let newValue: String
enum Kind: String {
case addition = "❇️"
case removal = "❌"
case update = "πŸ”„"
}
var kind: Kind {
if oldValue == Self.emptyValue { return .addition }
if newValue == Self.emptyValue { return .removal }
return .update
}
static let emptyValue = "-"
init(old: String?, new: String?) {
self.oldValue = old ?? Self.emptyValue
self.newValue = new ?? Self.emptyValue
}
}
// MARK: - Shell Commands
struct ShellCommand {
struct Result {
let output: String
let error: String?
let exitCode: Int32
var succeeded: Bool { exitCode == 0 }
var trimmedOutput: String { output.trimmingCharacters(in: .whitespacesAndNewlines) }
}
static func execute(_ launchPath: String, _ arguments: [String]) -> Result {
let task = Process()
task.executableURL = URL(fileURLWithPath: launchPath)
task.arguments = arguments
let outputPipe = Pipe()
let errorPipe = Pipe()
task.standardOutput = outputPipe
task.standardError = errorPipe
do {
try task.run()
} catch {
return Result(output: "", error: "\(error)", exitCode: -1)
}
task.waitUntilExit()
let outData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outData, encoding: .utf8) ?? ""
let errorOutput = String(data: errData, encoding: .utf8)
return Result(output: output, error: errorOutput, exitCode: task.terminationStatus)
}
}
// MARK: - Git Operations
struct GitOperations {
static func getFileContent(at path: String, with ref: String) -> String? {
let result = ShellCommand.execute("/usr/bin/git", ["show", "\(ref):\(path)"])
guard !result.output.isEmpty else { return nil }
return result.output
}
static func listFiles(in directory: String, ref: String) -> [URL] {
let result = ShellCommand.execute("/usr/bin/git", ["ls-tree", "-r", "--name-only", ref, directory])
guard result.succeeded else { return [] }
return result.output
.components(separatedBy: .newlines)
.filter { !$0.isEmpty }
.filter { $0.hasSuffix(".json") && !($0.split(separator: "/").last?.hasPrefix("$") ?? false) }
.map { URL(fileURLWithPath: $0, relativeTo: FileOperations.tokensDirectory) }
}
}
// MARK: - JSON Processing
struct JSONProcessor {
static func decodeJSON(_ string: String?) -> [String: Any]? {
guard let string = string,
let data = string.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return json
}
static func encodeJSON(_ object: Any) -> Data? {
try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
}
/// Flattens a JSON object recursively following the jq logic:
/// - If the value is not a dictionary, return an empty dictionary.
/// - If the dictionary has a key "value", then return [prefix: (if value is a dictionary, then its JSON string, else its string)].
/// - Otherwise, iterate over each key and flatten recursively.
static func flattenTokens(prefix: String = "", json: Any) -> [String: String] {
guard let dict = json as? [String: Any] else {
print("Error: JSON is not a dictionary: \(json)")
return [:]
}
// If the dictionary has "value", return that mapping.
if let value = dict["value"] {
let key = prefix
if let subDict = value as? [String: Any],
let jsonData = JSONProcessor.encodeJSON(subDict),
let jsonString = String(data: jsonData, encoding: .utf8) {
return [key: jsonString]
} else {
return [key: "\(value)"]
}
}
// Otherwise, iterate keys
var result: [String: String] = [:]
for (k, v) in dict {
let newPrefix = prefix.isEmpty ? k : "\(prefix).\(k)"
let partial = flattenTokens(prefix: newPrefix, json: v)
for (subKey, subValue) in partial {
result[subKey] = subValue
}
}
return result
}
}
// MARK: - File Operations
struct FileOperations {
static let fileManager = FileManager.default
static var currentDirectory: URL {
URL(fileURLWithPath: fileManager.currentDirectoryPath)
}
static var tokensDirectory: URL {
currentDirectory.appendingPathComponent("tokens")
}
static var diffsDirectory: URL {
currentDirectory.appendingPathComponent("diffs")
}
static func removeIfExisits(_ url: URL) -> Bool {
if fileManager.fileExists(atPath: url.path) {
// Remove the existing directory (and its contents) first.
do {
try fileManager.removeItem(at: url)
} catch {
print("Error deleting directory at \(url.path): \(error)")
return false
}
}
return true
}
static func writeJSON(_ object: Any, to url: URL) -> Bool {
guard let data = JSONProcessor.encodeJSON(object) else { return false }
do {
try? fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url)
return true
} catch {
print("Error writing JSON: \(error)")
return false
}
}
static func writeText(_ text: String, to url: URL) -> Bool {
do {
try? fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try text.write(to: url, atomically: true, encoding: .utf8)
return true
} catch {
print("Error writing text: \(error)")
return false
}
}
}
// MARK: - Report Generation
struct ReportGenerator {
static func generateCommentText(from changes: [URL: [String: TokenDiff]]) -> String {
var deletedTokens = [String: [String]]()
var changesText = "### Token Changes\n\n"
let sortedFiles = changes.keys.sorted { $0.relativePath < $1.relativePath }
for fileURL in sortedFiles {
let fileDiffs = changes[fileURL]!
let fileName = fileURL.lastPathComponent.replacingOccurrences(of: " ", with: "/");
let fileRemovals = -(fileDiffs.values.filter { $0.kind == .removal }.count)
let fileAdditions = fileDiffs.values.filter { $0.kind == .addition }.count
let fileChanges = [fileAdditions, fileRemovals]
.filter { $0 != 0 }
.map { $0 > 0 ? "+\($0)" : String($0) }
.joined(separator: ", ")
changesText += "#### \(fileName)\(fileChanges.isEmpty ? "" : " (\(fileChanges))")\n\n"
changesText += "| | Token Path | Old Value | New Value |\n"
changesText += "|-|------------|-----------|-----------|\n"
for key in fileDiffs.keys.sorted() {
let diff = fileDiffs[key]!
let tokenName = "`\(key)`"
if diff.kind == .removal {
var array = deletedTokens[fileName] ?? []
array.append(tokenName)
deletedTokens[fileName] = array
}
changesText += "| \(diff.kind.rawValue) | \(tokenName) | \(diff.oldValue) | \(diff.newValue) |\n"
}
changesText += "\n"
}
var commentText = ""
if !deletedTokens.isEmpty {
commentText += "## 🚨 Breaking changes!\n\n"
// Report deleted tokens
if !deletedTokens.isEmpty {
commentText += "### 🚨 Deleted Tokens\n\n"
for fileName in deletedTokens.keys.sorted() {
let tokens = deletedTokens[fileName]!
commentText += "- **\(fileName)**\n"
for token in tokens {
commentText += " - \(token)\n"
}
}
commentText += "\n---\n\n"
}
}
commentText += changesText
return commentText
}
}
// MARK: - Token Diff Calculator
class TokenDiffCalculator {
let baseRef: String
let headRef: String
let tokensFolder: URL
let diffsFolder: URL
init?(baseRef: String, headRef: String?) {
guard let headRef = headRef else {
print("Unable to determine HEAD commit hash")
return nil
}
self.baseRef = baseRef
self.headRef = headRef
print("Comparing to base ref: \(baseRef)")
print("Comparing to head ref: \(headRef)")
// Setup folders
self.tokensFolder = FileOperations.tokensDirectory
self.diffsFolder = FileOperations.diffsDirectory
// Ensure diffs folder exists
guard FileOperations.removeIfExisits(diffsFolder) else {
print("Failed to delete diffs folder")
return nil
}
}
func findTokenFiles() -> Set<URL> {
let baseFiles = Set(GitOperations.listFiles(in: "tokens", ref: baseRef))
let headFiles = Set(GitOperations.listFiles(in: "tokens", ref: headRef))
return baseFiles.union(headFiles)
}
func processTokenDiffs() -> [URL: [String: TokenDiff]] {
var allDiffs: [URL: [String: TokenDiff]] = [:]
let tokenFiles = findTokenFiles()
for fileURL in tokenFiles {
let fileRelativePath = fileURL.relativePath
guard let diff = processTokenFile(at: fileRelativePath) else {
print("πŸ”΄ \(fileRelativePath) β€” Error processing token file. Skipping diff generation.")
continue
}
guard !diff.isEmpty else {
print("πŸ”΅ \(fileRelativePath) β€” No differences found.")
continue
}
let diffFileURL = diffsFolder.appendingPathComponent(fileRelativePath)
// Store as TokenDiff objects
var tokenDiffs: [String: TokenDiff] = [:]
for (key, values) in diff {
tokenDiffs[key] = TokenDiff(old: values["old"], new: values["new"])
}
allDiffs[diffFileURL] = tokenDiffs
// Write diffs mapping
let success = FileOperations.writeJSON(diff, to: diffFileURL)
if success {
print("βœ… \(fileRelativePath) β€” Found \(diff.count) differences.")
}
}
return allDiffs
}
private func processTokenFile(at relativePath: String) -> [String: [String: String]]? {
// Get file content from both refs
let newContent = GitOperations.getFileContent(at: relativePath, with: headRef)
let oldContent = GitOperations.getFileContent(at: relativePath, with: baseRef)
// Parse JSON
let newJSON = JSONProcessor.decodeJSON(newContent) ?? [:]
let oldJSON = JSONProcessor.decodeJSON(oldContent) ?? [:]
// Flatten token structures
let flattenedOld = JSONProcessor.flattenTokens(json: oldJSON)
let flattenedNew = JSONProcessor.flattenTokens(json: newJSON)
// Create combined diff mapping
let allKeys = Set(flattenedOld.keys).union(flattenedNew.keys)
var diffMapping: [String: [String: String]] = [:]
for key in allKeys {
let oldVal = flattenedOld[key] ?? TokenDiff.emptyValue
let newVal = flattenedNew[key] ?? TokenDiff.emptyValue
if oldVal != newVal {
diffMapping[key] = ["old": oldVal, "new": newVal]
}
}
return diffMapping
}
}
// MARK: - Main
func main() {
// Accept the base ref as a command-line argument, default to "master" branch.
let base = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "master"
let head = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "HEAD"
// Initialize calculator
guard let calculator = TokenDiffCalculator(baseRef: base, headRef: head) else {
print("Failed to initialize token diff calculator")
exit(1)
}
// Process all token files present in the base and head refs
let diffs = calculator.processTokenDiffs()
if diffs.isEmpty {
print("No differences found.")
exit(0)
} else {
print("Diffs generated in: \(calculator.diffsFolder.path())")
}
// Generate report
let commentText = ReportGenerator.generateCommentText(from: diffs)
let commentURL = calculator.diffsFolder.appendingPathComponent("comment.md")
if FileOperations.writeText(commentText, to: commentURL) {
print("Comment file written to: \(commentURL.path())")
} else {
print("Failed to write comment file.")
}
}
// Run the script
main()
name: Token Diff Commenter
on:
pull_request:
paths:
- 'tokens/**.json' # Match all JSON files inside tokens folder
jobs:
token-diff:
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch base commit
run: git fetch origin ${{ github.event.pull_request.base.sha }}
- name: Compute token diff
run: swift .github/workflows/scripts/compute_token_diff.swift "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}"
- name: Check if comment file exists
id: check_file
run: |
COMMENT="diffs/comment.md"
if [ -f $COMMENT ]; then
echo "comment_path=$COMMENT" >> $GITHUB_OUTPUT
else
echo "comment_path=" >> $GITHUB_OUTPUT
fi
- name: Post comment on PR
if: steps.check_file.outputs.comment_path != ''
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr comment ${{ github.event.pull_request.html_url }} --body "$(cat ${{ steps.check_file.outputs.comment_path }})"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment