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