Skip to main content

release_kit/setup/
before_state.rs

1//! The before-state a setup step saves ahead of a change it cannot undo.
2//!
3//! A step that removes or replaces forge state reads what it is about to
4//! change, through the same read-only calls the observers make, and
5//! writes it to the run's snapshot directory before its script runs: the
6//! repository settings behind the default branch and the merge settings,
7//! the rulesets, each ruleset a step replaces read in full, and the
8//! classic protections, and every branch
9//! `single-trunk` deletes, by name and commit, with a bundle where this
10//! clone holds the commit. A read that fails refuses the step, because a
11//! change nobody can restore is not one this setup makes. A saved state
12//! is a backup: no later run reads it to decide anything.
13//!
14//! SATISFIES forge-setup:a-step-is-idempotent
15
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use serde_json::{Value, json};
20
21use super::context::Ctx;
22use super::observe::{self, Api, Runner};
23use crate::detect::Forge;
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26use crate::snapshot::{FORGE_DIR, REFS_DIR, Snapshot};
27
28/// Every step that removes or replaces forge state.
29pub const DESTRUCTIVE: [&str; 7] = [
30    "default-branch",
31    "single-trunk",
32    "merge-cleanup",
33    "protect-trunk",
34    "protect-tags",
35    "protect-release-lines",
36    "auto-merge",
37];
38
39/// Whether `step` removes or replaces forge state.
40#[must_use]
41pub fn destructive(step: &str) -> bool {
42    DESTRUCTIVE.contains(&step)
43}
44
45/// The read-only calls that capture what `step` changes.
46#[must_use]
47pub fn reads(ctx: &Ctx, step: &str) -> Vec<String> {
48    let Some(forge) = ctx.forge else {
49        return Vec::new();
50    };
51    let trunk = ctx.trunk();
52    match forge {
53        Forge::Github => {
54            let repo = &ctx.repo;
55            match step {
56                "default-branch" | "merge-cleanup" | "auto-merge" => vec![format!("repos/{repo}")],
57                // protect-trunk also rewrites the squash settings.
58                "protect-trunk" => vec![
59                    format!("repos/{repo}"),
60                    format!("repos/{repo}/rulesets"),
61                    format!("repos/{repo}/branches/{trunk}/protection"),
62                ],
63                "protect-tags" | "protect-release-lines" => vec![format!("repos/{repo}/rulesets")],
64                "single-trunk" => candidates(ctx)
65                    .iter()
66                    .map(|branch| format!("repos/{repo}/git/ref/heads/{branch}"))
67                    .collect(),
68                _ => Vec::new(),
69            }
70        }
71        Forge::Gitlab => {
72            let project = ctx.repo.replace('/', "%2F");
73            match step {
74                "default-branch" | "merge-cleanup" | "auto-merge" => {
75                    vec![format!("projects/{project}")]
76                }
77                // protect-trunk also rewrites the merge settings.
78                "protect-trunk" => vec![
79                    format!("projects/{project}"),
80                    format!("projects/{project}/protected_branches"),
81                ],
82                "protect-release-lines" => {
83                    vec![format!("projects/{project}/protected_branches")]
84                }
85                "protect-tags" => vec![format!("projects/{project}/protected_tags")],
86                "single-trunk" => candidates(ctx)
87                    .iter()
88                    .map(|branch| format!("projects/{project}/repository/branches/{branch}"))
89                    .collect(),
90                _ => Vec::new(),
91            }
92        }
93    }
94}
95
96/// The rulesets `step` replaces in place, by name. The listing names
97/// them only, without their rules, conditions, or bypass actors, so each
98/// one the step finds is read in full by its id.
99fn replaced_rulesets<'c>(ctx: &'c Ctx, step: &str) -> Vec<&'c str> {
100    match (ctx.forge, step) {
101        (Some(Forge::Github), "protect-trunk") => vec![ctx.safety_ruleset(), ctx.trunk_ruleset()],
102        (Some(Forge::Github), "protect-tags") => vec![ctx.tag_ruleset()],
103        (Some(Forge::Github), "protect-release-lines") => vec![ctx.lines_ruleset()],
104        _ => Vec::new(),
105    }
106}
107
108/// The refusal of a step whose before-state this run cannot read.
109fn cannot_read(step: &str, path: &str, reason: &str) -> RkError {
110    RkError::refusal(
111        Diagnostic::new(
112            Reason::ObservationIncomplete,
113            format!(
114                "{step} changes forge state this run cannot read first ({path}: {reason}), so it did not run"
115            ),
116        )
117        .expected("the state the step changes, read and saved before it changes")
118        .action("rerun where the forge CLI can read this project's settings")
119        .target_state("unchanged")
120        .step(step),
121    )
122}
123
124/// The long-lived branches `single-trunk` may delete: every retired name
125/// but the trunk itself.
126fn candidates(ctx: &Ctx) -> Vec<String> {
127    ctx.retired_branches()
128        .iter()
129        .filter(|branch| branch.as_str() != ctx.trunk())
130        .cloned()
131        .collect()
132}
133
134/// Read and save the before-state of `step` into `snapshot`, returning
135/// where it went.
136///
137/// # Errors
138///
139/// An `observation-incomplete` refusal where a read fails for a reason
140/// other than a definite not-found, and the snapshot's own write
141/// failures.
142pub fn save(
143    ctx: &Ctx,
144    step: &str,
145    snapshot: &Snapshot,
146    run: &mut Runner,
147) -> Result<PathBuf, RkError> {
148    let mut answers = serde_json::Map::new();
149    let mut branches = Vec::new();
150    for path in reads(ctx, step) {
151        let answer = match observe::api_get(ctx, run, &path)? {
152            Api::Ok(body) => body,
153            Api::Missing => Value::Null,
154            Api::Failed(reason) => return Err(cannot_read(step, &path, &reason)),
155        };
156        if step == "single-trunk"
157            && let Some(commit) = answer
158                .pointer("/object/sha")
159                .or_else(|| answer.pointer("/commit/id"))
160                .and_then(Value::as_str)
161        {
162            let name = path.rsplit('/').next().unwrap_or_default().to_owned();
163            branches.push((name, commit.to_owned()));
164        }
165        answers.insert(path, answer);
166    }
167    let listing = format!("repos/{}/rulesets", ctx.repo);
168    for name in replaced_rulesets(ctx, step) {
169        // The script replaces the first ruleset of that name, and creates
170        // one where none is listed, which replaces nothing.
171        let Some(id) = answers
172            .get(&listing)
173            .and_then(Value::as_array)
174            .and_then(|list| list.iter().find(|ruleset| ruleset["name"] == name))
175            .and_then(|ruleset| ruleset["id"].as_u64())
176        else {
177            continue;
178        };
179        let path = format!("{listing}/{id}");
180        let answer = match observe::api_get(ctx, run, &path)? {
181            Api::Ok(body) => body,
182            // A ruleset the listing names is no absence when it reads as
183            // missing, and its definition is what the step replaces.
184            Api::Missing => {
185                return Err(cannot_read(
186                    step,
187                    &path,
188                    "the listed ruleset reads as missing",
189                ));
190            }
191            Api::Failed(reason) => return Err(cannot_read(step, &path, &reason)),
192        };
193        answers.insert(path, answer);
194    }
195    let mut bundled = Vec::new();
196    for (name, commit) in &branches {
197        if let Some(bundle) = bundle(ctx, snapshot, name, commit)? {
198            bundled.push(bundle.display().to_string());
199        }
200    }
201    let document = json!({
202        "step": step,
203        "taken_at": crate::applog::now_utc(),
204        "reads": answers,
205        "branches": branches
206            .iter()
207            .map(|(name, commit)| json!({"name": name, "commit": commit}))
208            .collect::<Vec<_>>(),
209        "bundles": bundled,
210    });
211    let bytes = serde_json::to_vec_pretty(&document).map_err(anyhow::Error::from)?;
212    snapshot.save(&Path::new(FORGE_DIR).join(format!("{step}.json")), &bytes)
213}
214
215/// Bundle `branch` at `commit` where this clone holds the commit; `None`
216/// where it does not, in which case the saved name and commit are the
217/// record, because `single-trunk` deletes only a branch the trunk
218/// already contains.
219fn bundle(
220    ctx: &Ctx,
221    snapshot: &Snapshot,
222    branch: &str,
223    commit: &str,
224) -> Result<Option<PathBuf>, RkError> {
225    let held = git(ctx)
226        .args(["cat-file", "-e", &format!("{commit}^{{commit}}")])
227        .status()
228        .is_ok_and(|status| status.success());
229    if !held {
230        return Ok(None);
231    }
232    let scratch = std::env::temp_dir().join(format!(
233        "rk-bundle-{}-{}",
234        std::process::id(),
235        branch.replace('/', "-")
236    ));
237    let _ = std::fs::remove_dir_all(&scratch);
238    std::fs::create_dir_all(&scratch)?;
239    let outcome = bundle_in(ctx, &scratch, snapshot, branch, commit);
240    let _ = std::fs::remove_dir_all(&scratch);
241    outcome
242}
243
244/// The bundle steps inside one scratch directory.
245fn bundle_in(
246    ctx: &Ctx,
247    scratch: &Path,
248    snapshot: &Snapshot,
249    branch: &str,
250    commit: &str,
251) -> Result<Option<PathBuf>, RkError> {
252    let clone = scratch.join("objects.git");
253    let file = scratch.join("branch.bundle");
254    // A bundle names refs, so the commit rides under the branch's own
255    // name in a throwaway clone that shares the objects, never as a ref
256    // of the operator's clone.
257    let cloned = git(ctx)
258        .args(["clone", "--quiet", "--bare", "--shared", "."])
259        .arg(&clone)
260        .status()
261        .is_ok_and(|status| status.success());
262    let named = cloned
263        && bare(&clone)
264            .args(["update-ref", &format!("refs/heads/{branch}"), commit])
265            .status()
266            .is_ok_and(|status| status.success());
267    let bundled = named
268        && bare(&clone)
269            .args(["bundle", "create", "--quiet"])
270            .arg(&file)
271            .arg(format!("refs/heads/{branch}"))
272            .status()
273            .is_ok_and(|status| status.success());
274    if !bundled {
275        return Ok(None);
276    }
277    let bytes = std::fs::read(&file)?;
278    snapshot
279        .save(
280            &Path::new(REFS_DIR).join(format!("{branch}.bundle")),
281            &bytes,
282        )
283        .map(Some)
284}
285
286/// A git command against the throwaway clone.
287fn bare(clone: &Path) -> Command {
288    let mut command = Command::new(crate::probes::git_bin());
289    for var in crate::maintenance::GIT_HOOK_VARS {
290        command.env_remove(var);
291    }
292    command.arg("-C").arg(clone);
293    command
294}
295
296/// A git command against the target alone.
297fn git(ctx: &Ctx) -> Command {
298    let mut command = Command::new(crate::probes::git_bin());
299    for var in crate::maintenance::GIT_HOOK_VARS {
300        command.env_remove(var);
301    }
302    command.arg("-C").arg(ctx.target.as_std_path());
303    command
304}
305
306#[cfg(test)]
307mod tests {
308    use super::{DESTRUCTIVE, destructive};
309
310    #[test]
311    fn the_destructive_steps_are_named_once() {
312        for step in DESTRUCTIVE {
313            assert!(destructive(step));
314        }
315        for step in ["package-check", "install-bot", "protections-check"] {
316            assert!(!destructive(step), "{step}");
317        }
318    }
319}