Stitch any CI into Tangled
85
fork

Configure Feed

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

Initial commit

+735
+1
.envrc
··· 1 + use flake
+13
.gitignore
··· 1 + /tack 2 + .direnv/ 3 + result 4 + *.test 5 + *.out 6 + .DS_Store 7 + .env 8 + .env.* 9 + !.env.example 10 + *.db 11 + *.db-journal 12 + *.db-shm 13 + *.db-wal
+20
AGENTS.md
··· 1 + # Tack 2 + 3 + - Use `go doc` to find documentation for Go packages. For example: 4 + `go doc github.com/mitchellh/go-libghostty`. Full syntax is 5 + `go doc [<pkg>.][<sym>.]<methodOrField>` for full help output. 6 + 7 + ## Style Guide 8 + 9 + - Comment heavily, but not redundantly. Explain the "why" behind decisions 10 + clearly, don't repeat the "what." 11 + - Try to limit line length below 100 characters, but don't be afraid to break 12 + this rule if it improves readability. 13 + - Do not make lines too short to follow this rule either. Try 14 + to use as much of the max characters as possible without sacrificing 15 + readability. 16 + 17 + ## Go Guide 18 + 19 + - Add compile-time interface checks whenever a type implements an interface 20 + we care about. For example: `var _ io.Reader = (*MyType)(nil)`.
+53
README.md
··· 1 + # tack - Connect Tangled to your CI 2 + 3 + Tack is a custom [Tangled](https://tangled.org) spindle that runs 4 + CI on alternate providers and reports their results back 5 + to Tangled using standard ATProto records so they show up natively 6 + in Tangled's UI. 7 + 8 + ## What it does 9 + 10 + Tack is a drop-in alternative to the stock `spindle` runner. You run 11 + `tack` and [register it using the standard UI](https://tangled.org/settings/spindles). 12 + 13 + Instead of executing workflows in local containers, tack translates each 14 + Tangled pipeline trigger into a 3rd party CI build, and reports build state 15 + back to Tangled using the existing `sh.tangled.pipeline.status` wire format. 16 + 17 + This makes even 3rd party CIs integrate first class into Tangled so their 18 + status, counts, etc. can show up inline in things like pull requests. 19 + 20 + ``` 21 + sh.tangled.pipeline 22 + Jetstream ───────────────────────▶ tack 23 + 24 + │ Create Build 25 + 26 + Buildkite 27 + 28 + │ webhooks 29 + 30 + tack ──── /events (WebSocket) ────▶ Tangled appview 31 + sh.tangled.pipeline.status 32 + ``` 33 + 34 + ```sh 35 + go run . -addr :8080 36 + ``` 37 + 38 + ## Endpoints (planned) 39 + 40 + - `GET /events` — WebSocket stream of pipeline status events, 41 + consumed by the Tangled appview. 42 + - `POST /webhooks/buildkite` — Buildkite webhook receiver. 43 + - `POST /xrpc/sh.tangled.pipeline.cancelPipeline` — cancel a running build. 44 + 45 + ## Configuration (planned) 46 + 47 + | Env var | Description | 48 + | ---------------------- | ------------------------------------ | 49 + | `TACK_BUILDKITE_TOKEN` | Buildkite API token | 50 + | `TACK_BUILDKITE_ORG` | Buildkite organization slug | 51 + | `TACK_JETSTREAM_URL` | Tangled Jetstream WebSocket URL | 52 + | `TACK_DB_PATH` | Local SQLite path for the event log | 53 + | `TACK_OWNER_DID` | DID of the spindle operator |
+58
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1777425547, 24 + "narHash": "sha256-fUlUlthbjH+ppUqSdGoLFM+GbtuxcDhp8V8ouXEAgow=", 25 + "rev": "ebc08544afa77957cc348ba72dc490ec73b87f68", 26 + "type": "tarball", 27 + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.05pre988811.ebc08544afa7/nixexprs.tar.xz" 28 + }, 29 + "original": { 30 + "type": "tarball", 31 + "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" 32 + } 33 + }, 34 + "root": { 35 + "inputs": { 36 + "flake-utils": "flake-utils", 37 + "nixpkgs": "nixpkgs" 38 + } 39 + }, 40 + "systems": { 41 + "locked": { 42 + "lastModified": 1681028828, 43 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 44 + "owner": "nix-systems", 45 + "repo": "default", 46 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 47 + "type": "github" 48 + }, 49 + "original": { 50 + "owner": "nix-systems", 51 + "repo": "default", 52 + "type": "github" 53 + } 54 + } 55 + }, 56 + "root": "root", 57 + "version": 7 58 + }
+25
flake.nix
··· 1 + { 2 + description = "tack"; 3 + 4 + inputs = { 5 + nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; 6 + flake-utils.url = "github:numtide/flake-utils"; 7 + }; 8 + 9 + outputs = { 10 + nixpkgs, 11 + flake-utils, 12 + ... 13 + }: 14 + flake-utils.lib.eachDefaultSystem ( 15 + system: let 16 + pkgs = nixpkgs.legacyPackages.${system}; 17 + in { 18 + devShells.default = pkgs.mkShell { 19 + packages = [ 20 + pkgs.go 21 + ]; 22 + }; 23 + } 24 + ); 25 + }
+56
go.mod
··· 1 + module github.com/mitchellh/tack 2 + 3 + go 1.25.7 4 + 5 + require ( 6 + github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 7 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 8 + tangled.org/core v1.13.0-alpha 9 + ) 10 + 11 + require ( 12 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 + github.com/beorn7/perks v1.0.1 // indirect 14 + github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab // indirect 15 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 16 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 17 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 18 + github.com/charmbracelet/log v1.0.0 // indirect 19 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 20 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 21 + github.com/charmbracelet/x/term v0.2.1 // indirect 22 + github.com/go-logfmt/logfmt v0.6.1 // indirect 23 + github.com/goccy/go-json v0.10.5 // indirect 24 + github.com/ipfs/go-cid v0.6.0 // indirect 25 + github.com/klauspost/compress v1.18.0 // indirect 26 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 27 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 28 + github.com/mattn/go-isatty v0.0.20 // indirect 29 + github.com/mattn/go-runewidth v0.0.16 // indirect 30 + github.com/minio/sha256-simd v1.0.1 // indirect 31 + github.com/mr-tron/base58 v1.2.0 // indirect 32 + github.com/muesli/termenv v0.16.0 // indirect 33 + github.com/multiformats/go-base32 v0.1.0 // indirect 34 + github.com/multiformats/go-base36 v0.2.0 // indirect 35 + github.com/multiformats/go-multibase v0.2.0 // indirect 36 + github.com/multiformats/go-multihash v0.2.3 // indirect 37 + github.com/multiformats/go-varint v0.1.0 // indirect 38 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 39 + github.com/prometheus/client_golang v1.23.2 // indirect 40 + github.com/prometheus/client_model v0.6.2 // indirect 41 + github.com/prometheus/common v0.67.5 // indirect 42 + github.com/prometheus/procfs v0.19.2 // indirect 43 + github.com/rivo/uniseg v0.4.7 // indirect 44 + github.com/spaolacci/murmur3 v1.1.0 // indirect 45 + github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 46 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 47 + go.uber.org/atomic v1.11.0 // indirect 48 + go.yaml.in/yaml/v2 v2.4.3 // indirect 49 + golang.org/x/crypto v0.48.0 // indirect 50 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect 51 + golang.org/x/net v0.50.0 // indirect 52 + golang.org/x/sys v0.41.0 // indirect 53 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 54 + google.golang.org/protobuf v1.36.11 // indirect 55 + lukechampine.com/blake3 v1.4.1 // indirect 56 + )
+117
go.sum
··· 1 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 + github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab h1:Cs35T2tAN3Q6mMH5mBaY09nmCNOn/GkZS1F7jfMxlR8= 6 + github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 7 + github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654 h1:OK76FcHhZp8ohjRB0OMWgti0oYAWFlt3KDQcIkH1pfI= 8 + github.com/bluesky-social/jetstream v0.0.0-20260226214936-e0274250f654/go.mod h1:vt8kVRKtvrBspt9G38wDD8+BotjIMO8u8IYoVnyE4zY= 9 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 10 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 12 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 13 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 + github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4= 16 + github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA= 17 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 18 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 19 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 20 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 22 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 23 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 24 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 + github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= 26 + github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= 27 + github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 28 + github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 29 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 32 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 33 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 34 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 35 + github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 36 + github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 37 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 38 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 39 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 44 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 45 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 46 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 48 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 49 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 50 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 51 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 52 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 53 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 54 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 55 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 56 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 57 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 58 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 59 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 60 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 61 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 62 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 63 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 64 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 65 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 66 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 67 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 68 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 69 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 70 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 71 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 72 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 73 + github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= 74 + github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= 75 + github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= 76 + github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 77 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 78 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 79 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 80 + github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 81 + github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 82 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 83 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 84 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 85 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 86 + github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 87 + github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 88 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 89 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 90 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 91 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 92 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 93 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 94 + go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 95 + go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 96 + golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= 97 + golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 98 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= 99 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 100 + golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= 101 + golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 102 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 103 + golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= 104 + golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 105 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 106 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 107 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 108 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 109 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 110 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 111 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 112 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 113 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 114 + lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 115 + lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 116 + tangled.org/core v1.13.0-alpha h1:Mav54rAcz7s3we12JvXqdQoh0aBP9/PHSYauoRRZnY0= 117 + tangled.org/core v1.13.0-alpha/go.mod h1:geVdnl+VR4+1yDLokiMcuAJi5sMq1a+48Wdfv6EOjnI=
+151
http.go
··· 1 + package main 2 + 3 + // HTTP surface of the spindle. 4 + // 5 + // Three roles to keep in mind: 6 + // 7 + // 1. Verification: the Tangled appview hits /xrpc/sh.tangled.owner during 8 + // spindle registration to confirm the operator owns this instance. 9 + // 2. Event stream: the appview holds a long-lived websocket against 10 + // /events to receive sh.tangled.pipeline.status frames as builds 11 + // progress. Today this is just a keep-alive; payloads land once the 12 + // Buildkite webhook receiver is wired up. 13 + // 3. Webhooks: Buildkite POSTs build/job state changes to 14 + // /webhooks/buildkite, which we'll translate into pipeline.status 15 + // events on (2). 16 + 17 + import ( 18 + "context" 19 + "encoding/json" 20 + "errors" 21 + "fmt" 22 + "log/slog" 23 + "net/http" 24 + "time" 25 + 26 + "github.com/gorilla/websocket" 27 + "tangled.org/core/api/tangled" 28 + ) 29 + 30 + // runHTTP starts the spindle's HTTP server and blocks until ctx is 31 + // cancelled or the listener returns a fatal error. On ctx cancellation it 32 + // performs a graceful shutdown with a bounded timeout. 33 + // 34 + // The logger is read from ctx via loggerFrom. 35 + func runHTTP(ctx context.Context, cfg config) error { 36 + logger := loggerFrom(ctx) 37 + 38 + mux := http.NewServeMux() 39 + mux.HandleFunc("GET /", rootHandler()) 40 + mux.HandleFunc("GET /events", eventsHandler(logger)) 41 + mux.HandleFunc("GET /xrpc/"+tangled.OwnerNSID, ownerHandler(logger, cfg.OwnerDID)) 42 + mux.HandleFunc("POST /webhooks/buildkite", buildkiteWebhookHandler()) 43 + 44 + srv := &http.Server{ 45 + Addr: cfg.Addr, 46 + Handler: mux, 47 + ReadHeaderTimeout: 5 * time.Second, 48 + } 49 + 50 + // Run ListenAndServe on a goroutine so we can race it against ctx.Done. 51 + errCh := make(chan error, 1) 52 + go func() { 53 + logger.Info("listening", "addr", cfg.Addr, "owner", cfg.OwnerDID) 54 + errCh <- srv.ListenAndServe() 55 + }() 56 + 57 + select { 58 + case <-ctx.Done(): 59 + logger.Info("shutting down") 60 + case err := <-errCh: 61 + // ErrServerClosed means we shut ourselves down cleanly elsewhere 62 + // — anything else is a real failure to report. 63 + if err != nil && !errors.Is(err, http.ErrServerClosed) { 64 + return fmt.Errorf("http server: %w", err) 65 + } 66 + } 67 + 68 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 69 + defer cancel() 70 + return srv.Shutdown(shutdownCtx) 71 + } 72 + 73 + // rootHandler responds at "/" with a small identifier. Mainly useful as a 74 + // liveness check during deployment. 75 + func rootHandler() http.HandlerFunc { 76 + return func(w http.ResponseWriter, r *http.Request) { 77 + fmt.Fprintln(w, "tack: a Tangled spindle backed by Buildkite") 78 + } 79 + } 80 + 81 + // ownerHandler implements sh.tangled.owner so the Tangled appview can verify 82 + // this spindle's owner during registration. 83 + func ownerHandler(logger *slog.Logger, owner string) http.HandlerFunc { 84 + return func(w http.ResponseWriter, r *http.Request) { 85 + w.Header().Set("Content-Type", "application/json") 86 + if err := json.NewEncoder(w).Encode(tangled.Owner_Output{Owner: owner}); err != nil { 87 + logger.Error("encode owner response", "err", err) 88 + } 89 + } 90 + } 91 + 92 + // buildkiteWebhookHandler is a placeholder until we implement Buildkite -> 93 + // pipeline.status translation. 94 + func buildkiteWebhookHandler() http.HandlerFunc { 95 + return func(w http.ResponseWriter, r *http.Request) { 96 + http.Error(w, "not implemented", http.StatusNotImplemented) 97 + } 98 + } 99 + 100 + // eventsHandler upgrades to a WebSocket and emits no events yet. It exists 101 + // so the Tangled appview can connect; once we wire up Buildkite webhooks 102 + // this is where sh.tangled.pipeline.status frames will be sent. 103 + // 104 + // We send a periodic ping to keep intermediaries (load balancers, tunnels) 105 + // from idling the connection, and watch for client reads to detect a 106 + // disconnect. 107 + func eventsHandler(logger *slog.Logger) http.HandlerFunc { 108 + upgrader := websocket.Upgrader{ 109 + ReadBufferSize: 1024, 110 + WriteBufferSize: 1024, 111 + } 112 + return func(w http.ResponseWriter, r *http.Request) { 113 + conn, err := upgrader.Upgrade(w, r, nil) 114 + if err != nil { 115 + logger.Error("websocket upgrade failed", "err", err) 116 + return 117 + } 118 + defer conn.Close() 119 + logger.Debug("events client connected", "remote", r.RemoteAddr) 120 + 121 + ctx, cancel := context.WithCancel(r.Context()) 122 + defer cancel() 123 + 124 + // Detect client disconnect by trying to read; we don't expect any 125 + // payloads from the client, so any read result (including EOF) 126 + // signals the connection has gone away. 127 + go func() { 128 + for { 129 + if _, _, err := conn.NextReader(); err != nil { 130 + cancel() 131 + return 132 + } 133 + } 134 + }() 135 + 136 + ticker := time.NewTicker(30 * time.Second) 137 + defer ticker.Stop() 138 + for { 139 + select { 140 + case <-ctx.Done(): 141 + logger.Debug("events client disconnected", "remote", r.RemoteAddr) 142 + return 143 + case <-ticker.C: 144 + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil { 145 + logger.Debug("events ping failed", "err", err) 146 + return 147 + } 148 + } 149 + } 150 + } 151 + }
+119
jetstream.go
··· 1 + package main 2 + 3 + // This file wires tack into the AT Protocol firehose via Bluesky's 4 + // "jetstream" — a JSON projection of the firehose served over a websocket 5 + // (see https://github.com/bluesky-social/jetstream). Tangled rides on top of 6 + // AT Proto: things like "this user is a spindle member", "this repo wants 7 + // this spindle", etc. are all atproto records published to users' PDSes, 8 + // and jetstream is how a service like a spindle observes them in real time. 9 + // 10 + // As a spindle, the records we care about are: 11 + // 12 + // - sh.tangled.spindle.member — owner authorizes a DID to use us 13 + // - sh.tangled.repo — a repo declares us as its spindle 14 + // - sh.tangled.repo.collaborator — collaborators on those repos 15 + // 16 + // (Pipeline trigger records, sh.tangled.pipeline, do *not* come over 17 + // jetstream; they're delivered by the knot servers via a separate event 18 + // stream. That is plumbed in separately.) 19 + 20 + import ( 21 + "context" 22 + "fmt" 23 + "time" 24 + 25 + "github.com/bluesky-social/jetstream/pkg/client" 26 + "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 27 + jsmodels "github.com/bluesky-social/jetstream/pkg/models" 28 + "tangled.org/core/api/tangled" 29 + ) 30 + 31 + // startJetstream dials the configured jetstream endpoint and spawns a 32 + // background goroutine that consumes events for the lifetime of ctx. It 33 + // returns once the client is constructed; connection errors surface in 34 + // logs, not return values, because the read loop is expected to reconnect 35 + // on its own. 36 + // 37 + // The logger is pulled from ctx (see log.go); falls back to slog.Default() 38 + // if none is attached. 39 + func startJetstream(ctx context.Context, cfg config) error { 40 + logger := loggerFrom(ctx).With("component", "jetstream") 41 + 42 + // `wantedCollections` is a server-side filter: jetstream will only send 43 + // us commit events whose record collection (NSID) is in this list. The 44 + // NSIDs come from tangled-core's generated lexicon types so they stay 45 + // in sync with whatever the appview/knots are publishing. 46 + collections := []string{ 47 + tangled.SpindleMemberNSID, 48 + tangled.RepoNSID, 49 + tangled.RepoCollaboratorNSID, 50 + } 51 + 52 + // Configure our JetStream client. 53 + clientCfg := client.DefaultClientConfig() 54 + clientCfg.WebsocketURL = cfg.JetstreamURL 55 + clientCfg.WantedCollections = collections 56 + 57 + // Re-attach the component-scoped logger so handleJetstreamEvent — which 58 + // the scheduler invokes with the ctx we pass to ConnectAndRead — can 59 + // pull it back out via loggerFrom. 60 + ctx = loggerInto(ctx, logger) 61 + 62 + // The sequential scheduler processes events one-at-a-time in arrival 63 + // order. That's the right default for a spindle: ordering matters 64 + // (e.g. a member-added event must apply before any record from that 65 + // member is processed), and our event volume is tiny. 66 + c, err := client.NewClient( 67 + clientCfg, 68 + logger, 69 + sequential.NewScheduler( 70 + "tack", 71 + logger, 72 + handleJetstreamEvent), 73 + ) 74 + if err != nil { 75 + return fmt.Errorf("new jetstream client: %w", err) 76 + } 77 + 78 + // Reconnect loop. ConnectAndRead blocks on the websocket and returns 79 + // either when the connection drops (transient network error, server 80 + // restart, etc.) or when ctx is cancelled. On error we sleep briefly 81 + // and reconnect; on ctx cancellation we exit cleanly. 82 + // 83 + // TODO: pass a *cursor here once we persist one, so we resume from the 84 + // last seen event instead of "now" after a restart. 85 + go func() { 86 + for { 87 + if err := c.ConnectAndRead(ctx, nil); err != nil { 88 + if ctx.Err() != nil { 89 + return 90 + } 91 + logger.Error("jetstream read loop", "err", err) 92 + time.Sleep(2 * time.Second) 93 + continue 94 + } 95 + if ctx.Err() != nil { 96 + return 97 + } 98 + } 99 + }() 100 + 101 + return nil 102 + } 103 + 104 + // handleJetstreamEvent is the per-event callback for the JetStream. 105 + func handleJetstreamEvent(ctx context.Context, evt *jsmodels.Event) error { 106 + // We only care about commits, which are the actual record CRUD operations 107 + // on a user's PDS. 108 + if evt.Kind != jsmodels.EventKindCommit || evt.Commit == nil { 109 + return nil 110 + } 111 + 112 + loggerFrom(ctx).Debug("event", 113 + "did", evt.Did, 114 + "collection", evt.Commit.Collection, 115 + "op", evt.Commit.Operation, 116 + "rkey", evt.Commit.RKey, 117 + ) 118 + return nil 119 + }
+32
log.go
··· 1 + package main 2 + 3 + // Tiny helpers for stashing a *slog.Logger on a context.Context. The stdlib 4 + // doesn't define a context key for slog, so by convention every app rolls 5 + // its own — the unexported key type ensures we can't collide with anyone 6 + // else's value, and FromContext falls back to slog.Default() so callers 7 + // don't need to special-case a missing logger. 8 + 9 + import ( 10 + "context" 11 + "log/slog" 12 + ) 13 + 14 + // loggerCtxKey is unexported so only this package can write to the slot. 15 + type loggerCtxKey struct{} 16 + 17 + // loggerInto returns a copy of ctx that carries logger. 18 + func loggerInto(ctx context.Context, logger *slog.Logger) context.Context { 19 + return context.WithValue(ctx, loggerCtxKey{}, logger) 20 + } 21 + 22 + // loggerFrom retrieves the logger attached with loggerInto, or slog.Default 23 + // if none has been set. 24 + func loggerFrom(ctx context.Context) *slog.Logger { 25 + if ctx == nil { 26 + return slog.Default() 27 + } 28 + if l, ok := ctx.Value(loggerCtxKey{}).(*slog.Logger); ok { 29 + return l 30 + } 31 + return slog.Default() 32 + }
+90
main.go
··· 1 + // Tack is a custom Tangled spindle that translates sh.tangled.pipeline 2 + // trigger records into Buildkite builds, and publishes Buildkite job state 3 + // back as sh.tangled.pipeline.status events on a WebSocket stream that the 4 + // Tangled appview can consume. 5 + package main 6 + 7 + import ( 8 + "context" 9 + "errors" 10 + "flag" 11 + "log/slog" 12 + "os" 13 + "os/signal" 14 + "syscall" 15 + 16 + charmlog "github.com/charmbracelet/log" 17 + ) 18 + 19 + // config is the runtime configuration, sourced from environment variables and 20 + // flags. Env vars match the README so this can be swapped in for the stock 21 + // spindle without surprises. 22 + type config struct { 23 + Addr string 24 + OwnerDID string 25 + JetstreamURL string 26 + } 27 + 28 + func loadConfig() (config, error) { 29 + cfg := config{ 30 + Addr: envOr("TACK_LISTEN_ADDR", ":8080"), 31 + OwnerDID: os.Getenv("TACK_OWNER_DID"), 32 + JetstreamURL: envOr("TACK_JETSTREAM_URL", "wss://jetstream1.us-west.bsky.network/subscribe"), 33 + } 34 + addrFlag := flag.String("addr", cfg.Addr, "HTTP listen address (overrides TACK_LISTEN_ADDR)") 35 + flag.Parse() 36 + cfg.Addr = *addrFlag 37 + 38 + if cfg.OwnerDID == "" { 39 + return cfg, errors.New("TACK_OWNER_DID is required") 40 + } 41 + return cfg, nil 42 + } 43 + 44 + func envOr(key, def string) string { 45 + if v := os.Getenv(key); v != "" { 46 + return v 47 + } 48 + return def 49 + } 50 + 51 + func main() { 52 + // Logging setup. charmbracelet/log implements slog.Handler, so we wrap 53 + // it in slog.New to share the same backend with libraries that expect 54 + // a *slog.Logger (notably the jetstream client). 55 + charmHandler := charmlog.NewWithOptions(os.Stderr, charmlog.Options{ 56 + Level: charmlog.DebugLevel, 57 + ReportTimestamp: true, 58 + }) 59 + logger := slog.New(charmHandler) 60 + slog.SetDefault(logger) 61 + 62 + // Config loading 63 + cfg, err := loadConfig() 64 + if err != nil { 65 + logger.Error("invalid configuration", "err", err) 66 + os.Exit(2) 67 + } 68 + 69 + // Root context: cancelled on SIGINT/SIGTERM, with the logger attached 70 + // so any function we hand it to can pull it back out via loggerFrom. 71 + ctx, stop := signal.NotifyContext( 72 + context.Background(), 73 + os.Interrupt, syscall.SIGTERM, 74 + ) 75 + defer stop() 76 + ctx = loggerInto(ctx, logger) 77 + 78 + // Start the JetStream listener in the background. 79 + if err := startJetstream(ctx, cfg); err != nil { 80 + logger.Error("failed to start jetstream consumer", "err", err) 81 + os.Exit(1) 82 + } 83 + 84 + // Run the HTTP server. This blocks until ctx is cancelled or the 85 + // listener errors. 86 + if err := runHTTP(ctx, cfg); err != nil { 87 + logger.Error("http server error", "err", err) 88 + os.Exit(1) 89 + } 90 + }