Trigger CI for branch and tag changes made in the web UI

Only git push over HTTP or SSH started pipelines. Tags created on the
Tags page or the Releases form never ran CI. Web file edits, file
deletes, patch merges, and branch create or rename did not either.

gitcmd.Git gets an OnRefUpdate hook. updateRef and CreateTag call it
after a successful write. main connects the hook to the new
Runner.TriggerForRef. TriggerForPush now calls TriggerForRef for each
pushed ref.

TriggerForRef resolves the revision to its commit. A run from an
annotated tag now stores the commit sha, not the tag object sha.
AuthorKonata <konata@posteo.jp>
Date
Commite6b7e07459a961f1abba6f6207b8d183514ee710
Parent392ef01
7 files changed, 132 insertions(+), 37 deletions(-)
▾MCI.md
@@ -7,7 +7,8 @@ reference, and a link to the full [template](web/static/assets/hearthforge-ci-te
## How a run works
1. A push, a tag, or the **Run pipeline** button creates a run. The config is
1. A push, a tag, or the **Run pipeline** button creates a run. Branches and
tags changed in the web UI count as pushes. The config is
read from the pushed commit, so every branch can carry its own pipeline.
The branch picker next to the button selects the branch a manual run
builds. The form fields come from that branch's config.
▾Mcmd/hearthforge/main.go
@@ -109,6 +109,9 @@ func main() {
Patches: gitcmd.NewPatchCache(),
}
runner.ImportImage = srv.ImportImage
git.OnRefUpdate = func(repo, ref, rev string) {
go runner.TriggerForRef(context.Background(), repo, ref, rev)
}
if err := srv.SyncRepos(ctx); err != nil {
log.Fatalf("repo sync: %v", err)
}
▾Minternal/ci/push.go
@@ -3,8 +3,11 @@ package ci
import (
"context"
"log"
"os/exec"
"strconv"
"strings"
"hearthforge/internal/gitcmd"
)
// PreambleMax is how much of a pushed stream a transport keeps so
@@ -77,36 +80,52 @@ func parsePktLineRefUpdates(text string) []refUpdate {
// receive-pack stream. Both git transports call this.
func (r *Runner) TriggerForPush(ctx context.Context, repoName string, preamble []byte) {
for _, ref := range parsePktLineRefUpdates(string(preamble)) {
// A delete pushes the all-zero sha; there is nothing to build.
if ref.NewSha == "" || strings.Trim(ref.NewSha, "0") == "" {
continue
}
isBranch := strings.HasPrefix(ref.Refname, "refs/heads/")
isTag := strings.HasPrefix(ref.Refname, "refs/tags/")
if !isBranch && !isTag {
continue
}
cfg, err := r.ConfigAt(ctx, repoName, ref.NewSha)
if err != nil || cfg == nil {
continue
}
opts := TriggerOpts{CommitSha: ref.NewSha}
switch {
case isBranch:
branch := strings.TrimPrefix(ref.Refname, "refs/heads/")
if !shouldTriggerPush(cfg, branch) {
continue
}
opts.TriggerSource = "push"
opts.CommitBranch = branch
case cfg.On.Tag:
opts.TriggerSource = "tag"
opts.CommitTag = strings.TrimPrefix(ref.Refname, "refs/tags/")
default:
continue
}
if _, err := r.TriggerRun(ctx, repoName, opts); err != nil {
log.Printf("CI %s trigger failed for %s: %v", opts.TriggerSource, repoName, err)
r.TriggerForRef(ctx, repoName, ref.Refname, ref.NewSha)
}
}
// TriggerForRef starts a run for one updated branch or tag when its config
// asks for it. rev is any revision that names the new target. Pushes and web
// UI ref writes both call this.
func (r *Runner) TriggerForRef(ctx context.Context, repoName, refname, rev string) {
// A delete pushes the all-zero sha; there is nothing to build.
if rev == "" || strings.Trim(rev, "0") == "" {
return
}
isBranch := strings.HasPrefix(refname, "refs/heads/")
isTag := strings.HasPrefix(refname, "refs/tags/")
if !isBranch && !isTag {
return
}
// An annotated tag points at a tag object. CI_COMMIT_SHA must name the commit.
cmd := exec.CommandContext(ctx, "git", "-C", r.repoPath(repoName), "rev-parse",
"--verify", "--end-of-options", rev+"^{commit}")
cmd.Env = gitcmd.Env()
out, err := cmd.Output()
if err != nil {
return
}
sha := strings.TrimSpace(string(out))
cfg, err := r.ConfigAt(ctx, repoName, sha)
if err != nil || cfg == nil {
return
}
opts := TriggerOpts{CommitSha: sha}
switch {
case isBranch:
branch := strings.TrimPrefix(refname, "refs/heads/")
if !shouldTriggerPush(cfg, branch) {
return
}
opts.TriggerSource = "push"
opts.CommitBranch = branch
case cfg.On.Tag:
opts.TriggerSource = "tag"
opts.CommitTag = strings.TrimPrefix(refname, "refs/tags/")
default:
return
}
if _, err := r.TriggerRun(ctx, repoName, opts); err != nil {
log.Printf("CI %s trigger failed for %s: %v", opts.TriggerSource, repoName, err)
}
}
▾Minternal/gitcmd/gitcmd.go
@@ -91,6 +91,11 @@ type Git struct {
tags *util.Cache[string, []string]
archiveSem chan struct{}
// OnRefUpdate runs after a write op moves a branch or tag. rev is any
// revision that names the new target. It must not block: the repo lock
// is still held.
OnRefUpdate func(repo, ref, rev string)
}
func New(cfg *config.Config) *Git {
▾Minternal/gitcmd/write.go
@@ -107,14 +107,23 @@ func (g *Git) commitTree(ctx context.Context, p, tree, parent, msg string, autho
// updateRef moves ref to sha. oldSHA is the value the caller read before it
// built the new commit; git refuses the update when the ref moved since then.
// An empty oldSHA means the ref must not exist yet.
func (g *Git) updateRef(ctx context.Context, p, ref, sha, oldSHA string) error {
func (g *Git) updateRef(ctx context.Context, name, p, ref, sha, oldSHA string) error {
_, err := g.run(ctx, runOpts{}, "-C", p, "update-ref", ref, sha, oldSHA)
if err != nil && isRefRaceError(err) {
return fmt.Errorf("%q: %w", ref, ErrRefChanged)
}
if err == nil {
g.refUpdated(name, ref, sha)
}
return err
}
func (g *Git) refUpdated(name, ref, rev string) {
if g.OnRefUpdate != nil {
g.OnRefUpdate(name, ref, rev)
}
}
// isRefRaceError recognises the messages git prints when the old value did
// not match, which means someone else moved the ref first.
func isRefRaceError(err error) bool {
@@ -211,7 +220,7 @@ func (g *Git) ApplyPatch(ctx context.Context, name, patch string, author, commit
if err != nil {
return err
}
return g.updateRef(ctx, p, ref, sha, parent)
return g.updateRef(ctx, name, p, ref, sha, parent)
})
return sha, err
}
@@ -291,7 +300,7 @@ func (g *Git) EditFile(ctx context.Context, name, branch, base, oldPath, newPath
if err != nil {
return err
}
return g.updateRef(ctx, p, branchRef, sha, parent)
return g.updateRef(ctx, name, p, branchRef, sha, parent)
})
})
return sha, err
@@ -360,7 +369,7 @@ func (g *Git) DeleteFile(ctx context.Context, name, branch, base, filePath, mess
if err != nil {
return err
}
return g.updateRef(ctx, p, branchRef, sha, parent)
return g.updateRef(ctx, name, p, branchRef, sha, parent)
})
})
return sha, err
@@ -381,7 +390,7 @@ func (g *Git) CreateBranch(ctx context.Context, name, branch, sourceRef string)
return fmt.Errorf("branch %q: %w", branch, ErrExists)
}
defer g.InvalidateRefCache(name)
return g.updateRef(ctx, p, "refs/heads/"+branch, sha, "")
return g.updateRef(ctx, name, p, "refs/heads/"+branch, sha, "")
})
}
@@ -416,7 +425,7 @@ func (g *Git) RenameBranch(ctx context.Context, name, oldName, newName string) e
return fmt.Errorf("branch %q: %w", newName, ErrExists)
}
defer g.InvalidateRefCache(name)
if err := g.updateRef(ctx, p, "refs/heads/"+newName, sha, ""); err != nil {
if err := g.updateRef(ctx, name, p, "refs/heads/"+newName, sha, ""); err != nil {
return err
}
_, err = g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", oldRef)
@@ -448,6 +457,7 @@ func (g *Git) CreateTag(ctx context.Context, name, tagName, ref, message string,
return asBadRef(ref, err)
}
g.InvalidateRefCache(name)
g.refUpdated(name, "refs/tags/"+tagName, "refs/tags/"+tagName)
return nil
})
}
▾Minternal/web/e2e/ci_test.go
@@ -1875,3 +1875,57 @@ func TestCIArchivesBuiltOnHost(t *testing.T) {
}
})
}
// ── web UI ref writes ────────────────────────────────────────────────────
// ciRunAfter waits for a run newer than before and returns its trigger
// source, commit, branch and tag.
func ciRunAfter(e *env, before int64) (source, commit, branch, tag string) {
t := e.t
t.Helper()
var id int64
ciWaitFor(t, "a new run", func() bool {
id = ciLatestRunID(e)
return id > before
})
var b, tg sql.NullString
if err := e.DB.QueryRowContext(context.Background(),
`SELECT trigger_source, commit_sha, commit_branch, commit_tag FROM ci_runs WHERE id = ?`, id).
Scan(&source, &commit, &b, &tg); err != nil {
t.Fatal(err)
}
return source, commit, b.String, tg.String
}
func TestCIWebRefWrites(t *testing.T) {
e, _, admin := ciEnv(t)
sha := ciSeedToml(e, strings.Replace(ciSimpleTOML, `push = ["main"]`, `push = ["main"]
tag = true`, 1))
for _, tc := range []struct{ name, message string }{
{"v1-light", ""},
{"v1-annotated", "Release"},
} {
t.Run("tag "+tc.name, func(t *testing.T) {
before := ciLatestRunID(e)
admin.post("/ci-repo/tags/create", url.Values{
"name": {tc.name}, "ref": {"main"}, "message": {tc.message},
}).mustRedirect("/ci-repo/tags")
source, commit, _, tag := ciRunAfter(e, before)
if source != "tag" || commit != sha || tag != tc.name {
t.Errorf("run = (%q, %q, %q), want (tag, %q, %q)", source, commit, tag, sha, tc.name)
}
})
}
t.Run("file created on main", func(t *testing.T) {
before := ciLatestRunID(e)
admin.post("/ci-repo/new-file/main", url.Values{
"path": {"web.txt"}, "content": {"x"},
}).mustRedirect("/ci-repo/commit/")
source, commit, branch, _ := ciRunAfter(e, before)
if source != "push" || commit != e.headCommit("ci-repo") || branch != "main" {
t.Errorf("run = (%q, %q, %q), want (push, HEAD, main)", source, commit, branch)
}
})
}
▾Minternal/web/e2e/harness_test.go
@@ -95,6 +95,9 @@ func newEnv(t *testing.T, extraEnv ...string) *env {
Patches: gitcmd.NewPatchCache(),
}
runner.ImportImage = srv.ImportImage
git.OnRefUpdate = func(repo, ref, rev string) {
go runner.TriggerForRef(context.Background(), repo, ref, rev)
}
if err := srv.SyncRepos(ctx); err != nil {
t.Fatal(err)
}