-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgithub.go
More file actions
191 lines (176 loc) · 5.83 KB
/
Copy pathgithub.go
File metadata and controls
191 lines (176 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
package main
// The GitHub App's side: sign a JWT, find the App's installation on the org, mint an
// installation token, dispatch a workflow. No SDK, three requests.
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
)
// backoff is the wait before each retry of a transient failure: three retries, 70 s in all.
// A retry whose wait would pass the context's deadline is not made.
var backoff = []time.Duration{10 * time.Second, 20 * time.Second, 40 * time.Second}
// StatusError is GitHub answering with something other than 2xx.
type StatusError struct {
Status int
Msg string
}
func (e *StatusError) Error() string { return e.Msg }
// retryable is a failure another attempt may clear. Any other 4xx is a refusal no retry can
// change: a missing workflow file, an App without the permission, no installation.
//
// A request that starts a run (safe false) is retried only when GitHub cannot have acted on
// it: the connection never opened, or GitHub turned it away unread (408, 429). A 5xx or a
// timeout can follow a dispatch GitHub already accepted, and a retry would be a second run.
func retryable(err error, safe bool) bool {
var s *StatusError
if errors.As(err, &s) {
return s.Status == 408 || s.Status == 429 || s.Status >= 500 && safe
}
var op *net.OpError
return safe || errors.As(err, &op) && op.Op == "dial"
}
// ParseKey reads the App's private key. GitHub issues PKCS#1; PKCS#8 is accepted too, so a
// converted key still works.
func ParseKey(data []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("private key: no PEM block")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("private key: neither PKCS#1 nor PKCS#8: %w", err)
}
rk, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key: %T, not RSA", k)
}
return rk, nil
}
// AppJWT signs a short-lived App JWT. Backdated 60 s because GitHub rejects a future iat,
// and 9 minutes long against GitHub's 10-minute ceiling.
func AppJWT(appID string, key *rsa.PrivateKey, now time.Time) (string, error) {
enc := base64.RawURLEncoding
iat := now.Unix() - 60
claims, err := json.Marshal(map[string]any{"iat": iat, "exp": iat + 540, "iss": appID})
if err != nil {
return "", err
}
signed := enc.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + enc.EncodeToString(claims)
sum := sha256.Sum256([]byte(signed))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
if err != nil {
return "", err
}
return signed + "." + enc.EncodeToString(sig), nil
}
// GitHub dispatches workflows as the App. Prepare mints one installation token and keeps it
// in memory only.
type GitHub struct {
API string // https://api.github.com
AppID string
Org string // the one installation this App has
Key *rsa.PrivateKey
HTTP *http.Client
Sleep func(time.Duration)
Now func() time.Time
token string
}
// Prepare mints the installation token, once per process.
func (g *GitHub) Prepare(ctx context.Context) error {
if g.token != "" {
return nil
}
tok, err := g.mint(ctx)
if err != nil {
return fmt.Errorf("installation token: %w", err)
}
g.token = tok
return nil
}
func (g *GitHub) Dispatch(ctx context.Context, repo, file string) error {
if err := g.Prepare(ctx); err != nil {
return err
}
_, err := g.call(ctx, "POST", "/repos/"+repo+"/actions/workflows/"+file+"/dispatches", g.token,
[]byte(`{"ref":"main","inputs":{}}`), false)
return err
}
func (g *GitHub) mint(ctx context.Context) (string, error) {
jwt, err := AppJWT(g.AppID, g.Key, g.Now())
if err != nil {
return "", err
}
b, err := g.call(ctx, "GET", "/orgs/"+g.Org+"/installation", jwt, nil, true)
if err != nil {
return "", err
}
var inst struct{ ID int64 }
if err := json.Unmarshal(b, &inst); err != nil || inst.ID == 0 {
return "", fmt.Errorf("installation for %s: no id in %.200s", g.Org, b)
}
b, err = g.call(ctx, "POST", fmt.Sprintf("/app/installations/%d/access_tokens", inst.ID), jwt, nil, true)
if err != nil {
return "", err
}
var tok struct{ Token string }
if err := json.Unmarshal(b, &tok); err != nil || tok.Token == "" {
return "", errors.New("access token: none in the response")
}
return tok.Token, nil
}
// call sends one request, retrying transient failures after each backoff step. safe says a
// repeat has no effect beyond the first; see retryable.
func (g *GitHub) call(ctx context.Context, method, path, auth string, body []byte, safe bool) ([]byte, error) {
for attempt := 0; ; attempt++ {
b, err := g.once(ctx, method, path, auth, body)
if err == nil || !retryable(err, safe) || attempt == len(backoff) || ctx.Err() != nil {
return b, err
}
if dl, ok := ctx.Deadline(); ok && g.Now().Add(backoff[attempt]).After(dl) {
return b, err
}
g.Sleep(backoff[attempt])
}
}
func (g *GitHub) once(ctx context.Context, method, path, auth string, body []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, g.API+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+auth)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "katoptra-dispatch")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := g.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, err
}
if res.StatusCode/100 != 2 {
return nil, &StatusError{res.StatusCode, fmt.Sprintf("%s %s: %d %.500s", method, path, res.StatusCode, b)}
}
return b, nil
}