Skip to main content

release_kit/config/
dev_environment.rs

1//! The `dev_environment` table: how a command runs in the project's own
2//! development environment, and how CI puts that environment on a runner.
3//!
4//! release-kit assumes no environment manager. The operator records the
5//! answer, nothing infers it from a file the target carries, and a target
6//! with no answer renders what it rendered before the table existed.
7//!
8//! SATISFIES target-config:a-dev-environment-is-a-recorded-answer
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14/// The recorded answer. A configuration with no table gives no answer, and
15/// a table whose `run` is empty states that the project gives none, which
16/// clears a recorded one.
17#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
18#[serde(deny_unknown_fields, default)]
19pub struct DevEnvironment {
20    /// The command prefix that runs a command in the environment, such as
21    /// `nix develop --command` or `mise exec --`.
22    pub run: String,
23    /// How a GitHub runner obtains the environment.
24    #[serde(skip_serializing_if = "GithubSetup::is_empty")]
25    pub github: GithubSetup,
26    /// How a GitLab job obtains the environment.
27    #[serde(skip_serializing_if = "GitlabSetup::is_empty")]
28    pub gitlab: GitlabSetup,
29}
30
31/// The GitHub steps that put the environment on a runner.
32#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
33#[serde(deny_unknown_fields, default)]
34pub struct GithubSetup {
35    /// Steps every job that runs a project tool takes first.
36    #[serde(skip_serializing_if = "Vec::is_empty")]
37    pub setup: Vec<ActionStep>,
38    /// Steps that cache the environment. A job that publishes or holds a
39    /// write token never takes them, because any workflow that writes the
40    /// same cache would then feed the job that publishes.
41    #[serde(skip_serializing_if = "Vec::is_empty")]
42    pub cache: Vec<ActionStep>,
43}
44
45impl GithubSetup {
46    const fn is_empty(&self) -> bool {
47        self.setup.is_empty() && self.cache.is_empty()
48    }
49}
50
51/// The GitLab image and setup lines of a job that runs a project tool.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
53#[serde(deny_unknown_fields, default)]
54pub struct GitlabSetup {
55    /// The image the job runs in.
56    #[serde(skip_serializing_if = "String::is_empty")]
57    pub image: String,
58    /// Lines the job runs before its script.
59    #[serde(skip_serializing_if = "Vec::is_empty")]
60    pub before_script: Vec<String>,
61}
62
63impl GitlabSetup {
64    const fn is_empty(&self) -> bool {
65        self.image.is_empty() && self.before_script.is_empty()
66    }
67}
68
69/// One action step, pinned to a full commit.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
71#[serde(deny_unknown_fields, default)]
72pub struct ActionStep {
73    /// `owner/repo[/path]@<40 hex digits>`.
74    pub uses: String,
75    /// The release the commit belongs to, rendered as a trailing comment.
76    #[serde(skip_serializing_if = "String::is_empty")]
77    pub tag: String,
78    /// The step's inputs.
79    #[serde(rename = "with", skip_serializing_if = "BTreeMap::is_empty")]
80    pub inputs: BTreeMap<String, Input>,
81}
82
83/// One input value: the scalar kinds a workflow input takes.
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
85#[serde(untagged)]
86pub enum Input {
87    /// `true` or `false`.
88    Bool(bool),
89    /// A whole number.
90    Integer(i64),
91    /// Any one-line text.
92    Text(String),
93}
94
95impl Input {
96    /// The YAML scalar a workflow carries. Text renders as a JSON string,
97    /// which is a valid YAML double-quoted scalar, so it stays data.
98    #[must_use]
99    pub fn yaml(&self) -> String {
100        match self {
101            Self::Bool(value) => value.to_string(),
102            Self::Integer(value) => value.to_string(),
103            Self::Text(value) => serde_json::Value::String(value.clone()).to_string(),
104        }
105    }
106}
107
108impl DevEnvironment {
109    /// The answer a landing renders through, or `None` where the table
110    /// states that the project gives none.
111    #[must_use]
112    pub fn answered(self) -> Option<Self> {
113        (!self.run.is_empty()).then_some(self)
114    }
115
116    /// Judge the table, naming the first key that fails.
117    ///
118    /// # Errors
119    /// The refusal text, naming the key and what it must be.
120    pub fn check(&self) -> Result<(), String> {
121        if self.run.is_empty() {
122            if self.github.is_empty() && self.gitlab.is_empty() {
123                return Ok(());
124            }
125            return Err("dev_environment.run is empty while the table names a setup; an empty run states that the project gives no answer".to_owned());
126        }
127        check_run(&self.run)?;
128        for (list, steps) in [("setup", &self.github.setup), ("cache", &self.github.cache)] {
129            for (at, step) in steps.iter().enumerate() {
130                check_step(&format!("dev_environment.github.{list}[{at}]"), step)?;
131            }
132        }
133        if !self.gitlab.image.is_empty() && !is_image(&self.gitlab.image) {
134            return Err(format!(
135                "dev_environment.gitlab.image is {:?}, which is no image reference; write one such as nixos/nix:2.35.2",
136                self.gitlab.image
137            ));
138        }
139        for (at, line) in self.gitlab.before_script.iter().enumerate() {
140            one_line(&format!("dev_environment.gitlab.before_script[{at}]"), line)?;
141        }
142        Ok(())
143    }
144
145    /// Judge the answer against the forge it lands on: a job runs the
146    /// environment only where that forge's setup is named.
147    ///
148    /// # Errors
149    /// The refusal text, naming the missing key.
150    pub fn check_forge(&self, forge: Option<&str>) -> Result<(), String> {
151        match forge {
152            Some("github") if self.github.setup.is_empty() => Err(
153                "dev_environment.run is set and dev_environment.github.setup names no step; a GitHub runner needs the steps that put the environment on it".to_owned(),
154            ),
155            Some("gitlab") if self.gitlab.image.is_empty() => Err(
156                "dev_environment.run is set and dev_environment.gitlab.image is empty; a GitLab job needs the image that carries the environment".to_owned(),
157            ),
158            _ => Ok(()),
159        }
160    }
161}
162
163/// The prefix lands in a shell line and in a plain YAML scalar, so it is
164/// one line that starts with a word character and carries nothing YAML
165/// reads as structure.
166fn check_run(run: &str) -> Result<(), String> {
167    one_line("dev_environment.run", run)?;
168    let starts = run
169        .chars()
170        .next()
171        .is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '_'));
172    if !starts || run.trim() != run || run.contains(" #") || run.contains(": ") {
173        return Err(format!(
174            "dev_environment.run is {run:?}; a prefix starts with a letter, a digit, '.', '/', or '_', has no surrounding space, and carries no ' #' and no ': '"
175        ));
176    }
177    Ok(())
178}
179
180fn check_step(key: &str, step: &ActionStep) -> Result<(), String> {
181    if !is_pinned(&step.uses) {
182        return Err(format!(
183            "{key}.uses is {:?}, which is not pinned to a full commit SHA; write owner/repo@<40 hex digits>",
184            step.uses
185        ));
186    }
187    if !step.tag.is_empty()
188        && !step
189            .tag
190            .chars()
191            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
192    {
193        return Err(format!(
194            "{key}.tag is {:?}; a tag is letters, digits, '.', '_', '-', and '/'",
195            step.tag
196        ));
197    }
198    for (name, value) in &step.inputs {
199        let named = !name.is_empty()
200            && name
201                .chars()
202                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'));
203        if !named {
204            return Err(format!(
205                "{key}.with names {name:?}; an input name is letters, digits, '_', and '-'"
206            ));
207        }
208        if let Input::Text(text) = value {
209            one_line(&format!("{key}.with.{name}"), text)?;
210        }
211    }
212    Ok(())
213}
214
215/// `owner/repo[/path]@<40 lowercase hex digits>`.
216fn is_pinned(uses: &str) -> bool {
217    let Some((action, commit)) = uses.rsplit_once('@') else {
218        return false;
219    };
220    let mut parts = action.split('/');
221    let owner = parts.next().unwrap_or_default();
222    let repo = parts.next().unwrap_or_default();
223    let word = |part: &str| {
224        !part.is_empty()
225            && part
226                .chars()
227                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
228    };
229    word(owner)
230        && word(repo)
231        && parts.all(word)
232        && commit.len() == 40
233        && commit
234            .chars()
235            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
236}
237
238fn is_image(image: &str) -> bool {
239    image
240        .chars()
241        .next()
242        .is_some_and(|c| c.is_ascii_alphanumeric())
243        && image
244            .chars()
245            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '@'))
246}
247
248fn one_line(key: &str, value: &str) -> Result<(), String> {
249    if value.trim().is_empty() || value.chars().any(char::is_control) {
250        return Err(format!(
251            "{key} is {value:?}; it is one non-empty line with no control character"
252        ));
253    }
254    Ok(())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{ActionStep, DevEnvironment, GithubSetup, GitlabSetup, Input};
260
261    const SHA: &str = "90bb610b90bf290cad97484ba341453bd1cbefea";
262
263    fn answer(uses: &str) -> DevEnvironment {
264        DevEnvironment {
265            run: "nix develop --command".to_owned(),
266            github: GithubSetup {
267                setup: vec![ActionStep {
268                    uses: uses.to_owned(),
269                    tag: "v19".to_owned(),
270                    inputs: [("use-flakehub".to_owned(), Input::Bool(false))].into(),
271                }],
272                cache: Vec::new(),
273            },
274            gitlab: GitlabSetup::default(),
275        }
276    }
277
278    #[test]
279    fn a_pinned_answer_is_accepted() {
280        let uses = format!("DeterminateSystems/nix-installer-action@{SHA}");
281        assert_eq!(answer(&uses).check(), Ok(()));
282        assert_eq!(answer(&uses).check_forge(Some("github")), Ok(()));
283    }
284
285    #[test]
286    fn an_unpinned_setup_reference_refuses_by_name() {
287        for uses in [
288            "DeterminateSystems/nix-installer-action@v19",
289            "DeterminateSystems/nix-installer-action",
290            "nix-installer-action@90bb610b90bf290cad97484ba341453bd1cbefea",
291            "DeterminateSystems/nix-installer-action@90BB610B90BF290CAD97484BA341453BD1CBEFEA",
292        ] {
293            let refusal = answer(uses).check().expect_err(uses);
294            assert!(
295                refusal.contains("dev_environment.github.setup[0].uses")
296                    && refusal.contains("full commit SHA"),
297                "{refusal}"
298            );
299        }
300    }
301
302    #[test]
303    fn a_run_that_breaks_its_line_refuses() {
304        for run in [
305            "",
306            " nix develop",
307            "nix develop # here",
308            "a: b",
309            "nix\ndevelop",
310            "-x",
311        ] {
312            let mut held = answer(&format!("o/r@{SHA}"));
313            held.run = run.to_owned();
314            assert!(held.check().is_err(), "{run:?}");
315        }
316    }
317
318    #[test]
319    fn a_run_with_no_forge_setup_refuses_on_that_forge() {
320        let mut held = answer(&format!("o/r@{SHA}"));
321        held.github.setup.clear();
322        assert!(
323            held.check_forge(Some("github"))
324                .is_err_and(|refusal| refusal.contains("dev_environment.github.setup"))
325        );
326        assert!(
327            held.check_forge(Some("gitlab"))
328                .is_err_and(|refusal| refusal.contains("dev_environment.gitlab.image"))
329        );
330        assert_eq!(held.check_forge(None), Ok(()));
331    }
332
333    #[test]
334    fn an_empty_run_states_no_answer() {
335        assert_eq!(DevEnvironment::default().check(), Ok(()));
336        assert_eq!(DevEnvironment::default().answered(), None);
337        let mut held = answer(&format!("o/r@{SHA}"));
338        held.run.clear();
339        assert!(held.check().is_err());
340    }
341}