Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[Swift Heroes 2023] Making developer tools with...

[Swift Heroes 2023] Making developer tools with Swift

Avatar for Pol Piella Abadia

Pol Piella Abadia

May 06, 2023
Tweet

More Decks by Pol Piella Abadia

Other Decks in Programming

Transcript

  1. Who am I? 👨💻 Senior Software Engineer at BBC ✍

    Weekly blogs at polpiella.dev 📬 Curator of the iOS CI Newsle tt er 🐝 Based in Manchester 🇪🇸 From Barcelona
  2. What we’ll build today 💻 Complex metrics system 🚀 Metrics

    from Xcode Cloud and GHA 📈 Dashboard plots and displays metrics 💡 Inspired by a real-world system 📦 Swift Package monorepo 🆓 Everything is Open Source! ❤
  3. Why Swift though? 💻 Often not fi rst choice for

    writing tooling 🚀 Powerful language with a mature ecosystem. ➕ Beyond Apple app development 📈 Tools are often maintained and built by mobile developers. ❤ Focus on the system, not the language!
  4. # ! /bin/sh # Create an empty directory mkdir metrics

    Creating a Swift Package Creating a Swift Package
  5. # ! /bin/sh # Create an empty directory mkdir metrics

    # Move into the new directory cd metrics Creating a Swift Package Creating a Swift Package
  6. # ! /bin/sh # Create an empty directory mkdir metrics

    # Move into the new directory cd metrics # Create a new executable package swift package init -- name Metrics Creating a Swift Package Creating a Swift Package
  7. . ├── Package.swift ├── README.md ├── Sources │ └── Metrics

    │ └── Metrics.swift └── Tests └── MetricsTests └── MetricsTests.swift Creating a Swift Package Creating a Swift Package # ! /bin/sh # Create an empty directory mkdir metrics # Move into the new directory cd metrics # Create a new executable package swift package init -- name Metrics
  8. using Swift… The metrics backend 💿 PostgresSQL database ➕ POST

    endpoint 🆙 GET endpoint ❌ DELETE endpoint 🎉 Built entirely with Swift 💧 Using Vapor
  9. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ ], targets: [ .target(name: "Metrics"), .testTarget(name: "MetricsTests", dependencies: ["Metrics"]) ] )
  10. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ ], targets: [ .target(name: "Metrics"), .testTarget(name: "MetricsTests", dependencies: ["Metrics"]) ] )
  11. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ ], targets: [ ] )
  12. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), ], targets: [ ] )
  13. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ ] )
  14. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ ], swiftSettings: [ ] ), ] )
  15. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor") ], swiftSettings: [ ] ), ] )
  16. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor") ], swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] ), ] )
  17. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], dependencies: [ .package(url: "https: // github.com/vapor/vapor.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https: // github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") ], targets: [ .target( name: "App", dependencies: [ .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "Vapor", package: "vapor") ], swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] ), .executableTarget(name: "Run", dependencies: [.target(name: "App")]) ] )
  18. import Vapor import Fluent . ├── Package.swift ├── README.md ├──

    Sources │ └── App │ └── Model.swift Modelling data
  19. import Vapor import Fluent final class Metric: Model, Content {

    static let schema = "metrics" } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift
  20. import Vapor import Fluent final class Metric: Model, Content {

    static let schema = "metrics" @ID(key: .id) var id: UUID? @Field(key: "workflow") var workflow: String @Field(key: "duration") var duration: Int @Field(key: "date") var date: Date @Field(key: "repository") var repository: String @Field(key: "author") var author: String @Enum(key: "provider") var provider: Provider @Enum(key: "outcome") var outcome: Outcome init() {} } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift
  21. import Vapor import Fluent enum Provider: String, Codable { case

    xcodeCloud = "xcode-cloud" case githubActions = "github-actions" } enum Outcome: String, Codable { case success case failure case cancelled } final class Metric: Model, Content { static let schema = "metrics" @ID(key: .id) var id: UUID? @Field(key: "workflow") var workflow: String @Field(key: "duration") var duration: Int @Field(key: "date") var date: Date @Field(key: "repository") var repository: String @Field(key: "author") var author: String @Enum(key: "provider") var provider: Provider @Enum(key: "outcome") var outcome: Outcome init() {} } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift
  22. import Fluent struct CreateMetric: Migration { } . ├── Package.swift

    ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift Modelling data
  23. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  24. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  25. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") .id() .field("workflow", .string, .required) .field("duration", .int, .required) .field("date", .datetime, .required) .field("provider", .string, .required) .field("outcome", .string, .required) .field("repository", .string, .required) .field("author", .string, .required) } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  26. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") .id() .field("workflow", .string, .required) .field("duration", .int, .required) .field("date", .datetime, .required) .field("provider", .string, .required) .field("outcome", .string, .required) .field("repository", .string, .required) .field("author", .string, .required) .create() } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  27. import Fluent struct CreateMetric: Migration { func prepare(on database: Database)

    -> EventLoopFuture<Void> { database.schema("metrics") .id() .field("workflow", .string, .required) .field("duration", .int, .required) .field("date", .datetime, .required) .field("provider", .string, .required) .field("outcome", .string, .required) .field("repository", .string, .required) .field("author", .string, .required) .create() } func revert(on database: Database) -> EventLoopFuture<Void> { database.schema("metrics").delete() } } Modelling data . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift
  28. import Fluent import Vapor struct MetricsController: RouteCollection { } .

    ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift Defining the routes
  29. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  30. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  31. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) } func get(req: Request) async throws -> [Metric] { try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  32. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) api.get(":id", use: find) } func find(req: Request) async throws -> Metric { if let metric = try await Metric .find(req.parameters.get("id"), on: req.db) { return metric } else { throw Abort(.notFound) } } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  33. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) api.get(":id", use: find) api.post(use: create) } func create(req: Request) async throws -> Metric { let metric = try req.content.decode(Metric.self) try await metric.save(on: req.db) return metric } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  34. import Fluent import Vapor struct MetricsController: RouteCollection { func boot(routes:

    RoutesBuilder) throws { let api = routes.grouped("api", "metrics") api.get(use: get) api.get(":id", use: find) api.post(use: create) api.delete(":id", use: delete) } func delete(req: Request) async throws -> HTTPStatus { guard let metric = try await Metric .find( req.parameters.get("id"), on: req.db ) else { throw Abort(.notFound) } try await metric.delete(on: req.db) return .noContent } } Defining the endpoints . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift
  35. import Fluent import Vapor func routes(_ app: Application) throws {

    try app.register(collection: MetricsController()) } . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift Registering the routes
  36. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { } . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift Finally…
  37. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get("DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  38. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get("DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) app.migrations.add(CreateMetric()) try app.autoMigrate().wait() } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  39. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get(“DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) app.migrations.add(CreateMetric()) try app.autoMigrate().wait() app.logger.logLevel = .debug } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  40. import Fluent import FluentPostgresDriver import Vapor public func configure(_ app:

    Application) throws { app.databases.use(.postgres( hostname: Environment .get("DATABASE_HOST") ?? "localhost", port: Environment .get(“DATABASE_PORT") .flatMap(Int.init(_:)) ?? 5432, username: Environment .get("DATABASE_USERNAME") ?? "vapor_username", password: Environment .get("DATABASE_PASSWORD") ?? "vapor_password", database: Environment .get("DATABASE_NAME") ?? "vapor_database" ), as: .psql) app.migrations.add(CreateMetric()) try app.autoMigrate().wait() app.logger.logLevel = .debug try routes(app) } Finally… . ├── Package.swift ├── README.md ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift
  41. The run target . ├── Package.swift ├── README.md ├── Sources

    │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ └── Run │ └── main.swift
  42. The run target . ├── Package.swift ├── README.md ├── Sources

    │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ └── Run │ └── main.swift
  43. import App import Vapor var env = try Environment.detect() try

    LoggingSystem.bootstrap(from: &env) let app = Application(env) defer { app.shutdown() } try configure(app) try app.run() The run target . ├── Package.swift ├── README.md ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ └── Run │ └── main.swift
  44. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift 🍁 Creating a template
  45. 🍁 Creating a template . ├── Package.swift ├── README.md ├──

    Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  46. <html> <head> <script type="text/javascript" src="https: // www.gstatic.com/charts/loader.js" > </ script>

    <script src="https: // cdn.tailwindcss.com"> </ script> </ head> <body class="h-screen w-screen bg-gray-900"> <main> <div class="mx-auto my-8 text-3xl font-semibold max-w-5xl > <section class="flex flex-col gap-2"> <h1 class="text-5xl">QReate Metrics </ h1> <p class="text-sm font-normal"> A dashboard to aggregate all workflows data for y </ p> </ section> <section> <p class="text-2xl font-semibold mb-4">All workflow </ <div id="chart"> </ div> </ section> <section> <p class="text-2xl font-semibold mb-4">Recent build </ <div class="flex flex-col gap-6"> #for(metric in metrics): <div class="flex flex-col gap-1 text-lg round 🍁 Creating a template . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  47. <html> <head> <script type="text/javascript" src="https: // www.gstatic.com/charts/loader.js" > </ script>

    <script src="https: // cdn.tailwindcss.com"> </ script> </ head> <body class="h-screen w-screen bg-gray-900"> <main> <div class="mx-auto my-8 text-3xl font-semibold max-w-5xl > <section class="flex flex-col gap-2"> <h1 class="text-5xl">QReate Metrics </ h1> <p class="text-sm font-normal"> A dashboard to aggregate all workflows data for y </ p> </ section> <section> <p class="text-2xl font-semibold mb-4">All workflow </ <div id="chart"> </ div> </ section> <section> <p class="text-2xl font-semibold mb-4">Recent build </ <div class="flex flex-col gap-6"> #for(metric in metrics): <div class="flex flex-col gap-1 text-lg round 🍁 Creating a template . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  48. import Vapor struct FrontendController: RouteCollection { } . ├── Package.swift

    ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  49. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  50. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  51. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() return try await req.view.render( "Home", HomeContext(metrics: allMetrics) ) } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  52. import Vapor struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws

    { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() return try await req.view.render( "Home", HomeContext(metrics: allMetrics) ) } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  53. import Vapor struct HomeContext: Encodable { let metrics: [Metric] }

    struct FrontendController: RouteCollection { func boot(routes: RoutesBuilder) throws { routes.get(use: get) } func get(req: Request) async throws -> View { let allMetrics = try await Metric.query(on: req.db) .sort(\.$date, .descending) .all() return try await req.view.render( "Home", HomeContext(metrics: allMetrics) ) } } . ├── Package.swift ├── README.md ├── Resources │ └── Views │ └── Home.leaf ├── Sources │ └── App │ └── Model.swift │ └── Migrations.swift │ └── MetricsController.swift │ └── routes.swift │ └── configure.swift │ └── FrontendController.swift │ └── Run │ └── main.swift
  54. • App has a single GHA work fl ow •

    Send metric points to the metrics service. • Straight after the work fl ow’s execution is done. • No back-end service • A work fl ow using the work fl ow_run event • Run a Swift CLI 🚀 GitHub Actions Metrics
  55. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ ], targets: [ ] )
  56. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ ] ) ] )
  57. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "GithubActionsMetricsCLI", targets: ["GithubActionsMetricsCLI"]) ], dependencies: [ ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ ] ) ] )
  58. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "GithubActionsMetricsCLI", targets: ["GithubActionsMetricsCLI"]) ], dependencies: [ .package(url: "https: // github.com/apple/swift-argument-parser.git", exact: "1.2.2") ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ ] ) ] )
  59. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "GithubActionsMetricsCLI", targets: ["GithubActionsMetricsCLI"]) ], dependencies: [ .package(url: "https: // github.com/apple/swift-argument-parser.git", exact: "1.2.2") ], targets: [ .executableTarget( name: "GithubActionsMetricsCLI", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser") ] ) ] )
  60. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  61. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── GithubActionsMetricsCLI │ │ └── GithubActionsMetricsCLI.swift │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  62. import Foundation import ArgumentParser @main struct GithubActionsMetricsCLI: AsyncParsableCommand { @Argument

    private var workflow: String @Argument private var updatedAt: String @Argument private var date: String @Argument private var repository: String @Argument private var outcome: String @Argument private var author: String func run() async throws { } }
  63. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } } extension Date { init?(from isoString: String) { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" if let date = dateFormatter.date(from: isoString) { self = date } else { return nil } } }
  64. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } }
  65. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) }
  66. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } } private func adapt(githubActionsOutcome: GithubActionOutcome) -> Outcome? { switch githubActionsOutcome { case .success: return .success case .failure: return .failure case .cancelled: return .cancelled default: return nil } }
  67. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } }
  68. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return } }
  69. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return } let payload = Payload( workflow: workflow, duration: Int(duration), date: startedAtDate, provider: .githubActions, author: author, outcome: outcome, repository: repository ) }
  70. func run() async throws { guard let updateAtDate = Date(from:

    updatedAt), let startedAtDate = Date(from: date) else { return } let duration = updateAtDate.timeIntervalSince(startedAtDate) guard let outcome = adapt(githubActionsOutcome: outcome) else { print("Not handling the GHA outcome \(outcome.rawValue)") return } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return } let payload = Payload( workflow: workflow, duration: Int(duration), date: startedAtDate, provider: .githubActions, author: author, outcome: outcome, repository: repository ) let analyticsService = Factory.make(with: analyticsEndpoint) _ = try await analyticsService.send(payload: payload) }
  71. Creating a workflow . ├── .github │ └── workflows │

    └── lint.yml │ └── workflow_run.yml
  72. . ├── .github │ └── workflows │ └── lint.yml │

    └── workflow_run.yml name: Log workflow run metrics Creating a workflow
  73. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  74. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  75. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest env: ANALYTICS_ENDPOINT: ${{ secrets.ANALYTICS_ENDPOINT }} Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  76. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest env: ANALYTICS_ENDPOINT: ${{ secrets.ANALYTICS_ENDPOINT }} steps: - uses: actions/checkout@v3 with: repository: polpielladev/metrics Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  77. name: Log workflow run metrics on: workflow_run: workflows: [Lint] types:

    - completed jobs: log: runs-on: macos-latest env: ANALYTICS_ENDPOINT: ${{ secrets.ANALYTICS_ENDPOINT }} steps: - uses: actions/checkout@v3 with: repository: polpielladev/metrics - run: | swift run GithubActionsMetricsCLI \ "${{ github.event.workflow_run.name }}" \ "${{ github.event.workflow_run.updated_at }}" \ "${{ github.event.workflow_run.run_started_at }}" \ "${{ github.event.workflow_run.head_repository.name }}" \ "${{ github.event.workflow_run.conclusion }}" \ "${{ github.event.workflow_run.head_commit.author.name }}" Creating a workflow . ├── .github │ └── workflows │ └── lint.yml │ └── workflow_run.yml
  78. Another example Used by the Swift Package Index project to

    generate Pull Requests from issues using webhook logic directly in GHA work fl ows.
  79. Learn more I have an article on the topic where

    you can fi nd a more in- depth explanation of this section of the slides.
  80. • Send metrics from all Xcode Cloud work fl ows.

    • Straight after the work fl ow is done executing. • Using an Xcode Cloud webhook. • Serverless Swift lambda. Xcode Cloud metrics
  81. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ ], targets: [ ] )
  82. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ .package(url: "https: // github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"), .package(url: "https: // github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0") ], targets: [ ] )
  83. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ ], dependencies: [ .package(url: "https: // github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"), .package(url: "https: // github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0") ], targets: [ .executableTarget( name: "XcodeCloudWebhook", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") ] ) ] )
  84. // swift-tools-version:5.6 import PackageDescription let package = Package( name: "Metrics",

    platforms: [ .macOS(.v12) ], products: [ .executable(name: "XcodeCloudWebhook", targets: ["XcodeCloudWebhook"]) ], dependencies: [ .package(url: "https: // github.com/swift-server/swift-aws-lambda-runtime.git", exact: "1.0.0-alpha.1"), .package(url: "https: // github.com/swift-server/swift-aws-lambda-events.git", exact: "0.1.0") ], targets: [ .executableTarget( name: "XcodeCloudWebhook", dependencies: [ .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"), .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-events") ] ) ] )
  85. . ├── Package.swift ├── README.md ├── Resources │ └── Views

    │ └── Home.leaf ├── Sources │ └── XcodeCloudWebhook │ │ └── XcodeCloudWebhook.swift │ └── GithubActionsMetricsCLI │ │ └── GithubActionsMetricsCLI.swift │ └── App │ │ └── Model.swift │ │ └── Migrations.swift │ │ └── MetricsController.swift │ │ └── routes.swift │ │ └── configure.swift │ │ └── FrontendController.swift │ └── Run │ └── main.swift
  86. import AWSLambdaRuntime import AWSLambdaEvents import Foundation @main struct XcodeCloudWebhook: SimpleLambdaHandler

    { let decoder: JSONDecoder init() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(dateFormatter) self.decoder = decoder } }
  87. import AWSLambdaRuntime import AWSLambdaEvents import Foundation @main struct XcodeCloudWebhook: SimpleLambdaHandler

    { let decoder: JSONDecoder init() { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" let decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(dateFormatter) self.decoder = decoder } func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response { } }
  88. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData) else { return .init(statusCode: .ok, body: "Not handling the request ... ") } }
  89. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } }
  90. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } }
  91. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } let analyticsService = Factory.make(with: analyticsEndpoint) }
  92. func handle(_ request: APIGatewayV2Request, context: LambdaContext) async throws -> APIGatewayV2Response

    { guard let body = request.body, let bodyData = body.data(using: .utf8), let payload = try? decoder.decode(WebhookPayload.self, from: bodyData), payload.ciBuildRun.attributes.executionProgress == "COMPLETE" else { return .init(statusCode: .ok, body: "Not handling the request ... ") } guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } let analyticsService = Factory.make(with: analyticsEndpoint) guard let startDate = payload.ciBuildRun.attributes.startedDate, let duration = payload .ciBuildRun .attributes .finishedDate? .timeIntervalSince(startDate), let outcome = Self.adapt(xcodeCloudOutcome: payload.ciBuildRun.attributes.completionStatus) else { return .init(statusCode: .ok, body: "Not handling this request ... ") } }
  93. guard let analyticsEndpoint = ProcessInfo.processInfo.environment["ANALYTICS_ENDPOINT"] else { print("ANALYTICS_ENDPOINT is not

    set. Skipping sending analytics.") return .init(statusCode: .internalServerError) } let analyticsService = Factory.make(with: analyticsEndpoint) guard let startDate = payload.ciBuildRun.attributes.startedDate, let duration = payload .ciBuildRun .attributes .finishedDate? .timeIntervalSince(startDate), let outcome = Self.adapt(xcodeCloudOutcome: payload.ciBuildRun.attributes.completionStatus) else { return .init(statusCode: .ok, body: "Not handling this request ... ") } let analyticsPayload = Payload( workflow: payload.ciWorkflow.attributes.name, duration: Int(duration), date: startDate, provider: .xcodeCloud, author: payload.ciBuildRun.attributes.sourceCommit.author.displayName, outcome: outcome, repository: payload.scmRepository.attributes.repositoryName ) _ = try await analyticsService.send(payload: analyticsPayload) return .init(statusCode: .ok) }
  94. Let’s deploy the lambda! swift package \ --disable-sandbox archive \

    --swift-version 5.7 \ --output-path . \ --products XcodeCloudWebhook
  95. Let’s deploy the lambda! swift package \ --disable-sandbox archive \

    --swift-version 5.7 \ --output-path . \ --products XcodeCloudWebhook
  96. Let’s deploy the lambda! swift package \ --disable-sandbox archive \

    --swift-version 5.7 \ --output-path . \ --products XcodeCloudWebhook
  97. Learn more I have an article on the topic where

    you can fi nd a more in- depth explanation of this section of this section.
  98. 🎁 Let’s wrap up! ⚙ A full-stack website using Vapor

    🍎 An API using Vapor 💻 A command line tool for macOS, extensible to other platforms too! 🔗 A serverless Swift lambda using AWS 📦 Everything in the same Swift Package! ❤ Swift is great for developer tooling! 🤔 Great way to upskill!