porting all github actions from bluesky-social/indigo to tangled CI

Compare changes

Choose any two refs to compare.

Changed files
+315 -70
.github
workflows
.tangled
atproto
cmd
goat
docs
mst
pkg
robusthttp
+31
.github/workflows/sync-internal.yaml
··· 1 + name: Sync to internal repo 2 + 3 + on: 4 + push: 5 + branches: [main] 6 + 7 + jobs: 8 + sync: 9 + runs-on: ubuntu-latest 10 + if: github.repository == 'bluesky-social/indigo' 11 + steps: 12 + - name: Checkout public repo 13 + uses: actions/checkout@v4 14 + with: 15 + fetch-depth: 0 16 + - name: Generate GitHub App Token 17 + id: app-token 18 + uses: actions/create-github-app-token@v1 19 + with: 20 + app-id: ${{ vars.SYNC_INTERNAL_APP_ID }} 21 + private-key: ${{ secrets.SYNC_INTERNAL_PK }} 22 + repositories: indigo-internal 23 + - name: Push to internal repo 24 + env: 25 + TOKEN: ${{ steps.app-token.outputs.token }} 26 + run: | 27 + git config user.name "github-actions" 28 + git config user.email "test@users.noreply.github.com" 29 + git config --unset-all http.https://github.com/.extraheader 30 + git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/indigo-internal.git 31 + git push internal main --force
+1
.gitignore
··· 41 41 # Don't ignore this file itself, or other specific dotfiles 42 42 !.gitignore 43 43 !.github/ 44 + !.tangled/ 44 45 !.golangci.yaml 45 46 46 47 # Don't commit your (default location) creds
+17
.tangled/workflows/build.yml
··· 1 + when: 2 + - event: push 3 + branch: main 4 + 5 + dependencies: 6 + nixpkgs: 7 + - gcc 8 + - gnumake 9 + - go 10 + 11 + steps: 12 + 13 + - name: build 14 + command: make build 15 + 16 + 17 +
+16
.tangled/workflows/lint.yml
··· 1 + when: 2 + - event: push 3 + branch: main 4 + 5 + dependencies: 6 + nixpkgs: 7 + - go 8 + - gnumake 9 + - gcc 10 + 11 + steps: 12 + - name: lint 13 + command: make lint 14 + 15 + 16 +
+29
.tangled/workflows/lint_build_test.yml
··· 1 + when: 2 + - event: push 3 + branch: ["main", "ci"] 4 + 5 + dependencies: 6 + nixpkgs: 7 + - go 8 + - gnumake 9 + - gcc 10 + 11 + steps: 12 + - name: fetch deps 13 + command: go mod tidy 14 + 15 + - name: lint 16 + command: | 17 + ls 18 + echo "$GOPATH" 19 + make lint 20 + 21 + - name: build 22 + command: make build 23 + 24 + - name: test 25 + command: | 26 + make test 27 + 28 + 29 +
+16
.tangled/workflows/test.yml
··· 1 + when: 2 + - event: push 3 + branch: main 4 + 5 + dependencies: 6 + nixpkgs: 7 + - gcc 8 + - gnumake 9 + - go 10 + 11 + steps: 12 + 13 + - name: test 14 + command: make test 15 + 16 +
+1 -1
Makefile
··· 1 1 2 - SHELL = /bin/bash 2 + SHELL = bash 3 3 .SHELLFLAGS = -o pipefail -c 4 4 5 5 # base path for Lexicon document tree (for lexgen)
+2
atproto/label/label.go
··· 140 140 Cid: l.CID, 141 141 Cts: l.CreatedAt, 142 142 Exp: l.ExpiresAt, 143 + Neg: l.Negated, 143 144 Sig: []byte(l.Sig), 144 145 Src: l.SourceDID, 145 146 Uri: l.URI, ··· 157 158 CID: l.Cid, 158 159 CreatedAt: l.Cts, 159 160 ExpiresAt: l.Exp, 161 + Negated: l.Neg, 160 162 Sig: []byte(l.Sig), 161 163 SourceDID: l.Src, 162 164 URI: l.Uri,
+62
atproto/label/label_test.go
··· 4 4 "encoding/json" 5 5 "testing" 6 6 7 + comatproto "github.com/bluesky-social/indigo/api/atproto" 7 8 "github.com/bluesky-social/indigo/atproto/crypto" 8 9 9 10 "github.com/stretchr/testify/assert" ··· 89 90 assert.NoError(l.Sign(priv)) 90 91 assert.NoError(l.VerifySignature(pub)) 91 92 } 93 + 94 + func TestToLexicon(t *testing.T) { 95 + assert := assert.New(t) 96 + 97 + expiresAt := "2025-07-28T23:53:19.804Z" 98 + negated := true 99 + cid := "bafyreifxykqhed72s26cr4i64rxvrtofeqrly3j4vjzbkvo3ckkjbxjqtq" 100 + 101 + l := Label{ 102 + CID: &cid, 103 + CreatedAt: "2024-10-23T17:51:19.128Z", 104 + ExpiresAt: &expiresAt, 105 + Negated: &negated, 106 + SourceDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 107 + URI: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.actor.profile/self", 108 + Val: "good", 109 + Version: ATPROTO_LABEL_VERSION, 110 + Sig: []byte("sig"), // invalid, but we only care about the conversion 111 + } 112 + 113 + lex := l.ToLexicon() 114 + assert.Equal(l.Version, *lex.Ver) 115 + assert.Equal(l.CreatedAt, lex.Cts) 116 + assert.Equal(l.URI, lex.Uri) 117 + assert.Equal(l.Val, lex.Val) 118 + assert.Equal(l.CID, lex.Cid) 119 + assert.Equal(l.ExpiresAt, lex.Exp) 120 + assert.Equal(l.Negated, lex.Neg) 121 + assert.Equal(l.SourceDID, lex.Src) 122 + } 123 + 124 + func TestFromLexicon(t *testing.T) { 125 + assert := assert.New(t) 126 + 127 + expiresAt := "2025-07-28T23:53:19.804Z" 128 + negated := true 129 + cid := "bafyreifxykqhed72s26cr4i64rxvrtofeqrly3j4vjzbkvo3ckkjbxjqtq" 130 + version := int64(1) 131 + 132 + lex := &comatproto.LabelDefs_Label{ 133 + Cid: &cid, 134 + Cts: "2024-10-23T17:51:19.128Z", 135 + Exp: &expiresAt, 136 + Neg: &negated, 137 + Src: "did:plc:ewvi7nxzyoun6zhxrhs64oiz", 138 + Uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.actor.profile/self", 139 + Val: "good", 140 + Ver: &version, 141 + Sig: []byte("sig"), // invalid, but we only care about the conversion 142 + } 143 + 144 + l := FromLexicon(lex) 145 + assert.Equal(lex.Ver, &l.Version) 146 + assert.Equal(lex.Cts, l.CreatedAt) 147 + assert.Equal(lex.Uri, l.URI) 148 + assert.Equal(lex.Val, l.Val) 149 + assert.Equal(lex.Cid, l.CID) 150 + assert.Equal(lex.Exp, l.ExpiresAt) 151 + assert.Equal(lex.Neg, l.Negated) 152 + assert.Equal(lex.Src, l.SourceDID) 153 + }
+1
cmd/goat/account.go
··· 416 416 } 417 417 418 418 issString := cctx.String("iss") 419 + // TODO: support fragment identifiers 419 420 iss, err := syntax.ParseDID(issString) 420 421 if err != nil { 421 422 return fmt.Errorf("iss argument must be a valid DID: %w", err)
-26
docs/auth.md
··· 1 - # Auth 2 - 3 - The auth system uses two tokens, an access token and a refresh token. 4 - 5 - The access token is a jwt with the following values: 6 - ``` 7 - scope: "com.atproto.access" 8 - sub: <the users DID> 9 - iat: the current time, in unix epoch seconds 10 - exp: the expiry date, usually around an hour, but at least 15 minutes 11 - ``` 12 - 13 - The refresh token is a jwt with the following values: 14 - ``` 15 - scope: "com.atproto.refresh" 16 - sub: <the users DID> 17 - iat: the current time, in unix epoch seconds 18 - exp: the expiry date, usually around a week, must be significantly longer than the access token 19 - jti: a unique identifier for this token 20 - ``` 21 - 22 - The access token is what is used for all requests, however since it expires 23 - quickly, it must be refreshed periodically using the refresh token. 24 - When the refresh token is used, it must be marked as deleted, and the new token then replaces it. 25 - Note: The old access token is not necessarily disabled at that point of refreshing. 26 -
-37
docs/feed-proposal.md
··· 1 - # Feed Structuring Proposal 2 - 3 - Some thoughts on a new format for feeds. 4 - 5 - ## Motivation 6 - The interface for requesting and getting back feeds is something that I feel is really at the core of what bluesky offers. The user should be able to choose what feeds they subscribe to, feeds should be first class objects, they should be able to be efficiently generated and consumed, and they should be able to trustlessly come from anywhere. 7 - There are a lot of changes we *could* make to the current structure, but I don't want to stray too far from where we are at right now. 8 - 9 - 10 - ```go 11 - type Feed struct { 12 - Items []FeedItem 13 - Values map[Cid]Record 14 - ItemInfos map[Uri]ItemInfo 15 - ActorInfos map[Did]ActorInfo 16 - } 17 - 18 - type FeedItem struct { 19 - Uri string 20 - Replies []Uri 21 - ReplyTo Uri 22 - RepostedBy Did 23 - } 24 - 25 - type ItemInfo struct { 26 - Cid Cid 27 - Upvotes int 28 - Reposts int 29 - Replies int 30 - Author Did 31 - } 32 - ``` 33 - 34 - The main idea here is not repeating ourselves, while still providing all the information the client might need. 35 - With this structure too, the user could easily request *less* data, asking to 36 - skip the inclusion of records older than X, or saying they are okay with stale 37 - information in certain places for the sake of efficiency.
+1 -1
go.mod
··· 13 13 github.com/brianvoe/gofakeit/v6 v6.25.0 14 14 github.com/carlmjohnson/versioninfo v0.22.5 15 15 github.com/cockroachdb/pebble v1.1.2 16 + github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 16 17 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 17 18 github.com/flosch/pongo2/v6 v6.0.0 18 19 github.com/go-redis/cache/v9 v9.0.0 ··· 88 89 github.com/cockroachdb/redact v1.1.5 // indirect 89 90 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect 90 91 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 91 - github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 // indirect 92 92 github.com/getsentry/sentry-go v0.27.0 // indirect 93 93 github.com/go-redis/redis v6.15.9+incompatible // indirect 94 94 github.com/goccy/go-json v0.10.2 // indirect
-2
go.sum
··· 81 81 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 82 82 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 83 83 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 84 - github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797 h1:yYj4PNkUnWSh0Fhsl/pUoxMvBVaVeY6ZebkWMyGzW9k= 85 - github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= 86 84 github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88= 87 85 github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= 88 86 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE=
+1 -1
mst/mst_test.go
··· 532 532 for i := 0; i < 256; i++ { 533 533 f.Add([]byte{byte(i)}) 534 534 } 535 - rx := regexp.MustCompile("^[a-zA-Z0-9_:.-]+$") 535 + rx := regexp.MustCompile("^[a-zA-Z0-9_:.~-]+$") 536 536 f.Fuzz(func(t *testing.T, in []byte) { 537 537 s := string(in) 538 538 if a, b := rx.MatchString(s), keyHasAllValidChars(s); a != b {
+2 -2
mst/mst_util.go
··· 197 197 } 198 198 199 199 // keyHasAllValidChars reports whether s matches 200 - // the regexp /^[a-zA-Z0-9_:.-]+$/ without using regexp, 200 + // the regexp /^[a-zA-Z0-9_:.~-]+$/ without using regexp, 201 201 // which is slower. 202 202 func keyHasAllValidChars(s string) bool { 203 203 if len(s) == 0 { ··· 211 211 continue 212 212 } 213 213 switch b { 214 - case '_', ':', '.', '-': 214 + case '_', ':', '.', '~', '-': 215 215 continue 216 216 default: 217 217 return false
+135
pkg/robusthttp/client.go
··· 1 + package robusthttp 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/hashicorp/go-cleanhttp" 10 + "github.com/hashicorp/go-retryablehttp" 11 + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" 12 + ) 13 + 14 + type LeveledSlog struct { 15 + inner *slog.Logger 16 + } 17 + 18 + // re-writes HTTP client ERROR to WARN level (because of retries) 19 + func (l LeveledSlog) Error(msg string, keysAndValues ...any) { 20 + l.inner.Warn(msg, keysAndValues...) 21 + } 22 + 23 + func (l LeveledSlog) Warn(msg string, keysAndValues ...any) { 24 + l.inner.Warn(msg, keysAndValues...) 25 + } 26 + 27 + func (l LeveledSlog) Info(msg string, keysAndValues ...any) { 28 + l.inner.Info(msg, keysAndValues...) 29 + } 30 + 31 + func (l LeveledSlog) Debug(msg string, keysAndValues ...any) { 32 + l.inner.Debug(msg, keysAndValues...) 33 + } 34 + 35 + type Option func(*retryablehttp.Client) 36 + 37 + // WithMaxRetries sets the maximum number of retries for the HTTP client. 38 + func WithMaxRetries(maxRetries int) Option { 39 + return func(client *retryablehttp.Client) { 40 + client.RetryMax = maxRetries 41 + } 42 + } 43 + 44 + // WithRetryWaitMin sets the minimum wait time between retries. 45 + func WithRetryWaitMin(waitMin time.Duration) Option { 46 + return func(client *retryablehttp.Client) { 47 + client.RetryWaitMin = waitMin 48 + } 49 + } 50 + 51 + // WithRetryWaitMax sets the maximum wait time between retries. 52 + func WithRetryWaitMax(waitMax time.Duration) Option { 53 + return func(client *retryablehttp.Client) { 54 + client.RetryWaitMax = waitMax 55 + } 56 + } 57 + 58 + // WithLogger sets a custom logger for the HTTP client. 59 + func WithLogger(logger *slog.Logger) Option { 60 + return func(client *retryablehttp.Client) { 61 + client.Logger = retryablehttp.LeveledLogger(LeveledSlog{inner: logger}) 62 + } 63 + } 64 + 65 + // WithTransport sets a custom transport for the HTTP client. 66 + func WithTransport(transport http.RoundTripper) Option { 67 + return func(client *retryablehttp.Client) { 68 + client.HTTPClient.Transport = transport 69 + } 70 + } 71 + 72 + // WithRetryPolicy sets a custom retry policy for the HTTP client. 73 + func WithRetryPolicy(policy retryablehttp.CheckRetry) Option { 74 + return func(client *retryablehttp.Client) { 75 + client.CheckRetry = policy 76 + } 77 + } 78 + 79 + // Generates an HTTP client with decent general-purpose defaults around 80 + // timeouts and retries. The returned client has the stdlib http.Client 81 + // interface, but has Hashicorp retryablehttp logic internally. 82 + // 83 + // This client will retry on connection errors, 5xx status (except 501). 84 + // It will log intermediate failures with WARN level. This does not start from 85 + // http.DefaultClient. 86 + // 87 + // This should be usable for XRPC clients, and other general inter-service 88 + // client needs. CLI tools might want shorter timeouts and fewer retries by 89 + // default. 90 + func NewClient(options ...Option) *http.Client { 91 + logger := LeveledSlog{inner: slog.Default().With("subsystem", "RobustHTTPClient")} 92 + retryClient := retryablehttp.NewClient() 93 + retryClient.HTTPClient.Transport = otelhttp.NewTransport(cleanhttp.DefaultPooledTransport()) 94 + retryClient.RetryMax = 3 95 + retryClient.RetryWaitMin = 1 * time.Second 96 + retryClient.RetryWaitMax = 10 * time.Second 97 + retryClient.Logger = retryablehttp.LeveledLogger(logger) 98 + retryClient.CheckRetry = DefaultRetryPolicy 99 + 100 + for _, option := range options { 101 + option(retryClient) 102 + } 103 + 104 + client := retryClient.StandardClient() 105 + client.Timeout = 30 * time.Second 106 + return client 107 + } 108 + 109 + // For use in local integration tests. Short timeouts, no retries, etc 110 + func TestingHTTPClient() *http.Client { 111 + 112 + client := http.DefaultClient 113 + client.Timeout = 1 * time.Second 114 + return client 115 + } 116 + 117 + // DefaultRetryPolicy is a custom wrapper around retryablehttp.DefaultRetryPolicy. 118 + // It treats `429 Too Many Requests` as non-retryable, so the application can decide 119 + // how to deal with rate-limiting. 120 + func DefaultRetryPolicy(ctx context.Context, resp *http.Response, err error) (bool, error) { 121 + if err == nil && resp.StatusCode == http.StatusTooManyRequests { 122 + return false, nil 123 + } 124 + return retryablehttp.DefaultRetryPolicy(ctx, resp, err) 125 + } 126 + 127 + func NoInternalServerErrorPolicy(ctx context.Context, resp *http.Response, err error) (bool, error) { 128 + if err == nil && resp.StatusCode == http.StatusTooManyRequests { 129 + return false, nil 130 + } 131 + if err == nil && resp.StatusCode == http.StatusInternalServerError { 132 + return false, nil 133 + } 134 + return retryablehttp.DefaultRetryPolicy(ctx, resp, err) 135 + }