v3.4.1 de8f68e

push.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"fmt"
8	"io"
9	"net/http"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"regexp"
14	"strings"
15	"time"
16
17	"github.com/spf13/cobra"
18
19	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
20	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
21	"github.com/bnema/gordon/internal/domain"
22	"github.com/bnema/gordon/pkg/validation"
23)
24
25var newImageOpsFn = newImageOpsFromFlags
26
27// buildConfig holds image build settings for push commands.
28type buildConfig struct {
29	Enabled    bool
30	Platform   string
31	Dockerfile string
32	BuildArgs  []string
33}
34
35// imagePush holds resolved image identity for push operations.
36type imagePush struct {
37	Registry   string
38	ImageName  string
39	SourceRef  string
40	Version    string
41	VersionRef string
42	LatestRef  string
43}
44
45// pushRequest holds all inputs for the push command.
46type pushRequest struct {
47	ImageArg string
48	Tag      string
49	Build    buildConfig
50}
51
52func newPushCmd() *cobra.Command {
53	var (
54		build      bool
55		platform   string
56		tag        string
57		dockerfile string
58		buildArgs  []string
59	)
60
61	cmd := &cobra.Command{
62		Use:   "push [image]",
63		Short: "Tag and push an image to the Gordon registry",
64		Long: `Tags a local image for the Gordon registry and pushes it.
65Uses git tags for versioning. Push transfers OCI content only: it never
66deploys. Deploy separately with ` + "`gordon apps deploy`" + ` after applying
67the manifest that references the pushed tag.
68
69Examples:
70  gordon images push myapp --build --remote ...
71  gordon images push myapp:v1.2.3 --tag v1.2.3 --remote ...
72  gordon images push registry.example.com/myapp:v1.2.3 --build --remote ...
73  gordon images push --build --build-arg CGO_ENABLED=0 --remote ...`,
74		Args: cobra.MaximumNArgs(1),
75		RunE: func(cmd *cobra.Command, args []string) error {
76			var imageArg string
77			if len(args) > 0 {
78				imageArg = args[0]
79			}
80			return runPush(cmd.Context(), cmd.OutOrStdout(), pushRequest{
81				ImageArg: imageArg,
82				Tag:      tag,
83				Build:    buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
84			})
85		},
86	}
87
88	cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
89	cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
90	cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
91	cmd.Flags().StringVar(&tag, "tag", "", "Override pushed version tag (default: CI tag ref or git describe)")
92	cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
93
94	return cmd
95}
96
97func resolveDockerfile(dockerfile string, build bool) (string, error) {
98	if dockerfile != "" && !build {
99		return "", fmt.Errorf("--file can only be used with --build")
100	}
101	if dockerfile == "" {
102		return "Dockerfile", nil
103	}
104	return dockerfile, nil
105}
106
107func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
108	versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
109	latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
110	return versionRef, latestRef
111}
112
113type pushArgKind string
114
115const (
116	pushArgKindImage        pushArgKind = "image"
117	pushArgKindLegacyDomain pushArgKind = "legacy-domain"
118)
119
120type classifiedPushArg struct {
121	kind         pushArgKind
122	sourceRef    string
123	repository   string
124	legacyDomain string
125}
126
127func classifyPushArgument(arg string) classifiedPushArg {
128	if looksLikeLegacyDomain(arg) {
129		return classifiedPushArg{kind: pushArgKindLegacyDomain, legacyDomain: arg}
130	}
131
132	_, repository, _ := parseImageRef(arg)
133	if repository == "" {
134		repository, _ = validation.ParseImageReference(arg)
135	}
136	return classifiedPushArg{kind: pushArgKindImage, sourceRef: arg, repository: repository}
137}
138
139func looksLikeLegacyDomain(arg string) bool {
140	if arg == "" || strings.Contains(arg, "/") {
141		return false
142	}
143	host := arg
144	if idx := strings.Index(host, ":"); idx != -1 {
145		host = host[:idx]
146	}
147	return strings.Contains(host, ".")
148}
149
150// resolveImageTarget determines the registry and image name from the input.
151// A bare image name (no registry host) is pushed to the target Gordon's
152// registry domain. A fully qualified reference must name that same
153// registry, so the Gordon registry credentials never leave the target.
154// Push transfers OCI content only and never triggers deploys or route
155// lookups. The deploy target comes from the app manifest applied
156// separately via `gordon apps apply`.
157func resolveImageTarget(ctx context.Context, out io.Writer, handle *controlPlaneHandle, imageArg, dockerfile string) (registry, imageName, sourceRef string, err error) {
158	if imageArg == "" {
159		imageArg, err = detectImageName(dockerfile)
160		if err != nil {
161			return "", "", "", err
162		}
163		if err := cliWritef(out, "Detected image: %s\n", styles.Theme.Bold.Render(imageArg)); err != nil {
164			return "", "", "", err
165		}
166	}
167	if looksLikeLegacyDomain(imageArg) {
168		return "", "", "", fmt.Errorf("domain-style push targets are retired: push an image name, then `gordon apps apply` (got %q)", imageArg)
169	}
170	classified := classifyPushArgument(imageArg)
171	if classified.repository == "" {
172		return "", "", "", fmt.Errorf("cannot parse image name from %q", imageArg)
173	}
174
175	targetRegistry, err := targetRegistryDomain(ctx, handle)
176	if err != nil {
177		return "", "", "", err
178	}
179
180	registry, imageName, _ = parseImageRef(classified.sourceRef)
181	if registry == "" {
182		if err := cliWritef(out, "Registry: %s %s\n", styles.Theme.Bold.Render(targetRegistry), styles.Theme.Muted.Render("(from target)")); err != nil {
183			return "", "", "", err
184		}
185		return targetRegistry, classified.repository, classified.sourceRef, nil
186	}
187	if !sameRegistryHost(registry, targetRegistry) {
188		return "", "", "", fmt.Errorf("image registry %q does not match the target Gordon registry %q; retag the image as %s/%s or select the matching remote", registry, targetRegistry, targetRegistry, imageName)
189	}
190	return registry, imageName, classified.sourceRef, nil
191}
192
193// targetRegistryDomain asks the target Gordon instance for its registry domain.
194// Remote targets publish it on /admin/auth/verify, which any admin-scoped
195// token can read, so push needs no admin:status:read scope. The local admin
196// socket and servers that predate that field are asked through /admin/status.
197func targetRegistryDomain(ctx context.Context, handle *controlPlaneHandle) (string, error) {
198	if handle.isRemote {
199		verify, err := handle.plane.VerifyAuth(ctx)
200		if isForbidden(err) {
201			return "", fmt.Errorf("failed to read target registry domain: token needs an admin scope (e.g. admin:apps:read): %w", err)
202		}
203		if err != nil {
204			return "", fmt.Errorf("failed to read target registry domain: %w", err)
205		}
206		if !verify.Valid {
207			return "", errors.New("failed to read target registry domain: token is not valid")
208		}
209		if registryDomain := strings.TrimSpace(verify.RegistryDomain); registryDomain != "" {
210			return registryDomain, nil
211		}
212	}
213
214	status, err := handle.plane.GetStatus(ctx)
215	if isForbidden(err) && handle.isRemote {
216		return "", fmt.Errorf("failed to read target registry domain: the server did not report it on /admin/auth/verify (older server or server.registry_domain unset); upgrade or configure the server, or grant the token admin:status:read: %w", err)
217	}
218	if err != nil {
219		return "", fmt.Errorf("failed to read target registry domain: %w", err)
220	}
221	registryDomain := strings.TrimSpace(status.RegistryDomain)
222	if registryDomain == "" {
223		return "", errors.New("target Gordon instance does not report a registry domain; set server.registry_domain")
224	}
225	return registryDomain, nil
226}
227
228func isForbidden(err error) bool {
229	httpErr, ok := errors.AsType[*remote.HTTPError](err)
230	return ok && httpErr.StatusCode == http.StatusForbidden
231}
232
233func sameRegistryHost(a, b string) bool {
234	normalize := func(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") }
235	return normalize(a) == normalize(b)
236}
237
238// isRegistryHost applies Docker's rule for the first path component of an
239// image reference: it names a registry only if it contains a dot or a port,
240// or is "localhost".
241func isRegistryHost(component string) bool {
242	return strings.ContainsAny(component, ".:") || component == "localhost"
243}
244
245// resolveVersion determines and validates the version tag.
246func resolveVersion(ctx context.Context, tag string) (string, error) {
247	version := determineVersion(ctx, tag)
248	if version != "latest" {
249		if err := validation.ValidateReference(version); err != nil {
250			return "", fmt.Errorf("invalid version tag %q: %w", version, err)
251		}
252	}
253	return version, nil
254}
255
256func runPush(ctx context.Context, out io.Writer, req pushRequest) error {
257	dockerfile, inferredRemote, handle, err := resolvePushTarget(ctx, out, req)
258	if err != nil {
259		return err
260	}
261	defer handle.close()
262
263	version, err := resolveVersion(ctx, req.Tag)
264	if err != nil {
265		return err
266	}
267	if err := validateBuildArgsList(req.Build.BuildArgs); err != nil {
268		return err
269	}
270
271	registry, imageName, sourceRef, err := resolveImageTarget(ctx, out, handle, req.ImageArg, dockerfile)
272	if err != nil {
273		return err
274	}
275
276	img := imagePush{Registry: registry, ImageName: imageName, SourceRef: sourceRef, Version: version}
277	img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
278
279	imageOps, err := newPushImageOps(inferredRemote)
280	if err != nil {
281		return err
282	}
283
284	if err := cliWritef(out, "Image:  %s\n", styles.Theme.Bold.Render(img.VersionRef)); err != nil {
285		return err
286	}
287	if version != "latest" {
288		if err := cliWritef(out, "Also:   %s\n", styles.Theme.Bold.Render(img.LatestRef)); err != nil {
289			return err
290		}
291	}
292	build := buildConfig{Enabled: req.Build.Enabled, Platform: req.Build.Platform, Dockerfile: dockerfile, BuildArgs: req.Build.BuildArgs}
293	if err := pushResolvedImage(ctx, out, imageOps, build, img); err != nil {
294		return err
295	}
296
297	if err := cliWriteLine(out, styles.RenderSuccess("Push complete")); err != nil {
298		return err
299	}
300	return nil
301}
302
303func resolvePushTarget(ctx context.Context, out io.Writer, req pushRequest) (dockerfile string, inferredRemote *remote.ResolvedRemote, handle *controlPlaneHandle, err error) {
304	dockerfile, err = resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
305	if err != nil {
306		return "", nil, nil, err
307	}
308	inferredRemote, err = inferPushRemote(ctx, req.ImageArg, "", dockerfile)
309	if err != nil {
310		return "", nil, nil, err
311	}
312	if inferredRemote != nil {
313		handle = newRemoteControlPlaneHandle(inferredRemote)
314		if err := cliWritef(out, "Remote: %s %s\n", styles.Theme.Bold.Render(inferredRemote.DisplayName()), styles.Theme.Muted.Render("(auto-detected)")); err != nil {
315			return "", nil, nil, err
316		}
317		return dockerfile, inferredRemote, handle, nil
318	}
319	handle, err = resolveControlPlane(cliConfigPath)
320	if err != nil {
321		return "", nil, nil, err
322	}
323	return dockerfile, nil, handle, nil
324}
325
326func validateBuildArgsList(buildArgs []string) error {
327	for _, ba := range buildArgs {
328		if err := validateBuildArg(ba); err != nil {
329			return err
330		}
331	}
332	return nil
333}
334
335func newPushImageOps(inferredRemote *remote.ResolvedRemote) (pushImageOps, error) {
336	if inferredRemote != nil {
337		return newImageOpsForResolvedRemote(inferredRemote)
338	}
339	return newImageOpsFn()
340}
341
342func pushResolvedImage(ctx context.Context, out io.Writer, imageOps pushImageOps, build buildConfig, img imagePush) error {
343	if build.Enabled {
344		return buildAndPush(ctx, out, imageOps, build, img)
345	}
346	return tagAndPush(ctx, out, imageOps, img)
347}
348
349// detectImageName resolves the image name from Dockerfile labels or the
350// current directory name.
351func detectImageName(dockerfile string) (string, error) {
352	// Try Dockerfile labels
353	labels := parseDockerfileLabels(dockerfile)
354	if d := labels[domain.LabelDomain]; d != "" {
355		// Strip the registry prefix if it looks like a full image ref
356		_, name, _ := parseImageRef(d)
357		if name != "" {
358			return name, nil
359		}
360		return d, nil
361	}
362
363	// Fall back to current directory name
364	cwd, err := os.Getwd()
365	if err != nil {
366		return "", fmt.Errorf("cannot detect image name: %w", err)
367	}
368
369	dirName := filepath.Base(cwd)
370	if dirName == "." || dirName == "/" {
371		return "", fmt.Errorf("cannot detect image name from current directory; provide an image name")
372	}
373
374	return dirName, nil
375}
376
377// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
378// Returns a map of label key -> value. Only parses gordon.* labels.
379func parseDockerfileLabels(dockerfile string) map[string]string {
380	labels := make(map[string]string)
381
382	f, err := os.Open(dockerfile)
383	if err != nil {
384		return labels
385	}
386	defer f.Close()
387
388	scanner := bufio.NewScanner(f)
389	for scanner.Scan() {
390		line := strings.TrimSpace(scanner.Text())
391
392		// Skip comments and empty lines
393		if line == "" || strings.HasPrefix(line, "#") {
394			continue
395		}
396
397		// Match LABEL instructions
398		if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
399			continue
400		}
401
402		// Parse "LABEL key=value" or "LABEL key=\"value\""
403		labelContent := strings.TrimSpace(line[6:])
404		for _, pair := range splitLabelPairs(labelContent) {
405			key, value, ok := parseLabelPair(pair)
406			if ok && strings.HasPrefix(key, "gordon.") {
407				labels[key] = value
408			}
409		}
410	}
411
412	return labels
413}
414
415// splitLabelPairs splits LABEL content into key=value pairs,
416// handling quoted values that may contain spaces.
417func splitLabelPairs(content string) []string {
418	var pairs []string
419	var current strings.Builder
420	inQuote := false
421	quoteChar := byte(0)
422
423	for i := 0; i < len(content); i++ {
424		ch := content[i]
425		if inQuote {
426			current.WriteByte(ch)
427			if ch == quoteChar {
428				inQuote = false
429			}
430		} else if ch == '"' || ch == '\'' {
431			inQuote = true
432			quoteChar = ch
433			current.WriteByte(ch)
434		} else if ch == ' ' || ch == '\t' {
435			if current.Len() > 0 {
436				pairs = append(pairs, current.String())
437				current.Reset()
438			}
439		} else {
440			current.WriteByte(ch)
441		}
442	}
443	if current.Len() > 0 {
444		pairs = append(pairs, current.String())
445	}
446
447	return pairs
448}
449
450// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
451func parseLabelPair(pair string) (key, value string, ok bool) {
452	key, value, ok = strings.Cut(pair, "=")
453	if !ok {
454		return "", "", false
455	}
456	// Strip surrounding quotes
457	value = strings.Trim(value, "\"'")
458	return key, value, true
459}
460
461func determineVersion(ctx context.Context, tag string) string {
462	version := tag
463	if version == "" {
464		version = versionFromTagRefs(os.Getenv)
465	}
466	if version == "" {
467		version = getGitVersion(ctx)
468	}
469	if version == "" {
470		version = "latest"
471	}
472	return version
473}
474
475func versionFromTagRefs(getenv func(string) string) string {
476	if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
477		if tag := parseTagRef(ref); tag != "" {
478			return tag
479		}
480	}
481
482	if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
483		if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
484			return tag
485		}
486	}
487
488	if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
489		return tag
490	}
491
492	if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
493		if tag := parseTagRef(ref); tag != "" {
494			return tag
495		}
496	}
497
498	return ""
499}
500
501func parseTagRef(ref string) string {
502	ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
503	if !strings.HasPrefix(ref, "refs/tags/") {
504		return ""
505	}
506	tag := strings.TrimPrefix(ref, "refs/tags/")
507	if tag == "" {
508		return ""
509	}
510	return tag
511}
512
513func buildAndPush(ctx context.Context, out io.Writer, ops pushImageOps, build buildConfig, img imagePush) error {
514	if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
515		return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
516	}
517
518	// Build and load into local daemon (NOT --push).
519	// The native registry push client handles chunked uploads to stay
520	// within Cloudflare's 100MB per-request limit.
521	if err := cliWriteLine(out, "\nBuilding image..."); err != nil {
522		return err
523	}
524	if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
525		return err
526	}
527
528	if err := cliWriteLine(out, "Pushing..."); err != nil {
529		return err
530	}
531	if err := ops.Push(ctx, img.LatestRef); err != nil {
532		return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
533	}
534	if img.Version != "latest" {
535		if err := ops.Push(ctx, img.VersionRef); err != nil {
536			return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
537		}
538	}
539
540	return nil
541}
542
543// standardBuildArgs returns the standard set of git-related build args as
544// explicit KEY=VALUE pairs. User-supplied args are appended after and take
545// precedence (Docker uses the last occurrence of a duplicate key).
546func standardBuildArgs(ctx context.Context, version string) []string {
547	gitSHA := resolveGitSHA(ctx)
548	buildTime := time.Now().UTC().Format(time.RFC3339)
549	return []string{
550		"VERSION=" + version,
551		"GIT_TAG=" + version,
552		"GIT_SHA=" + gitSHA,
553		"BUILD_TIME=" + buildTime,
554	}
555}
556
557// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
558func resolveGitSHA(ctx context.Context) string {
559	out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
560	if err != nil {
561		return "unknown"
562	}
563	return strings.TrimSpace(string(out))
564}
565
566// buildImageArgs constructs the docker buildx build arguments.
567// Uses --load instead of --push so the image is loaded into the local
568// daemon, allowing docker push to handle the upload with chunked requests.
569func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
570	args := []string{
571		"buildx", "build",
572		"--platform", platform,
573		"-f", dockerfile,
574		"-t", latestRef,
575	}
576	if version != "latest" {
577		args = append(args, "-t", versionRef)
578	}
579
580	// Inject standard git build args as explicit KEY=VALUE pairs.
581	// User-supplied --build-arg flags are appended AFTER so they override defaults.
582	for _, ba := range standardBuildArgs(ctx, version) {
583		args = append(args, "--build-arg", ba)
584	}
585	for _, ba := range buildArgs {
586		args = append(args, "--build-arg", ba)
587	}
588
589	args = append(args, "--load", ".")
590	return args
591}
592
593func tagAndPush(ctx context.Context, out io.Writer, ops pushImageOps, img imagePush) error {
594	localImage := img.SourceRef
595	if localImage == "" {
596		return errors.New("push source image reference cannot be empty")
597	}
598
599	if err := cliWriteLine(out, "\nChecking local image..."); err != nil {
600		return err
601	}
602	exists, err := ops.Exists(ctx, localImage)
603	if err != nil {
604		return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
605	}
606	if !exists {
607		return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
608	}
609
610	if err := cliWriteLine(out, "Tagging..."); err != nil {
611		return err
612	}
613	if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
614		return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
615	}
616	if img.Version != "latest" {
617		if err := ops.Tag(ctx, localImage, img.LatestRef); err != nil {
618			return fmt.Errorf("failed to tag %s: %w", img.LatestRef, err)
619		}
620	}
621
622	if err := cliWriteLine(out, "Pushing..."); err != nil {
623		return err
624	}
625	if err := ops.Push(ctx, img.VersionRef); err != nil {
626		return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
627	}
628	if img.Version != "latest" {
629		if err := ops.Push(ctx, img.LatestRef); err != nil {
630			return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
631		}
632	}
633	return nil
634}
635
636func isInteractiveTerminal() bool {
637	term := os.Getenv("TERM")
638	if term == "" || term == "dumb" {
639		return false
640	}
641	info, err := os.Stdout.Stat()
642	if err != nil {
643		return false
644	}
645	return (info.Mode() & os.ModeCharDevice) != 0
646}
647
648// parseImageRef splits "registry/name:tag" into components. It returns empty
649// values when the reference does not start with a registry host.
650func parseImageRef(image string) (registry, name, tag string) {
651	// Handle "reg.example.com/app:latest" or "reg.example.com/app"
652	parts := strings.SplitN(image, "/", 2)
653	if len(parts) != 2 || !isRegistryHost(parts[0]) {
654		return "", "", ""
655	}
656	registry = parts[0]
657	nameTag := parts[1]
658	if idx := strings.LastIndex(nameTag, "@"); idx != -1 {
659		return registry, nameTag[:idx], nameTag[idx+1:]
660	}
661	if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
662		name = nameTag[:idx]
663		tag = nameTag[idx+1:]
664	} else {
665		name = nameTag
666		tag = "latest"
667	}
668	return
669}
670
671// getGitVersion returns git describe output, or empty string if unavailable.
672// When it falls back it prints a warning to stderr so the user knows the
673// image will be tagged "latest" rather than a real version.
674func getGitVersion(ctx context.Context) string {
675	out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
676	if err != nil {
677		fmt.Fprintf(os.Stderr, "Warning: unable to determine git tag (%v) — image version will be 'latest'. Tag your repo to get versioned images.\n", err)
678		return ""
679	}
680	return strings.TrimSpace(string(out))
681}
682
683// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
684var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
685
686func validateBuildArg(arg string) error {
687	if !buildArgPattern.MatchString(arg) {
688		return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
689	}
690	return nil
691}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close