v2.31.0-dev2 4b72742

push.go RawHistoryFolder

1package cli
2
3import (
4	"bufio"
5	"context"
6	"errors"
7	"fmt"
8	"net/http"
9	"os"
10	"os/exec"
11	"path/filepath"
12	"regexp"
13	"strings"
14	"time"
15
16	tea "github.com/charmbracelet/bubbletea"
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/components"
21	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
22	"github.com/bnema/gordon/internal/domain"
23	"github.com/bnema/gordon/pkg/validation"
24)
25
26var newImageOpsFn = newImageOpsFromFlags
27
28// buildConfig holds image build settings for push commands.
29type buildConfig struct {
30	Enabled    bool
31	Platform   string
32	Dockerfile string
33	BuildArgs  []string
34}
35
36// imagePush holds resolved image identity for push operations.
37type imagePush struct {
38	Registry   string
39	ImageName  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	Domain    string
49	Tag       string
50	Build     buildConfig
51	NoDeploy  bool
52	NoConfirm bool
53}
54
55func newPushCmd() *cobra.Command {
56	var (
57		noDeploy   bool
58		noConfirm  bool
59		build      bool
60		platform   string
61		tag        string
62		dockerfile string
63		buildArgs  []string
64		domainFlag string
65	)
66
67	cmd := &cobra.Command{
68		Use:   "push [image]",
69		Short: "Tag, push, and optionally deploy an image",
70		Long: `Tags a local image for the Gordon registry and pushes it.
71Uses git tags for versioning. Optionally triggers deployment after push.
72
73The image argument is optional. Resolution order:
74  1. If --domain is provided, uses the legacy domain-based lookup
75  2. If an image name is provided as argument, resolves domain(s) from the backend
76  3. If no argument, auto-detects from Dockerfile labels or current directory name
77
78With --build, builds the image first using docker buildx.
79
80Examples:
81  gordon push --build --remote ...
82  gordon push myapp --build --remote ...
83  gordon push --domain myapp.example.com --build --remote ...
84  gordon push myapp --tag v1.2.0 --no-deploy --remote ...
85  gordon push --build --build-arg CGO_ENABLED=0 --remote ...`,
86		Args: cobra.MaximumNArgs(1),
87		RunE: func(cmd *cobra.Command, args []string) error {
88			var imageArg string
89			if len(args) > 0 {
90				imageArg = args[0]
91			}
92			return runPush(cmd.Context(), pushRequest{
93				ImageArg:  imageArg,
94				Domain:    domainFlag,
95				Tag:       tag,
96				Build:     buildConfig{Enabled: build, Platform: platform, Dockerfile: dockerfile, BuildArgs: buildArgs},
97				NoDeploy:  noDeploy,
98				NoConfirm: noConfirm,
99			})
100		},
101	}
102
103	cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
104	cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
105	cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
106	cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
107	cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)")
108	cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: CI tag ref or git describe)")
109	cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
110	cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)")
111
112	return cmd
113}
114
115func resolveDockerfile(dockerfile string, build bool) (string, error) {
116	if dockerfile != "" && !build {
117		return "", fmt.Errorf("--file can only be used with --build")
118	}
119	if dockerfile == "" {
120		return "Dockerfile", nil
121	}
122	return dockerfile, nil
123}
124
125func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) {
126	versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version)
127	latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName)
128	return versionRef, latestRef
129}
130
131// resolveRoute determines the registry, image name, and domain from the input mode.
132func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) {
133	if domainFlag != "" {
134		return resolveFromDomain(ctx, cp, domainFlag)
135	}
136
137	if imageArg == "" {
138		imageArg, err = detectImageName(dockerfile)
139		if err != nil {
140			return "", "", "", err
141		}
142		fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg))
143	}
144
145	return resolveFromImage(ctx, cp, imageArg, dockerfile)
146}
147
148// resolveVersion determines and validates the version tag.
149func resolveVersion(ctx context.Context, tag string) (string, error) {
150	version := determineVersion(ctx, tag)
151	if version != "latest" {
152		if err := validation.ValidateReference(version); err != nil {
153			return "", fmt.Errorf("invalid version tag %q: %w", version, err)
154		}
155	}
156	return version, nil
157}
158
159func runPush(ctx context.Context, req pushRequest) error {
160	handle, err := resolveControlPlane(configPath)
161	if err != nil {
162		return err
163	}
164	defer handle.close()
165
166	dockerfile, err := resolveDockerfile(req.Build.Dockerfile, req.Build.Enabled)
167	if err != nil {
168		return err
169	}
170
171	registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, req.ImageArg, req.Domain, dockerfile)
172	if err != nil {
173		return err
174	}
175
176	version, err := resolveVersion(ctx, req.Tag)
177	if err != nil {
178		return err
179	}
180
181	for _, ba := range req.Build.BuildArgs {
182		if err := validateBuildArg(ba); err != nil {
183			return err
184		}
185	}
186
187	img := imagePush{
188		Registry:  registry,
189		ImageName: imageName,
190		Version:   version,
191	}
192	img.VersionRef, img.LatestRef = resolveImageRefs(registry, imageName, version)
193
194	imageOps, err := newImageOpsFn()
195	if err != nil {
196		return err
197	}
198
199	fmt.Printf("Image:  %s\n", styles.Theme.Bold.Render(img.VersionRef))
200	if version != "latest" {
201		fmt.Printf("Also:   %s\n", styles.Theme.Bold.Render(img.LatestRef))
202	}
203	fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain))
204
205	skipExplicitDeploy := shouldSkipDeploy(ctx, handle.plane, imageName, req.NoDeploy)
206
207	build := buildConfig{
208		Enabled:    req.Build.Enabled,
209		Platform:   req.Build.Platform,
210		Dockerfile: dockerfile,
211		BuildArgs:  req.Build.BuildArgs,
212	}
213
214	if build.Enabled {
215		if err := buildAndPush(ctx, imageOps, build, img); err != nil {
216			return err
217		}
218	} else {
219		if err := tagAndPush(ctx, imageOps, img); err != nil {
220			return err
221		}
222	}
223
224	fmt.Println(styles.RenderSuccess("Push complete"))
225
226	if !skipExplicitDeploy {
227		return deployAfterPush(ctx, handle.plane, pushDomain, req.NoConfirm)
228	}
229
230	return nil
231}
232
233// shouldSkipDeploy signals deploy intent and returns whether the explicit deploy
234// should be skipped (either because --no-deploy was set, or the token lacks scope).
235func shouldSkipDeploy(ctx context.Context, plane ControlPlane, imageName string, noDeploy bool) bool {
236	// Always call DeployIntent to suppress server-side auto-deploy, even with --no-deploy.
237	// Without this, the server's event listener would still auto-deploy the pushed image.
238	if err := plane.DeployIntent(ctx, imageName); err != nil {
239		if isInsufficientScope(err) {
240			fmt.Fprintln(os.Stderr, "info: deploy intent skipped (insufficient scope), server will auto-deploy on image receive")
241			return true
242		}
243		// Non-fatal: worst case we get a redundant deploy via event
244		fmt.Fprintf(os.Stderr, "warning: failed to register deploy intent: %v\n", err)
245	}
246	return noDeploy
247}
248
249// isInsufficientScope returns true if the error is an HTTP 403 Forbidden response,
250// meaning the token lacks the required admin scope.
251func isInsufficientScope(err error) bool {
252	var httpErr *remote.HTTPError
253	return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusForbidden
254}
255
256// resolveFromDomain resolves image info from a domain name (legacy mode).
257func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) {
258	route, err := cp.GetRoute(ctx, pushDomain)
259	if err != nil {
260		return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err)
261	}
262
263	registry, imageName, _ = parseImageRef(route.Image)
264	if registry == "" || imageName == "" {
265		return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
266	}
267
268	return registry, imageName, pushDomain, nil
269}
270
271// resolveFromImage resolves domain(s) from an image name using the backend.
272func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) {
273	// First, query the backend to find routes for this image
274	routes, err := cp.FindRoutesByImage(ctx, imageArg)
275	if err != nil {
276		return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err)
277	}
278
279	// Filter out preview routes (domains containing "--") so they don't
280	// pollute the selection when pushing the base image.
281	filtered := routes[:0]
282	for _, r := range routes {
283		if !strings.Contains(r.Domain, domain.DefaultPreviewSeparator) {
284			filtered = append(filtered, r)
285		}
286	}
287	routes = filtered
288
289	if len(routes) == 0 {
290		// bootstrap command is registered in bootstrap.go (see issue #98)
291		return "", "", "", fmt.Errorf(
292			"no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'\nIf this is an attachment image, use 'gordon attachments push %s'",
293			imageArg,
294			imageArg,
295			imageArg,
296			imageArg,
297		)
298	}
299
300	// Pick the target domain
301	var targetRoute domain.Route
302	if len(routes) == 1 {
303		targetRoute = routes[0]
304	} else {
305		// Multiple domains: check Dockerfile labels first, then prompt
306		targetRoute, err = selectDomain(routes, dockerfile)
307		if err != nil {
308			return "", "", "", err
309		}
310	}
311
312	registry, imageName, _ = parseImageRef(targetRoute.Image)
313	if registry == "" || imageName == "" {
314		return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image)
315	}
316
317	return registry, imageName, targetRoute.Domain, nil
318}
319
320// selectDomain picks the target domain from multiple routes.
321// Checks Dockerfile labels first, then falls back to interactive selection.
322func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) {
323	// Try to resolve from Dockerfile labels
324	labels := parseDockerfileLabels(dockerfile)
325	labelDomain := labels[domain.LabelDomain]
326	labelDomains := labels[domain.LabelDomains]
327
328	// Collect domains from labels
329	var labelDomainList []string
330	if labelDomain != "" {
331		labelDomainList = append(labelDomainList, labelDomain)
332	}
333	if labelDomains != "" {
334		for _, d := range strings.Split(labelDomains, ",") {
335			d = strings.TrimSpace(d)
336			if d != "" {
337				labelDomainList = append(labelDomainList, d)
338			}
339		}
340	}
341
342	// Try to find a matching route from labels
343	if len(labelDomainList) > 0 {
344		routeMap := make(map[string]domain.Route, len(routes))
345		for _, r := range routes {
346			routeMap[r.Domain] = r
347		}
348		for _, ld := range labelDomainList {
349			if r, ok := routeMap[ld]; ok {
350				return r, nil
351			}
352		}
353	}
354
355	// Fall back to interactive selection
356	items := make([]string, 0, len(routes))
357	for _, r := range routes {
358		items = append(items, fmt.Sprintf("%s  %s", r.Domain, styles.Theme.Muted.Render(r.Image)))
359	}
360
361	selected, err := components.RunSelector(
362		"Multiple domains found for this image. Select target:",
363		items,
364		"",
365	)
366	if err != nil {
367		return domain.Route{}, fmt.Errorf("selection error: %w", err)
368	}
369	if selected == "" {
370		return domain.Route{}, fmt.Errorf("no domain selected")
371	}
372
373	// Extract domain from the selected display string
374	for i, item := range items {
375		if item == selected {
376			return routes[i], nil
377		}
378	}
379
380	return domain.Route{}, fmt.Errorf("selected domain not found")
381}
382
383// detectImageName auto-detects the image name from context.
384// Resolution order:
385// 1. Dockerfile label gordon.domain (if Dockerfile exists)
386// 2. Current directory name
387func detectImageName(dockerfile string) (string, error) {
388	// Try Dockerfile labels
389	labels := parseDockerfileLabels(dockerfile)
390	if d := labels[domain.LabelDomain]; d != "" {
391		// Strip the registry prefix if it looks like a full image ref
392		_, name, _ := parseImageRef(d)
393		if name != "" {
394			return name, nil
395		}
396		return d, nil
397	}
398
399	// Fall back to current directory name
400	cwd, err := os.Getwd()
401	if err != nil {
402		return "", fmt.Errorf("cannot detect image name: %w", err)
403	}
404
405	dirName := filepath.Base(cwd)
406	if dirName == "." || dirName == "/" {
407		return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain")
408	}
409
410	return dirName, nil
411}
412
413// parseDockerfileLabels extracts LABEL instructions from a Dockerfile.
414// Returns a map of label key -> value. Only parses gordon.* labels.
415func parseDockerfileLabels(dockerfile string) map[string]string {
416	labels := make(map[string]string)
417
418	f, err := os.Open(dockerfile)
419	if err != nil {
420		return labels
421	}
422	defer f.Close()
423
424	scanner := bufio.NewScanner(f)
425	for scanner.Scan() {
426		line := strings.TrimSpace(scanner.Text())
427
428		// Skip comments and empty lines
429		if line == "" || strings.HasPrefix(line, "#") {
430			continue
431		}
432
433		// Match LABEL instructions
434		if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") {
435			continue
436		}
437
438		// Parse "LABEL key=value" or "LABEL key=\"value\""
439		labelContent := strings.TrimSpace(line[6:])
440		for _, pair := range splitLabelPairs(labelContent) {
441			key, value, ok := parseLabelPair(pair)
442			if ok && strings.HasPrefix(key, "gordon.") {
443				labels[key] = value
444			}
445		}
446	}
447
448	return labels
449}
450
451// splitLabelPairs splits LABEL content into key=value pairs,
452// handling quoted values that may contain spaces.
453func splitLabelPairs(content string) []string {
454	var pairs []string
455	var current strings.Builder
456	inQuote := false
457	quoteChar := byte(0)
458
459	for i := 0; i < len(content); i++ {
460		ch := content[i]
461		if inQuote {
462			current.WriteByte(ch)
463			if ch == quoteChar {
464				inQuote = false
465			}
466		} else if ch == '"' || ch == '\'' {
467			inQuote = true
468			quoteChar = ch
469			current.WriteByte(ch)
470		} else if ch == ' ' || ch == '\t' {
471			if current.Len() > 0 {
472				pairs = append(pairs, current.String())
473				current.Reset()
474			}
475		} else {
476			current.WriteByte(ch)
477		}
478	}
479	if current.Len() > 0 {
480		pairs = append(pairs, current.String())
481	}
482
483	return pairs
484}
485
486// parseLabelPair parses a single "key=value" or "key=\"value\"" pair.
487func parseLabelPair(pair string) (key, value string, ok bool) {
488	idx := strings.Index(pair, "=")
489	if idx == -1 {
490		return "", "", false
491	}
492	key = pair[:idx]
493	value = pair[idx+1:]
494	// Strip surrounding quotes
495	value = strings.Trim(value, "\"'")
496	return key, value, true
497}
498
499func determineVersion(ctx context.Context, tag string) string {
500	version := tag
501	if version == "" {
502		version = versionFromTagRefs(os.Getenv)
503	}
504	if version == "" {
505		version = getGitVersion(ctx)
506	}
507	if version == "" {
508		version = "latest"
509	}
510	return version
511}
512
513func versionFromTagRefs(getenv func(string) string) string {
514	if ref := strings.TrimSpace(getenv("GITHUB_REF")); ref != "" {
515		if tag := parseTagRef(ref); tag != "" {
516			return tag
517		}
518	}
519
520	if strings.TrimSpace(getenv("GITHUB_REF_TYPE")) == "tag" {
521		if tag := strings.TrimSpace(getenv("GITHUB_REF_NAME")); tag != "" {
522			return tag
523		}
524	}
525
526	if tag := strings.TrimSpace(getenv("CI_COMMIT_TAG")); tag != "" {
527		return tag
528	}
529
530	if ref := strings.TrimSpace(getenv("BUILD_SOURCEBRANCH")); ref != "" {
531		if tag := parseTagRef(ref); tag != "" {
532			return tag
533		}
534	}
535
536	return ""
537}
538
539func parseTagRef(ref string) string {
540	ref = strings.TrimSpace(strings.TrimSuffix(ref, "^{}"))
541	if !strings.HasPrefix(ref, "refs/tags/") {
542		return ""
543	}
544	tag := strings.TrimPrefix(ref, "refs/tags/")
545	if tag == "" {
546		return ""
547	}
548	return tag
549}
550
551func buildAndPush(ctx context.Context, ops pushImageOps, build buildConfig, img imagePush) error {
552	if _, err := os.Stat(build.Dockerfile); os.IsNotExist(err) {
553		return fmt.Errorf("dockerfile not found: %s", build.Dockerfile)
554	}
555
556	// Build and load into local daemon (NOT --push).
557	// The native registry push client handles chunked uploads to stay
558	// within Cloudflare's 100MB per-request limit.
559	fmt.Println("\nBuilding image...")
560	if err := ops.Build(ctx, buildImageArgs(ctx, img.Version, build.Platform, build.Dockerfile, build.BuildArgs, img.VersionRef, img.LatestRef)); err != nil {
561		return err
562	}
563
564	fmt.Println("Pushing...")
565	if err := ops.Push(ctx, img.LatestRef); err != nil {
566		return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
567	}
568	if img.Version != "latest" {
569		if err := ops.Push(ctx, img.VersionRef); err != nil {
570			return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
571		}
572	}
573
574	return nil
575}
576
577// standardBuildArgs returns the standard set of git-related build args as
578// explicit KEY=VALUE pairs. User-supplied args are appended after and take
579// precedence (Docker uses the last occurrence of a duplicate key).
580func standardBuildArgs(ctx context.Context, version string) []string {
581	gitSHA := resolveGitSHA(ctx)
582	buildTime := time.Now().UTC().Format(time.RFC3339)
583	return []string{
584		"VERSION=" + version,
585		"GIT_TAG=" + version,
586		"GIT_SHA=" + gitSHA,
587		"BUILD_TIME=" + buildTime,
588	}
589}
590
591// resolveGitSHA returns the short git SHA of HEAD, or "unknown" if unavailable.
592func resolveGitSHA(ctx context.Context) string {
593	out, err := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD").Output() // #nosec G204
594	if err != nil {
595		return "unknown"
596	}
597	return strings.TrimSpace(string(out))
598}
599
600// buildImageArgs constructs the docker buildx build arguments.
601// Uses --load instead of --push so the image is loaded into the local
602// daemon, allowing docker push to handle the upload with chunked requests.
603func buildImageArgs(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string {
604	args := []string{
605		"buildx", "build",
606		"--platform", platform,
607		"-f", dockerfile,
608		"-t", latestRef,
609	}
610	if version != "latest" {
611		args = append(args, "-t", versionRef)
612	}
613
614	// Inject standard git build args as explicit KEY=VALUE pairs.
615	// User-supplied --build-arg flags are appended AFTER so they override defaults.
616	for _, ba := range standardBuildArgs(ctx, version) {
617		args = append(args, "--build-arg", ba)
618	}
619	for _, ba := range buildArgs {
620		args = append(args, "--build-arg", ba)
621	}
622
623	args = append(args, "--load", ".")
624	return args
625}
626
627func tagAndPush(ctx context.Context, ops pushImageOps, img imagePush) error {
628	localImage := fmt.Sprintf("%s/%s", img.Registry, img.ImageName)
629
630	fmt.Println("\nChecking local image...")
631	exists, err := ops.Exists(ctx, localImage)
632	if err != nil {
633		return fmt.Errorf("failed to inspect local image %s: %w", localImage, err)
634	}
635	if !exists {
636		return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
637	}
638
639	fmt.Println("Tagging...")
640	if err := ops.Tag(ctx, localImage, img.VersionRef); err != nil {
641		return fmt.Errorf("failed to tag %s: %w", img.VersionRef, err)
642	}
643	if img.Version != "latest" {
644		if err := ops.Tag(ctx, localImage, img.LatestRef); err != nil {
645			return fmt.Errorf("failed to tag %s: %w", img.LatestRef, err)
646		}
647	}
648
649	fmt.Println("Pushing...")
650	if err := ops.Push(ctx, img.VersionRef); err != nil {
651		return fmt.Errorf("failed to push %s: %w", img.VersionRef, err)
652	}
653	if img.Version != "latest" {
654		if err := ops.Push(ctx, img.LatestRef); err != nil {
655			return fmt.Errorf("failed to push %s: %w", img.LatestRef, err)
656		}
657	}
658	return nil
659}
660
661func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, noConfirm bool) error {
662	if !noConfirm {
663		confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
664		if err != nil {
665			return err
666		}
667		if !confirmed {
668			return nil
669		}
670	}
671
672	var (
673		result *remote.DeployResult
674		err    error
675	)
676	if remoteCP, ok := cp.(*remoteControlPlane); ok {
677		result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
678	} else {
679		result, err = cp.Deploy(ctx, pushDomain)
680	}
681	if err != nil {
682		return fmt.Errorf("failed to deploy: %w", err)
683	}
684	containerID := result.ContainerID
685	if len(containerID) > 12 {
686		containerID = containerID[:12]
687	}
688	fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
689	return nil
690}
691
692func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
693	if !isInteractiveTerminal() {
694		fmt.Printf("Deploying %s...\n", pushDomain)
695		return client.Deploy(ctx, pushDomain)
696	}
697
698	done := make(chan deployOutcome, 1)
699	go func() {
700		result, err := client.Deploy(ctx, pushDomain)
701		done <- deployOutcome{result: result, err: err}
702	}()
703
704	model := newDeploySpinnerModel(pushDomain, done)
705	final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
706	fmt.Print("\r\033[K")
707	if err != nil {
708		return nil, err
709	}
710
711	deployModel, ok := final.(deploySpinnerModel)
712	if !ok {
713		return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
714	}
715	if !deployModel.finished {
716		return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
717	}
718
719	return deployModel.outcome.result, deployModel.outcome.err
720}
721
722type deployOutcome struct {
723	result *remote.DeployResult
724	err    error
725}
726
727type deployDoneMsg deployOutcome
728
729type deploySpinnerModel struct {
730	spinner  components.SpinnerModel
731	done     <-chan deployOutcome
732	outcome  deployOutcome
733	finished bool
734}
735
736func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
737	return deploySpinnerModel{
738		spinner: components.NewSpinner(
739			components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
740			components.WithSpinnerType(components.SpinnerMiniDot),
741		),
742		done: done,
743	}
744}
745
746func (m deploySpinnerModel) Init() tea.Cmd {
747	return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
748}
749
750func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
751	switch msg := msg.(type) {
752	case deployDoneMsg:
753		m.outcome = deployOutcome(msg)
754		m.finished = true
755		return m, tea.Quit
756	default:
757		updated, cmd := m.spinner.Update(msg)
758		spinnerModel, ok := updated.(components.SpinnerModel)
759		if ok {
760			m.spinner = spinnerModel
761		}
762		return m, cmd
763	}
764}
765
766func (m deploySpinnerModel) View() string {
767	return m.spinner.View()
768}
769
770func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
771	return func() tea.Msg {
772		return deployDoneMsg(<-done)
773	}
774}
775
776func isInteractiveTerminal() bool {
777	term := os.Getenv("TERM")
778	if term == "" || term == "dumb" {
779		return false
780	}
781	info, err := os.Stdout.Stat()
782	if err != nil {
783		return false
784	}
785	return (info.Mode() & os.ModeCharDevice) != 0
786}
787
788// parseImageRef splits "registry/name:tag" into components.
789func parseImageRef(image string) (registry, name, tag string) {
790	// Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
791	parts := strings.SplitN(image, "/", 2)
792	if len(parts) != 2 {
793		return "", "", ""
794	}
795	registry = parts[0]
796	nameTag := parts[1]
797	if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
798		name = nameTag[:idx]
799		tag = nameTag[idx+1:]
800	} else {
801		name = nameTag
802		tag = "latest"
803	}
804	return
805}
806
807// getGitVersion returns git describe output, or empty string if unavailable.
808// When it falls back it prints a warning to stderr so the user knows the
809// image will be tagged "latest" rather than a real version.
810func getGitVersion(ctx context.Context) string {
811	out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--dirty").Output() // #nosec G204
812	if err != nil {
813		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)
814		return ""
815	}
816	return strings.TrimSpace(string(out))
817}
818
819// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
820var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
821
822func validateBuildArg(arg string) error {
823	if !buildArgPattern.MatchString(arg) {
824		return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
825	}
826	return nil
827}

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