Skip to content

Commit 084c748

Browse files
committed
Add deployment foundation types: DeploymentTarget protocol, DeploymentDiff, DeploymentResult
Introduces the deployment framework for Ignite: - DeploymentTarget protocol with validate/deploy interface - DeploymentDiff for incremental file change tracking - DeploymentResult with summary formatting - DeploymentManifest (Codable) for persisting deploy state - DeploymentEnvironment for environment variable access - DeploymentError with descriptive messages Purely additive. Concrete targets (GitHub Pages, Netlify, etc.) and CLI command are planned as follow-ups.
1 parent 64fb3ff commit 084c748

6 files changed

Lines changed: 343 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//
2+
// DeploymentDiff.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
10+
/// Tracks which files changed between builds to enable incremental deployment.
11+
public struct DeploymentDiff: Sendable {
12+
public let added: Set<String>
13+
public let removed: Set<String>
14+
public let unchanged: Set<String>
15+
public let isAvailable: Bool
16+
17+
public init(added: Set<String>, removed: Set<String>, unchanged: Set<String>) {
18+
self.added = added
19+
self.removed = removed
20+
self.unchanged = unchanged
21+
self.isAvailable = true
22+
}
23+
24+
private init(added: Set<String>, removed: Set<String>, unchanged: Set<String>, isAvailable: Bool) {
25+
self.added = added
26+
self.removed = removed
27+
self.unchanged = unchanged
28+
self.isAvailable = isAvailable
29+
}
30+
31+
public var hasChanges: Bool {
32+
!added.isEmpty || !removed.isEmpty
33+
}
34+
35+
public var totalFileCount: Int {
36+
added.count + removed.count + unchanged.count
37+
}
38+
39+
/// Creates a diff representing a first-time deployment where every file is new.
40+
public static func firstDeploy(fileCount: Int) -> DeploymentDiff {
41+
DeploymentDiff(
42+
added: Set((0..<fileCount).map { "file-\($0)" }),
43+
removed: [],
44+
unchanged: [],
45+
isAvailable: false
46+
)
47+
}
48+
}
49+
50+
/// A record of what was deployed last time.
51+
public struct DeploymentManifest: Codable, Sendable {
52+
public let files: [String: String]
53+
public let timestamp: Date
54+
public let targetName: String
55+
56+
public init(files: [String: String], timestamp: Date, targetName: String) {
57+
self.files = files
58+
self.timestamp = timestamp
59+
self.targetName = targetName
60+
}
61+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// DeploymentEnvironment.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
10+
/// Reads deployment secrets from environment variables.
11+
public struct DeploymentEnvironment: Sendable {
12+
/// Reads a required environment variable, throwing if not set.
13+
public static func required(_ key: String) throws -> String {
14+
guard let value = ProcessInfo.processInfo.environment[key] else {
15+
throw DeploymentError.missingEnvironmentVariable(key)
16+
}
17+
return value
18+
}
19+
20+
/// Reads an optional environment variable.
21+
public static func optional(_ key: String) -> String? {
22+
ProcessInfo.processInfo.environment[key]
23+
}
24+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//
2+
// DeploymentError.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
10+
/// Errors that can occur during deployment.
11+
public enum DeploymentError: LocalizedError, Sendable {
12+
case missingEnvironmentVariable(String)
13+
case missingConfiguration(String)
14+
case invalidBuildDirectory(URL)
15+
case targetRejected(statusCode: Int, message: String)
16+
case fileError(String)
17+
case networkError(String)
18+
19+
public var errorDescription: String? {
20+
switch self {
21+
case .missingEnvironmentVariable(let key):
22+
"Missing environment variable: \(key). Set it before deploying."
23+
case .missingConfiguration(let detail):
24+
"Deployment configuration incomplete: \(detail)"
25+
case .invalidBuildDirectory(let url):
26+
"Build directory not found or empty: \(url.path)"
27+
case .targetRejected(let code, let message):
28+
"Deployment rejected (\(code)): \(message)"
29+
case .fileError(let detail):
30+
"File error during deployment: \(detail)"
31+
case .networkError(let detail):
32+
"Network error during deployment: \(detail)"
33+
}
34+
}
35+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// DeploymentResult.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
10+
/// The outcome of a deployment operation.
11+
public struct DeploymentResult: Sendable {
12+
public let siteURL: URL?
13+
public let filesUploaded: Int
14+
public let filesDeleted: Int
15+
public let filesSkipped: Int
16+
public let bytesTransferred: Int64
17+
public let duration: Duration
18+
public let isDryRun: Bool
19+
public let warnings: [String]
20+
21+
public var summary: String {
22+
let action = isDryRun ? "Would deploy" : "Deployed"
23+
var parts = ["\(action) \(filesUploaded) files"]
24+
if filesDeleted > 0 { parts.append("deleted \(filesDeleted)") }
25+
if filesSkipped > 0 { parts.append("skipped \(filesSkipped) unchanged") }
26+
if let url = siteURL { parts.append("to \(url.absoluteString)") }
27+
return parts.joined(separator: ", ")
28+
}
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// DeploymentTarget.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
10+
/// A hosting provider that Ignite can deploy to.
11+
public protocol DeploymentTarget: Sendable {
12+
var name: String { get }
13+
14+
func validate() throws
15+
16+
func deploy(
17+
buildDirectory: URL,
18+
diff: DeploymentDiff?,
19+
dryRun: Bool
20+
) async throws -> DeploymentResult
21+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//
2+
// DeploymentTests.swift
3+
// Ignite
4+
// https://www.github.com/twostraws/Ignite
5+
// See LICENSE for license information.
6+
//
7+
8+
import Foundation
9+
import Testing
10+
11+
@testable import Ignite
12+
13+
/// Tests for the deployment foundation types.
14+
@Suite("Deployment Tests")
15+
struct DeploymentTests {
16+
17+
// MARK: - DeploymentDiff
18+
19+
@Test("Diff reports added files")
20+
func diffAdded() {
21+
let diff = DeploymentDiff(
22+
added: ["index.html", "about.html"],
23+
removed: [],
24+
unchanged: ["style.css"]
25+
)
26+
#expect(diff.added.count == 2)
27+
#expect(diff.isAvailable)
28+
}
29+
30+
@Test("Diff reports removed files")
31+
func diffRemoved() {
32+
let diff = DeploymentDiff(
33+
added: [],
34+
removed: ["old-page.html"],
35+
unchanged: ["index.html"]
36+
)
37+
#expect(diff.removed.count == 1)
38+
}
39+
40+
@Test("Empty diff means no changes")
41+
func diffEmpty() {
42+
let diff = DeploymentDiff(
43+
added: [],
44+
removed: [],
45+
unchanged: ["index.html"]
46+
)
47+
#expect(!diff.hasChanges)
48+
}
49+
50+
@Test("Diff with changes reports hasChanges")
51+
func diffHasChanges() {
52+
let diff = DeploymentDiff(
53+
added: ["new.html"],
54+
removed: [],
55+
unchanged: []
56+
)
57+
#expect(diff.hasChanges)
58+
}
59+
60+
@Test("Total file count is correct")
61+
func diffTotalCount() {
62+
let diff = DeploymentDiff(
63+
added: ["a.html"],
64+
removed: ["b.html"],
65+
unchanged: ["c.html", "d.html"]
66+
)
67+
#expect(diff.totalFileCount == 4)
68+
}
69+
70+
@Test("First deploy diff is not available")
71+
func firstDeployDiff() {
72+
let diff = DeploymentDiff.firstDeploy(fileCount: 5)
73+
#expect(!diff.isAvailable)
74+
#expect(diff.added.count == 5)
75+
}
76+
77+
// MARK: - DeploymentResult
78+
79+
@Test("Result summary includes file counts")
80+
func resultSummary() {
81+
let result = DeploymentResult(
82+
siteURL: URL(string: "https://example.com"),
83+
filesUploaded: 10,
84+
filesDeleted: 2,
85+
filesSkipped: 5,
86+
bytesTransferred: 1024,
87+
duration: .seconds(3),
88+
isDryRun: false,
89+
warnings: []
90+
)
91+
#expect(result.summary.contains("10"))
92+
#expect(result.summary.contains("example.com"))
93+
}
94+
95+
@Test("Dry run summary says 'Would deploy'")
96+
func dryRunSummary() {
97+
let result = DeploymentResult(
98+
siteURL: nil,
99+
filesUploaded: 5,
100+
filesDeleted: 0,
101+
filesSkipped: 0,
102+
bytesTransferred: 0,
103+
duration: .seconds(1),
104+
isDryRun: true,
105+
warnings: []
106+
)
107+
#expect(result.summary.contains("Would deploy"))
108+
}
109+
110+
@Test("Real deploy summary says 'Deployed'")
111+
func realDeploySummary() {
112+
let result = DeploymentResult(
113+
siteURL: nil,
114+
filesUploaded: 3,
115+
filesDeleted: 0,
116+
filesSkipped: 0,
117+
bytesTransferred: 0,
118+
duration: .seconds(1),
119+
isDryRun: false,
120+
warnings: []
121+
)
122+
#expect(result.summary.contains("Deployed"))
123+
}
124+
125+
// MARK: - DeploymentManifest
126+
127+
@Test("Manifest is Codable round-trip")
128+
func manifestCodable() throws {
129+
let manifest = DeploymentManifest(
130+
files: ["index.html": "abc123", "style.css": "def456"],
131+
timestamp: Date(timeIntervalSince1970: 1000000),
132+
targetName: "GitHub Pages"
133+
)
134+
let data = try JSONEncoder().encode(manifest)
135+
let decoded = try JSONDecoder().decode(DeploymentManifest.self, from: data)
136+
137+
#expect(decoded.files.count == 2)
138+
#expect(decoded.files["index.html"] == "abc123")
139+
#expect(decoded.targetName == "GitHub Pages")
140+
}
141+
142+
// MARK: - DeploymentEnvironment
143+
144+
@Test("Optional returns nil for missing variable")
145+
func envOptionalMissing() {
146+
#expect(DeploymentEnvironment.optional("IGNITE_TEST_NONEXISTENT_VAR_12345") == nil)
147+
}
148+
149+
@Test("Required throws for missing variable")
150+
func envRequiredThrows() {
151+
#expect(throws: DeploymentError.self) {
152+
try DeploymentEnvironment.required("IGNITE_TEST_NONEXISTENT_VAR_12345")
153+
}
154+
}
155+
156+
// MARK: - DeploymentError
157+
158+
@Test("Error descriptions are human-readable")
159+
func errorDescriptions() {
160+
let errors: [DeploymentError] = [
161+
.missingEnvironmentVariable("TOKEN"),
162+
.missingConfiguration("repo URL"),
163+
.invalidBuildDirectory(URL(fileURLWithPath: "/tmp/build")),
164+
.targetRejected(statusCode: 403, message: "Forbidden"),
165+
.fileError("permission denied"),
166+
.networkError("timeout")
167+
]
168+
for error in errors {
169+
#expect(error.errorDescription != nil)
170+
#expect(!error.errorDescription!.isEmpty)
171+
}
172+
}
173+
}

0 commit comments

Comments
 (0)