atproto utils for zig zat.dev
atproto sdk zig

feat: add ATProto doc publishing on tag releases

- scripts/publish-docs.zig: uses zat to publish README, roadmap, and
changelog as site.standard.document records
- .tangled/workflows/publish-docs.yml: triggers on v* tags
- build.zig: adds publish-docs executable target

requires ATPROTO_HANDLE and ATPROTO_PASSWORD secrets in repo settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+250 -1
.tangled
workflows
scripts
src
internal
+15
.tangled/workflows/publish-docs.yml
··· 1 + when: 2 + - event: push 3 + tag: "v*" 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - zig 10 + 11 + steps: 12 + - name: build and publish docs to ATProto 13 + command: | 14 + zig build 15 + ./zig-out/bin/publish-docs
+12
build.zig
··· 15 15 16 16 const test_step = b.step("test", "run unit tests"); 17 17 test_step.dependOn(&run_tests.step); 18 + 19 + // publish-docs script (uses zat to publish docs to ATProto) 20 + const publish_docs = b.addExecutable(.{ 21 + .name = "publish-docs", 22 + .root_module = b.createModule(.{ 23 + .root_source_file = b.path("scripts/publish-docs.zig"), 24 + .target = target, 25 + .optimize = optimize, 26 + .imports = &.{.{ .name = "zat", .module = mod }}, 27 + }), 28 + }); 29 + b.installArtifact(publish_docs); 18 30 }
+218
scripts/publish-docs.zig
··· 1 + const std = @import("std"); 2 + const zat = @import("zat"); 3 + 4 + const Allocator = std.mem.Allocator; 5 + 6 + /// docs to publish as site.standard.document records 7 + const docs = [_]struct { path: []const u8, file: []const u8 }{ 8 + .{ .path = "/", .file = "README.md" }, 9 + .{ .path = "/roadmap", .file = "docs/roadmap.md" }, 10 + .{ .path = "/changelog", .file = "CHANGELOG.md" }, 11 + }; 12 + 13 + pub fn main() !void { 14 + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; 15 + defer _ = gpa.deinit(); 16 + const allocator = gpa.allocator(); 17 + 18 + const handle = "zat.dev"; 19 + 20 + const password = std.posix.getenv("ATPROTO_PASSWORD") orelse { 21 + std.debug.print("error: ATPROTO_PASSWORD not set\n", .{}); 22 + return error.MissingEnv; 23 + }; 24 + 25 + const pds = std.posix.getenv("ATPROTO_PDS") orelse "https://bsky.social"; 26 + 27 + var client = zat.XrpcClient.init(allocator, pds); 28 + defer client.deinit(); 29 + 30 + const session = try createSession(&client, allocator, handle, password); 31 + defer { 32 + allocator.free(session.did); 33 + allocator.free(session.access_token); 34 + } 35 + 36 + std.debug.print("authenticated as {s}\n", .{session.did}); 37 + client.setAuth(session.access_token); 38 + 39 + // generate TID for publication (fixed timestamp for deterministic rkey) 40 + // using 2024-01-01 00:00:00 UTC as base timestamp (1704067200 seconds = 1704067200000000 microseconds) 41 + const pub_tid = zat.Tid.fromTimestamp(1704067200000000, 0); 42 + const pub_record = Publication{ 43 + .url = "https://zat.dev", 44 + .name = "zat", 45 + .description = "AT Protocol building blocks for zig", 46 + }; 47 + 48 + try putRecord(&client, allocator, session.did, "site.standard.publication", pub_tid.str(), pub_record); 49 + std.debug.print("created publication: at://{s}/site.standard.publication/{s}\n", .{ session.did, pub_tid.str() }); 50 + 51 + var pub_uri_buf: std.ArrayList(u8) = .empty; 52 + defer pub_uri_buf.deinit(allocator); 53 + try pub_uri_buf.print(allocator, "at://{s}/site.standard.publication/{s}", .{ session.did, pub_tid.str() }); 54 + const pub_uri = pub_uri_buf.items; 55 + 56 + // publish each doc with deterministic TIDs (same base timestamp, incrementing clock_id) 57 + const now = timestamp(); 58 + 59 + for (docs, 0..) |doc, i| { 60 + const content = std.fs.cwd().readFileAlloc(allocator, doc.file, 1024 * 1024) catch |err| { 61 + std.debug.print("warning: could not read {s}: {}\n", .{ doc.file, err }); 62 + continue; 63 + }; 64 + defer allocator.free(content); 65 + 66 + const title = extractTitle(content) orelse doc.file; 67 + const tid = zat.Tid.fromTimestamp(1704067200000000, @intCast(i + 1)); // clock_id 1, 2, 3... 68 + 69 + const doc_record = Document{ 70 + .site = pub_uri, 71 + .title = title, 72 + .path = doc.path, 73 + .textContent = content, 74 + .publishedAt = &now, 75 + }; 76 + 77 + try putRecord(&client, allocator, session.did, "site.standard.document", tid.str(), doc_record); 78 + std.debug.print("published: {s} -> at://{s}/site.standard.document/{s}\n", .{ doc.file, session.did, tid.str() }); 79 + } 80 + 81 + std.debug.print("done\n", .{}); 82 + } 83 + 84 + const Publication = struct { 85 + @"$type": []const u8 = "site.standard.publication", 86 + url: []const u8, 87 + name: []const u8, 88 + description: ?[]const u8 = null, 89 + }; 90 + 91 + const Document = struct { 92 + @"$type": []const u8 = "site.standard.document", 93 + site: []const u8, 94 + title: []const u8, 95 + path: ?[]const u8 = null, 96 + textContent: ?[]const u8 = null, 97 + publishedAt: []const u8, 98 + }; 99 + 100 + const Session = struct { 101 + did: []const u8, 102 + access_token: []const u8, 103 + }; 104 + 105 + fn createSession(client: *zat.XrpcClient, allocator: Allocator, handle: []const u8, password: []const u8) !Session { 106 + const CreateSessionInput = struct { 107 + identifier: []const u8, 108 + password: []const u8, 109 + }; 110 + 111 + var buf: std.ArrayList(u8) = .empty; 112 + defer buf.deinit(allocator); 113 + try buf.print(allocator, "{f}", .{std.json.fmt(CreateSessionInput{ 114 + .identifier = handle, 115 + .password = password, 116 + }, .{})}); 117 + 118 + const nsid = zat.Nsid.parse("com.atproto.server.createSession").?; 119 + var response = try client.procedure(nsid, buf.items); 120 + defer response.deinit(); 121 + 122 + if (!response.ok()) { 123 + std.debug.print("createSession failed: {s}\n", .{response.body}); 124 + return error.AuthFailed; 125 + } 126 + 127 + var parsed = try response.json(); 128 + defer parsed.deinit(); 129 + 130 + const did = zat.json.getString(parsed.value, "did") orelse return error.MissingDid; 131 + const token = zat.json.getString(parsed.value, "accessJwt") orelse return error.MissingToken; 132 + 133 + return .{ 134 + .did = try allocator.dupe(u8, did), 135 + .access_token = try allocator.dupe(u8, token), 136 + }; 137 + } 138 + 139 + fn putRecord(client: *zat.XrpcClient, allocator: Allocator, repo: []const u8, collection: []const u8, rkey: []const u8, record: anytype) !void { 140 + // serialize record to json 141 + var record_buf: std.ArrayList(u8) = .empty; 142 + defer record_buf.deinit(allocator); 143 + try record_buf.print(allocator, "{f}", .{std.json.fmt(record, .{})}); 144 + 145 + // build request body 146 + var body: std.ArrayList(u8) = .empty; 147 + defer body.deinit(allocator); 148 + 149 + try body.appendSlice(allocator, "{\"repo\":\""); 150 + try body.appendSlice(allocator, repo); 151 + try body.appendSlice(allocator, "\",\"collection\":\""); 152 + try body.appendSlice(allocator, collection); 153 + try body.appendSlice(allocator, "\",\"rkey\":\""); 154 + try body.appendSlice(allocator, rkey); 155 + try body.appendSlice(allocator, "\",\"record\":"); 156 + try body.appendSlice(allocator, record_buf.items); 157 + try body.append(allocator, '}'); 158 + 159 + const nsid = zat.Nsid.parse("com.atproto.repo.putRecord").?; 160 + var response = try client.procedure(nsid, body.items); 161 + defer response.deinit(); 162 + 163 + if (!response.ok()) { 164 + std.debug.print("putRecord failed: {s}\n", .{response.body}); 165 + return error.PutFailed; 166 + } 167 + } 168 + 169 + fn extractTitle(content: []const u8) ?[]const u8 { 170 + var lines = std.mem.splitScalar(u8, content, '\n'); 171 + while (lines.next()) |line| { 172 + const trimmed = std.mem.trim(u8, line, " \t\r"); 173 + if (trimmed.len > 2 and trimmed[0] == '#' and trimmed[1] == ' ') { 174 + var title = trimmed[2..]; 175 + // strip markdown link: [text](url) -> text 176 + if (std.mem.indexOf(u8, title, "](")) |bracket| { 177 + if (title[0] == '[') { 178 + title = title[1..bracket]; 179 + } 180 + } 181 + return title; 182 + } 183 + } 184 + return null; 185 + } 186 + 187 + fn timestamp() [24]u8 { 188 + const epoch_seconds = std.time.timestamp(); 189 + const days: i32 = @intCast(@divFloor(epoch_seconds, std.time.s_per_day)); 190 + const day_secs: u32 = @intCast(@mod(epoch_seconds, std.time.s_per_day)); 191 + 192 + // calculate year/month/day from days since epoch (1970-01-01) 193 + var y: i32 = 1970; 194 + var remaining = days; 195 + while (true) { 196 + const year_days: i32 = if (@mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0)) 366 else 365; 197 + if (remaining < year_days) break; 198 + remaining -= year_days; 199 + y += 1; 200 + } 201 + 202 + const is_leap = @mod(y, 4) == 0 and (@mod(y, 100) != 0 or @mod(y, 400) == 0); 203 + const month_days = [12]u8{ 31, if (is_leap) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 204 + var m: usize = 0; 205 + while (m < 12 and remaining >= month_days[m]) : (m += 1) { 206 + remaining -= month_days[m]; 207 + } 208 + 209 + const hours = day_secs / 3600; 210 + const mins = (day_secs % 3600) / 60; 211 + const secs = day_secs % 60; 212 + 213 + var buf: [24]u8 = undefined; 214 + _ = std.fmt.bufPrint(&buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ 215 + @as(u32, @intCast(y)), @as(u32, @intCast(m + 1)), @as(u32, @intCast(remaining + 1)), hours, mins, secs, 216 + }) catch unreachable; 217 + return buf; 218 + }
+5 -1
src/internal/xrpc.zig
··· 18 18 /// bearer token for authenticated requests 19 19 access_token: ?[]const u8 = null, 20 20 21 + /// atproto JWTs are ~1KB; buffer needs room for "Bearer " prefix 22 + const max_auth_header_len = 2048; 23 + 21 24 pub fn init(allocator: std.mem.Allocator, host: []const u8) XrpcClient { 22 25 return .{ 23 26 .allocator = allocator, ··· 89 92 // https://github.com/ziglang/zig/issues/25021 90 93 var extra_headers: std.http.Client.Request.Headers = .{ 91 94 .accept_encoding = .{ .override = "identity" }, 95 + .content_type = if (body != null) .{ .override = "application/json" } else .default, 92 96 }; 93 - var auth_header_buf: [256]u8 = undefined; 97 + var auth_header_buf: [max_auth_header_len]u8 = undefined; 94 98 if (self.access_token) |token| { 95 99 const auth_value = try std.fmt.bufPrint(&auth_header_buf, "Bearer {s}", .{token}); 96 100 extra_headers.authorization = .{ .override = auth_value };