push.go
⎇
Raw
1package ci
2
3import (
4 "context"
5 "log"
6 "os/exec"
7 "strconv"
8 "strings"
9
10 "hearthforge/internal/gitcmd"
11)
12
13// PreambleMax is how much of a pushed stream a transport keeps so
14// TriggerForPush can read the ref updates. The pkt-line ref list always fits.
15const PreambleMax = 4096
16
17// CapWriter keeps only the first Limit bytes written to it. The git transports
18// tee the push stream through it.
19type CapWriter struct {
20 Limit int
21 buf []byte
22}
23
24func (w *CapWriter) Write(p []byte) (int, error) {
25 if n := w.Limit - len(w.buf); n > 0 {
26 if n > len(p) {
27 n = len(p)
28 }
29 w.buf = append(w.buf, p[:n]...)
30 }
31 return len(p), nil
32}
33
34// Bytes returns the captured prefix. A nil CapWriter returns nothing, so a
35// transport can pass one it never allocated.
36func (w *CapWriter) Bytes() []byte {
37 if w == nil {
38 return nil
39 }
40 return w.buf
41}
42
43// refUpdate is one "old new refname" line of a receive-pack request.
44type refUpdate struct {
45 OldSha string
46 NewSha string
47 Refname string
48}
49
50// parsePktLineRefUpdates reads the ref updates from the start of a
51// receive-pack stream. It stops at the flush packet or at truncated input,
52// so it works on the first few kilobytes alone.
53func parsePktLineRefUpdates(text string) []refUpdate {
54 var refs []refUpdate
55 for pos := 0; pos+4 <= len(text); {
56 length, err := strconv.ParseUint(text[pos:pos+4], 16, 32)
57 if err != nil || length == 0 {
58 break
59 }
60 if length < 4 || pos+int(length) > len(text) {
61 break
62 }
63 line := text[pos+4 : pos+int(length)]
64 // Capabilities follow a NUL byte on the first line.
65 if i := strings.IndexByte(line, 0); i >= 0 {
66 line = line[:i]
67 }
68 pos += int(length)
69
70 parts := strings.Split(strings.TrimSpace(line), " ")
71 if len(parts) >= 3 && parts[2] != "" {
72 refs = append(refs, refUpdate{OldSha: parts[0], NewSha: parts[1], Refname: parts[2]})
73 }
74 }
75 return refs
76}
77
78// TriggerForPush starts a run for every pushed branch or tag whose config at
79// the new commit asks for it. preamble is the captured start of the
80// receive-pack stream. Both git transports call this.
81func (r *Runner) TriggerForPush(ctx context.Context, repoName string, preamble []byte) {
82 for _, ref := range parsePktLineRefUpdates(string(preamble)) {
83 r.TriggerForRef(ctx, repoName, ref.Refname, ref.NewSha)
84 }
85}
86
87// TriggerForRef starts a run for one updated branch or tag when its config
88// asks for it. rev is any revision that names the new target. Pushes and web
89// UI ref writes both call this.
90func (r *Runner) TriggerForRef(ctx context.Context, repoName, refname, rev string) {
91 // A delete pushes the all-zero sha; there is nothing to build.
92 if rev == "" || strings.Trim(rev, "0") == "" {
93 return
94 }
95 isBranch := strings.HasPrefix(refname, "refs/heads/")
96 isTag := strings.HasPrefix(refname, "refs/tags/")
97 if !isBranch && !isTag {
98 return
99 }
100 // An annotated tag points at a tag object. CI_COMMIT_SHA must name the commit.
101 cmd := exec.CommandContext(ctx, "git", "-C", r.repoPath(repoName), "rev-parse",
102 "--verify", "--end-of-options", rev+"^{commit}")
103 cmd.Env = gitcmd.Env()
104 out, err := cmd.Output()
105 if err != nil {
106 return
107 }
108 sha := strings.TrimSpace(string(out))
109 cfg, err := r.ConfigAt(ctx, repoName, sha)
110 if err != nil || cfg == nil {
111 return
112 }
113 opts := TriggerOpts{CommitSha: sha}
114 switch {
115 case isBranch:
116 branch := strings.TrimPrefix(refname, "refs/heads/")
117 if !shouldTriggerPush(cfg, branch) {
118 return
119 }
120 opts.TriggerSource = "push"
121 opts.CommitBranch = branch
122 case cfg.On.Tag:
123 opts.TriggerSource = "tag"
124 opts.CommitTag = strings.TrimPrefix(refname, "refs/tags/")
125 default:
126 return
127 }
128 if _, err := r.TriggerRun(ctx, repoName, opts); err != nil {
129 log.Printf("CI %s trigger failed for %s: %v", opts.TriggerSource, repoName, err)
130 }
131}
132