vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Let a job declare the secrets it reads
4ac42680eca3181efc70099f65d6e2c9f45e9dd2 · Keenan Nemetz · 7d ago · parent 0c40a1d3
Let a job declare the secrets it reads
A job now names the repository secrets it needs, and the instance delivers
those values when the job is leased. The workflow carries names only, so the
queue row, the metadata branch and the run record never hold a value: the
record names the version of each value the job ran with, which is what makes a
result say which one it used.
jobs:
deploy:
image: alpine:3.22
secrets: [DEPLOY_KEY]
A name the repository has no value for is not a failure. The variable is left
unset rather than empty, the step's shell can tell the difference, and the
runner writes one line to the log saying which value was missing. That keeps
an optional secret optional and leaves "required" with the step, where someone
can write the message: ${DEPLOY_KEY:?DEPLOY_KEY is not set}.
The value reaches the step through a file the runner writes into the run
directory it already mounts at /run/vole for an event payload, and each step is
prefixed with `. /run/vole/secrets.env`. That is deliberately not an env-file
argument to the runtime, which cannot carry a line break, and not --env, which
puts the value in the process's argument list where every process on the host
can read it. A local `vole workflow run` writes the same file, reading the
values with the developer's own credential, and says which ones it could not
read.
The name rule and the 128 KiB limit moved to ci, next to the environment rules
they mirror, so the store refuses a name no workflow could declare and the
workflow refuses a name no process environment could carry.
Slice 2 of #154.
ci/workflow.go +60
| @@ -88,6 +88,9 @@ var ( | |||
| 88 | 88 | // dispatchInputRE is a name that can reach a shell as a variable when a step | |
| 89 | 89 | // chooses to export the inputs it reads from the event payload. | |
| 90 | 90 | dispatchInputRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,31}$`) | |
| 91 | // secretNameRE is a secret's name and therefore the name of the variable a step | ||
| 92 | // reads it as. Upper case only, so one value has one spelling. | ||
| 93 | secretNameRE = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,63}$`) | ||
| 91 | 94 | // pathGlob allows the two wildcards a path filter needs and nothing that could | |
| 92 | 95 | // be read as a revision or escape the tree. | |
| 93 | 96 | pathGlob = regexp.MustCompile(`^[A-Za-z0-9*?\[\]!._/-]+$`) | |
| @@ -171,6 +174,12 @@ type Job struct { | |||
| 171 | 174 | ImageDefinition string | |
| 172 | 175 | // ImageSHA is the digest of ImageDefinition. | |
| 173 | 176 | ImageSHA string | |
| 177 | // Secrets are the repository secrets this job reads, by name. Each name is | ||
| 178 | // also the variable a step finds it in, and the workflow carries names only: | ||
| 179 | // the instance delivers the values when the job is leased. A declared name the | ||
| 180 | // repository has no value for leaves the variable unset, so a secret can be | ||
| 181 | // optional and a required one is ${NAME:?} in the step. | ||
| 182 | Secrets []string | ||
| 174 | 183 | // Env is the environment every step of this job runs with, on top of the | |
| 175 | 184 | // image's own. It is where a job opts in to things the runner provides but does | |
| 176 | 185 | // not decide for it, such as the cache mounted at /cache. | |
| @@ -728,9 +737,39 @@ type rawJob struct { | |||
| 728 | 737 | ImageFile string `yaml:"image-file"` | |
| 729 | 738 | Artifacts []rawArtifact `yaml:"artifacts"` | |
| 730 | 739 | Pages *rawPages `yaml:"pages"` | |
| 740 | Secrets rawSecretNames `yaml:"secrets"` | ||
| 731 | 741 | Env map[string]string `yaml:"env"` | |
| 732 | 742 | } | |
| 733 | 743 | ||
| 744 | // rawSecretNames accepts one name or a list, the same way runs-on takes one label | ||
| 745 | // or a list: `secrets: DEPLOY_KEY` when there is one is the obvious thing to write. | ||
| 746 | // Only names are accepted here, so a value in a workflow file is a parse error | ||
| 747 | // rather than a secret that quietly ends up in Git. | ||
| 748 | type rawSecretNames []string | ||
| 749 | |||
| 750 | func (s *rawSecretNames) UnmarshalYAML(node *yaml.Node) error { | ||
| 751 | if node.Tag == "!!null" { | ||
| 752 | return nil | ||
| 753 | } | ||
| 754 | switch node.Kind { | ||
| 755 | case yaml.ScalarNode: | ||
| 756 | var one string | ||
| 757 | if err := node.Decode(&one); err != nil { | ||
| 758 | return err | ||
| 759 | } | ||
| 760 | *s = []string{one} | ||
| 761 | return nil | ||
| 762 | case yaml.SequenceNode: | ||
| 763 | var list []string | ||
| 764 | if err := node.Decode(&list); err != nil { | ||
| 765 | return err | ||
| 766 | } | ||
| 767 | *s = list | ||
| 768 | return nil | ||
| 769 | } | ||
| 770 | return fmt.Errorf("secrets takes a name or a list of names (secrets: DEPLOY_KEY, or secrets: [DEPLOY_KEY, TOKEN])") | ||
| 771 | } | ||
| 772 | |||
| 734 | 773 | // rawLabels accepts both spellings of runs-on, because both are the obvious way to | |
| 735 | 774 | // write it: `runs-on: linux` when there is one label and `runs-on: [linux, arm64]` | |
| 736 | 775 | // when there are several. Making the scalar a decode error would be a dialect | |
| @@ -1089,6 +1128,27 @@ func Parse(file string, content string) (*Workflow, []string) { | |||
| 1089 | 1128 | } | |
| 1090 | 1129 | job.Artifacts = append(job.Artifacts, Artifact{Path: p, Release: ar.Release}) | |
| 1091 | 1130 | } | |
| 1131 | // Secrets are names. The value is the instance's, not the workflow's, so a | ||
| 1132 | // name that is not stored is not a push error: the job runs without that | ||
| 1133 | // variable and its log says which one was missing. | ||
| 1134 | seenSecrets := map[string]bool{} | ||
| 1135 | for _, raw := range jr.Secrets { | ||
| 1136 | name := strings.TrimSpace(raw) | ||
| 1137 | if err := ValidateSecretName(name); err != nil { | ||
| 1138 | bad("jobs.%s.secrets: %v", id, err) | ||
| 1139 | continue | ||
| 1140 | } | ||
| 1141 | if reservedEnv[name] { | ||
| 1142 | bad("jobs.%s.secrets: %s is set by the runner, so a secret cannot replace it", id, name) | ||
| 1143 | continue | ||
| 1144 | } | ||
| 1145 | if seenSecrets[name] { | ||
| 1146 | bad("jobs.%s.secrets: %s is listed twice", id, name) | ||
| 1147 | continue | ||
| 1148 | } | ||
| 1149 | seenSecrets[name] = true | ||
| 1150 | job.Secrets = append(job.Secrets, name) | ||
| 1151 | } | ||
| 1092 | 1152 | // The job's own environment. The runner sets a small fixed set (CI, | |
| 1093 | 1153 | // VOLE_CI, HOME, TMPDIR, GIT_TERMINAL_PROMPT) that the sandbox depends on, | |
| 1094 | 1154 | // so a workflow cannot replace those: the refusal names the variable rather | |