summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 16:29:04 -0500
committerChristian Cleberg <[email protected]>2026-08-07 16:29:04 -0500
commit07156180789b02d4f22a87f22a37c912e7543190 (patch)
tree6d943fc410b37d3dc7dfec72406688dbd97ac724
parent18b1c475983ae9e6a52fcedb2e2992193130035c (diff)
downloadhutch-07156180789b02d4f22a87f22a37c912e7543190.tar.gz
hutch-07156180789b02d4f22a87f22a37c912e7543190.tar.bz2
hutch-07156180789b02d4f22a87f22a37c912e7543190.zip
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.
-rw-r--r--Hutch.xcodeproj/project.pbxproj24
-rw-r--r--Hutch/Networking/RepositoryDeployKeyService.swift100
-rw-r--r--Hutch/Views/Repositories/RepositoryDeployKeysView.swift272
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift16
-rw-r--r--HutchTests/RepositoryDeployKeyTests.swift27
-rw-r--r--ROADMAP.txt21
6 files changed, 438 insertions, 22 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index efff26f..48a1c0f 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -597,7 +597,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.10.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.10.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.10.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.10.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.10.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 95;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.10.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/Networking/RepositoryDeployKeyService.swift b/Hutch/Networking/RepositoryDeployKeyService.swift
new file mode 100644
index 0000000..39e2da1
--- /dev/null
+++ b/Hutch/Networking/RepositoryDeployKeyService.swift
@@ -0,0 +1,100 @@
+import Foundation
+
+/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`).
+struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable {
+ let rid: String
+ let keyType: String
+ let fingerprintSHA256: String
+ let comment: String?
+ let access: AccessMode
+
+ var id: String { rid }
+}
+
+private struct DeployKeysQueryResponse: Decodable, Sendable {
+ let repository: DeployKeysRepository?
+}
+
+private struct DeployKeysRepository: Decodable, Sendable {
+ let deployKeys: DeployKeysPage
+}
+
+private struct DeployKeysPage: Decodable, Sendable {
+ let results: [RepositoryDeployKey]
+}
+
+/// `createDeployKey`'s response returns an empty `access` (the stored value is
+/// correct — the list query reports it), so we select only `rid` here and let
+/// callers reload rather than decode the partial key.
+private struct CreateDeployKeyResponse: Decodable, Sendable {}
+
+/// Delete returns the removed key; only success matters here.
+private struct DeleteDeployKeyResponse: Decodable, Sendable {}
+
+/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`),
+/// owner-only, alongside repository ACLs.
+struct RepositoryDeployKeyService: Sendable {
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] {
+ let response = try await client.execute(
+ service: .git,
+ query: Self.deployKeysQuery,
+ variables: ["rid": repositoryRid],
+ responseType: DeployKeysQueryResponse.self
+ )
+ return response.repository?.deployKeys.results ?? []
+ }
+
+ func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws {
+ _ = try await client.execute(
+ service: .git,
+ query: Self.createDeployKeyMutation,
+ variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key],
+ responseType: CreateDeployKeyResponse.self
+ )
+ }
+
+ func deleteDeployKey(rid: String) async throws {
+ _ = try await client.execute(
+ service: .git,
+ query: Self.deleteDeployKeyMutation,
+ variables: ["rid": rid],
+ responseType: DeleteDeployKeyResponse.self
+ )
+ }
+}
+
+private extension RepositoryDeployKeyService {
+ static let deployKeysQuery = """
+ query repositoryDeployKeys($rid: ID!) {
+ repository(rid: $rid) {
+ deployKeys {
+ results {
+ rid
+ keyType
+ fingerprintSHA256
+ comment
+ access
+ }
+ }
+ }
+ }
+ """
+
+ static let createDeployKeyMutation = """
+ mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) {
+ createDeployKey(repo: $repo, mode: $mode, key: $key) { rid }
+ }
+ """
+
+ static let deleteDeployKeyMutation = """
+ mutation deleteDeployKey($rid: ID!) {
+ deleteDeployKey(rid: $rid) { rid }
+ }
+ """
+}
diff --git a/Hutch/Views/Repositories/RepositoryDeployKeysView.swift b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift
new file mode 100644
index 0000000..71e252b
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift
@@ -0,0 +1,272 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class RepositoryDeployKeysViewModel {
+ private(set) var keys: [RepositoryDeployKey] = []
+ private(set) var isLoading = false
+ private(set) var isSaving = false
+ private(set) var deletingRID: String?
+ var loadError: String?
+ var error: String?
+ var saveError: String?
+
+ let repositoryRid: String
+ private let service: RepositoryDeployKeyService
+
+ init(repositoryRid: String, service: RepositoryDeployKeyService) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ }
+
+ func load() async {
+ guard !isLoading else { return }
+ isLoading = true
+ loadError = nil
+ defer { isLoading = false }
+ do {
+ keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
+ } catch {
+ if keys.isEmpty {
+ loadError = error.userFacingMessage
+ } else {
+ self.error = error.userFacingMessage
+ }
+ }
+ }
+
+ func addKey(publicKey: String, mode: AccessMode) async -> Bool {
+ let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !isSaving else { return false }
+ isSaving = true
+ saveError = nil
+ defer { isSaving = false }
+ do {
+ try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
+ // The create response omits the key's fields, so reload the list.
+ keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
+ return true
+ } catch {
+ saveError = error.userFacingMessage
+ return false
+ }
+ }
+
+ func deleteKey(_ key: RepositoryDeployKey) async {
+ guard deletingRID == nil else { return }
+ deletingRID = key.rid
+ error = nil
+ defer { deletingRID = nil }
+ do {
+ try await service.deleteDeployKey(rid: key.rid)
+ keys.removeAll { $0.rid == key.rid }
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+}
+
+struct RepositoryDeployKeysView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+ var showsDoneButton = false
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositoryDeployKeysViewModel?
+ @State private var showAddSheet = false
+ @State private var pendingDeletion: RepositoryDeployKey?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading deploy keys…")
+ }
+ }
+ .navigationTitle("Deploy Keys")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if showsDoneButton {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showAddSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add deploy key")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = RepositoryDeployKeysViewModel(
+ repositoryRid: repository.rid,
+ service: RepositoryDeployKeyService(client: client)
+ )
+ viewModel = vm
+ await vm.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
+ Group {
+ if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
+ SRHTLoadingStateView(message: "Loading deploy keys…")
+ } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Deploy Keys",
+ message: loadError,
+ retryAction: { await viewModel.load() }
+ )
+ } else {
+ List {
+ if viewModel.keys.isEmpty {
+ Section {
+ ContentUnavailableView(
+ "No Deploy Keys",
+ systemImage: "key",
+ description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
+ )
+ .themedRow()
+ }
+ } else {
+ Section {
+ ForEach(viewModel.keys) { key in
+ DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
+ .themedRow()
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ pendingDeletion = key
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ } footer: {
+ Text("Deploy keys are SSH keys scoped to this repository only.")
+ }
+ }
+ }
+ .themedList()
+ .refreshable { await viewModel.load() }
+ }
+ }
+ .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
+ .confirmationDialog(
+ "Delete this deploy key?",
+ isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
+ titleVisibility: .visible
+ ) {
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ Button("Delete", role: .destructive) {
+ if let key = pendingDeletion {
+ pendingDeletion = nil
+ Task { await viewModel.deleteKey(key) }
+ }
+ }
+ } message: {
+ Text("This revokes the key's access to \(repository.name). This cannot be undone.")
+ }
+ .sheet(isPresented: $showAddSheet) {
+ AddDeployKeyView(viewModel: viewModel)
+ }
+ }
+}
+
+private struct DeployKeyRow: View {
+ let key: RepositoryDeployKey
+ let isDeleting: Bool
+
+ var body: some View {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
+ .font(.body)
+ .lineLimit(1)
+ Text(key.fingerprintSHA256)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ Spacer()
+ if isDeleting {
+ ProgressView().controlSize(.small)
+ } else {
+ Text(key.access.displayName)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct AddDeployKeyView: View {
+ let viewModel: RepositoryDeployKeysViewModel
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var publicKey = ""
+ @State private var mode: AccessMode = .ro
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("SSH Public Key") {
+ TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
+ .lineLimit(3...8)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .font(.body.monospaced())
+ .themedRow()
+ }
+ Section {
+ Picker("Access", selection: $mode) {
+ Text("Read Only").tag(AccessMode.ro)
+ Text("Read/Write").tag(AccessMode.rw)
+ }
+ .themedRow()
+ } footer: {
+ Text("Read/Write lets the key push to this repository.")
+ }
+ if let saveError = viewModel.saveError, !saveError.isEmpty {
+ Section {
+ Text(saveError).foregroundStyle(.red).themedRow()
+ }
+ }
+ }
+ .themedList()
+ .navigationTitle("Add Deploy Key")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if await viewModel.addKey(publicKey: publicKey, mode: mode) {
+ dismiss()
+ }
+ }
+ } label: {
+ if viewModel.isSaving {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("Add")
+ }
+ }
+ .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 8f466ba..a4ac25e 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -12,6 +12,7 @@ struct RepositoryDetailView: View {
@State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
@State private var showACLs = false
+ @State private var showDeployKeys = false
@State private var currentRepository: RepositorySummary
@State private var pinChangeCount = 0
@@ -82,6 +83,15 @@ struct RepositoryDetailView: View {
)
}
}
+ .sheet(isPresented: $showDeployKeys) {
+ NavigationStack {
+ RepositoryDeployKeysView(
+ repository: currentRepository,
+ client: appState.client,
+ showsDoneButton: true
+ )
+ }
+ }
.task {
if viewModel == nil {
viewModel = RepositoryDetailViewModel(
@@ -201,6 +211,12 @@ struct RepositoryDetailView: View {
}
Button {
+ showDeployKeys = true
+ } label: {
+ Label("Deploy Keys", systemImage: "key")
+ }
+
+ Button {
showSettings = true
} label: {
Label("Repository Settings", systemImage: "gear")
diff --git a/HutchTests/RepositoryDeployKeyTests.swift b/HutchTests/RepositoryDeployKeyTests.swift
new file mode 100644
index 0000000..c6ff667
--- /dev/null
+++ b/HutchTests/RepositoryDeployKeyTests.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct RepositoryDeployKeyTests {
+ @Test
+ func decodesDeployKey() throws {
+ let json = """
+ { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" }
+ """
+ let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
+ #expect(key.rid == "k1")
+ #expect(key.id == "k1")
+ #expect(key.access == .rw)
+ #expect(key.comment == "laptop")
+ }
+
+ @Test
+ func decodesDeployKeyWithNullComment() throws {
+ let json = """
+ { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" }
+ """
+ let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
+ #expect(key.comment == nil)
+ #expect(key.access == .ro)
+ }
+}
diff --git a/ROADMAP.txt b/ROADMAP.txt
index 6c4765e..25c32e5 100644
--- a/ROADMAP.txt
+++ b/ROADMAP.txt
@@ -144,7 +144,7 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
| 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 |
-| v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
+| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); "What's cooking" ingest; doc truth-up; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
| v3.11.0 | Accessibility | Independent, device-verified |
| v4.0.0 | Localization *with* translations | The only true re-presentation |
| — | 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
pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
not just a green suite.
-### Ingest "What's cooking on SourceHut?" — v3.9.0
+### Ingest "What's cooking on SourceHut?" — v3.10.0
sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
@@ -294,19 +294,20 @@ flags two openings:
- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
resources. ~~Rechecked and shipped~~: project create/edit, resource
link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
- below. `SCOPE.txt` still needs its "hub has no public API / no discovery"
- claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a
+ below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
+ been corrected. `mailingListSubscribe` was *not* unblocked — that needs a
per-list subscription field lists.sr.ht still lacks.
-- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey`
- are in the SDL). Hutch never calls them.
+- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
+ `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
+ into the repository actions menu, owner-only, alongside ACLs.
Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
were captured.
-Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one
-self-contained feature it has already surfaced and that the SDL confirms exists.
-That gives the release something a user can see. Everything else the ingest turns
-up gets filed, not built, and hub.sr.ht gets its own bucket below.
+Deploy keys — the one self-contained feature the ingest had already surfaced and
+that the SDL confirmed — shipped in v3.10.0, so this bucket is now the ingest
+itself: research that files what changed rather than building. Everything else it
+turns up gets filed, not built.
### hub.sr.ht writes — projects and discovery done