Let admins pick the branch for a manual pipeline run
The Pipelines page now has a branch picker next to the Run pipeline button. Changing the branch reloads the page. The variable fields and the disabled-button reason then come from that branch's config. The run form sends the branch in a hidden field. The server accepts only names from the branch list. Unknown names, SHAs and expressions like HEAD~1 get a 400. Without a branch field, the run still builds the default branch.
MCI.md
@@ -9,6 +9,8 @@ reference, and a link to the full [template](public/assets/hearthforge-ci-templa
1. A push, a tag, or the **Run pipeline** button creates a run. 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.
2. Hearthforge pulls `image`, creates the cache volumes, and starts one
container named `hearthforge-ci-<run id>` with `work_dir` as its working
directory. Files listed under `[[copy]]` are taken from their source images
Minternal/web/ci.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
@@ -69,15 +70,19 @@ type ciHeadState struct {
Problem string
}
// ciHeadState reads .hearthforge-ci.toml at the tip of the default branch.
func (s *Server) ciHeadState(ctx context.Context, repo *db.Repo) ciHeadState {
// ciHeadState reads .hearthforge-ci.toml at the tip of branch. An empty branch
// means the default branch.
func (s *Server) ciHeadState(ctx context.Context, repo *db.Repo, branch string) ciHeadState {
git := s.Git
branch := repo.DefaultBranch
branches, err := git.Branches(ctx, repo.Name)
if branch != "" && (err != nil || !slices.Contains(branches, branch)) {
return ciHeadState{Problem: "unknown_branch"}
}
if branch == "" {
branches, err := git.Branches(ctx, repo.Name)
if err == nil && len(branches) > 0 {
branch = branches[0]
}
branch = repo.DefaultBranch
}
if branch == "" && len(branches) > 0 {
branch = branches[0]
}
if branch == "" {
return ciHeadState{Problem: "no_branches"}
@@ -98,16 +103,18 @@ func (s *Server) ciHeadState(ctx context.Context, repo *db.Repo) ciHeadState {
// ciHistoryReasons explains a disabled manual trigger button.
var ciHistoryReasons = map[string]string{
"no_branches": "No branches — push a commit first",
"no_commits": "No commits yet",
"no_toml": "No .hearthforge-ci.toml found in repository",
"bad_toml": "Failed to parse .hearthforge-ci.toml",
"unknown_branch": "Unknown branch",
"no_branches": "No branches — push a commit first",
"no_commits": "No commits yet",
"no_toml": "No .hearthforge-ci.toml found in repository",
"bad_toml": "Failed to parse .hearthforge-ci.toml",
}
// ciTriggerErrors are the messages the manual trigger POST answers with.
var ciTriggerErrors = map[string]string{
"no_branches": "No branches",
"no_commits": "No commits",
"unknown_branch": "Unknown branch",
"no_branches": "No branches",
"no_commits": "No commits",
"no_toml": "No .hearthforge-ci.toml found at HEAD. Add one to your repository to " +
"use CI pipelines.",
"bad_toml": "Failed to parse .hearthforge-ci.toml. Check the file for syntax errors.",
@@ -166,19 +173,29 @@ func (s *Server) ciHistory(w http.ResponseWriter, r *http.Request) {
// Only an admin sees the manual trigger, so only they need the config.
var reason string
var variables []views.CiVariable
var branches []string
q := r.URL.Query()
branch := q.Get("branch")
if u := User(r); u != nil && u.IsAdmin {
state := s.ciHeadState(ctx, repo)
state := s.ciHeadState(ctx, repo, branch)
reason = ciHistoryReasons[state.Problem]
variables = ciVariables(state.Cfg)
if state.Branch != "" {
branch = state.Branch
}
branches, _ = s.Git.Branches(ctx, repo.Name)
}
q := r.URL.Query()
views.Render(w, http.StatusOK, views.CiHistory(s.Cfg, User(r), repo, summaries,
views.PageInfo{
Page: page.Page, TotalPages: page.TotalPages,
URLTemplate: "/" + repo.Name + "/ci?page={page}",
},
reason, variables, q.Get("success"), q.Get("error")))
views.CiTrigger{
DisabledReason: reason, Variables: variables,
Branches: branches, Branch: branch,
},
q.Get("success"), q.Get("error")))
}
func (s *Server) ciRunDetail(w http.ResponseWriter, r *http.Request) {
@@ -219,7 +236,7 @@ func (s *Server) ciTrigger(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
state := s.ciHeadState(r.Context(), repo)
state := s.ciHeadState(r.Context(), repo, r.Form.Get("branch"))
if state.Problem != "" {
http.Error(w, ciTriggerErrors[state.Problem], http.StatusBadRequest)
return
Minternal/web/e2e/ci_test.go
@@ -81,9 +81,9 @@ func ciSeedToml(e *env, toml string) string {
return gitRun(t, work, "rev-parse", "HEAD")
}
// ciTrigger posts the manual trigger and returns the new run id. The route
// always builds HEAD of the default branch, so the expected sha is checked
// and a stale fixture fails loudly.
// ciTrigger posts the manual trigger and returns the new run id. Without a
// branch field the route builds HEAD of the default branch, so the expected
// sha is checked and a stale fixture fails loudly.
func ciTrigger(e *env, admin *session, sha string, overrides url.Values) int64 {
t := e.t
t.Helper()
@@ -112,7 +112,7 @@ func ciLatestRunID(e *env) int64 {
}
// ciPushRun pushes a commit to a branch over HTTP and returns the run the
// push trigger created. The manual route always builds the default branch.
// push trigger created.
func ciPushRun(e *env, sha, branch string) int64 {
t := e.t
t.Helper()
@@ -333,6 +333,81 @@ run_sh = "echo $GREETING"
ciWaitForRun(e, id)
})
t.Run("the run form builds the selected branch with its own config", func(t *testing.T) {
mainSHA := ciSeedToml(e, ciSimpleTOML)
work := t.TempDir()
gitRun(t, work, "clone", "-q", e.repoPath("ci-repo"), ".")
if err := os.WriteFile(filepath.Join(work, ".hearthforge-ci.toml"), []byte(`
image = "debian:latest"
[variables]
[variables.FEATURE_ONLY]
default = "on"
[[steps]]
name = "say"
run_sh = "echo $FEATURE_ONLY"
`), 0o644); err != nil {
t.Fatal(err)
}
gitRun(t, work, "commit", "-q", "-am", "Feature CI config")
gitRun(t, work, "push", "-q", "origin", "HEAD:refs/heads/feature")
e.Srv.Git.InvalidateRefCache("ci-repo")
featureSHA := gitRun(t, work, "rev-parse", "HEAD")
defer func() {
gitRun(t, e.repoPath("ci-repo"), "branch", "-D", "feature")
e.Srv.Git.InvalidateRefCache("ci-repo")
}()
r := admin.get("/ci-repo/ci").mustStatus(200)
if got := r.Value(`input[name="branch"]`); got != "main" {
t.Errorf("default branch field = %q", got)
}
if r.Has(`input[name="var_FEATURE_ONLY"]`) {
t.Error("main shows the feature branch variable")
}
r = admin.get("/ci-repo/ci?branch=feature").mustStatus(200)
if got := r.Value(`input[name="branch"]`); got != "feature" {
t.Errorf("branch field = %q", got)
}
if got := r.Value(`select[name="branch"]`); got != "feature" {
t.Errorf("selected branch = %q", got)
}
if got := r.Value(`input[name="var_FEATURE_ONLY"]`); got != "on" {
t.Errorf("var_FEATURE_ONLY default = %q", got)
}
m.reset()
m.queueExec(execResp{output: "off\n"})
loc := admin.post("/ci-repo/ci/run", url.Values{
"branch": {"feature"}, "var_FEATURE_ONLY": {"off"},
}).mustRedirect("/ci-repo/ci/")
runID, err := strconv.ParseInt(idFromPath(t, loc), 10, 64)
if err != nil {
t.Fatal(err)
}
ciWaitForRun(e, runID)
var sha, branch string
if err := e.DB.QueryRowContext(context.Background(),
`SELECT commit_sha, commit_branch FROM ci_runs WHERE id = ?`, runID).
Scan(&sha, &branch); err != nil {
t.Fatal(err)
}
if sha != featureSHA || sha == mainSHA || branch != "feature" {
t.Errorf("run built %s on %q, want %s on feature", sha, branch, featureSHA)
}
if got := ciOverrides(e, runID); got["FEATURE_ONLY"] != "off" {
t.Errorf("overrides = %v", got)
}
for _, b := range []string{"nope", mainSHA, "HEAD~1"} {
r := admin.post("/ci-repo/ci/run", url.Values{"branch": {b}})
if r.Code != http.StatusBadRequest {
t.Errorf("branch %q: status = %d", b, r.Code)
}
}
})
t.Run("help section is collapsible and contains template download", func(t *testing.T) {
r := admin.get("/ci-repo/ci").mustStatus(200)
if !r.Has("details.ci-help") {
Minternal/web/views/ci.go
@@ -42,6 +42,14 @@ type CiVariable struct {
Description string
}
// CiTrigger is what the manual trigger shows for the selected branch.
type CiTrigger struct {
DisabledReason string
Variables []CiVariable
Branches []string
Branch string
}
// CiRunSummary is one row of the history list.
type CiRunSummary struct {
Run db.CiRun
@@ -184,8 +192,10 @@ func ciHelp(repo *db.Repo) g.Node {
// plain submit button. With variables it opens a panel of inputs seeded with
// their defaults, so a field left untouched sends the default back and counts
// as no override.
func ciRunPipeline(repo *db.Repo, disabledReason string, variables []CiVariable) g.Node {
func ciRunPipeline(repo *db.Repo, trigger CiTrigger) g.Node {
disabledReason, variables := trigger.DisabledReason, trigger.Variables
form := Form(Method("POST"), Action("/"+repo.Name+"/ci/run"),
Input(Type("hidden"), Name("branch"), Value(trigger.Branch)),
g.If(len(variables) > 0, Div(Class("ci-run-vars"),
g.Map(variables, func(v CiVariable) g.Node {
return Label(Class("form-group ci-run-var"),
@@ -213,11 +223,28 @@ func ciRunPipeline(repo *db.Repo, disabledReason string, variables []CiVariable)
)
}
// ciBranchPicker reloads the page for another branch, so the trigger shows
// that branch's variables and config problems.
func ciBranchPicker(repo *db.Repo, trigger CiTrigger) g.Node {
if len(trigger.Branches) == 0 {
return nil
}
return Form(Method("GET"), Action("/"+repo.Name+"/ci"), Class("branch-selector"),
Span(Class("branch-selector-icon"), g.Text("⎇")),
Select(Name("branch"), Class("branch-select"), g.Attr("data-autosubmit"),
Title("Branch for the manual run"),
g.Map(trigger.Branches, func(b string) g.Node {
return Option(Value(b), g.If(b == trigger.Branch, Selected()), g.Text(b))
}),
),
NoScript(Button(Type("submit"), Class("btn btn-sm"), g.Text("Go"))),
)
}
// CiHistory renders the Pipelines tab: the run list, the manual trigger and
// the pipeline file tutorial.
func CiHistory(cfg *config.Config, user *db.SessionUser, repo *db.Repo, runs []CiRunSummary,
page PageInfo, manualTriggerDisabledReason string, variables []CiVariable,
success, errMsg string,
page PageInfo, trigger CiTrigger, success, errMsg string,
) g.Node {
isRunning := false
for _, r := range runs {
@@ -242,7 +269,8 @@ func CiHistory(cfg *config.Config, user *db.SessionUser, repo *db.Repo, runs []C
Title("Delete all Docker cache volumes for this repository"),
g.Text("Purge caches")),
),
ciRunPipeline(repo, manualTriggerDisabledReason, variables),
ciBranchPicker(repo, trigger),
ciRunPipeline(repo, trigger),
)),
),
g.If(len(runs) == 0, Div(Class("empty-state"),