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 resolved forge CLI binary.
318 pub cli: PathBuf,
319 /// The detected technology, where the version file names one.
320 pub tech: Option<&'static str>,
321 /// The one permanent branch this target states, or the compiled
322 /// default where it states none.
323 trunk: String,
324 /// The release-line prefix this target states, or the compiled
325 /// default where it states none.
326 line_prefix: String,
327 /// The long-lived branches a single trunk retires, as this target
328 /// names them.
329 retired_branches: Vec<String>,
330 /// Whether a full apply runs the release-line protection, which a
331 /// project that keeps no line does not want run at all.
332 release_lines: bool,
333 /// The steps this target declared it does not run, each against its
334 /// stated reason. A run reports them and judges none of them.
335 excluded_steps: std::collections::BTreeMap<String, String>,
336 /// The bot App's public identifier where this target states one; the
337 /// environment still wins over it, and no private credential is here.
338 bot_app_id: Option<String>,
339 /// The bot identity's name where this target states one. Unset takes
340 /// the forge's default, which on a GitLab service account carries the
341 /// project id only the forge answers.
342 bot_name: Option<String>,
343 /// The ruleset that protects the trunk, as this target names it.
344 trunk_ruleset: String,
345 /// The ruleset that keeps the trunk undeletable and unrewritable, as
346 /// this target names it. It names no bypass actor.
347 safety_ruleset: String,
348 /// The ruleset that makes published tags immutable.
349 tag_ruleset: String,
350 /// The ruleset that protects the release lines.
351 lines_ruleset: String,
352 /// The context the landed forge gate reports under: the title job under
353 /// `squash`, the commit gate's job under `rebase`.
354 title_check: String,
355 /// The effective policy this target runs under: what the target
356 /// stated, resolved against the recorded integration mode. Every
357 /// stated value passed the floor table at load, so a run passes this
358 /// to a step without judging it again, and one resolution serves the
359 /// body a step sends, the observer that reads the answer back, and
360 /// every prerequisite — so a run cannot install one shape and then
361 /// fault its own result for not being another.
362 protection: crate::config::Protection,
363 /// The destinations the landing record names, or `None` where nothing
364 /// landed here. The package check claims them as release-kit's own.
365 landed: Option<Vec<String>>,
366 /// Which authority carries an implementation onto this target's trunk.
367 /// A local-integration trunk takes the direct push that mode's
368 /// integrations end in, so the protection a run installs is not the
369 /// forge-integration one.
370 integration: crate::landing::Integration,
371 /// How the commits of a branch reach this target's trunk, as the record
372 /// says: it decides the merge settings and the forge gate a run
373 /// installs and expects.
374 merge_form: crate::landing::MergeForm,
375 /// Whether the release tags are protected, as the effective protection
376 /// decides it from the release mode.
377 pub(super) tag_protection: bool,
378 /// Whether a forge gate lands, as the effective protection decides it:
379 /// the trunk then requires the gate's check, and GitLab the pipeline.
380 pub(super) forge_gate: bool,
381}
382
383impl Ctx {
384 /// Resolve detection, overrides, and the forge CLI in one pass, before
385 /// any step runs.
386 ///
387 /// # Errors
388 ///
389 /// Refuses when the target is missing, when no remote resolves and no
390 /// override covers the gap, when the host is unrecognized, and when the
391 /// forge CLI is not on `PATH`.
392 pub fn resolve(
393 target: &Utf8PathBuf,
394 repo_flag: Option<&str>,
395 forge_flag: Option<&str>,
396 required_check: Option<&str>,
397 ) -> Result<Self, RkError> {
398 if !target.is_dir() {
399 return Err(RkError::missing(
400 Diagnostic::new(
401 Reason::TargetNotFound,
402 format!("target {target} is not a directory; nothing was run"),
403 )
404 .expected("an existing repository to set up"),
405 ));
406 }
407 let forge_flag = forge_flag
408 .map(|name| {
409 detect::Forge::parse(name).ok_or_else(|| {
410 RkError::Usage(format!(
411 "unknown forge '{name}'; the forges are: github, gitlab"
412 ))
413 })
414 })
415 .transpose()?;
416 let detected = detect::detect(target.as_std_path());
417 let config = crate::config::load(target.as_std_path())?;
418 let record = crate::landing::manifest::load(target)?;
419 // The setup reads the same target configuration a landing records,
420 // so which steps apply follows the profile rather than a second
421 // detection of its own.
422 let resolved = crate::profile::Params::resolve(
423 target,
424 &crate::profile::Inputs {
425 forge: forge_flag.map(Forge::as_str),
426 repo: repo_flag,
427 ..crate::profile::Inputs::default()
428 },
429 config.as_ref(),
430 record.as_ref(),
431 crate::profile::Purpose::Preview,
432 )?;
433 let declared_forge = resolved.forge().map(str::to_owned);
434 let forge = declared_forge.as_deref().and_then(Forge::parse);
435 let repo = resolved.repo().to_owned();
436 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
437 String::new()
438 } else {
439 repo
440 };
441 // The forge CLI is not a prerequisite of building a context: it is
442 // a prerequisite of the steps a run actually calls the forge for,
443 // which `require_cli` resolves at that point.
444 let cli = PathBuf::new();
445 let answers = config
446 .as_ref()
447 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
448 // The flag wins, and the committed answer fills the gap on GitHub
449 // alone: GitLab names no individual check and refuses a supplied
450 // one, so a shared configuration must not make that refusal fire.
451 let required_check = required_check.map(str::to_owned).or_else(|| {
452 Some(answers.required_check.clone())
453 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
454 });
455 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
456 let bot_name = Some(answers.bot.name.trim().to_owned()).filter(|name| !name.is_empty());
457 let trunk = resolved.trunk().to_owned();
458 let (integration, merge_form) = recorded_workflow(record.as_ref());
459 let landed = record.as_ref().map(|held| {
460 held.files
461 .iter()
462 .map(|file| file.destination.clone())
463 .collect()
464 });
465 let stated = config.as_ref().map(|held| &held.protection);
466 let effective =
467 effective_protection(stated, integration, merge_form, resolved.release_mode());
468 let protection = effective.policy;
469 Ok(Self {
470 target: target.clone(),
471 repo,
472 forge,
473 host: detected.host,
474 required_check,
475 cli,
476 tech: resolved.driver().and_then(|driver| {
477 ["rust", "python", "bash"]
478 .into_iter()
479 .find(|known| *known == driver)
480 }),
481 trunk_ruleset: protection.trunk_ruleset(&trunk),
482 safety_ruleset: protection.safety_ruleset(&trunk),
483 tag_ruleset: protection.tag_ruleset.clone(),
484 lines_ruleset: protection.lines_ruleset.clone(),
485 title_check: gate_check(&protection, merge_form),
486 protection,
487 tag_protection: effective.tag_protection,
488 forge_gate: effective.forge_gate,
489 integration,
490 merge_form,
491 landed,
492 trunk,
493 line_prefix: resolved.line_prefix().to_owned(),
494 profile: resolved.profile().clone(),
495 capabilities: resolved.capabilities().clone(),
496 declared_forge,
497 retired_branches: answers.retired_branches,
498 release_lines: answers.release_lines,
499 excluded_steps: answers.excluded_steps,
500 bot_app_id,
501 bot_name,
502 })
503 }
504
505 /// The context of a target as its configuration and its record state
506 /// it, with no setup flag and no forge call, for a reader that judges
507 /// offline.
508 ///
509 /// # Errors
510 ///
511 /// The resolution's refusals, as [`Self::resolve`] raises them.
512 pub fn offline(target: &Utf8PathBuf) -> Result<Self, RkError> {
513 Self::resolve(target, None, None, None)
514 }
515
516 /// Resolve the forge CLI where this run will call the forge for one of
517 /// `steps`, and refuse where it cannot be found.
518 ///
519 /// The prerequisite belongs to the call, not to the command. A step is
520 /// only a caller when the target's configuration selects it, the
521 /// target has not excluded it, and the step reaches the forge at this
522 /// particular forge. A preview writes nothing, and asks anyway for the
523 /// steps it would act on, because it names the command it would run
524 /// and a CLI nothing could find is worth saying then.
525 ///
526 /// # Errors
527 /// Propagates the refusal when the forge CLI cannot be resolved.
528 ///
529 /// SATISFIES forge-setup:applicability-follows-the-target-configuration
530 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
531 let Some(forge) = self.forge else {
532 return Ok(());
533 };
534 let acting: Vec<&&crate::setup::steps::StepSpec> = steps
535 .iter()
536 .filter(|step| crate::setup::report::stance(self, step).acts())
537 .collect();
538 let calls = acting.iter().any(|step| step.forge_cli.contains(&forge));
539 // The tools the acting steps run, checked before the first of them:
540 // the shell every step script runs through, and the forge CLI where
541 // a step calls the forge. The bot install signs and sends its App
542 // token in-process.
543 let mut tools = Vec::new();
544 if !acting.is_empty() {
545 tools.push(crate::requirements::Tool::Sh);
546 }
547 if calls {
548 tools.push(crate::requirements::Tool::ForgeCli(forge));
549 }
550 crate::requirements::require_in("rk setup", &tools, Some(self.target.as_std_path()))?;
551 if calls && self.cli.as_os_str().is_empty() {
552 self.cli = resolve_cli(forge)?;
553 }
554 // The step's search path leads with the checked CLI's directory, so
555 // the shell is judged again under that path, from the target, where
556 // the step resolves it, before the first forge read.
557 if !acting.is_empty()
558 && let Some(path) = self.child_path()
559 && let Some(unmet) = crate::requirements::check_under_path(
560 crate::requirements::Tool::Sh,
561 Some(self.target.as_std_path()),
562 &path,
563 )
564 {
565 return Err(crate::requirements::refusal(
566 "rk setup",
567 &[crate::requirements::Tool::Sh],
568 &[unmet],
569 ));
570 }
571 Ok(())
572 }
573
574 /// A context the integration tests build directly, for an observer
575 /// exercised against recorded forge answers rather than a repository.
576 /// The trunk and the prefix take their compiled defaults, because such
577 /// a test reads no target configuration.
578 #[doc(hidden)]
579 #[must_use]
580 pub fn for_tests(
581 target: Utf8PathBuf,
582 repo: String,
583 forge: Forge,
584 cli: PathBuf,
585 tech: Option<&'static str>,
586 ) -> Self {
587 let defaults = crate::config::Protection::default();
588 Self {
589 // The forge-integration shape, which is what every observer
590 // and request-body test here asserts; a local-mode case states it.
591 integration: crate::landing::Integration::Forge,
592 merge_form: crate::landing::MergeForm::Squash,
593 tag_protection: true,
594 forge_gate: true,
595 landed: None,
596 target,
597 repo,
598 forge: Some(forge),
599 declared_forge: Some(forge.as_str().to_owned()),
600 profile: ProfileSnapshot {
601 technologies: tech.into_iter().map(str::to_owned).collect(),
602 forge: Some(forge.as_str().to_owned()),
603 release: crate::profile::ReleaseIntent {
604 mode: ReleaseMode::Automatic,
605 driver: tech.map(str::to_owned),
606 style: Some(crate::landing::Style::Trunk),
607 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
608 registry: None,
609 build: None,
610 },
611 },
612 capabilities: CapabilityRequests {
613 nix_packaging: false,
614 reporting_policy: true,
615 scorecard: false,
616 code_scanning: None,
617 workflow_guards: Some(true),
618 },
619 host: None,
620 required_check: None,
621 cli,
622 tech,
623 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
624 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
625 retired_branches: crate::config::Setup::default().retired_branches,
626 release_lines: false,
627 excluded_steps: std::collections::BTreeMap::new(),
628 bot_app_id: None,
629 bot_name: None,
630 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
631 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
632 tag_ruleset: defaults.tag_ruleset.clone(),
633 lines_ruleset: defaults.lines_ruleset.clone(),
634 title_check: defaults.title_check.clone(),
635 protection: defaults,
636 }
637 }
638
639 /// The destinations the landing record names, or `None` where nothing
640 /// landed here.
641 #[must_use]
642 pub fn landed_destinations(&self) -> Option<&[String]> {
643 self.landed.as_deref()
644 }
645
646 /// Whether the run has a forge adapter to act through.
647 #[must_use]
648 pub const fn has_adapter(&self) -> bool {
649 self.forge.is_some()
650 }
651
652 /// The forge adapter, or the refusal a forge operation answers where
653 /// the profile names no forge this release drives.
654 ///
655 /// # Errors
656 ///
657 /// A `prerequisite-unmet` refusal naming the declared forge and the
658 /// ones this release drives.
659 pub fn adapter(&self) -> Result<Forge, RkError> {
660 self.forge.ok_or_else(|| {
661 let named = self.declared_forge.as_deref();
662 let message = named.map_or_else(
663 || "the profile names no forge, and this operation acts on one".to_owned(),
664 |name| {
665 format!(
666 "the profile names the forge {name}, which this release has no adapter for"
667 )
668 },
669 );
670 RkError::refusal(
671 Diagnostic::new(Reason::PrerequisiteUnmet, message)
672 .expected("a profile naming github or gitlab")
673 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
674 .target_state("unchanged"),
675 )
676 })
677 }
678
679 /// Whether this target's release is one release-kit drives.
680 #[must_use]
681 pub const fn automatic_release(&self) -> bool {
682 matches!(self.profile.release.mode, ReleaseMode::Automatic)
683 }
684
685 /// The release driver, where the profile names one.
686 #[must_use]
687 pub fn driver(&self) -> Option<&str> {
688 self.profile.release.driver.as_deref()
689 }
690
691 /// The forge the profile declares, whatever the adapter says.
692 #[must_use]
693 pub fn declared_forge(&self) -> Option<&str> {
694 self.declared_forge.as_deref()
695 }
696
697 /// Whether the target requested the landed reporting policy.
698 #[must_use]
699 pub const fn reporting_policy(&self) -> bool {
700 self.capabilities.reporting_policy
701 }
702
703 /// The one permanent branch this run asserts.
704 #[must_use]
705 pub fn trunk(&self) -> &str {
706 &self.trunk
707 }
708
709 /// The release-line prefix this run asserts.
710 #[must_use]
711 pub fn line_prefix(&self) -> &str {
712 &self.line_prefix
713 }
714
715 /// The long-lived branches this run's single-trunk step retires.
716 #[must_use]
717 pub fn retired_branches(&self) -> &[String] {
718 &self.retired_branches
719 }
720
721 /// Whether a full apply runs the release-line protection.
722 #[must_use]
723 pub const fn release_lines(&self) -> bool {
724 self.release_lines
725 }
726
727 /// Why this target does not run the named step, where it declared an
728 /// exclusion for it. The reason is what the report prints, so an
729 /// excluded step is always visible with the answer behind it.
730 #[must_use]
731 pub fn excluded(&self, step: &str) -> Option<&str> {
732 self.excluded_steps.get(step).map(String::as_str)
733 }
734
735 /// How many steps this target declared it does not run.
736 #[must_use]
737 pub fn excluded_count(&self) -> usize {
738 self.excluded_steps.len()
739 }
740
741 /// The bot App's public identifier this target states, where it does.
742 #[must_use]
743 pub fn bot_app_id(&self) -> Option<&str> {
744 self.bot_app_id.as_deref()
745 }
746
747 /// The bot identity's name this target states, where it does.
748 #[must_use]
749 pub fn bot_name(&self) -> Option<&str> {
750 self.bot_name.as_deref()
751 }
752
753 /// The ruleset that protects the trunk.
754 #[must_use]
755 pub fn trunk_ruleset(&self) -> &str {
756 &self.trunk_ruleset
757 }
758
759 /// The ruleset that keeps the trunk undeletable and unrewritable.
760 #[must_use]
761 pub fn safety_ruleset(&self) -> &str {
762 &self.safety_ruleset
763 }
764
765 /// The ruleset that makes published tags immutable.
766 #[must_use]
767 pub fn tag_ruleset(&self) -> &str {
768 &self.tag_ruleset
769 }
770
771 /// The ruleset that protects the release lines.
772 #[must_use]
773 pub fn lines_ruleset(&self) -> &str {
774 &self.lines_ruleset
775 }
776
777 /// The context the landed forge gate reports under: the title job under
778 /// `squash`, the commit gate's job under `rebase`.
779 #[must_use]
780 pub fn title_check(&self) -> &str {
781 &self.title_check
782 }
783
784 /// Which authority carries an implementation onto this trunk.
785 #[must_use]
786 pub const fn integration(&self) -> crate::landing::Integration {
787 self.integration
788 }
789
790 /// How the commits of a branch reach this trunk, as the record says.
791 #[must_use]
792 pub const fn merge_form(&self) -> crate::landing::MergeForm {
793 self.merge_form
794 }
795
796 /// Whether the release tags are protected: wherever the release mode
797 /// is not `none`.
798 #[must_use]
799 pub const fn tag_protection(&self) -> bool {
800 self.tag_protection
801 }
802
803 /// Whether a forge gate lands, so the trunk requires its check and
804 /// GitLab requires the pipeline.
805 #[must_use]
806 pub const fn forge_gate(&self) -> bool {
807 self.forge_gate
808 }
809
810 /// The safety ruleset's rule types, sorted, as the compact JSON array
811 /// the protection check compares against the forge's answer.
812 fn safety_rule_types(&self) -> String {
813 let mut types: Vec<&str> = self
814 .protection
815 .owned_trunk_rules
816 .iter()
817 .map(String::as_str)
818 .filter(|rule| crate::config::SAFETY_RULES.contains(rule))
819 .collect();
820 types.sort_unstable();
821 types.dedup();
822 serde_json::to_string(&types).unwrap_or_else(|_| "[]".to_owned())
823 }
824
825 /// The trunk ruleset's rules: the request and the check it carries,
826 /// which the recorded bypass actors may be excused from.
827 fn trunk_rules(&self) -> String {
828 compose_rules(
829 &self.protection,
830 &crate::config::REQUEST_RULES,
831 self.required_check.as_deref().unwrap_or_default(),
832 &self.title_check,
833 )
834 }
835
836 /// The safety ruleset's rules: what holds against every actor.
837 fn safety_rules(&self) -> String {
838 compose_rules(
839 &self.protection,
840 &crate::config::SAFETY_RULES,
841 self.required_check.as_deref().unwrap_or_default(),
842 &self.title_check,
843 )
844 }
845
846 /// The setup answers a proof is judged against, by name: everything
847 /// that decides which steps run and what each one asserts, and
848 /// nothing about the host the run happened on. Each value is its
849 /// canonical text, so two runs that resolve the same target produce
850 /// the same map.
851 ///
852 /// SATISFIES setup-proof:the-proof-carries-no-secret-or-machine-coordinate
853 #[must_use]
854 pub fn proof_fields(&self) -> std::collections::BTreeMap<String, String> {
855 let mut fields = std::collections::BTreeMap::new();
856 let mut put = |key: &str, value: String| {
857 fields.insert(key.to_owned(), value);
858 };
859 put("forge", self.declared_forge.clone().unwrap_or_default());
860 put("host", self.host.clone().unwrap_or_default());
861 put("repo", self.repo.clone());
862 put("profile", canonical(&self.profile));
863 put("capabilities", canonical(&self.capabilities));
864 put("trunk", self.trunk.clone());
865 put("line_prefix", self.line_prefix.clone());
866 put("retired_branches", canonical(&self.retired_branches));
867 put("release_lines", bool_word(self.release_lines).to_owned());
868 put("integration", canonical(&self.integration));
869 put(
870 "required_check",
871 self.required_check.clone().unwrap_or_default(),
872 );
873 put("bot_app_id", self.bot_app_id.clone().unwrap_or_default());
874 // Only where it is set: a proof counts a key one side lacks as a
875 // change, so an always-present key would turn every proof committed
876 // before the name existed stale.
877 if let Some(name) = &self.bot_name {
878 put("bot_name", name.clone());
879 }
880 put("trunk_ruleset", self.trunk_ruleset.clone());
881 put("safety_ruleset", self.safety_ruleset.clone());
882 put("tag_ruleset", self.tag_ruleset.clone());
883 put("lines_ruleset", self.lines_ruleset.clone());
884 put("title_check", self.title_check.clone());
885 put("protection", canonical(&self.protection));
886 fields
887 }
888
889 /// The floored policy this target states, already judged at load.
890 #[must_use]
891 pub const fn protection(&self) -> &crate::config::Protection {
892 &self.protection
893 }
894
895 /// Whether this run targets a GitLab instance that is not gitlab.com,
896 /// where registry trusted publishing cannot reach.
897 #[must_use]
898 pub fn self_hosted_gitlab(&self) -> bool {
899 self.forge == Some(Forge::Gitlab)
900 && self
901 .host
902 .as_deref()
903 .is_some_and(|host| host != "gitlab.com")
904 }
905
906 /// Whether this run targets gitlab.com itself, as a detected host.
907 ///
908 /// Positive on purpose: a run with no detected host answers false, so
909 /// a rule that loosens only for gitlab.com stays closed where the host
910 /// is unknown.
911 #[must_use]
912 pub fn on_gitlab_com(&self) -> bool {
913 self.forge == Some(Forge::Gitlab) && self.host.as_deref() == Some("gitlab.com")
914 }
915
916 /// The constructed environment a step receives. Secrets enter only for
917 /// the step that consumes them; the caller records their handling.
918 #[must_use]
919 #[allow(
920 clippy::too_many_lines,
921 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
922 )]
923 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
924 let mut env: Vec<(OsString, OsString)> = vec![
925 (
926 "RK_FORGE".into(),
927 self.forge.map_or("", Forge::as_str).into(),
928 ),
929 ("RK_REPO".into(), self.repo.clone().into()),
930 // The bot identity's configured name, empty where the forge's
931 // default applies, and whether the host is gitlab.com, where
932 // the identity form follows the namespace's tier.
933 (
934 "RK_BOT_NAME".into(),
935 self.bot_name.clone().unwrap_or_default().into(),
936 ),
937 (
938 "RK_GITLAB_COM".into(),
939 bool_word(self.on_gitlab_com()).into(),
940 ),
941 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
942 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
943 // The head-branch prefix of the release driver's own request,
944 // which merge-on-order disarms.
945 (
946 "RK_REQUEST_PREFIX".into(),
947 crate::projection::release_driver_shape(self.driver())
948 .unwrap_or_default()
949 .into(),
950 ),
951 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
952 (
953 "RK_SAFETY_RULESET".into(),
954 self.safety_ruleset.clone().into(),
955 ),
956 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
957 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
958 // The context the landed forge gate reports under, which the
959 // trunk ruleset requires: the title job under `squash` and the
960 // commit gate's job under `rebase`.
961 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
962 // The recorded merge form, so a step asserts the squash
963 // settings only where a squash merge reads them.
964 ("RK_MERGE_FORM".into(), self.merge_form.as_str().into()),
965 // The two requirements the profile decides: the release tags
966 // are protected wherever a release mints them, and the trunk
967 // requires the gate's check, or GitLab's pipeline, wherever a
968 // forge gate lands to report it.
969 (
970 "RK_TAG_PROTECTION".into(),
971 bool_word(self.tag_protection).into(),
972 ),
973 ("RK_FORGE_GATE".into(), bool_word(self.forge_gate).into()),
974 // The floored policy, already judged against the floor table
975 // at load. A step receives values, never a judgment: one
976 // owner decides what passes, and it is not a shell script.
977 (
978 "RK_TAG_PATTERN".into(),
979 self.protection.tag_pattern.clone().into(),
980 ),
981 (
982 "RK_REVIEW_COUNT".into(),
983 self.protection
984 .required_approving_review_count
985 .to_string()
986 .into(),
987 ),
988 (
989 "RK_DISMISS_STALE_REVIEWS".into(),
990 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
991 ),
992 (
993 "RK_CODE_OWNER_REVIEW".into(),
994 bool_word(self.protection.require_code_owner_review).into(),
995 ),
996 (
997 "RK_LAST_PUSH_APPROVAL".into(),
998 bool_word(self.protection.require_last_push_approval).into(),
999 ),
1000 (
1001 "RK_MERGE_METHODS".into(),
1002 json_list(&self.protection.allowed_merge_methods).into(),
1003 ),
1004 (
1005 "RK_STRICT_CHECKS".into(),
1006 bool_word(self.protection.strict_required_status_checks).into(),
1007 ),
1008 (
1009 "RK_BYPASS_ACTORS".into(),
1010 github_bypass_actors(&self.protection.bypass_actors)
1011 .to_string()
1012 .into(),
1013 ),
1014 (
1015 "RK_SQUASH_TITLE_SOURCE".into(),
1016 self.protection.github.squash_title_source.clone().into(),
1017 ),
1018 (
1019 "RK_SQUASH_BODY_SOURCE".into(),
1020 self.protection.github.squash_body_source.clone().into(),
1021 ),
1022 (
1023 "RK_GITLAB_MERGE_METHOD".into(),
1024 self.protection.gitlab.merge_method.clone().into(),
1025 ),
1026 (
1027 "RK_GITLAB_SQUASH_OPTION".into(),
1028 self.protection.gitlab.squash_option.clone().into(),
1029 ),
1030 (
1031 "RK_GITLAB_SQUASH_TEMPLATE".into(),
1032 self.protection.gitlab.squash_commit_template.clone().into(),
1033 ),
1034 (
1035 "RK_GITLAB_PUSH_LEVEL".into(),
1036 self.protection.gitlab.push_access_level.to_string().into(),
1037 ),
1038 // The two rulesets' rules, built from the one key the floor
1039 // table judges and the observer reads, so the body a run
1040 // sends cannot install a rule the check does not expect, or
1041 // omit one it does. The split is what a bypass costs: an
1042 // actor excused from the trunk ruleset is excused from every
1043 // rule in it, so the rules that must hold against everyone
1044 // live in the safety ruleset, which names nobody.
1045 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
1046 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
1047 (
1048 "RK_SAFETY_RULE_TYPES".into(),
1049 self.safety_rule_types().into(),
1050 ),
1051 (
1052 "RK_GITLAB_MERGE_LEVEL".into(),
1053 self.protection.gitlab.merge_access_level.to_string().into(),
1054 ),
1055 ("GH_PAGER".into(), "".into()),
1056 ("GLAB_PAGER".into(), "".into()),
1057 ];
1058 if let Some(check) = &self.required_check
1059 && self.forge == Some(Forge::Github)
1060 && self.forge_gate
1061 && matches!(step, "protect-trunk" | "protections-check")
1062 {
1063 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
1064 }
1065 for name in PASSTHROUGH {
1066 if let Some(value) = std::env::var_os(name) {
1067 env.push((name.into(), value));
1068 }
1069 }
1070 // A step resolves the forge CLI by name, so the directory of the
1071 // file `resolve_cli` checked leads the child's search path. The
1072 // step runs in the target, and the directory is absolute, so the
1073 // name resolves to the checked file there too. The rest of the
1074 // search path passes through unchanged.
1075 if let Some(joined) = self.child_path() {
1076 env.retain(|(name, _)| name != "PATH");
1077 env.push(("PATH".into(), joined));
1078 }
1079 if step == "bot-secrets" {
1080 for name in SECRET_VARS {
1081 if let Some(value) = secrets::value_of(name) {
1082 env.push((name.into(), value));
1083 }
1084 }
1085 }
1086 env
1087 }
1088
1089 /// The search path a step receives where it differs from this
1090 /// process's own: the checked CLI's directory first, then the rest.
1091 fn child_path(&self) -> Option<std::ffi::OsString> {
1092 let dir = self.cli_dir()?;
1093 let mut paths: Vec<PathBuf> = vec![dir];
1094 if let Some(existing) = std::env::var_os("PATH") {
1095 paths.extend(std::env::split_paths(&existing));
1096 }
1097 std::env::join_paths(paths).ok()
1098 }
1099
1100 /// The absolute directory of the forge CLI this run resolved and
1101 /// checked, where it resolved one.
1102 fn cli_dir(&self) -> Option<PathBuf> {
1103 if self.cli.as_os_str().is_empty() {
1104 return None;
1105 }
1106 let cli = std::path::absolute(&self.cli).unwrap_or_else(|_| self.cli.clone());
1107 cli.parent().map(Path::to_path_buf)
1108 }
1109
1110 /// The secret bytes a run must keep out of its own output: the values
1111 /// the environment carries. Every buffer is scrubbed on drop; none is
1112 /// ever logged or echoed.
1113 ///
1114 /// Key material is not read here. The step that transmits a key adds
1115 /// the very bytes it sends, so the needle cannot describe one file
1116 /// while the child receives another.
1117 #[must_use]
1118 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
1119 SECRET_VARS
1120 .iter()
1121 .filter_map(|name| secrets::value_of(name))
1122 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
1123 .collect()
1124 }
1125}
1126
1127/// Resolve the forge CLI once, at context time: the `RK_GH_BIN` and
1128/// `RK_GLAB_BIN` overrides first, then a `PATH` search.
1129///
1130/// The search takes the first executable file named for the CLI, as the
1131/// operating system's own search does, so a file that cannot run never
1132/// shadows one that can. `rk branches prune` shares it for the verify
1133/// path. The shared tool check then judges that exact file, by its
1134/// absolute path, so every caller runs the file the check judged.
1135///
1136/// # Errors
1137///
1138/// Refuses when the override or the search resolves no usable binary,
1139/// or the binary it resolves runs below its floor.
1140pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
1141 let tool = crate::requirements::Tool::ForgeCli(forge);
1142 let override_var = forge.cli_override();
1143 let resolved =
1144 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
1145 let path = PathBuf::from(&overridden);
1146 if !path.is_file() {
1147 // The shared check names the missing override, with its action.
1148 crate::requirements::require("rk", &[tool])?;
1149 return Err(RkError::refusal(
1150 Diagnostic::new(
1151 Reason::PrerequisiteUnmet,
1152 format!(
1153 "{override_var} names {}, which does not exist",
1154 path.display()
1155 ),
1156 )
1157 .expected("the override to name the forge CLI binary")
1158 .action(format!(
1159 "point {override_var} at the {} binary, or unset it",
1160 forge.cli()
1161 )),
1162 ));
1163 }
1164 std::path::absolute(&path).unwrap_or(path)
1165 } else {
1166 let name = forge.cli();
1167 let found = std::env::var_os("PATH").and_then(|path| {
1168 std::env::split_paths(&path)
1169 .map(|dir| dir.join(name))
1170 .find(|candidate| runnable(candidate))
1171 .map(|candidate| std::path::absolute(&candidate).unwrap_or(candidate))
1172 });
1173 let Some(found) = found else {
1174 // The shared check names what it finds, with its action.
1175 crate::requirements::require("rk", &[tool])?;
1176 return Err(RkError::refusal(
1177 Diagnostic::new(
1178 Reason::PrerequisiteUnmet,
1179 format!(
1180 "{name} is not on PATH, and a step this run acts on calls it on {}",
1181 forge.as_str()
1182 ),
1183 )
1184 .expected(format!("the {name} CLI installed and authenticated"))
1185 .action(format!("install {name}, then run {name} auth login")),
1186 ));
1187 };
1188 found
1189 };
1190 if let Some(unmet) = crate::requirements::check_program(tool, resolved.as_os_str(), None) {
1191 return Err(crate::requirements::refusal("rk", &[tool], &[unmet]));
1192 }
1193 // The scripts invoke the CLI by its canonical name through the
1194 // child's search path, so an override under any other name would
1195 // split one lifecycle across two binaries: observed through the
1196 // override, applied through whatever the name resolves to.
1197 if resolved.file_name().is_none_or(|name| name != forge.cli()) {
1198 return Err(RkError::refusal(
1199 Diagnostic::new(
1200 Reason::PrerequisiteUnmet,
1201 format!(
1202 "{override_var} must name a binary called {}, and {} is not one",
1203 forge.cli(),
1204 resolved.display()
1205 ),
1206 )
1207 .expected(format!(
1208 "an override whose file name is {}, so scripts and observations run one binary",
1209 forge.cli()
1210 ))
1211 .action(format!(
1212 "point {override_var} at a file named {}, or unset it",
1213 forge.cli()
1214 )),
1215 ));
1216 }
1217 Ok(resolved)
1218}
1219
1220/// Whether `path` is a file the operating system's search would run.
1221fn runnable(path: &Path) -> bool {
1222 std::fs::metadata(path).is_ok_and(|meta| {
1223 #[cfg(unix)]
1224 {
1225 use std::os::unix::fs::PermissionsExt as _;
1226 meta.is_file() && meta.permissions().mode() & 0o111 != 0
1227 }
1228 #[cfg(not(unix))]
1229 {
1230 meta.is_file()
1231 }
1232 })
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 /// Both rulesets' rules come from the one owned-rules key, so what a
1238 /// run installs, what the floor table judges, and what the check
1239 /// expects cannot disagree. The split is what a bypass costs: a
1240 /// recorded actor is excused from every rule in the ruleset it names,
1241 /// so the rules that hold against everyone are composed apart, and a
1242 /// target under either authority composes the same two halves.
1243 #[test]
1244 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
1245 let kinds = |text: &str| -> Vec<String> {
1246 let parsed: Vec<serde_json::Value> =
1247 serde_json::from_str(text).expect("the rules parse");
1248 parsed
1249 .iter()
1250 .filter_map(|rule| rule["type"].as_str())
1251 .map(str::to_owned)
1252 .collect()
1253 };
1254 let mut policy = crate::config::Protection::default();
1255 let request =
1256 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
1257 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
1258 let safety =
1259 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
1260 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
1261
1262 let parsed: Vec<serde_json::Value> =
1263 serde_json::from_str(&request).expect("the rules parse");
1264 let checks = parsed
1265 .iter()
1266 .find(|rule| rule["type"] == "required_status_checks")
1267 .expect("the check rule");
1268 assert_eq!(
1269 checks["parameters"]["required_status_checks"],
1270 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
1271 );
1272
1273 // The recorded bypass changes who is excused, never which rules
1274 // each ruleset carries.
1275 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
1276 assert_eq!(
1277 kinds(&super::compose_rules(
1278 &policy,
1279 &crate::config::REQUEST_RULES,
1280 "gate",
1281 "pr-title"
1282 )),
1283 ["pull_request", "required_status_checks"]
1284 );
1285 assert_eq!(
1286 kinds(&super::compose_rules(
1287 &policy,
1288 &crate::config::SAFETY_RULES,
1289 "gate",
1290 "pr-title"
1291 )),
1292 ["deletion", "non_fast_forward"]
1293 );
1294 }
1295
1296 /// One effective policy serves the body a step sends, the observer
1297 /// that reads the answer back, and every prerequisite.
1298 ///
1299 /// A target that stated nothing gets the compiled defaults, which
1300 /// describe forge integration because that is the shape this
1301 /// convention had before the axis existed — so under local
1302 /// integration they are adjusted: the GitHub administrator role gains
1303 /// the bypass that admits the direct push, and the GitLab level moves
1304 /// off the zero that would close the trunk to that push. A
1305 /// target that stated a policy keeps every value it stated, because
1306 /// the floor table already judged it under the same mode. The former
1307 /// compiled local tuple is migrated before setup can reinstall it.
1308 #[test]
1309 fn the_effective_policy_follows_the_recorded_authority() {
1310 use crate::landing::{Integration, MergeForm};
1311 let silent = super::effective_protection(
1312 None,
1313 Integration::Forge,
1314 MergeForm::Squash,
1315 crate::profile::ReleaseMode::Automatic,
1316 )
1317 .policy;
1318 assert!(
1319 silent
1320 .owned_trunk_rules
1321 .contains(&"pull_request".to_owned())
1322 );
1323 assert_eq!(silent.gitlab.push_access_level, 0);
1324
1325 let silent = super::effective_protection(
1326 None,
1327 Integration::Local,
1328 MergeForm::Squash,
1329 crate::profile::ReleaseMode::Automatic,
1330 )
1331 .policy;
1332 assert_eq!(
1333 silent.owned_trunk_rules,
1334 crate::config::Protection::default().owned_trunk_rules,
1335 "local integration retains the release request's atomic check"
1336 );
1337 assert_eq!(
1338 silent.bypass_actors,
1339 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
1340 );
1341 assert_eq!(
1342 silent.gitlab.push_access_level, 40,
1343 "zero would close the trunk to the push this mode ends in"
1344 );
1345
1346 let mut stated = crate::config::Protection::default();
1347 stated.gitlab.push_access_level = 0;
1348 stated.owned_trunk_rules = vec!["deletion".into()];
1349 let held = super::effective_protection(
1350 Some(&stated),
1351 Integration::Local,
1352 MergeForm::Squash,
1353 crate::profile::ReleaseMode::Automatic,
1354 )
1355 .policy;
1356 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
1357 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
1358
1359 let legacy = crate::config::Protection {
1360 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1361 gitlab: crate::config::Gitlab {
1362 push_access_level: 40,
1363 ..crate::config::Gitlab::default()
1364 },
1365 ..crate::config::Protection::default()
1366 };
1367 let migrated = super::effective_protection(
1368 Some(&legacy),
1369 Integration::Local,
1370 MergeForm::Squash,
1371 crate::profile::ReleaseMode::Automatic,
1372 )
1373 .policy;
1374 assert_eq!(
1375 migrated,
1376 crate::config::local_protection(),
1377 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1378 );
1379 }
1380
1381 /// The merge values follow the recorded form under both authorities,
1382 /// whether the target stated a policy or not, and only the rebase form
1383 /// adds the linear-history rule, to the safety half.
1384 ///
1385 /// SATISFIES target-config:the-effective-protection-follows-the-profile
1386 #[test]
1387 fn merge_form_decides_the_effective_merge_values() {
1388 use crate::landing::{Integration, MergeForm};
1389 let kinds = |text: &str| -> Vec<String> {
1390 let parsed: Vec<serde_json::Value> =
1391 serde_json::from_str(text).expect("the rules parse");
1392 parsed
1393 .iter()
1394 .filter_map(|rule| rule["type"].as_str())
1395 .map(str::to_owned)
1396 .collect()
1397 };
1398 let stated = crate::config::Protection::default();
1399 for integration in [Integration::Forge, Integration::Local] {
1400 for held in [None, Some(&stated)] {
1401 let squash = super::effective_protection(
1402 held,
1403 integration,
1404 MergeForm::Squash,
1405 crate::profile::ReleaseMode::Automatic,
1406 )
1407 .policy;
1408 assert_eq!(squash.allowed_merge_methods, ["squash".to_owned()]);
1409 assert_eq!(squash.gitlab.squash_option, "always");
1410 assert!(
1411 !squash
1412 .owned_trunk_rules
1413 .contains(&crate::config::LINEAR_HISTORY_RULE.to_owned())
1414 );
1415 assert_eq!(
1416 kinds(&super::compose_rules(
1417 &squash,
1418 &crate::config::SAFETY_RULES,
1419 "gate",
1420 "pr-title"
1421 )),
1422 ["deletion", "non_fast_forward"]
1423 );
1424
1425 let rebase = super::effective_protection(
1426 held,
1427 integration,
1428 MergeForm::Rebase,
1429 crate::profile::ReleaseMode::Automatic,
1430 )
1431 .policy;
1432 assert_eq!(rebase.allowed_merge_methods, ["rebase".to_owned()]);
1433 assert_eq!(rebase.gitlab.squash_option, "never");
1434 assert_eq!(rebase.gitlab.merge_method, "ff");
1435 assert_eq!(
1436 kinds(&super::compose_rules(
1437 &rebase,
1438 &crate::config::SAFETY_RULES,
1439 "gate",
1440 "pr-commits"
1441 )),
1442 ["deletion", "non_fast_forward", "required_linear_history"]
1443 );
1444 assert_eq!(
1445 kinds(&super::compose_rules(
1446 &rebase,
1447 &crate::config::REQUEST_RULES,
1448 "gate",
1449 "pr-commits"
1450 )),
1451 ["pull_request", "required_status_checks"],
1452 "the linear-history rule never sits where a bypass excuses it"
1453 );
1454 }
1455 }
1456 }
1457
1458 /// The required context is the gate the form lands.
1459 #[test]
1460 fn merge_form_names_the_required_gate() {
1461 use crate::landing::MergeForm;
1462 let policy = crate::config::Protection::default();
1463 assert_eq!(super::gate_check(&policy, MergeForm::Squash), "pr-title");
1464 assert_eq!(super::gate_check(&policy, MergeForm::Rebase), "pr-commits");
1465 }
1466 /// The effective protection decides the two requirements from the
1467 /// profile: the tag protection follows the release mode alone, and the
1468 /// required check, carried by the trunk ruleset's check rule, follows
1469 /// the forge gate alone. Deletion and force-push hold in every cell.
1470 ///
1471 /// SATISFIES target-config:the-effective-protection-follows-the-profile
1472 #[test]
1473 fn release_less_effective_protection_follows_the_profile() {
1474 use crate::landing::{Integration, MergeForm};
1475 use crate::profile::ReleaseMode;
1476 for integration in [Integration::Forge, Integration::Local] {
1477 for form in [MergeForm::Squash, MergeForm::Rebase] {
1478 for mode in [
1479 ReleaseMode::Automatic,
1480 ReleaseMode::External,
1481 ReleaseMode::None,
1482 ] {
1483 let effective = super::effective_protection(None, integration, form, mode);
1484 let at = format!("{integration:?} {form:?} {mode:?}");
1485 let gate = integration == Integration::Forge || mode == ReleaseMode::Automatic;
1486 assert_eq!(effective.tag_protection, mode != ReleaseMode::None, "{at}");
1487 assert_eq!(effective.forge_gate, gate, "{at}");
1488 let rules = &effective.policy.owned_trunk_rules;
1489 assert_eq!(
1490 rules.iter().any(|rule| rule == "required_status_checks"),
1491 gate,
1492 "{at}: {rules:?}"
1493 );
1494 for kept in ["deletion", "non_fast_forward", "pull_request"] {
1495 assert!(rules.iter().any(|rule| rule == kept), "{at}: {kept}");
1496 }
1497 assert_eq!(
1498 effective.policy.allowed_merge_methods,
1499 [form.as_str().to_owned()],
1500 "{at}"
1501 );
1502 }
1503 }
1504 }
1505 }
1506}