Commit 3958f6060f

3958f6060f1694acd4e19359575dc8f90188367f

parent: 8217f743ae

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-16 03:02 UTC

Repository settings: build secrets and deploy keys

Build secrets lists a repository's secret names, sets or replaces one
with its value on stdin, and removes one. Deploy keys lists keys bound
to the repository with their mode, last use and expiry, adds one
read-only or read-write with an optional lifetime, and removes one.
Both are links in settings beside Webhooks. The secret field is
cleared before its sheet closes, so iOS does not offer to save the
value as a password.

Closes #42
Closes #43

Layout: unified · split

gitbay/ContentView.swift +4
@@ -327,6 +327,10 @@ private struct RouteDestinations: ViewModifier {
327327 WebhookListView(client: client, repo: repo)
328328 case .webhookDeliveries(let repo):
329329 WebhookDeliveriesView(client: client, repo: repo)
330 case .buildSecrets(let repo):
331 BuildSecretsView(client: client, repo: repo)
332 case .deployKeys(let repo):
333 DeployKeysView(client: client, repo: repo)
330334 case .wiki(let repo):
331335 WikiView(client: client, repo: repo)
332336 case .wikiPage(let repo, let page):
gitbay/Repos/RepoCredentials.swift added +139
@@ -0,0 +1,139 @@
1import Foundation
2import Observation
3
4/// `repo secret list`, `set` and `remove`. Names only: a value goes to
5/// the server on stdin and never comes back.
6@Observable
7@MainActor
8final class BuildSecretsViewModel {
9
10 private(set) var state: LoadState<[String]> = .loading
11 private(set) var actionError: String?
12 private(set) var working = false
13
14 private let client: GitbayClient
15 let repoPath: String
16
17 init(client: GitbayClient, repoPath: String) {
18 self.client = client
19 self.repoPath = repoPath
20 }
21
22 func load() async {
23 do {
24 let names = try await client.readList(["repo", "secret", "list", repoPath], of: String.self)
25 state = names.isEmpty ? .empty("No build secrets yet.") : .loaded(names)
26 } catch {
27 state = .from(error)
28 }
29 }
30
31 /// Sets or replaces a secret. The name is the user's, so a refusal
32 /// of it is shown as the server words it.
33 func set(name: String, value: String) async -> Bool {
34 let name = name.trimmingCharacters(in: .whitespaces)
35 guard !name.isEmpty, !value.isEmpty else { return false }
36 return await perform(["repo", "secret", "set", repoPath, name], stdin: value)
37 }
38
39 func remove(_ name: String) async {
40 await perform(["repo", "secret", "remove", repoPath, name])
41 }
42
43 @discardableResult
44 private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
45 working = true
46 actionError = nil
47 defer { working = false }
48 do {
49 try await client.run(argv, stdin: stdin)
50 await load()
51 return true
52 } catch let error as GitbayError {
53 actionError = error.messageForTypedInput
54 } catch {
55 actionError = GitbayError.transport(error).userFacingMessage
56 }
57 return false
58 }
59}
60
61/// One row of `repo deploy-key list`.
62nonisolated struct DeployKey: Decodable, Sendable, Hashable, Identifiable {
63 let fingerprint: String
64 let algo: String
65 /// `ro` or `rw`.
66 let mode: String
67 /// The key's comment; empty when it had none.
68 let label: String
69 var lastUsedAt: Date? = nil
70 var expiresAt: Date? = nil
71 var id: String { fingerprint }
72
73 var canPush: Bool { mode == "rw" }
74
75 enum CodingKeys: String, CodingKey {
76 case fingerprint, algo, mode, label
77 case lastUsedAt = "last_used_at"
78 case expiresAt = "expires_at"
79 }
80}
81
82/// `repo deploy-key list`, `add` and `remove`: keys bound to this one
83/// repository, read-only unless added with `--rw`.
84@Observable
85@MainActor
86final class DeployKeysViewModel {
87
88 private(set) var state: LoadState<[DeployKey]> = .loading
89 private(set) var actionError: String?
90 private(set) var working = false
91
92 private let client: GitbayClient
93 let repoPath: String
94
95 init(client: GitbayClient, repoPath: String) {
96 self.client = client
97 self.repoPath = repoPath
98 }
99
100 func load() async {
101 do {
102 let keys = try await client.readList(["repo", "deploy-key", "list", repoPath], of: DeployKey.self)
103 state = keys.isEmpty ? .empty("No deploy keys yet.") : .loaded(keys)
104 } catch {
105 state = .from(error)
106 }
107 }
108
109 /// `repo deploy-key add <repo> [--rw] [--ttl <d>] < key.pub`.
110 func add(publicKey: String, canPush: Bool, ttl: String?) async -> Bool {
111 let key = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
112 guard !key.isEmpty else { return false }
113 var argv = ["repo", "deploy-key", "add", repoPath]
114 if canPush { argv.append("--rw") }
115 if let ttl { argv += ["--ttl", ttl] }
116 return await perform(argv, stdin: key)
117 }
118
119 func remove(_ key: DeployKey) async {
120 await perform(["repo", "deploy-key", "remove", repoPath, key.fingerprint])
121 }
122
123 @discardableResult
124 private func perform(_ argv: [String], stdin: String? = nil) async -> Bool {
125 working = true
126 actionError = nil
127 defer { working = false }
128 do {
129 try await client.run(argv, stdin: stdin)
130 await load()
131 return true
132 } catch let error as GitbayError {
133 actionError = error.messageForTypedInput
134 } catch {
135 actionError = GitbayError.transport(error).userFacingMessage
136 }
137 return false
138 }
139}
gitbay/Views/Repos/RepoCredentialViews.swift added +274
@@ -0,0 +1,274 @@
1import SwiftUI
2
3/// A repository's build secrets: their names, a way to set or replace
4/// one, and removal. Values are never shown, here or anywhere.
5struct BuildSecretsView: View {
6
7 @State private var model: BuildSecretsViewModel
8 @State private var adding = false
9 @State private var removing: String?
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: BuildSecretsViewModel(client: client, repoPath: repo))
13 }
14
15 var body: some View {
16 List {
17 if let error = model.actionError, !adding {
18 Section { GBNotice(error, .gbWarn) }
19 }
20 Section {
21 ForEach(model.state.value ?? [], id: \.self) { name in
22 Text(name)
23 .font(.gbMono(.subheadline))
24 .swipeActions {
25 Button("Remove", role: .destructive) { removing = name }
26 .disabled(model.working)
27 }
28 }
29 } footer: {
30 if model.state.value != nil {
31 Text("Builds read each secret as an environment variable. Setting a name again replaces its value.")
32 }
33 }
34 }
35 .overlay { LoadStateOverlay(state: model.state) }
36 .navigationTitle("Build Secrets")
37 .navigationBarTitleDisplayMode(.inline)
38 .toolbar {
39 ToolbarItem(placement: .topBarTrailing) {
40 Button { adding = true } label: { Image(systemName: "plus") }
41 .accessibilityLabel("Set Secret")
42 .disabled(model.working)
43 .accessibilityIdentifier("secret-add-button")
44 }
45 }
46 .sheet(isPresented: $adding) {
47 SecretSetSheet(model: model) { adding = false }
48 }
49 .confirmationDialog(
50 "Remove \(removing ?? "")? Builds that read it will no longer find it.",
51 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
52 titleVisibility: .visible
53 ) {
54 Button("Remove", role: .destructive) {
55 if let name = removing { Task { await model.remove(name) } }
56 removing = nil
57 }
58 Button("Cancel", role: .cancel) {}
59 }
60 .task { await model.load() }
61 .refreshable { await model.load() }
62 }
63}
64
65private struct SecretSetSheet: View {
66 let model: BuildSecretsViewModel
67 let onDone: () -> Void
68
69 @State private var name = ""
70 @State private var value = ""
71
72 var body: some View {
73 NavigationStack {
74 Form {
75 Section {
76 TextField("NAME", text: $name)
77 .font(.gbMono(.body))
78 .autocorrectionDisabled()
79 .textInputAutocapitalization(.characters)
80 .accessibilityIdentifier("secret-name")
81 SecureField("Value", text: $value)
82 .autocorrectionDisabled()
83 .textInputAutocapitalization(.never)
84 .accessibilityIdentifier("secret-value")
85 } footer: {
86 Text("Uppercase letters, digits and underscores. The value is sent once and cannot be read back.")
87 }
88 if let error = model.actionError {
89 Section { GBNotice(error, .gbWarn) }
90 }
91 }
92 .navigationTitle("Set Secret")
93 .navigationBarTitleDisplayMode(.inline)
94 .toolbar {
95 ToolbarItem(placement: .cancellationAction) {
96 Button("Cancel") { onDone() }.disabled(model.working)
97 }
98 ToolbarItem(placement: .confirmationAction) {
99 if model.working {
100 ProgressView()
101 } else {
102 Button("Set") {
103 Task {
104 guard await model.set(name: name, value: value) else { return }
105 // A secure field that still holds text as the sheet
106 // goes makes iOS offer to save it as a password.
107 value = ""
108 await Task.yield()
109 onDone()
110 }
111 }
112 .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || value.isEmpty)
113 .accessibilityIdentifier("secret-submit")
114 }
115 }
116 }
117 .interactiveDismissDisabled(model.working)
118 }
119 }
120}
121
122/// A repository's deploy keys: keys bound to it alone, read-only unless
123/// they may push, with when each was used and when it expires.
124struct DeployKeysView: View {
125
126 @State private var model: DeployKeysViewModel
127 @State private var adding = false
128 @State private var removing: DeployKey?
129
130 init(client: GitbayClient, repo: String) {
131 _model = State(initialValue: DeployKeysViewModel(client: client, repoPath: repo))
132 }
133
134 var body: some View {
135 List {
136 if let error = model.actionError, !adding {
137 Section { GBNotice(error, .gbWarn) }
138 }
139 Section {
140 ForEach(model.state.value ?? []) { key in
141 VStack(alignment: .leading, spacing: 3) {
142 if !key.label.isEmpty {
143 Text(key.label).font(.gbSans(.subheadline))
144 }
145 Text(key.fingerprint)
146 .font(.gbMono(.caption))
147 .lineLimit(1)
148 .truncationMode(.middle)
149 HStack(spacing: 6) {
150 GBChip(key.canPush ? "read-write" : "read-only", key.canPush ? Color.gbWarn : Color.secondary)
151 dates(key)
152 }
153 }
154 .padding(.vertical, 2)
155 .accessibilityElement(children: .combine)
156 .swipeActions {
157 Button("Remove", role: .destructive) { removing = key }
158 .disabled(model.working)
159 }
160 }
161 } footer: {
162 if model.state.value != nil {
163 Text("A deploy key reaches this repository and nothing else. A read-write key can push.")
164 }
165 }
166 }
167 .overlay { LoadStateOverlay(state: model.state) }
168 .navigationTitle("Deploy Keys")
169 .navigationBarTitleDisplayMode(.inline)
170 .toolbar {
171 ToolbarItem(placement: .topBarTrailing) {
172 Button { adding = true } label: { Image(systemName: "plus") }
173 .accessibilityLabel("Add Deploy Key")
174 .disabled(model.working)
175 .accessibilityIdentifier("deploy-key-add-button")
176 }
177 }
178 .sheet(isPresented: $adding) {
179 DeployKeyAddSheet(model: model) { adding = false }
180 }
181 .confirmationDialog(
182 "Remove the deploy key \(removing.map { $0.label.isEmpty ? $0.fingerprint : $0.label } ?? "")?",
183 isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
184 titleVisibility: .visible
185 ) {
186 Button("Remove", role: .destructive) {
187 if let key = removing { Task { await model.remove(key) } }
188 removing = nil
189 }
190 Button("Cancel", role: .cancel) {}
191 }
192 .task { await model.load() }
193 .refreshable { await model.load() }
194 }
195
196 private func dates(_ key: DeployKey) -> some View {
197 HStack(spacing: 4) {
198 if let used = key.lastUsedAt {
199 Text("used \(used.formatted(.relative(presentation: .named)))")
200 } else {
201 Text("never used")
202 }
203 if let expires = key.expiresAt {
204 Text("·")
205 if expires <= .now {
206 Text("expired \(expires.formatted(date: .abbreviated, time: .omitted))")
207 .foregroundStyle(Color.gbBad)
208 } else {
209 Text("expires \(expires.formatted(date: .abbreviated, time: .omitted))")
210 }
211 }
212 }
213 .font(.gbSans(.caption2))
214 .foregroundStyle(.secondary)
215 }
216}
217
218private struct DeployKeyAddSheet: View {
219 let model: DeployKeysViewModel
220 let onDone: () -> Void
221
222 @State private var text = ""
223 @State private var canPush = false
224 @State private var ttl: String?
225
226 var body: some View {
227 NavigationStack {
228 Form {
229 Section {
230 TextEditor(text: $text)
231 .frame(minHeight: 120)
232 .font(.gbMono(.caption))
233 .autocorrectionDisabled()
234 .textInputAutocapitalization(.never)
235 .accessibilityIdentifier("deploy-key-text")
236 } footer: {
237 Text("Paste the public key, as in authorized_keys. Its comment becomes the label.")
238 }
239 Section {
240 Toggle("Allow pushes", isOn: $canPush)
241 .accessibilityIdentifier("deploy-key-rw")
242 Picker("Expires", selection: $ttl) {
243 Text("Never").tag(String?.none)
244 Text("In 30 days").tag(String?.some("30d"))
245 Text("In 90 days").tag(String?.some("90d"))
246 Text("In a year").tag(String?.some("365d"))
247 }
248 }
249 if let error = model.actionError {
250 Section { GBNotice(error, .gbWarn) }
251 }
252 }
253 .navigationTitle("Add Deploy Key")
254 .navigationBarTitleDisplayMode(.inline)
255 .toolbar {
256 ToolbarItem(placement: .cancellationAction) {
257 Button("Cancel") { onDone() }.disabled(model.working)
258 }
259 ToolbarItem(placement: .confirmationAction) {
260 if model.working {
261 ProgressView()
262 } else {
263 Button("Add") {
264 Task { if await model.add(publicKey: text, canPush: canPush, ttl: ttl) { onDone() } }
265 }
266 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
267 .accessibilityIdentifier("deploy-key-submit")
268 }
269 }
270 }
271 .interactiveDismissDisabled(model.working)
272 }
273 }
274}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -25,6 +25,8 @@ nonisolated enum RepoRoute: Hashable {
2525 case queryResults(name: String)
2626 case webhooks(repo: String)
2727 case webhookDeliveries(repo: String)
28 case buildSecrets(repo: String)
29 case deployKeys(repo: String)
2830 case profile(String)
2931 case account
3032 case tokens
gitbay/Views/Repos/RepoSettingsView.swift +10
@@ -551,6 +551,16 @@ struct RepoSettingsView: View {
551551 .font(.gbSans(.subheadline))
552552 }
553553 .accessibilityIdentifier("settings-webhooks-link")
554 NavigationLink(value: RepoRoute.buildSecrets(repo: model.repoPath)) {
555 Label("Build secrets", systemImage: "key.horizontal")
556 .font(.gbSans(.subheadline))
557 }
558 .accessibilityIdentifier("settings-secrets-link")
559 NavigationLink(value: RepoRoute.deployKeys(repo: model.repoPath)) {
560 Label("Deploy keys", systemImage: "key")
561 .font(.gbSans(.subheadline))
562 }
563 .accessibilityIdentifier("settings-deploy-keys-link")
554564 }
555565 }
556566
gitbayTests/RepoCredentialTests.swift added +136
@@ -0,0 +1,136 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func body(_ seen: StubProtocol.Seen) throws -> [String: Any] {
16 try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17}
18
19private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
20private let secretsJSON = #"{"protocol_version":1,"data":["DEPLOY_TOKEN","NPM_TOKEN"],"exit_code":0}"#
21private let deployKeysJSON = """
22 {"protocol_version":1,"data":[\
23 {"fingerprint":"SHA256:abc","algo":"ssh-ed25519","mode":"rw","label":"ci@box","last_used_at":"2026-10-10T08:00:00.000Z"},\
24 {"fingerprint":"SHA256:def","algo":"ssh-ed25519","mode":"ro","label":"","expires_at":"2026-11-10T08:00:00Z"}\
25 ],"exit_code":0}
26 """
27
28@MainActor
29struct BuildSecretsViewModelTests {
30
31 @Test func listsSecretNames() async throws {
32 let (client, stub) = try makeClient()
33 stub.enqueue(.init(status: 200, json: secretsJSON))
34 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
35 await model.load()
36 #expect(model.state.value == ["DEPLOY_TOKEN", "NPM_TOKEN"])
37 #expect(stub.seen.first?.url.query() == "argv=repo&argv=secret&argv=list&argv=krz/gitbay")
38 }
39
40 @Test func noSecretsIsAnEmptyState() async throws {
41 let (client, stub) = try makeClient()
42 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":null,"exit_code":0}"#))
43 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
44 await model.load()
45 guard case .empty = model.state else {
46 Issue.record("expected .empty, got \(model.state)")
47 return
48 }
49 }
50
51 @Test func setSendsTheValueOnStdinNeverInArgv() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"secret":"DEPLOY_TOKEN"},"exit_code":0}"#, match: "cmd"))
54 stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list"))
55 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
56
57 #expect(await model.set(name: " DEPLOY_TOKEN ", value: "s3cret"))
58
59 let sent = try body(try #require(stub.seen.first))
60 #expect(sent["argv"] as? [String] == ["repo", "secret", "set", "krz/gitbay", "DEPLOY_TOKEN"])
61 #expect(sent["stdin"] as? String == "s3cret")
62 #expect(model.state.value == ["DEPLOY_TOKEN", "NPM_TOKEN"])
63 }
64
65 @Test func aRefusedNameSaysWhy() async throws {
66 let (client, stub) = try makeClient()
67 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"secret names are env-var shaped: uppercase letters, digits, _","exit_code":2}"#))
68 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
69
70 #expect(await model.set(name: "lower", value: "x") == false)
71 #expect(model.actionError == "secret names are env-var shaped: uppercase letters, digits, _")
72 }
73
74 @Test func removeNamesTheSecret() async throws {
75 let (client, stub) = try makeClient()
76 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: secretsJSON, match: "argv=list"))
78 let model = BuildSecretsViewModel(client: client, repoPath: "krz/gitbay")
79
80 await model.remove("NPM_TOKEN")
81
82 #expect(try body(try #require(stub.seen.first))["argv"] as? [String]
83 == ["repo", "secret", "remove", "krz/gitbay", "NPM_TOKEN"])
84 }
85}
86
87@MainActor
88struct DeployKeysViewModelTests {
89
90 @Test func listsKeysWithModeAndDates() async throws {
91 let (client, stub) = try makeClient()
92 stub.enqueue(.init(status: 200, json: deployKeysJSON))
93 let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay")
94 await model.load()
95
96 let keys = try #require(model.state.value)
97 #expect(keys.map(\.fingerprint) == ["SHA256:abc", "SHA256:def"])
98 #expect(keys[0].canPush && !keys[1].canPush)
99 #expect(keys[0].lastUsedAt != nil && keys[0].expiresAt == nil)
100 #expect(keys[1].lastUsedAt == nil && keys[1].expiresAt != nil)
101 #expect(stub.seen.first?.url.query() == "argv=repo&argv=deploy-key&argv=list&argv=krz/gitbay")
102 }
103
104 @Test func addSendsTheKeyOnStdinWithModeAndLifetime() async throws {
105 let (client, stub) = try makeClient()
106 for _ in 0..<2 {
107 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"fingerprint":"SHA256:x","mode":"ro"},"exit_code":0}"#, match: "cmd"))
108 stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list"))
109 }
110 let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay")
111
112 #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci\n", canPush: false, ttl: nil))
113 #expect(await model.add(publicKey: "ssh-ed25519 AAAA ci", canPush: true, ttl: "30d"))
114
115 let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body)
116 #expect(writes.map { $0["argv"] as? [String] } == [
117 ["repo", "deploy-key", "add", "krz/gitbay"],
118 ["repo", "deploy-key", "add", "krz/gitbay", "--rw", "--ttl", "30d"],
119 ])
120 #expect(writes.allSatisfy { $0["stdin"] as? String == "ssh-ed25519 AAAA ci" })
121 }
122
123 @Test func removeNamesTheFingerprint() async throws {
124 let (client, stub) = try makeClient()
125 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
126 stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list"))
127 stub.enqueue(.init(status: 200, json: deployKeysJSON, match: "argv=list"))
128 let model = DeployKeysViewModel(client: client, repoPath: "krz/gitbay")
129 await model.load()
130
131 await model.remove(try #require(model.state.value?.first))
132
133 let writes = try stub.seen.filter { $0.url.path().hasSuffix("/cmd") }.map(body)
134 #expect(writes.first?["argv"] as? [String] == ["repo", "deploy-key", "remove", "krz/gitbay", "SHA256:abc"])
135 }
136}