Skip to main content

release_kit/setup/
context.rs

1//! The resolved context one setup run works in: target, repository, forge,
2//! the forge CLI binary, and the environment a step receives.
3//!
4//! The environment is constructed, not inherited: `env_clear` plus exactly
5//! the declared variables, the forge CLI's own configuration and
6//! authentication variables, and — only for the steps that need them — the
7//! bot credentials. The parent's environment does not leak into a
8//! privileged child, no secret is ever an argv value, and key material
9//! reaches no environment at all: `rk` reads the key the operator named and
10//! writes it to the step's standard input. [`super::secrets`] owns that
11//! boundary.
12
13use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use camino::Utf8PathBuf;
17use zeroize::Zeroizing;
18
19use super::secrets;
20use crate::detect::{self, Forge};
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
24
25// The trunk every setup asserts is the one permanent branch the target
26// states in its own committed configuration, read through `Ctx::trunk`.
27// A target that names none keeps the compiled default, so a landing
28// predating the key behaves exactly as it did.
29
30/// A policy boolean as the JSON word a forge body carries.
31/// The compact JSON text of one answer, so the proof fields read alike
32/// whatever type holds them. A value that cannot serialize reads as
33/// empty, which no real answer is.
34fn canonical<T: serde::Serialize>(value: &T) -> String {
35    serde_json::to_string(value).unwrap_or_default()
36}
37
38const fn bool_word(value: bool) -> &'static str {
39    if value { "true" } else { "false" }
40}
41
42/// A policy list as the JSON array a forge body carries. Every element
43/// passed the floor table, so this quotes rather than escapes.
44fn json_list(values: &[String]) -> String {
45    let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
46    format!("[{}]", inner.join(", "))
47}
48
49/// The GitHub API objects named by the policy's stable bypass vocabulary.
50///
51/// GitHub's built-in repository-administrator role is actor 5. Keeping that
52/// API encoding here lets configuration, diagnostics, and prose name intent
53/// while the setup owns the forge representation in one place.
54pub(super) fn github_bypass_actors(values: &[String]) -> serde_json::Value {
55    serde_json::Value::Array(
56        values
57            .iter()
58            .filter_map(|value| match value.as_str() {
59                crate::config::LOCAL_GITHUB_BYPASS => Some(serde_json::json!({
60                    "actor_id": 5,
61                    "actor_type": "RepositoryRole",
62                    "bypass_mode": "always",
63                })),
64                _ => None,
65            })
66            .collect(),
67    )
68}
69
70/// One GitHub ruleset's `rules` array, as JSON.
71///
72/// Every rule comes from `protection.owned_trunk_rules`, which the floor
73/// table judges per integration mode and the observer checks against, so
74/// one key decides what a run installs and what the check expects. A rule
75/// this convention parameterizes carries its parameters; the rest are
76/// bare type entries.
77///
78/// `wanted` selects which of the owned rules this ruleset carries. The
79/// trunk's rules live in two rulesets because a bypass actor is recorded
80/// on a ruleset: the rules a bypass may excuse are kept apart from the
81/// rules that must hold against everyone.
82fn compose_rules(
83    protection: &crate::config::Protection,
84    wanted: &[&str],
85    required_check: &str,
86    title_check: &str,
87) -> String {
88    let rules: Vec<serde_json::Value> = protection
89        .owned_trunk_rules
90        .iter()
91        .filter(|rule| wanted.iter().any(|kind| kind == &rule.as_str()))
92        .map(|rule| match rule.as_str() {
93            "pull_request" => serde_json::json!({
94                "type": "pull_request",
95                "parameters": {
96                    "required_approving_review_count": protection.required_approving_review_count,
97                    "dismiss_stale_reviews_on_push": protection.dismiss_stale_reviews_on_push,
98                    "require_code_owner_review": protection.require_code_owner_review,
99                    "require_last_push_approval": protection.require_last_push_approval,
100                    "required_review_thread_resolution": false,
101                    "require_extra_approval_for_unattributed_changes": false,
102                    "allowed_merge_methods": protection.allowed_merge_methods,
103                }
104            }),
105            "required_status_checks" => serde_json::json!({
106                "type": "required_status_checks",
107                "parameters": {
108                    "do_not_enforce_on_create": true,
109                    "strict_required_status_checks_policy":
110                        protection.strict_required_status_checks,
111                    "required_status_checks": [
112                        { "context": required_check },
113                        { "context": title_check },
114                    ],
115                }
116            }),
117            other => serde_json::json!({ "type": other }),
118        })
119        .collect();
120    // Pretty rather than compact: the body a run sends is what an
121    // operator reads back off the forge when a protection is in doubt.
122    serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
123}
124
125/// One target's effective protection policy: what it stated, resolved
126/// against the authority that carries an implementation onto its trunk.
127///
128/// A target that stated a policy gets its own values: the floor table
129/// already judged them under the same mode, and an operator who narrowed
130/// or widened something meant it. The one exception is the former compiled
131/// local tuple: it remains readable so an upgrade can migrate it, but setup
132/// must never reinstall its missing freshness rules. A target that stated nothing gets the
133/// compiled defaults, which describe forge integration because that is
134/// the shape this convention had before the axis existed — so under
135/// local integration they are adjusted rather than installed.
136///
137/// Two adjustments, and both only where the target stated nothing. GitHub
138/// names the repository-administrator role as the bypass authority while
139/// retaining every rule, so a local integrator may push but the release App
140/// may merge only the tested request. The GitLab push level moves off zero
141/// to the narrowest level that still admits a push.
142///
143/// The merge values follow the recorded merge form whatever the target
144/// stated, because the floors hold them to exactly that form's values:
145/// GitHub allows the one method the form names, and GitLab squashes every
146/// merge request under `squash` and none under `rebase`. Under `rebase`
147/// the safety ruleset also carries the linear-history rule, so a merge
148/// commit reaches the trunk from no actor.
149///
150/// The profile decides two requirements beside the policy. The tag
151/// protection follows the release mode alone: a target that releases
152/// nothing mints no tag to protect. The required status check and the
153/// GitLab pipeline requirement follow the forge gate alone, as
154/// [`crate::profile::catalog::forge_gate_lands`] answers it: where no gate
155/// lands, no workflow reports a check and no pipeline runs, so the trunk
156/// ruleset drops its required-check rule and the GitLab trunk drops the
157/// pipeline requirement, while deletion and force-push hold in every case.
158/// The floors still require the check rule in the stated key; the
159/// relaxation lives here, in the one result every reader takes.
160///
161/// One resolution serves the body a step sends, the observer that reads
162/// the answer back, and the prerequisites, so a canonical apply cannot
163/// install one shape and then fault its own result for not being another.
164///
165/// SATISFIES target-config:the-effective-protection-follows-the-profile
166fn effective_protection(
167    stated: Option<&crate::config::Protection>,
168    integration: crate::landing::Integration,
169    form: crate::landing::MergeForm,
170    mode: ReleaseMode,
171) -> Effective {
172    let authority = stated.map_or_else(
173        || {
174            if integration == crate::landing::Integration::Local {
175                crate::config::local_protection()
176            } else {
177                crate::config::Protection::default()
178            }
179        },
180        |stated| {
181            if integration == crate::landing::Integration::Local
182                && crate::config::legacy_local_protection(stated)
183            {
184                let mut migrated = stated.clone();
185                let current = crate::config::local_protection();
186                migrated.bypass_actors = current.bypass_actors;
187                migrated.owned_trunk_rules = current.owned_trunk_rules;
188                migrated.gitlab.push_access_level = current.gitlab.push_access_level;
189                migrated
190            } else {
191                stated.clone()
192            }
193        },
194    );
195    let mut policy = crate::config::with_merge_form(&authority, form);
196    if form == crate::landing::MergeForm::Rebase
197        && !policy
198            .owned_trunk_rules
199            .iter()
200            .any(|rule| rule == crate::config::LINEAR_HISTORY_RULE)
201    {
202        policy
203            .owned_trunk_rules
204            .push(crate::config::LINEAR_HISTORY_RULE.to_owned());
205    }
206    let forge_gate = crate::profile::catalog::forge_gate_lands(integration, mode);
207    if !forge_gate {
208        policy
209            .owned_trunk_rules
210            .retain(|rule| rule != REQUIRED_CHECK_RULE);
211    }
212    Effective {
213        policy,
214        tag_protection: mode != ReleaseMode::None,
215        forge_gate,
216    }
217}
218
219/// The ruleset rule that requires a status check, which the trunk carries
220/// only where a forge gate reports one.
221const REQUIRED_CHECK_RULE: &str = "required_status_checks";
222
223/// The effective protection: the policy, and the two requirements the
224/// profile decides beside it.
225struct Effective {
226    /// The policy every setter, observer, and check reads.
227    policy: crate::config::Protection,
228    /// Whether the release tags are protected: wherever the release mode
229    /// is not `none`.
230    tag_protection: bool,
231    /// Whether a forge gate lands, so the trunk requires its check on
232    /// GitHub and the whole pipeline on GitLab.
233    forge_gate: bool,
234}
235
236/// The authority and the merge form a setup configures the forge for.
237///
238/// The record, not the resolution's compiled default: a setup configures a
239/// forge to match what landed, and a target with no record has landed
240/// nothing here. Forge integration and `squash` are what such a target
241/// carries, the same compatibility answers `rk integrate` reads, and a
242/// record predating the merge form reads as `squash` too.
243fn recorded_workflow(
244    record: Option<&crate::landing::manifest::Manifest>,
245) -> (crate::landing::Integration, crate::landing::MergeForm) {
246    record.map_or_else(
247        || {
248            (
249                crate::landing::manifest::integration_forge(),
250                crate::landing::manifest::merge_form_squash(),
251            )
252        },
253        |held| (held.git.integration, held.git.merge_form),
254    )
255}
256
257/// The context the landed forge gate reports under: the title check the
258/// target names under `squash`, and the commit gate's job under `rebase`.
259///
260/// The commit gate's job name is a constant of its rendered file, as the
261/// title job's is, and no key renames it: a renamed context would require
262/// a check nothing reports.
263fn gate_check(protection: &crate::config::Protection, form: crate::landing::MergeForm) -> String {
264    match form {
265        crate::landing::MergeForm::Squash => protection.title_check.clone(),
266        crate::landing::MergeForm::Rebase => crate::config::COMMIT_GATE_CHECK.to_owned(),
267    }
268}
269
270/// The variables that pass through from the operator's environment to a
271/// step: the interpreter's search path, the forge CLI's configuration and
272/// authentication, and nothing else.
273const PASSTHROUGH: [&str; 11] = [
274    "PATH",
275    "HOME",
276    "XDG_CONFIG_HOME",
277    "GH_TOKEN",
278    "GITHUB_TOKEN",
279    "GH_HOST",
280    "GH_CONFIG_DIR",
281    "GLAB_TOKEN",
282    "GITLAB_TOKEN",
283    "GITLAB_HOST",
284    "GLAB_CONFIG_DIR",
285];
286
287/// The value-bearing bot variables, forwarded only to the steps that
288/// consume them and recorded in the journal as handling, never as value.
289/// The key is in no list here: it reaches its step as bytes on standard
290/// input, and neither it nor its path is ever put in an environment.
291/// [`secrets`] owns that.
292pub use super::secrets::VALUE_VARS as SECRET_VARS;
293
294/// One resolved run context.
295#[derive(Debug, Clone)]
296pub struct Ctx {
297    /// The repository being set up.
298    pub target: Utf8PathBuf,
299    /// The project path on the forge, empty where the profile names none.
300    pub repo: String,
301    /// The forge adapter the run acts through, where the profile names a
302    /// forge this release drives. A target with no forge, or one this
303    /// release has no adapter for, carries none: its local steps still
304    /// run and its forge steps report as not applicable.
305    pub forge: Option<Forge>,
306    /// The forge the profile declares, preserved whatever the adapter
307    /// says, so an unknown name stays readable.
308    pub declared_forge: Option<String>,
309    /// What the project is, as the target configuration resolves it.
310    pub profile: ProfileSnapshot,
311    /// Which optional products the target requests.
312    pub capabilities: CapabilityRequests,
313    /// The remote host, where one was detected.
314    pub host: Option<String>,
315    /// The value of `--required-check`, where given.
316    pub required_check: Option<String>,
317    /// The committed `setup.required_workflow`, on GitHub alone. No flag
318    /// answers it: the setup never writes it, it reads it to prove that
319    /// the workflow the release gate waits on is the workflow that carries
320    /// the check the gate judges.
321    pub required_workflow: Option<String>,
322    /// The resolved forge CLI binary.
323    pub cli: PathBuf,
324    /// The detected technology, where the version file names one.
325    pub tech: Option<&'static str>,
326    /// The one permanent branch this target states, or the compiled
327    /// default where it states none.
328    trunk: String,
329    /// The release-line prefix this target states, or the compiled
330    /// default where it states none.
331    line_prefix: String,
332    /// The long-lived branches a single trunk retires, as this target
333    /// names them.
334    retired_branches: Vec<String>,
335    /// Whether a full apply runs the release-line protection, which a
336    /// project that keeps no line does not want run at all.
337    release_lines: bool,
338    /// The steps this target declared it does not run, each against its
339    /// stated reason. A run reports them and judges none of them.
340    excluded_steps: std::collections::BTreeMap<String, String>,
341    /// The bot App's public identifier where this target states one; the
342    /// environment still wins over it, and no private credential is here.
343    bot_app_id: Option<String>,
344    /// The ruleset that protects the trunk, as this target names it.
345    trunk_ruleset: String,
346    /// The ruleset that keeps the trunk undeletable and unrewritable, as
347    /// this target names it. It names no bypass actor.
348    safety_ruleset: String,
349    /// The ruleset that makes published tags immutable.
350    tag_ruleset: String,
351    /// The ruleset that protects the release lines.
352    lines_ruleset: String,
353    /// The context the landed forge gate reports under: the title job under
354    /// `squash`, the commit gate's job under `rebase`.
355    title_check: String,
356    /// The effective policy this target runs under: what the target
357    /// stated, resolved against the recorded integration mode. Every
358    /// stated value passed the floor table at load, so a run passes this
359    /// to a step without judging it again, and one resolution serves the
360    /// body a step sends, the observer that reads the answer back, and
361    /// every prerequisite — so a run cannot install one shape and then
362    /// fault its own result for not being another.
363    protection: crate::config::Protection,
364    /// The destinations the landing record names, or `None` where nothing
365    /// landed here. The package check claims them as release-kit's own.
366    landed: Option<Vec<String>>,
367    /// Which authority carries an implementation onto this target's trunk.
368    /// A local-integration trunk takes the direct push that mode's
369    /// integrations end in, so the protection a run installs is not the
370    /// forge-integration one.
371    integration: crate::landing::Integration,
372    /// How the commits of a branch reach this target's trunk, as the record
373    /// says: it decides the merge settings and the forge gate a run
374    /// installs and expects.
375    merge_form: crate::landing::MergeForm,
376    /// Whether the release tags are protected, as the effective protection
377    /// decides it from the release mode.
378    pub(super) tag_protection: bool,
379    /// Whether a forge gate lands, as the effective protection decides it:
380    /// the trunk then requires the gate's check, and GitLab the pipeline.
381    pub(super) forge_gate: bool,
382}
383
384impl Ctx {
385    /// Resolve detection, overrides, and the forge CLI in one pass, before
386    /// any step runs.
387    ///
388    /// # Errors
389    ///
390    /// Refuses when the target is missing, when no remote resolves and no
391    /// override covers the gap, when the host is unrecognized, and when the
392    /// forge CLI is not on `PATH`.
393    pub fn resolve(
394        target: &Utf8PathBuf,
395        repo_flag: Option<&str>,
396        forge_flag: Option<&str>,
397        required_check: Option<&str>,
398    ) -> Result<Self, RkError> {
399        if !target.is_dir() {
400            return Err(RkError::missing(
401                Diagnostic::new(
402                    Reason::TargetNotFound,
403                    format!("target {target} is not a directory; nothing was run"),
404                )
405                .expected("an existing repository to set up"),
406            ));
407        }
408        let forge_flag = forge_flag
409            .map(|name| {
410                detect::Forge::parse(name).ok_or_else(|| {
411                    RkError::Usage(format!(
412                        "unknown forge '{name}'; the forges are: github, gitlab"
413                    ))
414                })
415            })
416            .transpose()?;
417        let detected = detect::detect(target.as_std_path());
418        let config = crate::config::load(target.as_std_path())?;
419        let record = crate::landing::manifest::load(target)?;
420        // The setup reads the same target configuration a landing records,
421        // so which steps apply follows the profile rather than a second
422        // detection of its own.
423        let resolved = crate::profile::Params::resolve(
424            target,
425            &crate::profile::Inputs {
426                forge: forge_flag.map(Forge::as_str),
427                repo: repo_flag,
428                ..crate::profile::Inputs::default()
429            },
430            config.as_ref(),
431            record.as_ref(),
432            crate::profile::Purpose::Preview,
433        )?;
434        let declared_forge = resolved.forge().map(str::to_owned);
435        let forge = declared_forge.as_deref().and_then(Forge::parse);
436        let repo = resolved.repo().to_owned();
437        let repo = if repo == crate::projection::REPO_PLACEHOLDER {
438            String::new()
439        } else {
440            repo
441        };
442        // The forge CLI is not a prerequisite of building a context: it is
443        // a prerequisite of the steps a run actually calls the forge for,
444        // which `require_cli` resolves at that point.
445        let cli = PathBuf::new();
446        let answers = config
447            .as_ref()
448            .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
449        // The flag wins, and the committed answer fills the gap on GitHub
450        // alone: GitLab names no individual check and refuses a supplied
451        // one, so a shared configuration must not make that refusal fire.
452        let required_check = required_check.map(str::to_owned).or_else(|| {
453            Some(answers.required_check.clone())
454                .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
455        });
456        let required_workflow = Some(answers.required_workflow.clone())
457            .filter(|name| !name.is_empty() && forge == Some(Forge::Github));
458        let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
459        let trunk = resolved.trunk().to_owned();
460        let (integration, merge_form) = recorded_workflow(record.as_ref());
461        let landed = record.as_ref().map(|held| {
462            held.files
463                .iter()
464                .map(|file| file.destination.clone())
465                .collect()
466        });
467        let stated = config.as_ref().map(|held| &held.protection);
468        let effective =
469            effective_protection(stated, integration, merge_form, resolved.release_mode());
470        let protection = effective.policy;
471        Ok(Self {
472            target: target.clone(),
473            repo,
474            forge,
475            host: detected.host,
476            required_check,
477            required_workflow,
478            cli,
479            tech: resolved.driver().and_then(|driver| {
480                ["rust", "python", "bash"]
481                    .into_iter()
482                    .find(|known| *known == driver)
483            }),
484            trunk_ruleset: protection.trunk_ruleset(&trunk),
485            safety_ruleset: protection.safety_ruleset(&trunk),
486            tag_ruleset: protection.tag_ruleset.clone(),
487            lines_ruleset: protection.lines_ruleset.clone(),
488            title_check: gate_check(&protection, merge_form),
489            protection,
490            tag_protection: effective.tag_protection,
491            forge_gate: effective.forge_gate,
492            integration,
493            merge_form,
494            landed,
495            trunk,
496            line_prefix: resolved.line_prefix().to_owned(),
497            profile: resolved.profile().clone(),
498            capabilities: resolved.capabilities().clone(),
499            declared_forge,
500            retired_branches: answers.retired_branches,
501            release_lines: answers.release_lines,
502            excluded_steps: answers.excluded_steps,
503            bot_app_id,
504        })
505    }
506
507    /// The context of a target as its configuration and its record state
508    /// it, with no setup flag and no forge call, for a reader that judges
509    /// offline.
510    ///
511    /// # Errors
512    ///
513    /// The resolution's refusals, as [`Self::resolve`] raises them.
514    pub fn offline(target: &Utf8PathBuf) -> Result<Self, RkError> {
515        Self::resolve(target, None, None, None)
516    }
517
518    /// Resolve the forge CLI where this run will call the forge for one of
519    /// `steps`, and refuse where it cannot be found.
520    ///
521    /// The prerequisite belongs to the call, not to the command. A step is
522    /// only a caller when the target's configuration selects it, the
523    /// target has not excluded it, and the step reaches the forge at this
524    /// particular forge. A preview writes nothing, and asks anyway for the
525    /// steps it would act on, because it names the command it would run
526    /// and a CLI nothing could find is worth saying then.
527    ///
528    /// # Errors
529    /// Propagates the refusal when the forge CLI cannot be resolved.
530    ///
531    /// SATISFIES forge-setup:applicability-follows-the-target-configuration
532    pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
533        let Some(forge) = self.forge else {
534            return Ok(());
535        };
536        let calls = steps.iter().any(|step| {
537            step.forge_cli.contains(&forge) && crate::setup::report::stance(self, step).acts()
538        });
539        if calls && self.cli.as_os_str().is_empty() {
540            self.cli = resolve_cli(forge)?;
541        }
542        Ok(())
543    }
544
545    /// A context the integration tests build directly, for an observer
546    /// exercised against recorded forge answers rather than a repository.
547    /// The trunk and the prefix take their compiled defaults, because such
548    /// a test reads no target configuration.
549    #[doc(hidden)]
550    #[must_use]
551    pub fn for_tests(
552        target: Utf8PathBuf,
553        repo: String,
554        forge: Forge,
555        cli: PathBuf,
556        tech: Option<&'static str>,
557    ) -> Self {
558        let defaults = crate::config::Protection::default();
559        Self {
560            // The forge-integration shape, which is what every observer
561            // and request-body test here asserts; a local-mode case states it.
562            integration: crate::landing::Integration::Forge,
563            merge_form: crate::landing::MergeForm::Squash,
564            tag_protection: true,
565            forge_gate: true,
566            landed: None,
567            target,
568            repo,
569            forge: Some(forge),
570            declared_forge: Some(forge.as_str().to_owned()),
571            profile: ProfileSnapshot {
572                technologies: tech.into_iter().map(str::to_owned).collect(),
573                forge: Some(forge.as_str().to_owned()),
574                release: crate::profile::ReleaseIntent {
575                    mode: ReleaseMode::Automatic,
576                    driver: tech.map(str::to_owned),
577                    style: Some(crate::landing::Style::Trunk),
578                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
579                    registry: None,
580                },
581            },
582            capabilities: CapabilityRequests {
583                nix_packaging: false,
584                reporting_policy: true,
585                scorecard: false,
586                code_scanning: None,
587                workflow_guards: Some(true),
588            },
589            host: None,
590            required_check: None,
591            required_workflow: None,
592            cli,
593            tech,
594            trunk: crate::config::TRUNK_DEFAULT.to_owned(),
595            line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
596            retired_branches: crate::config::Setup::default().retired_branches,
597            release_lines: false,
598            excluded_steps: std::collections::BTreeMap::new(),
599            bot_app_id: None,
600            trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
601            safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
602            tag_ruleset: defaults.tag_ruleset.clone(),
603            lines_ruleset: defaults.lines_ruleset.clone(),
604            title_check: defaults.title_check.clone(),
605            protection: defaults,
606        }
607    }
608
609    /// The destinations the landing record names, or `None` where nothing
610    /// landed here.
611    #[must_use]
612    pub fn landed_destinations(&self) -> Option<&[String]> {
613        self.landed.as_deref()
614    }
615
616    /// Whether the run has a forge adapter to act through.
617    #[must_use]
618    pub const fn has_adapter(&self) -> bool {
619        self.forge.is_some()
620    }
621
622    /// The forge adapter, or the refusal a forge operation answers where
623    /// the profile names no forge this release drives.
624    ///
625    /// # Errors
626    ///
627    /// A `prerequisite-unmet` refusal naming the declared forge and the
628    /// ones this release drives.
629    pub fn adapter(&self) -> Result<Forge, RkError> {
630        self.forge.ok_or_else(|| {
631            let named = self.declared_forge.as_deref();
632            let message = named.map_or_else(
633                || "the profile names no forge, and this operation acts on one".to_owned(),
634                |name| {
635                    format!(
636                        "the profile names the forge {name}, which this release has no adapter for"
637                    )
638                },
639            );
640            RkError::refusal(
641                Diagnostic::new(Reason::PrerequisiteUnmet, message)
642                    .expected("a profile naming github or gitlab")
643                    .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
644                    .target_state("unchanged"),
645            )
646        })
647    }
648
649    /// Whether this target's release is one release-kit drives.
650    #[must_use]
651    pub const fn automatic_release(&self) -> bool {
652        matches!(self.profile.release.mode, ReleaseMode::Automatic)
653    }
654
655    /// The release driver, where the profile names one.
656    #[must_use]
657    pub fn driver(&self) -> Option<&str> {
658        self.profile.release.driver.as_deref()
659    }
660
661    /// The forge the profile declares, whatever the adapter says.
662    #[must_use]
663    pub fn declared_forge(&self) -> Option<&str> {
664        self.declared_forge.as_deref()
665    }
666
667    /// Whether the target requested the landed reporting policy.
668    #[must_use]
669    pub const fn reporting_policy(&self) -> bool {
670        self.capabilities.reporting_policy
671    }
672
673    /// The one permanent branch this run asserts.
674    #[must_use]
675    pub fn trunk(&self) -> &str {
676        &self.trunk
677    }
678
679    /// The release-line prefix this run asserts.
680    #[must_use]
681    pub fn line_prefix(&self) -> &str {
682        &self.line_prefix
683    }
684
685    /// The long-lived branches this run's single-trunk step retires.
686    #[must_use]
687    pub fn retired_branches(&self) -> &[String] {
688        &self.retired_branches
689    }
690
691    /// Whether a full apply runs the release-line protection.
692    #[must_use]
693    pub const fn release_lines(&self) -> bool {
694        self.release_lines
695    }
696
697    /// Why this target does not run the named step, where it declared an
698    /// exclusion for it. The reason is what the report prints, so an
699    /// excluded step is always visible with the answer behind it.
700    #[must_use]
701    pub fn excluded(&self, step: &str) -> Option<&str> {
702        self.excluded_steps.get(step).map(String::as_str)
703    }
704
705    /// How many steps this target declared it does not run.
706    #[must_use]
707    pub fn excluded_count(&self) -> usize {
708        self.excluded_steps.len()
709    }
710
711    /// The bot App's public identifier this target states, where it does.
712    #[must_use]
713    pub fn bot_app_id(&self) -> Option<&str> {
714        self.bot_app_id.as_deref()
715    }
716
717    /// The ruleset that protects the trunk.
718    #[must_use]
719    pub fn trunk_ruleset(&self) -> &str {
720        &self.trunk_ruleset
721    }
722
723    /// The ruleset that keeps the trunk undeletable and unrewritable.
724    #[must_use]
725    pub fn safety_ruleset(&self) -> &str {
726        &self.safety_ruleset
727    }
728
729    /// The ruleset that makes published tags immutable.
730    #[must_use]
731    pub fn tag_ruleset(&self) -> &str {
732        &self.tag_ruleset
733    }
734
735    /// The ruleset that protects the release lines.
736    #[must_use]
737    pub fn lines_ruleset(&self) -> &str {
738        &self.lines_ruleset
739    }
740
741    /// The context the landed forge gate reports under: the title job under
742    /// `squash`, the commit gate's job under `rebase`.
743    #[must_use]
744    pub fn title_check(&self) -> &str {
745        &self.title_check
746    }
747
748    /// Which authority carries an implementation onto this trunk.
749    #[must_use]
750    pub const fn integration(&self) -> crate::landing::Integration {
751        self.integration
752    }
753
754    /// How the commits of a branch reach this trunk, as the record says.
755    #[must_use]
756    pub const fn merge_form(&self) -> crate::landing::MergeForm {
757        self.merge_form
758    }
759
760    /// Whether the release tags are protected: wherever the release mode
761    /// is not `none`.
762    #[must_use]
763    pub const fn tag_protection(&self) -> bool {
764        self.tag_protection
765    }
766
767    /// Whether a forge gate lands, so the trunk requires its check and
768    /// GitLab requires the pipeline.
769    #[must_use]
770    pub const fn forge_gate(&self) -> bool {
771        self.forge_gate
772    }
773
774    /// The safety ruleset's rule types, sorted, as the compact JSON array
775    /// the protection check compares against the forge's answer.
776    fn safety_rule_types(&self) -> String {
777        let mut types: Vec<&str> = self
778            .protection
779            .owned_trunk_rules
780            .iter()
781            .map(String::as_str)
782            .filter(|rule| crate::config::SAFETY_RULES.contains(rule))
783            .collect();
784        types.sort_unstable();
785        types.dedup();
786        serde_json::to_string(&types).unwrap_or_else(|_| "[]".to_owned())
787    }
788
789    /// The trunk ruleset's rules: the request and the check it carries,
790    /// which the recorded bypass actors may be excused from.
791    fn trunk_rules(&self) -> String {
792        compose_rules(
793            &self.protection,
794            &crate::config::REQUEST_RULES,
795            self.required_check.as_deref().unwrap_or_default(),
796            &self.title_check,
797        )
798    }
799
800    /// The safety ruleset's rules: what holds against every actor.
801    fn safety_rules(&self) -> String {
802        compose_rules(
803            &self.protection,
804            &crate::config::SAFETY_RULES,
805            self.required_check.as_deref().unwrap_or_default(),
806            &self.title_check,
807        )
808    }
809
810    /// The setup answers a proof is judged against, by name: everything
811    /// that decides which steps run and what each one asserts, and
812    /// nothing about the host the run happened on. Each value is its
813    /// canonical text, so two runs that resolve the same target produce
814    /// the same map.
815    ///
816    /// SATISFIES setup-proof:the-proof-carries-no-secret-or-machine-coordinate
817    #[must_use]
818    pub fn proof_fields(&self) -> std::collections::BTreeMap<String, String> {
819        let mut fields = std::collections::BTreeMap::new();
820        let mut put = |key: &str, value: String| {
821            fields.insert(key.to_owned(), value);
822        };
823        put("forge", self.declared_forge.clone().unwrap_or_default());
824        put("host", self.host.clone().unwrap_or_default());
825        put("repo", self.repo.clone());
826        put("profile", canonical(&self.profile));
827        put("capabilities", canonical(&self.capabilities));
828        put("trunk", self.trunk.clone());
829        put("line_prefix", self.line_prefix.clone());
830        put("retired_branches", canonical(&self.retired_branches));
831        put("release_lines", bool_word(self.release_lines).to_owned());
832        put("integration", canonical(&self.integration));
833        put(
834            "required_check",
835            self.required_check.clone().unwrap_or_default(),
836        );
837        put(
838            "required_workflow",
839            self.required_workflow.clone().unwrap_or_default(),
840        );
841        put("bot_app_id", self.bot_app_id.clone().unwrap_or_default());
842        put("trunk_ruleset", self.trunk_ruleset.clone());
843        put("safety_ruleset", self.safety_ruleset.clone());
844        put("tag_ruleset", self.tag_ruleset.clone());
845        put("lines_ruleset", self.lines_ruleset.clone());
846        put("title_check", self.title_check.clone());
847        put("protection", canonical(&self.protection));
848        fields
849    }
850
851    /// The floored policy this target states, already judged at load.
852    #[must_use]
853    pub const fn protection(&self) -> &crate::config::Protection {
854        &self.protection
855    }
856
857    /// Whether this run targets a GitLab instance that is not gitlab.com,
858    /// where registry trusted publishing cannot reach.
859    #[must_use]
860    pub fn self_hosted_gitlab(&self) -> bool {
861        self.forge == Some(Forge::Gitlab)
862            && self
863                .host
864                .as_deref()
865                .is_some_and(|host| host != "gitlab.com")
866    }
867
868    /// Whether this run targets gitlab.com itself, as a detected host.
869    ///
870    /// Positive on purpose: a run with no detected host answers false, so
871    /// a rule that loosens only for gitlab.com stays closed where the host
872    /// is unknown.
873    #[must_use]
874    pub fn on_gitlab_com(&self) -> bool {
875        self.forge == Some(Forge::Gitlab) && self.host.as_deref() == Some("gitlab.com")
876    }
877
878    /// The constructed environment a step receives. Secrets enter only for
879    /// the step that consumes them; the caller records their handling.
880    #[must_use]
881    #[allow(
882        clippy::too_many_lines,
883        reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
884    )]
885    pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
886        let mut env: Vec<(OsString, OsString)> = vec![
887            (
888                "RK_FORGE".into(),
889                self.forge.map_or("", Forge::as_str).into(),
890            ),
891            ("RK_REPO".into(), self.repo.clone().into()),
892            ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
893            ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
894            ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
895            (
896                "RK_SAFETY_RULESET".into(),
897                self.safety_ruleset.clone().into(),
898            ),
899            ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
900            ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
901            // The context the landed forge gate reports under, which the
902            // trunk ruleset requires: the title job under `squash` and the
903            // commit gate's job under `rebase`.
904            ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
905            // The recorded merge form, so a step asserts the squash
906            // settings only where a squash merge reads them.
907            ("RK_MERGE_FORM".into(), self.merge_form.as_str().into()),
908            // The two requirements the profile decides: the release tags
909            // are protected wherever a release mints them, and the trunk
910            // requires the gate's check, or GitLab's pipeline, wherever a
911            // forge gate lands to report it.
912            (
913                "RK_TAG_PROTECTION".into(),
914                bool_word(self.tag_protection).into(),
915            ),
916            ("RK_FORGE_GATE".into(), bool_word(self.forge_gate).into()),
917            // The floored policy, already judged against the floor table
918            // at load. A step receives values, never a judgment: one
919            // owner decides what passes, and it is not a shell script.
920            (
921                "RK_TAG_PATTERN".into(),
922                self.protection.tag_pattern.clone().into(),
923            ),
924            (
925                "RK_REVIEW_COUNT".into(),
926                self.protection
927                    .required_approving_review_count
928                    .to_string()
929                    .into(),
930            ),
931            (
932                "RK_DISMISS_STALE_REVIEWS".into(),
933                bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
934            ),
935            (
936                "RK_CODE_OWNER_REVIEW".into(),
937                bool_word(self.protection.require_code_owner_review).into(),
938            ),
939            (
940                "RK_LAST_PUSH_APPROVAL".into(),
941                bool_word(self.protection.require_last_push_approval).into(),
942            ),
943            (
944                "RK_MERGE_METHODS".into(),
945                json_list(&self.protection.allowed_merge_methods).into(),
946            ),
947            (
948                "RK_STRICT_CHECKS".into(),
949                bool_word(self.protection.strict_required_status_checks).into(),
950            ),
951            (
952                "RK_BYPASS_ACTORS".into(),
953                github_bypass_actors(&self.protection.bypass_actors)
954                    .to_string()
955                    .into(),
956            ),
957            (
958                "RK_SQUASH_TITLE_SOURCE".into(),
959                self.protection.github.squash_title_source.clone().into(),
960            ),
961            (
962                "RK_SQUASH_BODY_SOURCE".into(),
963                self.protection.github.squash_body_source.clone().into(),
964            ),
965            (
966                "RK_GITLAB_MERGE_METHOD".into(),
967                self.protection.gitlab.merge_method.clone().into(),
968            ),
969            (
970                "RK_GITLAB_SQUASH_OPTION".into(),
971                self.protection.gitlab.squash_option.clone().into(),
972            ),
973            (
974                "RK_GITLAB_SQUASH_TEMPLATE".into(),
975                self.protection.gitlab.squash_commit_template.clone().into(),
976            ),
977            (
978                "RK_GITLAB_PUSH_LEVEL".into(),
979                self.protection.gitlab.push_access_level.to_string().into(),
980            ),
981            // The two rulesets' rules, built from the one key the floor
982            // table judges and the observer reads, so the body a run
983            // sends cannot install a rule the check does not expect, or
984            // omit one it does. The split is what a bypass costs: an
985            // actor excused from the trunk ruleset is excused from every
986            // rule in it, so the rules that must hold against everyone
987            // live in the safety ruleset, which names nobody.
988            ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
989            ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
990            (
991                "RK_SAFETY_RULE_TYPES".into(),
992                self.safety_rule_types().into(),
993            ),
994            (
995                "RK_GITLAB_MERGE_LEVEL".into(),
996                self.protection.gitlab.merge_access_level.to_string().into(),
997            ),
998            ("GH_PAGER".into(), "".into()),
999            ("GLAB_PAGER".into(), "".into()),
1000        ];
1001        if let Some(check) = &self.required_check
1002            && self.forge == Some(Forge::Github)
1003            && self.forge_gate
1004            && matches!(step, "protect-trunk" | "protections-check")
1005        {
1006            env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
1007        }
1008        for name in PASSTHROUGH {
1009            if let Some(value) = std::env::var_os(name) {
1010                env.push((name.into(), value));
1011            }
1012        }
1013        // The forge CLI override substitutes the binary for the run's own
1014        // calls; a step resolves the CLI by name, so the override's
1015        // directory leads the child's search path.
1016        if let Some(dir) = self.cli_override_dir() {
1017            let mut paths: Vec<PathBuf> = vec![dir];
1018            if let Some(existing) = std::env::var_os("PATH") {
1019                paths.extend(std::env::split_paths(&existing));
1020            }
1021            if let Ok(joined) = std::env::join_paths(paths) {
1022                env.retain(|(name, _)| name != "PATH");
1023                env.push(("PATH".into(), joined));
1024            }
1025        }
1026        if step == "bot-secrets" {
1027            for name in SECRET_VARS {
1028                if let Some(value) = secrets::value_of(name) {
1029                    env.push((name.into(), value));
1030                }
1031            }
1032        }
1033        env
1034    }
1035
1036    /// The directory of an explicitly overridden forge CLI, where one is set.
1037    fn cli_override_dir(&self) -> Option<PathBuf> {
1038        let overridden = std::env::var_os(match self.forge? {
1039            Forge::Github => "RK_GH_BIN",
1040            Forge::Gitlab => "RK_GLAB_BIN",
1041        })?;
1042        Path::new(&overridden).parent().map(Path::to_path_buf)
1043    }
1044
1045    /// The secret bytes a run must keep out of its own output: the values
1046    /// the environment carries. Every buffer is scrubbed on drop; none is
1047    /// ever logged or echoed.
1048    ///
1049    /// Key material is not read here. The step that transmits a key adds
1050    /// the very bytes it sends, so the needle cannot describe one file
1051    /// while the child receives another.
1052    #[must_use]
1053    pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
1054        SECRET_VARS
1055            .iter()
1056            .filter_map(|name| secrets::value_of(name))
1057            .map(|value| Zeroizing::new(value.into_encoded_bytes()))
1058            .collect()
1059    }
1060}
1061
1062/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
1063/// `RK_GLAB_BIN` overrides first, then a `PATH` search.
1064///
1065/// Not found and not executable are distinct failures, in the shell
1066/// convention. `rk branches prune` shares it for the verify path.
1067///
1068/// # Errors
1069///
1070/// Refuses when the override or the search resolves no usable binary.
1071pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
1072    let override_var = match forge {
1073        Forge::Github => "RK_GH_BIN",
1074        Forge::Gitlab => "RK_GLAB_BIN",
1075    };
1076    if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
1077        let path = PathBuf::from(&overridden);
1078        if !path.is_file() {
1079            return Err(RkError::refusal(
1080                Diagnostic::new(
1081                    Reason::PrerequisiteUnmet,
1082                    format!(
1083                        "{override_var} names {}, which does not exist",
1084                        path.display()
1085                    ),
1086                )
1087                .expected("the override to name the forge CLI binary"),
1088            ));
1089        }
1090        // The scripts invoke the CLI by its canonical name through the
1091        // child's search path, so an override under any other name would
1092        // split one lifecycle across two binaries: observed through the
1093        // override, applied through whatever the name resolves to.
1094        if path.file_name().is_none_or(|name| name != forge.cli()) {
1095            return Err(RkError::refusal(
1096                Diagnostic::new(
1097                    Reason::PrerequisiteUnmet,
1098                    format!(
1099                        "{override_var} must name a binary called {}, and {} is not one",
1100                        forge.cli(),
1101                        path.display()
1102                    ),
1103                )
1104                .expected(format!(
1105                    "an override whose file name is {}, so scripts and observations run one binary",
1106                    forge.cli()
1107                )),
1108            ));
1109        }
1110        return Ok(path);
1111    }
1112    let name = forge.cli();
1113    let found = std::env::var_os("PATH").and_then(|path| {
1114        std::env::split_paths(&path)
1115            .map(|dir| dir.join(name))
1116            .find(|candidate| candidate.is_file())
1117    });
1118    found.ok_or_else(|| {
1119        RkError::refusal(
1120            Diagnostic::new(
1121                Reason::PrerequisiteUnmet,
1122                format!(
1123                    "{name} is not on PATH, and a step this run acts on calls it on {}",
1124                    forge.as_str()
1125                ),
1126            )
1127            .expected(format!("the {name} CLI installed and authenticated"))
1128            .action(format!("install {name}, then run {name} auth login")),
1129        )
1130    })
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    /// Both rulesets' rules come from the one owned-rules key, so what a
1136    /// run installs, what the floor table judges, and what the check
1137    /// expects cannot disagree. The split is what a bypass costs: a
1138    /// recorded actor is excused from every rule in the ruleset it names,
1139    /// so the rules that hold against everyone are composed apart, and a
1140    /// target under either authority composes the same two halves.
1141    #[test]
1142    fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
1143        let kinds = |text: &str| -> Vec<String> {
1144            let parsed: Vec<serde_json::Value> =
1145                serde_json::from_str(text).expect("the rules parse");
1146            parsed
1147                .iter()
1148                .filter_map(|rule| rule["type"].as_str())
1149                .map(str::to_owned)
1150                .collect()
1151        };
1152        let mut policy = crate::config::Protection::default();
1153        let request =
1154            super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
1155        assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
1156        let safety =
1157            super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
1158        assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
1159
1160        let parsed: Vec<serde_json::Value> =
1161            serde_json::from_str(&request).expect("the rules parse");
1162        let checks = parsed
1163            .iter()
1164            .find(|rule| rule["type"] == "required_status_checks")
1165            .expect("the check rule");
1166        assert_eq!(
1167            checks["parameters"]["required_status_checks"],
1168            serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
1169        );
1170
1171        // The recorded bypass changes who is excused, never which rules
1172        // each ruleset carries.
1173        policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
1174        assert_eq!(
1175            kinds(&super::compose_rules(
1176                &policy,
1177                &crate::config::REQUEST_RULES,
1178                "gate",
1179                "pr-title"
1180            )),
1181            ["pull_request", "required_status_checks"]
1182        );
1183        assert_eq!(
1184            kinds(&super::compose_rules(
1185                &policy,
1186                &crate::config::SAFETY_RULES,
1187                "gate",
1188                "pr-title"
1189            )),
1190            ["deletion", "non_fast_forward"]
1191        );
1192    }
1193
1194    /// One effective policy serves the body a step sends, the observer
1195    /// that reads the answer back, and every prerequisite.
1196    ///
1197    /// A target that stated nothing gets the compiled defaults, which
1198    /// describe forge integration because that is the shape this
1199    /// convention had before the axis existed — so under local
1200    /// integration they are adjusted: the GitHub administrator role gains
1201    /// the bypass that admits the direct push, and the GitLab level moves
1202    /// off the zero that would close the trunk to that push. A
1203    /// target that stated a policy keeps every value it stated, because
1204    /// the floor table already judged it under the same mode. The former
1205    /// compiled local tuple is migrated before setup can reinstall it.
1206    #[test]
1207    fn the_effective_policy_follows_the_recorded_authority() {
1208        use crate::landing::{Integration, MergeForm};
1209        let silent = super::effective_protection(
1210            None,
1211            Integration::Forge,
1212            MergeForm::Squash,
1213            crate::profile::ReleaseMode::Automatic,
1214        )
1215        .policy;
1216        assert!(
1217            silent
1218                .owned_trunk_rules
1219                .contains(&"pull_request".to_owned())
1220        );
1221        assert_eq!(silent.gitlab.push_access_level, 0);
1222
1223        let silent = super::effective_protection(
1224            None,
1225            Integration::Local,
1226            MergeForm::Squash,
1227            crate::profile::ReleaseMode::Automatic,
1228        )
1229        .policy;
1230        assert_eq!(
1231            silent.owned_trunk_rules,
1232            crate::config::Protection::default().owned_trunk_rules,
1233            "local integration retains the release request's atomic check"
1234        );
1235        assert_eq!(
1236            silent.bypass_actors,
1237            [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
1238        );
1239        assert_eq!(
1240            silent.gitlab.push_access_level, 40,
1241            "zero would close the trunk to the push this mode ends in"
1242        );
1243
1244        let mut stated = crate::config::Protection::default();
1245        stated.gitlab.push_access_level = 0;
1246        stated.owned_trunk_rules = vec!["deletion".into()];
1247        let held = super::effective_protection(
1248            Some(&stated),
1249            Integration::Local,
1250            MergeForm::Squash,
1251            crate::profile::ReleaseMode::Automatic,
1252        )
1253        .policy;
1254        assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
1255        assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
1256
1257        let legacy = crate::config::Protection {
1258            owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1259            gitlab: crate::config::Gitlab {
1260                push_access_level: 40,
1261                ..crate::config::Gitlab::default()
1262            },
1263            ..crate::config::Protection::default()
1264        };
1265        let migrated = super::effective_protection(
1266            Some(&legacy),
1267            Integration::Local,
1268            MergeForm::Squash,
1269            crate::profile::ReleaseMode::Automatic,
1270        )
1271        .policy;
1272        assert_eq!(
1273            migrated,
1274            crate::config::local_protection(),
1275            "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1276        );
1277    }
1278
1279    /// The merge values follow the recorded form under both authorities,
1280    /// whether the target stated a policy or not, and only the rebase form
1281    /// adds the linear-history rule, to the safety half.
1282    ///
1283    /// SATISFIES target-config:the-effective-protection-follows-the-profile
1284    #[test]
1285    fn merge_form_decides_the_effective_merge_values() {
1286        use crate::landing::{Integration, MergeForm};
1287        let kinds = |text: &str| -> Vec<String> {
1288            let parsed: Vec<serde_json::Value> =
1289                serde_json::from_str(text).expect("the rules parse");
1290            parsed
1291                .iter()
1292                .filter_map(|rule| rule["type"].as_str())
1293                .map(str::to_owned)
1294                .collect()
1295        };
1296        let stated = crate::config::Protection::default();
1297        for integration in [Integration::Forge, Integration::Local] {
1298            for held in [None, Some(&stated)] {
1299                let squash = super::effective_protection(
1300                    held,
1301                    integration,
1302                    MergeForm::Squash,
1303                    crate::profile::ReleaseMode::Automatic,
1304                )
1305                .policy;
1306                assert_eq!(squash.allowed_merge_methods, ["squash".to_owned()]);
1307                assert_eq!(squash.gitlab.squash_option, "always");
1308                assert!(
1309                    !squash
1310                        .owned_trunk_rules
1311                        .contains(&crate::config::LINEAR_HISTORY_RULE.to_owned())
1312                );
1313                assert_eq!(
1314                    kinds(&super::compose_rules(
1315                        &squash,
1316                        &crate::config::SAFETY_RULES,
1317                        "gate",
1318                        "pr-title"
1319                    )),
1320                    ["deletion", "non_fast_forward"]
1321                );
1322
1323                let rebase = super::effective_protection(
1324                    held,
1325                    integration,
1326                    MergeForm::Rebase,
1327                    crate::profile::ReleaseMode::Automatic,
1328                )
1329                .policy;
1330                assert_eq!(rebase.allowed_merge_methods, ["rebase".to_owned()]);
1331                assert_eq!(rebase.gitlab.squash_option, "never");
1332                assert_eq!(rebase.gitlab.merge_method, "ff");
1333                assert_eq!(
1334                    kinds(&super::compose_rules(
1335                        &rebase,
1336                        &crate::config::SAFETY_RULES,
1337                        "gate",
1338                        "pr-commits"
1339                    )),
1340                    ["deletion", "non_fast_forward", "required_linear_history"]
1341                );
1342                assert_eq!(
1343                    kinds(&super::compose_rules(
1344                        &rebase,
1345                        &crate::config::REQUEST_RULES,
1346                        "gate",
1347                        "pr-commits"
1348                    )),
1349                    ["pull_request", "required_status_checks"],
1350                    "the linear-history rule never sits where a bypass excuses it"
1351                );
1352            }
1353        }
1354    }
1355
1356    /// The required context is the gate the form lands.
1357    #[test]
1358    fn merge_form_names_the_required_gate() {
1359        use crate::landing::MergeForm;
1360        let policy = crate::config::Protection::default();
1361        assert_eq!(super::gate_check(&policy, MergeForm::Squash), "pr-title");
1362        assert_eq!(super::gate_check(&policy, MergeForm::Rebase), "pr-commits");
1363    }
1364    /// The effective protection decides the two requirements from the
1365    /// profile: the tag protection follows the release mode alone, and the
1366    /// required check, carried by the trunk ruleset's check rule, follows
1367    /// the forge gate alone. Deletion and force-push hold in every cell.
1368    ///
1369    /// SATISFIES target-config:the-effective-protection-follows-the-profile
1370    #[test]
1371    fn release_less_effective_protection_follows_the_profile() {
1372        use crate::landing::{Integration, MergeForm};
1373        use crate::profile::ReleaseMode;
1374        for integration in [Integration::Forge, Integration::Local] {
1375            for form in [MergeForm::Squash, MergeForm::Rebase] {
1376                for mode in [
1377                    ReleaseMode::Automatic,
1378                    ReleaseMode::External,
1379                    ReleaseMode::None,
1380                ] {
1381                    let effective = super::effective_protection(None, integration, form, mode);
1382                    let at = format!("{integration:?} {form:?} {mode:?}");
1383                    let gate = integration == Integration::Forge || mode == ReleaseMode::Automatic;
1384                    assert_eq!(effective.tag_protection, mode != ReleaseMode::None, "{at}");
1385                    assert_eq!(effective.forge_gate, gate, "{at}");
1386                    let rules = &effective.policy.owned_trunk_rules;
1387                    assert_eq!(
1388                        rules.iter().any(|rule| rule == "required_status_checks"),
1389                        gate,
1390                        "{at}: {rules:?}"
1391                    );
1392                    for kept in ["deletion", "non_fast_forward", "pull_request"] {
1393                        assert!(rules.iter().any(|rule| rule == kept), "{at}: {kept}");
1394                    }
1395                    assert_eq!(
1396                        effective.policy.allowed_merge_methods,
1397                        [form.as_str().to_owned()],
1398                        "{at}"
1399                    );
1400                }
1401            }
1402        }
1403    }
1404}