Commit f85e26bb97

f85e26bb97194203761fa4b7d0f317adcd8c9d92

parent: 20fdd56fcc

Unsigned

cmc <hello@cleberg.net> · 2026-08-07 21:29 UTC

Deploy keys: manage git.sr.ht repository deploy keys (v3.10.0)

Wire git.sr.ht createDeployKey / deleteDeployKey and Repository.deployKeys
into an owner-only Deploy Keys sheet in the repository actions menu, next to
ACLs: list keys (fingerprint, comment, access), add an SSH public key with
RO/RW access, and delete behind a confirmation.

Verified live against the ~hutch account. The createDeployKey response
returns an empty access value, so create selects only rid and the list is
reloaded rather than decoding the partial key.

Bumps to 3.10.0 (build 95); roadmap marks deploy keys shipped.

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +12 −12
@@ -597,7 +597,7 @@
597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 597 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 598 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
599 CODE_SIGN_STYLE = Automatic; 599 CODE_SIGN_STYLE = Automatic;
600 CURRENT_PROJECT_VERSION = 94; 600 CURRENT_PROJECT_VERSION = 95;
601 DEVELOPMENT_TEAM = ZCNAX3VL9D; 601 DEVELOPMENT_TEAM = ZCNAX3VL9D;
602 ENABLE_PREVIEWS = YES; 602 ENABLE_PREVIEWS = YES;
603 GENERATE_INFOPLIST_FILE = YES; 603 GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
614 "$(inherited)", 614 "$(inherited)",
615 "@executable_path/Frameworks", 615 "@executable_path/Frameworks",
616 ); 616 );
617 MARKETING_VERSION = 3.9.0; 617 MARKETING_VERSION = 3.10.0;
618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 618 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
619 PRODUCT_NAME = "$(TARGET_NAME)"; 619 PRODUCT_NAME = "$(TARGET_NAME)";
620 STRING_CATALOG_GENERATE_SYMBOLS = YES; 620 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 634 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 635 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
636 CODE_SIGN_STYLE = Automatic; 636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 94; 637 CURRENT_PROJECT_VERSION = 95;
638 DEVELOPMENT_TEAM = ZCNAX3VL9D; 638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639 ENABLE_PREVIEWS = YES; 639 ENABLE_PREVIEWS = YES;
640 GENERATE_INFOPLIST_FILE = YES; 640 GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
651 "$(inherited)", 651 "$(inherited)",
652 "@executable_path/Frameworks", 652 "@executable_path/Frameworks",
653 ); 653 );
654 MARKETING_VERSION = 3.9.0; 654 MARKETING_VERSION = 3.10.0;
655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
656 PRODUCT_NAME = "$(TARGET_NAME)"; 656 PRODUCT_NAME = "$(TARGET_NAME)";
657 STRING_CATALOG_GENERATE_SYMBOLS = YES; 657 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
714 APPLICATION_EXTENSION_API_ONLY = YES; 714 APPLICATION_EXTENSION_API_ONLY = YES;
715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 715 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
716 CODE_SIGN_STYLE = Automatic; 716 CODE_SIGN_STYLE = Automatic;
717 CURRENT_PROJECT_VERSION = 94; 717 CURRENT_PROJECT_VERSION = 95;
718 DEVELOPMENT_TEAM = ZCNAX3VL9D; 718 DEVELOPMENT_TEAM = ZCNAX3VL9D;
719 GENERATE_INFOPLIST_FILE = NO; 719 GENERATE_INFOPLIST_FILE = NO;
720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 720 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
724 "@executable_path/Frameworks", 724 "@executable_path/Frameworks",
725 "@executable_path/../../Frameworks", 725 "@executable_path/../../Frameworks",
726 ); 726 );
727 MARKETING_VERSION = 3.9.0; 727 MARKETING_VERSION = 3.10.0;
728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 728 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
729 PRODUCT_NAME = "$(TARGET_NAME)"; 729 PRODUCT_NAME = "$(TARGET_NAME)";
730 SKIP_INSTALL = YES; 730 SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
743 APPLICATION_EXTENSION_API_ONLY = YES; 743 APPLICATION_EXTENSION_API_ONLY = YES;
744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 744 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
745 CODE_SIGN_STYLE = Automatic; 745 CODE_SIGN_STYLE = Automatic;
746 CURRENT_PROJECT_VERSION = 94; 746 CURRENT_PROJECT_VERSION = 95;
747 DEVELOPMENT_TEAM = ZCNAX3VL9D; 747 DEVELOPMENT_TEAM = ZCNAX3VL9D;
748 GENERATE_INFOPLIST_FILE = NO; 748 GENERATE_INFOPLIST_FILE = NO;
749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 749 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
753 "@executable_path/Frameworks", 753 "@executable_path/Frameworks",
754 "@executable_path/../../Frameworks", 754 "@executable_path/../../Frameworks",
755 ); 755 );
756 MARKETING_VERSION = 3.9.0; 756 MARKETING_VERSION = 3.10.0;
757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 757 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
758 PRODUCT_NAME = "$(TARGET_NAME)"; 758 PRODUCT_NAME = "$(TARGET_NAME)";
759 SKIP_INSTALL = YES; 759 SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
772 APPLICATION_EXTENSION_API_ONLY = YES; 772 APPLICATION_EXTENSION_API_ONLY = YES;
773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 773 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
774 CODE_SIGN_STYLE = Automatic; 774 CODE_SIGN_STYLE = Automatic;
775 CURRENT_PROJECT_VERSION = 94; 775 CURRENT_PROJECT_VERSION = 95;
776 DEVELOPMENT_TEAM = ZCNAX3VL9D; 776 DEVELOPMENT_TEAM = ZCNAX3VL9D;
777 GENERATE_INFOPLIST_FILE = NO; 777 GENERATE_INFOPLIST_FILE = NO;
778 INFOPLIST_FILE = HutchSafariExtension/Info.plist; 778 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
782 "@executable_path/Frameworks", 782 "@executable_path/Frameworks",
783 "@executable_path/../../Frameworks", 783 "@executable_path/../../Frameworks",
784 ); 784 );
785 MARKETING_VERSION = 3.9.0; 785 MARKETING_VERSION = 3.10.0;
786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 786 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
787 PRODUCT_NAME = "$(TARGET_NAME)"; 787 PRODUCT_NAME = "$(TARGET_NAME)";
788 SKIP_INSTALL = YES; 788 SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
801 APPLICATION_EXTENSION_API_ONLY = YES; 801 APPLICATION_EXTENSION_API_ONLY = YES;
802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 802 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
803 CODE_SIGN_STYLE = Automatic; 803 CODE_SIGN_STYLE = Automatic;
804 CURRENT_PROJECT_VERSION = 94; 804 CURRENT_PROJECT_VERSION = 95;
805 DEVELOPMENT_TEAM = ZCNAX3VL9D; 805 DEVELOPMENT_TEAM = ZCNAX3VL9D;
806 GENERATE_INFOPLIST_FILE = NO; 806 GENERATE_INFOPLIST_FILE = NO;
807 INFOPLIST_FILE = HutchSafariExtension/Info.plist; 807 INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
811 "@executable_path/Frameworks", 811 "@executable_path/Frameworks",
812 "@executable_path/../../Frameworks", 812 "@executable_path/../../Frameworks",
813 ); 813 );
814 MARKETING_VERSION = 3.9.0; 814 MARKETING_VERSION = 3.10.0;
815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension; 815 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
816 PRODUCT_NAME = "$(TARGET_NAME)"; 816 PRODUCT_NAME = "$(TARGET_NAME)";
817 SKIP_INSTALL = YES; 817 SKIP_INSTALL = YES;
Hutch/Networking/RepositoryDeployKeyService.swift added +100
@@ -0,0 +1,100 @@
1import Foundation
2
3/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`).
4struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable {
5 let rid: String
6 let keyType: String
7 let fingerprintSHA256: String
8 let comment: String?
9 let access: AccessMode
10
11 var id: String { rid }
12}
13
14private struct DeployKeysQueryResponse: Decodable, Sendable {
15 let repository: DeployKeysRepository?
16}
17
18private struct DeployKeysRepository: Decodable, Sendable {
19 let deployKeys: DeployKeysPage
20}
21
22private struct DeployKeysPage: Decodable, Sendable {
23 let results: [RepositoryDeployKey]
24}
25
26/// `createDeployKey`'s response returns an empty `access` (the stored value is
27/// correct — the list query reports it), so we select only `rid` here and let
28/// callers reload rather than decode the partial key.
29private struct CreateDeployKeyResponse: Decodable, Sendable {}
30
31/// Delete returns the removed key; only success matters here.
32private struct DeleteDeployKeyResponse: Decodable, Sendable {}
33
34/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`),
35/// owner-only, alongside repository ACLs.
36struct RepositoryDeployKeyService: Sendable {
37 private let client: SRHTClient
38
39 init(client: SRHTClient) {
40 self.client = client
41 }
42
43 func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] {
44 let response = try await client.execute(
45 service: .git,
46 query: Self.deployKeysQuery,
47 variables: ["rid": repositoryRid],
48 responseType: DeployKeysQueryResponse.self
49 )
50 return response.repository?.deployKeys.results ?? []
51 }
52
53 func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws {
54 _ = try await client.execute(
55 service: .git,
56 query: Self.createDeployKeyMutation,
57 variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key],
58 responseType: CreateDeployKeyResponse.self
59 )
60 }
61
62 func deleteDeployKey(rid: String) async throws {
63 _ = try await client.execute(
64 service: .git,
65 query: Self.deleteDeployKeyMutation,
66 variables: ["rid": rid],
67 responseType: DeleteDeployKeyResponse.self
68 )
69 }
70}
71
72private extension RepositoryDeployKeyService {
73 static let deployKeysQuery = """
74 query repositoryDeployKeys($rid: ID!) {
75 repository(rid: $rid) {
76 deployKeys {
77 results {
78 rid
79 keyType
80 fingerprintSHA256
81 comment
82 access
83 }
84 }
85 }
86 }
87 """
88
89 static let createDeployKeyMutation = """
90 mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) {
91 createDeployKey(repo: $repo, mode: $mode, key: $key) { rid }
92 }
93 """
94
95 static let deleteDeployKeyMutation = """
96 mutation deleteDeployKey($rid: ID!) {
97 deleteDeployKey(rid: $rid) { rid }
98 }
99 """
100}
Hutch/Views/Repositories/RepositoryDeployKeysView.swift added +272
@@ -0,0 +1,272 @@
1import SwiftUI
2
3@Observable
4@MainActor
5final class RepositoryDeployKeysViewModel {
6 private(set) var keys: [RepositoryDeployKey] = []
7 private(set) var isLoading = false
8 private(set) var isSaving = false
9 private(set) var deletingRID: String?
10 var loadError: String?
11 var error: String?
12 var saveError: String?
13
14 let repositoryRid: String
15 private let service: RepositoryDeployKeyService
16
17 init(repositoryRid: String, service: RepositoryDeployKeyService) {
18 self.repositoryRid = repositoryRid
19 self.service = service
20 }
21
22 func load() async {
23 guard !isLoading else { return }
24 isLoading = true
25 loadError = nil
26 defer { isLoading = false }
27 do {
28 keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
29 } catch {
30 if keys.isEmpty {
31 loadError = error.userFacingMessage
32 } else {
33 self.error = error.userFacingMessage
34 }
35 }
36 }
37
38 func addKey(publicKey: String, mode: AccessMode) async -> Bool {
39 let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
40 guard !trimmed.isEmpty, !isSaving else { return false }
41 isSaving = true
42 saveError = nil
43 defer { isSaving = false }
44 do {
45 try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
46 // The create response omits the key's fields, so reload the list.
47 keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
48 return true
49 } catch {
50 saveError = error.userFacingMessage
51 return false
52 }
53 }
54
55 func deleteKey(_ key: RepositoryDeployKey) async {
56 guard deletingRID == nil else { return }
57 deletingRID = key.rid
58 error = nil
59 defer { deletingRID = nil }
60 do {
61 try await service.deleteDeployKey(rid: key.rid)
62 keys.removeAll { $0.rid == key.rid }
63 } catch {
64 self.error = error.userFacingMessage
65 }
66 }
67}
68
69struct RepositoryDeployKeysView: View {
70 let repository: RepositorySummary
71 let client: SRHTClient
72 var showsDoneButton = false
73
74 @Environment(\.dismiss) private var dismiss
75 @State private var viewModel: RepositoryDeployKeysViewModel?
76 @State private var showAddSheet = false
77 @State private var pendingDeletion: RepositoryDeployKey?
78
79 var body: some View {
80 Group {
81 if let viewModel {
82 content(viewModel)
83 } else {
84 SRHTLoadingStateView(message: "Loading deploy keys…")
85 }
86 }
87 .navigationTitle("Deploy Keys")
88 .navigationBarTitleDisplayMode(.inline)
89 .toolbar {
90 if showsDoneButton {
91 ToolbarItem(placement: .cancellationAction) {
92 Button("Done") { dismiss() }
93 }
94 }
95 if viewModel != nil {
96 ToolbarItem(placement: .topBarTrailing) {
97 Button {
98 showAddSheet = true
99 } label: {
100 Image(systemName: "plus")
101 }
102 .accessibilityLabel("Add deploy key")
103 }
104 }
105 }
106 .task {
107 if viewModel == nil {
108 let vm = RepositoryDeployKeysViewModel(
109 repositoryRid: repository.rid,
110 service: RepositoryDeployKeyService(client: client)
111 )
112 viewModel = vm
113 await vm.load()
114 }
115 }
116 }
117
118 @ViewBuilder
119 private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
120 Group {
121 if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
122 SRHTLoadingStateView(message: "Loading deploy keys…")
123 } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
124 SRHTErrorStateView(
125 title: "Couldn't Load Deploy Keys",
126 message: loadError,
127 retryAction: { await viewModel.load() }
128 )
129 } else {
130 List {
131 if viewModel.keys.isEmpty {
132 Section {
133 ContentUnavailableView(
134 "No Deploy Keys",
135 systemImage: "key",
136 description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
137 )
138 .themedRow()
139 }
140 } else {
141 Section {
142 ForEach(viewModel.keys) { key in
143 DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
144 .themedRow()
145 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
146 Button(role: .destructive) {
147 pendingDeletion = key
148 } label: {
149 Label("Delete", systemImage: "trash")
150 }
151 }
152 }
153 } footer: {
154 Text("Deploy keys are SSH keys scoped to this repository only.")
155 }
156 }
157 }
158 .themedList()
159 .refreshable { await viewModel.load() }
160 }
161 }
162 .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
163 .confirmationDialog(
164 "Delete this deploy key?",
165 isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
166 titleVisibility: .visible
167 ) {
168 Button("Cancel", role: .cancel) { pendingDeletion = nil }
169 Button("Delete", role: .destructive) {
170 if let key = pendingDeletion {
171 pendingDeletion = nil
172 Task { await viewModel.deleteKey(key) }
173 }
174 }
175 } message: {
176 Text("This revokes the key's access to \(repository.name). This cannot be undone.")
177 }
178 .sheet(isPresented: $showAddSheet) {
179 AddDeployKeyView(viewModel: viewModel)
180 }
181 }
182}
183
184private struct DeployKeyRow: View {
185 let key: RepositoryDeployKey
186 let isDeleting: Bool
187
188 var body: some View {
189 HStack(spacing: 12) {
190 VStack(alignment: .leading, spacing: 3) {
191 Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
192 .font(.body)
193 .lineLimit(1)
194 Text(key.fingerprintSHA256)
195 .font(.caption.monospaced())
196 .foregroundStyle(.secondary)
197 .lineLimit(1)
198 .truncationMode(.middle)
199 }
200 Spacer()
201 if isDeleting {
202 ProgressView().controlSize(.small)
203 } else {
204 Text(key.access.displayName)
205 .font(.caption.weight(.medium))
206 .foregroundStyle(.secondary)
207 }
208 }
209 .padding(.vertical, 2)
210 }
211}
212
213private struct AddDeployKeyView: View {
214 let viewModel: RepositoryDeployKeysViewModel
215
216 @Environment(\.dismiss) private var dismiss
217 @State private var publicKey = ""
218 @State private var mode: AccessMode = .ro
219
220 var body: some View {
221 NavigationStack {
222 Form {
223 Section("SSH Public Key") {
224 TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
225 .lineLimit(3...8)
226 .textInputAutocapitalization(.never)
227 .autocorrectionDisabled()
228 .font(.body.monospaced())
229 .themedRow()
230 }
231 Section {
232 Picker("Access", selection: $mode) {
233 Text("Read Only").tag(AccessMode.ro)
234 Text("Read/Write").tag(AccessMode.rw)
235 }
236 .themedRow()
237 } footer: {
238 Text("Read/Write lets the key push to this repository.")
239 }
240 if let saveError = viewModel.saveError, !saveError.isEmpty {
241 Section {
242 Text(saveError).foregroundStyle(.red).themedRow()
243 }
244 }
245 }
246 .themedList()
247 .navigationTitle("Add Deploy Key")
248 .navigationBarTitleDisplayMode(.inline)
249 .toolbar {
250 ToolbarItem(placement: .cancellationAction) {
251 Button("Cancel") { dismiss() }
252 }
253 ToolbarItem(placement: .confirmationAction) {
254 Button {
255 Task {
256 if await viewModel.addKey(publicKey: publicKey, mode: mode) {
257 dismiss()
258 }
259 }
260 } label: {
261 if viewModel.isSaving {
262 ProgressView().controlSize(.small)
263 } else {
264 Text("Add")
265 }
266 }
267 .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
268 }
269 }
270 }
271 }
272}
Hutch/Views/Repositories/RepositoryDetailView.swift +16
@@ -12,6 +12,7 @@ struct RepositoryDetailView: View {
12 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary 12 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
13 @State private var showSettings = false 13 @State private var showSettings = false
14 @State private var showACLs = false 14 @State private var showACLs = false
15 @State private var showDeployKeys = false
15 @State private var currentRepository: RepositorySummary 16 @State private var currentRepository: RepositorySummary
16 @State private var pinChangeCount = 0 17 @State private var pinChangeCount = 0
17 18
@@ -82,6 +83,15 @@ struct RepositoryDetailView: View {
82 ) 83 )
83 } 84 }
84 } 85 }
86 .sheet(isPresented: $showDeployKeys) {
87 NavigationStack {
88 RepositoryDeployKeysView(
89 repository: currentRepository,
90 client: appState.client,
91 showsDoneButton: true
92 )
93 }
94 }
85 .task { 95 .task {
86 if viewModel == nil { 96 if viewModel == nil {
87 viewModel = RepositoryDetailViewModel( 97 viewModel = RepositoryDetailViewModel(
@@ -200,6 +210,12 @@ struct RepositoryDetailView: View {
200 Label("Manage ACLs", systemImage: "person.2") 210 Label("Manage ACLs", systemImage: "person.2")
201 } 211 }
202 212
213 Button {
214 showDeployKeys = true
215 } label: {
216 Label("Deploy Keys", systemImage: "key")
217 }
218
203 Button { 219 Button {
204 showSettings = true 220 showSettings = true
205 } label: { 221 } label: {
HutchTests/RepositoryDeployKeyTests.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct RepositoryDeployKeyTests {
6 @Test
7 func decodesDeployKey() throws {
8 let json = """
9 { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" }
10 """
11 let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
12 #expect(key.rid == "k1")
13 #expect(key.id == "k1")
14 #expect(key.access == .rw)
15 #expect(key.comment == "laptop")
16 }
17
18 @Test
19 func decodesDeployKeyWithNullComment() throws {
20 let json = """
21 { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" }
22 """
23 let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
24 #expect(key.comment == nil)
25 #expect(key.access == .ro)
26 }
27}
ROADMAP.txt +11 −10
@@ -144,7 +144,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
144| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all | 144| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
145| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface | 145| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
146| v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session | 146| v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session |
147| v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map | 147| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); "What's cooking" ingest; doc truth-up; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
148| v3.11.0 | Accessibility | Independent, device-verified | 148| v3.11.0 | Accessibility | Independent, device-verified |
149| v4.0.0 | Localization *with* translations | The only true re-presentation | 149| v4.0.0 | Localization *with* translations | The only true re-presentation |
150| — | Swift 6 language mode; cache reads | Internal; ride along, no tag | 150| — | Swift 6 language mode; cache reads | Internal; ride along, no tag |
@@ -276,7 +276,7 @@ exception earns the release its own line: the `forceRefresh` fix changes what
276pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships, 276pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
277not just a green suite. 277not just a green suite.
278 278
279### Ingest "What's cooking on SourceHut?" — v3.9.0 279### Ingest "What's cooking on SourceHut?" — v3.10.0
280 280
281sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at 281sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
282<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and 282<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
@@ -294,19 +294,20 @@ flags two openings:
294- **hub.sr.ht gained a writable GraphQL API** for managing projects and project 294- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
295 resources. ~~Rechecked and shipped~~: project create/edit, resource 295 resources. ~~Rechecked and shipped~~: project create/edit, resource
296 link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes" 296 link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
297 below. `SCOPE.txt` still needs its "hub has no public API / no discovery" 297 below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
298 claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a 298 been corrected. `mailingListSubscribe` was *not* unblocked — that needs a
299 per-list subscription field lists.sr.ht still lacks. 299 per-list subscription field lists.sr.ht still lacks.
300- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey` 300- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
301 are in the SDL). Hutch never calls them. 301 `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
302 into the repository actions menu, owner-only, alongside ACLs.
302 303
303Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps 304Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
304were captured. 305were captured.
305 306
306Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one 307Deploy keys — the one self-contained feature the ingest had already surfaced and
307self-contained feature it has already surfaced and that the SDL confirms exists. 308that the SDL confirmed — shipped in v3.10.0, so this bucket is now the ingest
308That gives the release something a user can see. Everything else the ingest turns 309itself: research that files what changed rather than building. Everything else it
309up gets filed, not built, and hub.sr.ht gets its own bucket below. 310turns up gets filed, not built.
310 311
311### hub.sr.ht writes — projects and discovery done 312### hub.sr.ht writes — projects and discovery done
312 313