atproto utils for zig zat.dev
atproto sdk zig
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

zzstoatzz 155c45e5 9f39b57f

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