Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 7 slice 7: the build deploys the site

88e980b0e88b24a20605ceeb9e42fd0dacee7842 · Keenan Nemetz · 17d ago · parent 213f4d6d

Phase 7 slice 7: the build deploys the site

pages: {path: site} on a job publishes that directory as the site branch, using
the same snapshot code `vole pages deploy` uses — a site published by a workflow
and one published by a person have to be the same commit shape, or the version
that drifts is the one nobody runs by hand.

- workflow: pages.path validated at push — one relative directory, no glob (a
  site is one tree), and only one job per workflow may declare it, since two
  publishers race and the winner is whichever runner finished last
- the directory is pinned into the queued run beside the steps and the artifact
  patterns; the branch comes from the server (pages_ref), which a workflow
  cannot know
- runner: after the artifacts, checks that the run's SHA is still the tip of its
  branch and refuses otherwise. Deploy is a snapshot, so a stale build finishing
  late would silently revert the site; the failure names both commits
- the site commit says where it came from: "site: main from run <id> (<sha>)"

Fixed on the way, caught by the end-to-end test: the shared push helper treated a
URL as a missing *named* remote, so a runner's deploy committed locally and
reported success while the site 404'd. A URL is now a remote in its own right.

Measured through the binary: two pushes queued two runs; the older one was
refused ("this run built f1f415ec but main is at 6beaa530 now; the site was not
deployed") and published nothing; the newer one deployed and the site host served
the new tree, with `git log pages` showing
"site: main from run 01M5MSC3... (6beaa530)".

internal/workflow/workflow.go +43−2

@@ -62,9 +62,20 @@ type Artifact struct {
62 62 }
63 63
64 64 type Job struct {
65 ID string
66 Steps []Step
65 ID string
66 Steps []Step
67 // Artifacts are the files this job publishes as the run's outputs.
67 68 Artifacts []Artifact
69 // Pages, when set, publishes a directory as the repository's site. It is on the
70 // job because a site is a build, and the job is the thing that builds.
71 Pages *Pages
72 }
73
74 // Pages declares a site deploy: one directory, published as the site branch's tree.
75 // A directory rather than a glob, because "which tree is the site" has to have one
76 // answer.
77 type Pages struct {
78 Path string
68 79 }
69 80
70 81 type Workflow struct {
@@ -97,9 +108,14 @@ type rawArtifact struct {
97 108 Path string `yaml:"path"`
98 109 }
99 110
111 type rawPages struct {
112 Path string `yaml:"path"`
113 }
114
100 115 type rawJob struct {
101 116 Steps []rawStep `yaml:"steps"`
102 117 Artifacts []rawArtifact `yaml:"artifacts"`
118 Pages *rawPages `yaml:"pages"`
103 119 }
104 120
105 121 type rawWorkflow struct {
@@ -160,6 +176,9 @@ func Parse(file string, content string) (*Workflow, []string) {
160 176 bad("%d jobs, limit %d", len(rw.Jobs), MaxJobs)
161 177 }
162 178
179 // One site, one publisher: a second job declaring pages would race the first,
180 // and the winner would depend on which runner finished last.
181 pagesJobs := 0
163 182 ids := make([]string, 0, len(rw.Jobs))
164 183 for id := range rw.Jobs {
165 184 ids = append(ids, id)
@@ -240,6 +259,28 @@ func Parse(file string, content string) (*Workflow, []string) {
240 259 }
241 260 job.Artifacts = append(job.Artifacts, Artifact{Path: p})
242 261 }
262 if jr.Pages != nil {
263 where := fmt.Sprintf("jobs.%s.pages.path", id)
264 p := strings.TrimSpace(jr.Pages.Path)
265 switch {
266 case p == "":
267 bad("%s: a directory is required, e.g. site/ or dist", where)
268 case len(p) > MaxArtifactPathBytes:
269 bad("%s: path is %d bytes, limit %d", where, len(p), MaxArtifactPathBytes)
270 case strings.HasPrefix(p, "/") || filepath.IsAbs(p):
271 bad("%s: %q must be relative to the checkout", where, p)
272 case strings.Contains("/"+p+"/", "/../") || p == "..":
273 bad("%s: %q climbs out of the checkout", where, p)
274 case strings.ContainsAny(p, "*?["):
275 bad("%s: %q is a glob; a site is one directory, not a set of them", where, p)
276 default:
277 job.Pages = &Pages{Path: strings.TrimSuffix(p, "/")}
278 if pagesJobs++; pagesJobs > 1 {
279 bad("jobs.%s: only one job per workflow may publish the site; two jobs would "+
280 "race to be the site's tree", id)
281 }
282 }
283 }
243 284 wf.Jobs = append(wf.Jobs, job)
244 285 }
245 286 if len(problems) > 0 {