1// SPDX-License-Identifier: MIT
2
3package action
4
5import (
6 "fmt"
7 "strings"
8)
9
10// UsesKind says where the action named by a "uses:" value lives.
11type UsesKind int
12
13// The kinds of "uses:" value.
14const (
15 // UsesDocker is "docker://image[:tag]".
16 UsesDocker UsesKind = iota + 1
17 // UsesLocal is "./path" or ".\path", relative to the workspace.
18 UsesLocal
19 // UsesRepository is "owner/repo[/path]@ref".
20 UsesRepository
21 // UsesSelfRepository is "$/[path]": an action in the repository of the
22 // workflow or action that contains the step.
23 UsesSelfRepository
24)
25
26// Uses is a parsed "uses:" value of a step.
27type Uses struct {
28 Kind UsesKind
29 // Image is the image of a UsesDocker value, without "docker://".
30 Image string
31 // Path is the value itself for UsesLocal ("./x"), the directory inside
32 // the repository for UsesRepository ("" for the repository's root, else
33 // "a/b" with / as separator), and the directory after "$/" for
34 // UsesSelfRepository.
35 Path string
36 // Owner and Repo name the repository of a UsesRepository value, and Name is
37 // "Owner/Repo". Ref is its ref: a branch, a tag or a commit sha.
38 Owner, Repo, Name, Ref string
39}
40
41// UsesError is the error of [ParseUses].
42type UsesError struct {
43 // Value is the "uses:" value that was refused.
44 Value string
45 // Message is the text GitHub's runner reports, without a position.
46 Message string
47}
48
49func (e *UsesError) Error() string { return e.Message }
50
51// ParseUses parses the "uses:" value of a step. It is the runner's check
52// (WorkflowTemplateConverter.ConvertToStep and the legacy
53// PipelineTemplateConverter.ConvertToStep, which agree) and returns its
54// messages:
55//
56// - "Expected format {org}/{repo}[/path]@ref. Actual 'x'" for a value that is
57// not docker://, ./ or .\ or $/ and does not have exactly one "@", a
58// non-empty ref, and an owner and a repository before it. Path separators
59// are / and \, and empty segments are ignored ("a//b@c" is a/b@c, and
60// "/a/b@c" too).
61// - "Self-repository references do not support an '@ref' suffix. Actual
62// '$/x@v1'" for a $/ value with an "@".
63//
64// The prefixes docker:// are compared as written (case-sensitive), as the
65// converter does. The language service words the first message differently
66// ("{owner}/{repo}[/path]@{ref}") and refuses a blank value with another
67// text; PORTING.md lists the differences. Whether the repository exists, or
68// whether the ref does, is for whoever resolves the action to say.
69func ParseUses(s string) (Uses, error) {
70 switch {
71 case strings.HasPrefix(s, "docker://"):
72 return Uses{Kind: UsesDocker, Image: s[len("docker://"):]}, nil
73 case strings.HasPrefix(s, "./"), strings.HasPrefix(s, `.\`):
74 return Uses{Kind: UsesLocal, Path: s}, nil
75 case strings.HasPrefix(s, "$/"):
76 path := strings.TrimLeft(s[2:], "/")
77 if strings.Contains(path, "@") {
78 return Uses{}, &UsesError{Value: s, Message: fmt.Sprintf("Self-repository references do not support an '@ref' suffix. Actual '%s'", s)}
79 }
80 return Uses{Kind: UsesSelfRepository, Path: path}, nil
81 }
82 segments := strings.Split(s, "@")
83 path := strings.FieldsFunc(segments[0], func(r rune) bool { return r == '/' || r == '\\' })
84 if len(segments) != 2 || len(path) < 2 || segments[1] == "" {
85 return Uses{}, &UsesError{Value: s, Message: fmt.Sprintf("Expected format {org}/{repo}[/path]@ref. Actual '%s'", s)}
86 }
87 return Uses{
88 Kind: UsesRepository,
89 Owner: path[0],
90 Repo: path[1],
91 Name: path[0] + "/" + path[1],
92 Path: strings.Join(path[2:], "/"),
93 Ref: segments[1],
94 }, nil
95}
96
97// ImageKind says what a docker action's "image" is.
98type ImageKind int
99
100// The kinds of "runs.image".
101const (
102 // ImageDockerfile is a path to a Dockerfile in the action's directory.
103 ImageDockerfile ImageKind = iota + 1
104 // ImageRegistry is "docker://image[:tag]": an image to pull.
105 ImageRegistry
106)
107
108// Image is a parsed "runs.image" of a docker action.
109type Image struct {
110 Kind ImageKind
111 // Path is the Dockerfile's path relative to the action's directory, for
112 // ImageDockerfile.
113 Path string
114 // Ref is the image to pull, without "docker://", for ImageRegistry.
115 Ref string
116}
117
118// ParseImage classifies the "image" of a docker action as the runner does
119// (ActionManager.PrepareRepositoryActionAsync and DockerUtil.IsDockerfile):
120// a path whose last segment starts with "Dockerfile." or ends with
121// "Dockerfile" (case-insensitively) is a Dockerfile, "docker://" (any case)
122// followed by an image is an image to pull, and anything else fails with
123// "'x' should be either '[path]/Dockerfile' or 'docker://image[:tag]'.".
124// [Parse] does not call it: the runner checks the image when it prepares the
125// action, after loading the manifest.
126func ParseImage(image string) (Image, error) {
127 if hasPrefixFold(image, "docker://") {
128 return Image{Kind: ImageRegistry, Ref: image[len("docker://"):]}, nil
129 }
130 last := image[strings.LastIndex(image, "/")+1:]
131 if hasPrefixFold(last, "Dockerfile.") || hasSuffixFold(last, "Dockerfile") {
132 return Image{Kind: ImageDockerfile, Path: image}, nil
133 }
134 return Image{}, &UsesError{Value: image, Message: fmt.Sprintf("'%s' should be either '[path]/Dockerfile' or 'docker://image[:tag]'.", image)}
135}
136
137func hasPrefixFold(s, prefix string) bool {
138 return len(s) >= len(prefix) && asciiEqualFold(s[:len(prefix)], prefix)
139}
140
141func hasSuffixFold(s, suffix string) bool {
142 return len(s) >= len(suffix) && asciiEqualFold(s[len(s)-len(suffix):], suffix)
143}