Skip to main content

vgi_forge_github/
plan.rs

1//! GitHub's bootstrap plan (§5.3) and the files it commits.
2//!
3//! The order is load-bearing: files go in first, then the variables, then
4//! the protection. The ruleset has no bypass actors — the bridge included —
5//! so after it exists every change to the default branch goes through a pull
6//! request and the check.
7//!
8//! **The pull request must not be able to satisfy its own check** (§9). A
9//! `pull_request` workflow runs from the pull request's own files, so the
10//! plan takes one of two guards, chosen per namespace ([`CheckGuard`]):
11//!
12//! - [`CheckGuard::RequiredWorkflow`] (organisations with org rulesets): the
13//!   check is not committed to the repository at all. It lives in the
14//!   bridge-managed `<org>/.vgi` repository and an org ruleset requires it
15//!   at a **pinned commit**, so nothing in the pull request changes what
16//!   runs. The DIDs and the exempt keyring are written into that workflow,
17//!   not read from the repository under test.
18//! - Elsewhere (personal accounts, organisations without org rulesets) the
19//!   workflow is committed to the repository, with the DIDs as literals:
20//!   - [`CheckGuard::OwnerReview`], two or more owners: `CODEOWNERS` makes
21//!     every change under `.github/` — the workflow, the keyring,
22//!     `CODEOWNERS` itself — need another owner's approving review, which
23//!     the ruleset requires (dismissed by later pushes, never the last
24//!     pusher's own).
25//!   - [`CheckGuard::SoloOwner`], one owner: no review requirement, only the
26//!     check. The owner could weaken their own workflow; accepted (the
27//!     user's decision), since they control the repository anyway.
28//!
29//!   In both, repository writers are trusted not to forge a "Verify commit
30//!   trust" check run from a workflow on another branch; only the required
31//!   workflow closes that.
32//! - [`CheckGuard::BridgePosted`] (the same namespaces, when the adapter is
33//!   configured with [`crate::GitHubConfig::bridge_checks`]): no workflow at
34//!   all. The bridge runs verify-trust itself on every pull request and
35//!   merge group and posts the check as the community's App; the ruleset
36//!   requires the check **from that App's integration id**, which no
37//!   workflow can post as. This is what closes the forged-check gap outside
38//!   a required workflow (§9, decided 2026-09-23).
39
40use vgi_forge::{
41    BootstrapComponent, BootstrapStep, ForgeAccount, ForgeError, ProtectionSpec, RepoSpec,
42    Resource, Result, StepAction, VgiConfig, validate_repo_path,
43};
44
45/// Where the workflow is committed (in the repository, or in `.vgi`).
46pub const WORKFLOW_PATH: &str = ".github/workflows/verify-trust.yml";
47/// Where the exempt platform keyring is committed.
48pub const KEYRING_PATH: &str = ".github/trusted-platform-keys.asc";
49/// Where the managed code-owner rules are committed. GitHub reads
50/// `.github/CODEOWNERS` ahead of `CODEOWNERS` and `docs/CODEOWNERS`.
51pub const CODEOWNERS_PATH: &str = ".github/CODEOWNERS";
52/// Every place GitHub looks for a `CODEOWNERS` file; the first one found is
53/// the only one used.
54pub const CODEOWNERS_LOCATIONS: [&str; 3] = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"];
55/// The paths owner review guards: everything a workflow run reads from the
56/// repository under test to decide how to check it.
57pub const GUARDED_PATH: &str = "/.github/";
58/// Name of the ruleset the adapter manages. Found by name on re-runs.
59pub const RULESET_NAME: &str = "VGI commit trust";
60/// Name of the org ruleset that requires the namespace workflow.
61pub const ORG_RULESET_NAME: &str = "VGI required workflow";
62/// The bridge-managed repository in an organisation that holds the required
63/// workflow. Public, so the workflow may run on repositories of any
64/// visibility (a private source repository's workflow may run only on
65/// private repositories); it holds nothing secret.
66pub const CENTRAL_REPO: &str = ".vgi";
67/// Registry DID variable.
68pub const VAR_REGISTRY: &str = "TRUST_REGISTRY_DID";
69/// VTC DID variable.
70pub const VAR_VTC: &str = "VTC_DID";
71
72/// How the plan keeps the pull request away from its own check (§9).
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum CheckGuard {
76    /// An org ruleset requires the workflow in `<org>/.vgi` at a pinned
77    /// commit.
78    RequiredWorkflow,
79    /// Two or more owners: `CODEOWNERS` names them for `.github/`, and the
80    /// ruleset requires one approval — a code owner's, not the last
81    /// pusher's, dismissed by later pushes.
82    OwnerReview {
83        /// Who may approve workflow changes. At least two.
84        owners: Vec<ForgeAccount>,
85    },
86    /// Exactly one owner: no review requirement, only the required check.
87    /// The owner could weaken their own workflow — accepted, since they
88    /// control the repository anyway (the user's decision). Re-plan when a
89    /// second owner arrives.
90    SoloOwner,
91    /// The bridge posts the check under its own App, and the ruleset pins
92    /// the required check to that App. No workflow is committed; one left
93    /// from an earlier guard is removed.
94    BridgePosted,
95}
96
97impl CheckGuard {
98    /// The guard for a repository with these (linked) owners, outside a
99    /// required-workflow namespace. Duplicate ids count once.
100    pub fn for_owners(owners: &[ForgeAccount]) -> CheckGuard {
101        let mut distinct: Vec<ForgeAccount> = Vec::new();
102        for o in owners {
103            if !distinct.iter().any(|d| d.id == o.id) {
104                distinct.push(o.clone());
105            }
106        }
107        if distinct.len() == 1 {
108            CheckGuard::SoloOwner
109        } else {
110            CheckGuard::OwnerReview { owners: distinct }
111        }
112    }
113}
114
115/// Build the plan. Validates everything that ends up in a file or a
116/// variable, so a bad config fails here rather than half-way through.
117pub fn github_plan(
118    repo: &RepoSpec,
119    cfg: &VgiConfig,
120    checkout_action: &str,
121    guard: &CheckGuard,
122) -> Result<Vec<BootstrapStep>> {
123    if repo.resource.is_namespace() {
124        return Err(ForgeError::WrongResource {
125            resource: repo.resource.to_string(),
126            expected: "a repository, not a namespace".into(),
127        });
128    }
129    check_did("trust_registry_did", &cfg.trust_registry_did)?;
130    check_did("vtc_did", &cfg.vtc_did)?;
131    check_pinned("checkout action", checkout_action)?;
132    check_pinned("verify-trust action", &cfg.verify_trust_action)?;
133    check_version(&cfg.verify_trust_version)?;
134    check_check_name(&cfg.required_check)?;
135    // The bridge-posted check reads no keyring from the repository (the
136    // bridge holds its own, optionally), so only the workflow guards need
137    // one to commit or embed.
138    let keyring: &[u8] = match (cfg.platform_keyring.as_deref(), guard) {
139        (Some(k), _) => {
140            check_keyring(k)?;
141            k
142        }
143        (None, CheckGuard::BridgePosted) => &[],
144        (None, _) => {
145            return Err(ForgeError::Config(
146                "no platform keyring: GitHub web-UI merges are signed by `web-flow`, and without                  its key in the exempt keyring every merge commit fails the check. Supply it in                  the config (for github.com, the contents of https://github.com/web-flow.gpg)"
147                    .into(),
148            ));
149        }
150    };
151
152    let mut steps = Vec::new();
153    if matches!(
154        guard,
155        CheckGuard::OwnerReview { .. } | CheckGuard::SoloOwner
156    ) {
157        steps.push(BootstrapStep::new(
158            "workflow",
159            BootstrapComponent::Workflow,
160            StepAction::WriteFile {
161                path: WORKFLOW_PATH.into(),
162                contents: render_workflow(cfg, checkout_action, &repo.resource).into_bytes(),
163                message: "ci: add the VGI commit-trust check".into(),
164            },
165        ));
166        steps.push(BootstrapStep::new(
167            "keyring",
168            BootstrapComponent::Keyring,
169            StepAction::WriteFile {
170                path: KEYRING_PATH.into(),
171                contents: keyring.to_vec(),
172                message: "ci: add the exempt platform keyring for web-flow merges".into(),
173            },
174        ));
175    }
176
177    let mut community_owners: Option<&[u8]> = None;
178    for file in &cfg.extra_files {
179        validate_repo_path(&file.path)?;
180        if file.path == WORKFLOW_PATH || file.path == KEYRING_PATH {
181            return Err(ForgeError::Config(format!(
182                "extra file `{}` would overwrite a file the bootstrap manages",
183                file.path
184            )));
185        }
186        if matches!(guard, CheckGuard::OwnerReview { .. })
187            && CODEOWNERS_LOCATIONS.contains(&file.path.as_str())
188        {
189            // The managed `.github/CODEOWNERS` would shadow a community one
190            // anywhere else (GitHub reads only the first it finds), so the
191            // community's rules are folded into it instead, ahead of the
192            // managed rule.
193            if community_owners.replace(&file.contents).is_some() {
194                return Err(ForgeError::Config(
195                    "more than one CODEOWNERS among the extra files; GitHub would use only one"
196                        .into(),
197                ));
198            }
199            continue;
200        }
201        steps.push(BootstrapStep::new(
202            format!("file:{}", file.path),
203            BootstrapComponent::Extra,
204            StepAction::WriteFile {
205                path: file.path.clone(),
206                contents: file.contents.clone(),
207                message: format!("chore: add {}", file.path),
208            },
209        ));
210    }
211
212    match guard {
213        CheckGuard::RequiredWorkflow => {
214            check_embeddable_keyring(keyring)?;
215            steps.push(BootstrapStep::new(
216                "required-workflow",
217                BootstrapComponent::Workflow,
218                StepAction::RequireNamespaceWorkflow {
219                    contents: render_required_workflow(
220                        cfg,
221                        checkout_action,
222                        &repo.resource,
223                        keyring,
224                    )?
225                    .into_bytes(),
226                    check: cfg.required_check.clone(),
227                    message: "ci: pin the VGI commit-trust check".into(),
228                },
229            ));
230            steps.push(BootstrapStep::new(
231                "ruleset",
232                BootstrapComponent::RequiredCheck,
233                StepAction::ProtectDefaultBranch(
234                    ProtectionSpec::standard(cfg.required_check.clone())
235                        .with_check_enforced_by_namespace()
236                        .with_required_approvals(cfg.required_approvals),
237                ),
238            ));
239            // A repository that had the owner-review guard before its org
240            // gained org rulesets: its own workflow, keyring and variables
241            // are no longer what runs. The ruleset step above already drops
242            // its status-check rule.
243            steps.extend(cleanup_variables());
244            for (id, path) in [
245                ("cleanup:workflow", WORKFLOW_PATH),
246                ("cleanup:keyring", KEYRING_PATH),
247            ] {
248                steps.push(BootstrapStep::new(
249                    id,
250                    BootstrapComponent::Extra,
251                    StepAction::RemoveFile {
252                        path: path.into(),
253                        message: "ci: the VGI check now runs as the org's required workflow".into(),
254                    },
255                ));
256            }
257        }
258        CheckGuard::OwnerReview { owners } => {
259            if owners.len() < 2 {
260                return Err(ForgeError::Config(format!(
261                    "`{}`: owner review needs at least two owners with linked GitHub accounts \
262                     (one owner is a solo repository, none cannot be planned)",
263                    repo.resource
264                )));
265            }
266            let community_rules = community_owners.map(<[u8]>::to_vec).unwrap_or_default();
267            if std::str::from_utf8(&community_rules).is_err() {
268                return Err(ForgeError::Config(
269                    "community CODEOWNERS is not UTF-8".into(),
270                ));
271            }
272            steps.push(BootstrapStep::new(
273                "codeowners",
274                BootstrapComponent::Workflow,
275                StepAction::RequireOwnerReview {
276                    paths: vec![GUARDED_PATH.into()],
277                    owners: owners.clone(),
278                    community_rules,
279                    message: "ci: require an owner's review for workflow changes".into(),
280                },
281            ));
282            steps.push(BootstrapStep::new(
283                "ruleset",
284                BootstrapComponent::RequiredCheck,
285                StepAction::ProtectDefaultBranch(
286                    ProtectionSpec::standard(cfg.required_check.clone())
287                        .with_code_owner_review()
288                        .with_required_approvals(cfg.required_approvals),
289                ),
290            ));
291            steps.extend(cleanup_variables());
292        }
293        CheckGuard::SoloOwner => {
294            steps.push(BootstrapStep::new(
295                "ruleset",
296                BootstrapComponent::RequiredCheck,
297                StepAction::ProtectDefaultBranch(
298                    ProtectionSpec::standard(cfg.required_check.clone())
299                        .with_required_approvals(cfg.required_approvals),
300                ),
301            ));
302            steps.extend(cleanup_variables());
303        }
304        CheckGuard::BridgePosted => {
305            // The adapter pins this rule's check to its own App when it runs
306            // the step: the plan is the same ruleset, what differs is who
307            // may satisfy it.
308            steps.push(BootstrapStep::new(
309                "ruleset",
310                BootstrapComponent::RequiredCheck,
311                StepAction::ProtectDefaultBranch(
312                    ProtectionSpec::standard(cfg.required_check.clone())
313                        .with_required_approvals(cfg.required_approvals),
314                ),
315            ));
316            steps.extend(cleanup_variables());
317            // A workflow from an earlier guard would still run and post an
318            // Actions check that no longer counts; remove it so nothing
319            // suggests it matters. After the ruleset, like the
320            // required-workflow clean-up: the protection comes first.
321            steps.push(BootstrapStep::new(
322                "cleanup:workflow",
323                BootstrapComponent::Extra,
324                StepAction::RemoveFile {
325                    path: WORKFLOW_PATH.into(),
326                    message: "ci: the VGI check is now posted by the community's bridge".into(),
327                },
328            ));
329        }
330    }
331    Ok(steps)
332}
333
334/// The DIDs are literals in the workflow now (a repository variable could
335/// be changed by any repository admin, and overrides an org one): the old
336/// variables are removed so nothing suggests they still matter.
337fn cleanup_variables() -> Vec<BootstrapStep> {
338    [VAR_REGISTRY, VAR_VTC]
339        .into_iter()
340        .map(|name| {
341            BootstrapStep::new(
342                format!("cleanup:variable:{name}"),
343                BootstrapComponent::Variables,
344                StepAction::RemoveVariable { name: name.into() },
345            )
346        })
347        .collect()
348}
349
350/// First line of the managed block in a `CODEOWNERS` file.
351pub const MANAGED_BEGIN: &str = "# BEGIN VGI managed owner rules";
352/// Last line of the managed block.
353pub const MANAGED_END: &str = "# END VGI managed owner rules";
354
355/// A `CODEOWNERS` file: `community_rules` (the file's own rules, the managed
356/// block removed), then the managed block last, so it wins for `paths` (the
357/// last matching pattern takes precedence). `logins` are resolved from
358/// numeric ids at run time.
359pub fn render_codeowners(community_rules: &str, paths: &[String], logins: &[String]) -> String {
360    let mut out = String::new();
361    let community = strip_managed(community_rules);
362    if !community.trim().is_empty() {
363        out.push_str(community.trim_end());
364        out.push_str("\n\n");
365    }
366    out.push_str(MANAGED_BEGIN);
367    out.push_str(
368        "\n# Kept last by this community's VGI bridge: every change to the repository's\n\
369         # workflows (and to this file) needs another owner's approving review, so the\n\
370         # commit-trust check is not editable by the pull request it checks.\n",
371    );
372    let owners: Vec<String> = logins.iter().map(|l| format!("@{l}")).collect();
373    for p in paths {
374        out.push_str(&format!("{p} {}\n", owners.join(" ")));
375    }
376    out.push_str(MANAGED_END);
377    out.push('\n');
378    out
379}
380
381/// `text` without the managed block (so re-rendering keeps only the
382/// community's own rules).
383pub fn strip_managed(text: &str) -> String {
384    let mut out = Vec::new();
385    let mut inside = false;
386    for line in text.lines() {
387        match line.trim() {
388            l if l == MANAGED_BEGIN => inside = true,
389            l if l == MANAGED_END && inside => inside = false,
390            _ if inside => {}
391            _ => out.push(line),
392        }
393    }
394    let mut s = out.join("\n");
395    if !s.is_empty() {
396        s.push('\n');
397    }
398    s
399}
400
401/// The managed block of a `CODEOWNERS` file, if it is there and nothing but
402/// comments follows it: `(pattern, owners, 1-based line)` per rule.
403pub fn managed_rules(text: &str) -> Option<Vec<(String, Vec<String>, usize)>> {
404    let lines: Vec<&str> = text.lines().collect();
405    let begin = lines.iter().rposition(|l| l.trim() == MANAGED_BEGIN)?;
406    let end = begin
407        + lines[begin..]
408            .iter()
409            .position(|l| l.trim() == MANAGED_END)?;
410    let is_rule = |l: &&str| {
411        let t = l.trim();
412        !t.is_empty() && !t.starts_with('#')
413    };
414    if lines[end + 1..].iter().any(is_rule) {
415        return None;
416    }
417    let rules = lines[begin + 1..end]
418        .iter()
419        .enumerate()
420        .filter(|(_, l)| is_rule(l))
421        .map(|(i, l)| {
422            let mut parts = l.split('#').next().unwrap_or("").split_whitespace();
423            let pattern = parts.next().unwrap_or("").to_string();
424            (pattern, parts.map(str::to_string).collect(), begin + 2 + i)
425        })
426        .collect();
427    Some(rules)
428}
429
430/// The workflow committed to the repository (outside a required-workflow
431/// namespace). Differs from the dormant one in the runbook: there is no
432/// `if: vars.TRUST_REGISTRY_DID != ''` guard (a *skipped* required job
433/// counts as passing), and the DIDs are literals rather than `vars.*`,
434/// which any repository admin could change.
435///
436/// The namespace is the fallback resource ([`fallback_resource`]): the VTC
437/// publishes namespace-wide commit rights — a `git.ns.admin`'s implied
438/// `git.commit.sign`, the bridge's service grant — on it, not on each
439/// repository.
440pub fn render_workflow(cfg: &VgiConfig, checkout_action: &str, repo: &Resource) -> String {
441    format!(
442        r#"# Managed by this community's VGI bridge. It is rewritten on bootstrap;
443# propose changes to the VTC rather than editing it here.
444name: verify-trust
445
446on:
447  pull_request:
448  # A merge queue runs required checks on its own merge commits; without
449  # this trigger the check never reports there and queued merges stall.
450  merge_group:
451
452# Reads the repository and downloads a public release; writes nothing.
453permissions:
454  contents: read
455
456jobs:
457  verify:
458    name: {job_name}
459    runs-on: ubuntu-latest
460    steps:
461      - uses: {checkout}
462        with:
463          # The base ref must be present so `origin/<base>..HEAD` resolves.
464          fetch-depth: 0
465          persist-credentials: false
466
467      - name: verify-trust
468        uses: {action}
469        with:
470          # merge_group events have no base_ref; the queue names its base
471          # commit instead.
472          range: ${{{{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{{0}}', github.base_ref) }}}}..HEAD
473          # Literals, not `vars.*`: repository variables are any
474          # repository admin's to change.
475          registry-did: {registry}
476          vtc-did: {vtc}
477{transport}          resource-format: qualified
478{fallback}          # GitHub web-UI merge/squash commits are PGP-signed by web-flow;
479          # they pass only via this committed keyring.
480          exempt-keyring: {keyring}
481          version: {version}
482"#,
483        job_name = yaml_single_quoted(&cfg.required_check),
484        checkout = checkout_action,
485        action = cfg.verify_trust_action,
486        registry = yaml_single_quoted(&cfg.trust_registry_did),
487        vtc = yaml_single_quoted(&cfg.vtc_did),
488        transport = cfg.verify_trust_transport.workflow_input_line("          "),
489        fallback = fallback_block(repo),
490        keyring = KEYRING_PATH,
491        version = cfg.verify_trust_version,
492    )
493}
494
495/// The required workflow held in `<org>/.vgi`. It runs in the context of
496/// the repository under test (its `GITHUB_REPOSITORY`, its pull request),
497/// but everything that decides *how* to check is fixed here, at the pinned
498/// commit:
499///
500/// - the DIDs are literals, not `vars.*` — a repository variable overrides
501///   an organisation one of the same name, and repository admins set those;
502/// - the exempt keyring is written from this file to the runner's temp
503///   directory, not read from the repository, where the pull request could
504///   add its own key to it.
505///
506/// It is shared by every managed repository of the organisation, so the
507/// fallback resource names the namespace of the repository it runs for, read
508/// at run time ([`fallback_resource`]) rather than written in.
509pub fn render_required_workflow(
510    cfg: &VgiConfig,
511    checkout_action: &str,
512    repo: &Resource,
513    keyring: &[u8],
514) -> Result<String> {
515    let keyring = std::str::from_utf8(keyring)
516        .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
517    let mut block = String::new();
518    for line in keyring.lines() {
519        if line.is_empty() {
520            block.push('\n');
521        } else {
522            block.push_str("            ");
523            block.push_str(line);
524            block.push('\n');
525        }
526    }
527    Ok(format!(
528        r#"# Managed by this community's VGI bridge, and required on the community's
529# repositories by the org ruleset "{ruleset}" at a pinned commit. A change
530# here takes effect only when the bridge pins it; propose changes to the VTC.
531name: verify-trust
532
533on:
534  pull_request:
535  # A merge queue runs required workflows on its own merge commits; without
536  # this trigger the check never reports there and queued merges stall.
537  merge_group:
538
539# Reads the repository and downloads a public release; writes nothing.
540permissions:
541  contents: read
542
543jobs:
544  verify:
545    name: {job_name}
546    runs-on: ubuntu-latest
547    steps:
548      - uses: {checkout}
549        with:
550          # The base ref must be present so `origin/<base>..HEAD` resolves.
551          fetch-depth: 0
552          persist-credentials: false
553
554      # GitHub web-UI merge/squash commits are PGP-signed by web-flow and pass
555      # only via this keyring. It is part of this pinned file, not of the
556      # repository under test, which a pull request could edit.
557      - name: exempt platform keyring
558        env:
559          VGI_PLATFORM_KEYRING: |
560{keyring}        run: printf '%s' "$VGI_PLATFORM_KEYRING" > "$RUNNER_TEMP/vgi-platform-keys.asc"
561
562      - name: verify-trust
563        uses: {action}
564        with:
565          # merge_group events have no base_ref; the queue names its base
566          # commit instead.
567          range: ${{{{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{{0}}', github.base_ref) }}}}..HEAD
568          # Literals, not `vars.*`: a repository variable of the same name
569          # would override an organisation one.
570          registry-did: {registry}
571          vtc-did: {vtc}
572{transport}          resource-format: qualified
573{fallback}          exempt-keyring: ${{{{ runner.temp }}}}/vgi-platform-keys.asc
574          version: {version}
575"#,
576        ruleset = ORG_RULESET_NAME,
577        job_name = yaml_single_quoted(&cfg.required_check),
578        checkout = checkout_action,
579        keyring = block,
580        action = cfg.verify_trust_action,
581        registry = yaml_single_quoted(&cfg.trust_registry_did),
582        vtc = yaml_single_quoted(&cfg.vtc_did),
583        transport = cfg.verify_trust_transport.workflow_input_line("          "),
584        fallback = fallback_block(repo),
585        version = cfg.verify_trust_version,
586    ))
587}
588
589/// The `fallback-resource` value both workflows pass:
590/// `<forge-host>/${{ github.repository_owner }}`.
591///
592/// The VTC publishes a namespace's commit rights — every `git.ns.admin`'s
593/// implied `git.commit.sign`, a namespace-wide grant, the bridge's service
594/// grant that its re-signed Dependabot commits rely on — on the namespace
595/// resource (`github.com/acme`), and a bridge that sets up a repository's
596/// check must make the namespace its fallback (git-ns `right/grant` 0.1).
597///
598/// Exactly the repository's own namespace, never broader:
599///
600/// - the owner is the one GitHub runs the job for, read at run time, so one
601///   file serves every repository of an organisation (the required
602///   workflow) and a copy in another owner's repository names that owner,
603///   never this one;
604/// - the host is the forge's, fixed here (a resource names no scheme, so
605///   `github.server_url` cannot be used as is);
606/// - the value reaches verify-trust through the action's environment, never
607///   a script, and verify-trust refuses a fallback that does not contain the
608///   repository's own resource (another owner, another forge);
609/// - it is only ever written next to `resource-format: qualified` — a legacy
610///   run takes no forge-qualified fallback.
611pub fn fallback_resource(repo: &Resource) -> String {
612    format!("{}/${{{{ github.repository_owner }}}}", repo.host())
613}
614
615fn fallback_block(repo: &Resource) -> String {
616    format!(
617        "          # The namespace: where the VTC publishes namespace-wide commit rights.\n          \
618         fallback-resource: {}\n",
619        fallback_resource(repo)
620    )
621}
622
623fn yaml_single_quoted(s: &str) -> String {
624    format!("'{}'", s.replace('\'', "''"))
625}
626
627fn check_did(field: &str, did: &str) -> Result<()> {
628    // `${{` would make the value an Actions expression where it is written
629    // into a workflow as a literal.
630    let ok = did.starts_with("did:")
631        && !did.contains("${{")
632        && did.len() <= 2048
633        && did
634            .bytes()
635            .all(|b| b.is_ascii_graphic() && b != b'\'' && b != b'"');
636    if ok {
637        Ok(())
638    } else {
639        Err(ForgeError::Config(format!(
640            "{field} `{did}` is not a DID (expected `did:<method>:…`, no spaces or quotes)"
641        )))
642    }
643}
644
645/// `owner/repo[/path]@<40 hex>` — the only form the workflow will `uses:`.
646fn check_pinned(what: &str, reference: &str) -> Result<()> {
647    let pinned = reference.rsplit_once('@').is_some_and(|(path, sha)| {
648        sha.len() == 40
649            && sha.bytes().all(|b| b.is_ascii_hexdigit())
650            && path.split('/').count() >= 2
651            && path.split('/').all(|s| {
652                !s.is_empty()
653                    && s != ".."
654                    && s.bytes()
655                        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
656            })
657    });
658    if pinned {
659        Ok(())
660    } else {
661        Err(ForgeError::Config(format!(
662            "{what} `{reference}` must be pinned to a commit: `owner/repo[/path]@<40-hex sha>`"
663        )))
664    }
665}
666
667fn check_version(v: &str) -> Result<()> {
668    let ok = !v.is_empty()
669        && v.len() <= 64
670        && v.bytes()
671            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
672    if ok {
673        Ok(())
674    } else {
675        Err(ForgeError::Config(format!(
676            "verify-trust version `{v}` must be a release tag like `v0.5.0` or `latest`"
677        )))
678    }
679}
680
681fn check_check_name(name: &str) -> Result<()> {
682    // `${{` would make the job name an Actions expression.
683    if name.trim().is_empty()
684        || name.len() > 100
685        || name.chars().any(char::is_control)
686        || name.contains("${{")
687    {
688        return Err(ForgeError::Config(format!(
689            "required check name `{name}` must be 1–100 printable characters"
690        )));
691    }
692    Ok(())
693}
694
695fn check_keyring(bytes: &[u8]) -> Result<()> {
696    let text = std::str::from_utf8(bytes)
697        .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
698    if text.contains("PRIVATE KEY") {
699        // Committing it would publish it.
700        return Err(ForgeError::Config(
701            "platform keyring contains a PRIVATE key block; supply the public key only".into(),
702        ));
703    }
704    if !text.contains("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
705        return Err(ForgeError::Config(
706            "platform keyring is not an armored PGP public key block".into(),
707        ));
708    }
709    Ok(())
710}
711
712/// The keyring is written into the required workflow as a YAML block
713/// scalar inside `env:`, where Actions evaluates expressions: refuse
714/// anything that could become one, or that is not plain armor text.
715fn check_embeddable_keyring(bytes: &[u8]) -> Result<()> {
716    let text = std::str::from_utf8(bytes)
717        .map_err(|_| ForgeError::Config("platform keyring is not ASCII armor".into()))?;
718    // A lone `\r` is a line break to a YAML parser but not to `lines()`, so
719    // it could end the block scalar early: only `\r\n` is allowed.
720    let lone_cr = text
721        .char_indices()
722        .any(|(i, c)| c == '\r' && !text[i + 1..].starts_with('\n'));
723    if text.contains("${{")
724        || lone_cr
725        || text
726            .chars()
727            .any(|c| c.is_control() && c != '\n' && c != '\r')
728    {
729        return Err(ForgeError::Config(
730            "platform keyring holds characters that cannot be embedded in the required workflow"
731                .into(),
732        ));
733    }
734    Ok(())
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use vgi_forge::Resource;
741
742    const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
743
744    fn cfg() -> VgiConfig {
745        VgiConfig::new(
746            "did:webvh:reg",
747            "did:webvh:vtc",
748            format!("OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@{SHA}"),
749            "v0.5.0",
750        )
751        .with_platform_keyring(
752            "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nx\n-----END PGP PUBLIC KEY BLOCK-----\n",
753        )
754    }
755
756    fn spec() -> RepoSpec {
757        RepoSpec::new(Resource::parse("github.com/acme/gadgets").unwrap())
758    }
759
760    fn owner_review() -> CheckGuard {
761        CheckGuard::OwnerReview {
762            owners: vec![ForgeAccount::new(7, "bob"), ForgeAccount::new(9, "carol")],
763        }
764    }
765
766    const CHECKOUT: &str = crate::config::DEFAULT_CHECKOUT_ACTION;
767
768    fn ids(plan: &[BootstrapStep]) -> Vec<&str> {
769        plan.iter().map(|s| s.id.as_str()).collect()
770    }
771
772    /// The community's `required_approvals` goes into the ruleset step of
773    /// every guard, not only owner review.
774    #[test]
775    fn every_guard_carries_the_required_approvals() {
776        let cfg = cfg().with_required_approvals(2);
777        for guard in [
778            CheckGuard::RequiredWorkflow,
779            owner_review(),
780            CheckGuard::SoloOwner,
781            CheckGuard::BridgePosted,
782        ] {
783            let plan = github_plan(&spec(), &cfg, CHECKOUT, &guard).unwrap();
784            let protection = plan
785                .iter()
786                .find_map(|s| match &s.action {
787                    StepAction::ProtectDefaultBranch(p) => Some(p.clone()),
788                    _ => None,
789                })
790                .expect("a ruleset step");
791            assert_eq!(protection.required_approvals, 2, "{guard:?}");
792        }
793    }
794
795    #[test]
796    fn the_guard_follows_the_owner_count() {
797        let bob = ForgeAccount::new(7, "bob");
798        assert_eq!(
799            CheckGuard::for_owners(std::slice::from_ref(&bob)),
800            CheckGuard::SoloOwner
801        );
802        assert_eq!(
803            CheckGuard::for_owners(&[bob.clone(), ForgeAccount::new(7, "bob-renamed")]),
804            CheckGuard::SoloOwner,
805            "one id, twice, is one owner"
806        );
807        assert!(matches!(
808            CheckGuard::for_owners(&[bob, ForgeAccount::new(9, "carol")]),
809            CheckGuard::OwnerReview { owners } if owners.len() == 2
810        ));
811    }
812
813    #[test]
814    fn owner_review_plans_files_then_codeowners_then_ruleset_then_cleanup() {
815        let plan = github_plan(
816            &spec(),
817            &cfg()
818                .with_extra_file("LICENSE", "MIT\n")
819                .with_extra_file("CODEOWNERS", "* @acme/owners\n"),
820            CHECKOUT,
821            &owner_review(),
822        )
823        .unwrap();
824        assert_eq!(
825            ids(&plan),
826            [
827                "workflow",
828                "keyring",
829                "file:LICENSE",
830                "codeowners",
831                "ruleset",
832                "cleanup:variable:TRUST_REGISTRY_DID",
833                "cleanup:variable:VTC_DID",
834            ]
835        );
836        // The community's CODEOWNERS is folded into the managed one, which
837        // GitHub would otherwise read instead of it.
838        let StepAction::RequireOwnerReview {
839            paths,
840            owners,
841            community_rules,
842            ..
843        } = &plan[3].action
844        else {
845            panic!("{:?}", plan[3]);
846        };
847        assert_eq!(paths, &["/.github/"]);
848        assert_eq!(owners.len(), 2);
849        assert_eq!(community_rules, b"* @acme/owners\n");
850        let StepAction::ProtectDefaultBranch(p) = &plan[4].action else {
851            panic!()
852        };
853        assert!(p.require_code_owner_review && p.require_status_check);
854
855        for owners in [vec![], vec![ForgeAccount::new(7, "bob")]] {
856            let e = github_plan(
857                &spec(),
858                &cfg(),
859                CHECKOUT,
860                &CheckGuard::OwnerReview { owners },
861            )
862            .unwrap_err();
863            assert!(e.to_string().contains("at least two owners"), "{e}");
864        }
865        let two = cfg()
866            .with_extra_file("CODEOWNERS", "")
867            .with_extra_file("docs/CODEOWNERS", "");
868        assert!(github_plan(&spec(), &two, CHECKOUT, &owner_review()).is_err());
869    }
870
871    #[test]
872    fn a_solo_repository_gets_the_check_and_no_review() {
873        let plan = github_plan(
874            &spec(),
875            &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
876            CHECKOUT,
877            &CheckGuard::SoloOwner,
878        )
879        .unwrap();
880        assert_eq!(
881            ids(&plan),
882            [
883                "workflow",
884                "keyring",
885                "file:CODEOWNERS",
886                "ruleset",
887                "cleanup:variable:TRUST_REGISTRY_DID",
888                "cleanup:variable:VTC_DID",
889            ]
890        );
891        let StepAction::ProtectDefaultBranch(p) = &plan[3].action else {
892            panic!()
893        };
894        assert!(p.require_status_check && !p.require_code_owner_review);
895    }
896
897    #[test]
898    fn required_workflow_plans_no_repo_workflow_keyring_or_variables() {
899        let plan = github_plan(
900            &spec(),
901            &cfg().with_extra_file("CODEOWNERS", "* @acme/owners\n"),
902            CHECKOUT,
903            &CheckGuard::RequiredWorkflow,
904        )
905        .unwrap();
906        assert_eq!(
907            ids(&plan),
908            [
909                "file:CODEOWNERS",
910                "required-workflow",
911                "ruleset",
912                "cleanup:variable:TRUST_REGISTRY_DID",
913                "cleanup:variable:VTC_DID",
914                "cleanup:workflow",
915                "cleanup:keyring"
916            ]
917        );
918        let StepAction::ProtectDefaultBranch(p) = &plan[2].action else {
919            panic!()
920        };
921        assert!(!p.require_status_check && !p.require_code_owner_review);
922    }
923
924    #[test]
925    fn the_required_workflow_fixes_everything_the_pr_could_touch() {
926        let wf = render_required_workflow(
927            &cfg(),
928            CHECKOUT,
929            &spec().resource,
930            cfg().platform_keyring.as_deref().unwrap(),
931        )
932        .unwrap();
933        assert!(wf.contains("    name: 'Verify commit trust'\n"));
934        assert!(wf.contains("  pull_request:\n") && wf.contains("  merge_group:\n"));
935        assert!(wf.contains("registry-did: 'did:webvh:reg'\n"));
936        assert!(wf.contains("vtc-did: 'did:webvh:vtc'\n"));
937        assert!(
938            !wf.contains("${{ vars"),
939            "no repository-overridable variables"
940        );
941        assert!(wf.contains("exempt-keyring: ${{ runner.temp }}/vgi-platform-keys.asc\n"));
942        assert!(
943            !wf.contains(KEYRING_PATH),
944            "the keyring is not read from the repo"
945        );
946        assert!(wf.contains(
947            "          VGI_PLATFORM_KEYRING: |\n            -----BEGIN PGP PUBLIC KEY BLOCK-----\n\n            x\n            -----END PGP PUBLIC KEY BLOCK-----\n        run: "
948        ));
949        assert!(wf.contains(FALLBACK_LINES), "{wf}");
950        assert!(!wf.contains("if:"));
951
952        let mut c = cfg();
953        c.vtc_did = "did:x:${{github.token}}".into();
954        assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
955        let c =
956            cfg().with_platform_keyring("-----BEGIN PGP PUBLIC KEY BLOCK-----\n${{ secrets.X }}\n");
957        assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
958        // A lone CR is a YAML line break: it could end the block scalar.
959        let c = cfg().with_platform_keyring(
960            "-----BEGIN PGP PUBLIC KEY BLOCK-----\rrun: evil\n-----END PGP PUBLIC KEY BLOCK-----\n",
961        );
962        assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_err());
963        // CRLF armor is fine.
964        let c = cfg().with_platform_keyring(
965            "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nx\r\n-----END PGP PUBLIC KEY BLOCK-----\r\n",
966        );
967        assert!(github_plan(&spec(), &c, CHECKOUT, &CheckGuard::RequiredWorkflow).is_ok());
968    }
969
970    #[test]
971    fn codeowners_puts_the_managed_block_last_and_rerenders_stably() {
972        let out = render_codeowners(
973            "* @acme/owners\n",
974            &["/.github/".into()],
975            &["alice".into(), "bob".into()],
976        );
977        assert!(out.starts_with("* @acme/owners\n\n# BEGIN VGI managed owner rules\n"));
978        assert!(
979            out.ends_with("/.github/ @alice @bob\n# END VGI managed owner rules\n"),
980            "{out}"
981        );
982        let rules = managed_rules(&out).unwrap();
983        assert_eq!(rules.len(), 1);
984        assert_eq!(rules[0].0, "/.github/");
985        assert_eq!(rules[0].1, ["@alice", "@bob"]);
986        assert_eq!(
987            out.lines().nth(rules[0].2 - 1),
988            Some("/.github/ @alice @bob")
989        );
990        // Re-rendering over its own output keeps the community rules once.
991        assert_eq!(
992            render_codeowners(&out, &["/.github/".into()], &["alice".into(), "bob".into()]),
993            out
994        );
995        // A rule after the block means the block no longer wins.
996        assert!(managed_rules(&format!("{out}* @mallory\n")).is_none());
997        assert!(managed_rules(&format!("{out}# just a comment\n")).is_some());
998        assert!(managed_rules("* @acme/owners\n").is_none());
999    }
1000
1001    /// What both workflows pass verify-trust, byte for byte: the qualified
1002    /// form, then the namespace of the repository the job runs for as the
1003    /// fallback (git-ns `right/grant` 0.1, the namespace projection).
1004    const FALLBACK_LINES: &str = "          resource-format: qualified\n          \
1005        # The namespace: where the VTC publishes namespace-wide commit rights.\n          \
1006        fallback-resource: github.com/${{ github.repository_owner }}\n";
1007
1008    #[test]
1009    fn the_fallback_is_the_running_repositorys_own_namespace_on_this_forge() {
1010        // One value for every repository of the namespace — the required
1011        // workflow is shared — with the owner read at run time: never a
1012        // literal another owner's repository could inherit.
1013        let a = Resource::parse("github.com/acme/gadgets").unwrap();
1014        let b = Resource::parse("github.com/acme/widgets").unwrap();
1015        assert_eq!(
1016            fallback_resource(&a),
1017            "github.com/${{ github.repository_owner }}"
1018        );
1019        assert_eq!(fallback_resource(&a), fallback_resource(&b));
1020        let keyring = cfg().platform_keyring.unwrap();
1021        assert_eq!(
1022            render_required_workflow(&cfg(), CHECKOUT, &a, &keyring).unwrap(),
1023            render_required_workflow(&cfg(), CHECKOUT, &b, &keyring).unwrap(),
1024            "the org's required workflow must not differ per repository"
1025        );
1026        // An Enterprise Server names its own host.
1027        let ghes = Resource::parse("ghe.example.com/acme/gadgets").unwrap();
1028        let wf = render_workflow(&cfg(), CHECKOUT, &ghes);
1029        assert!(
1030            wf.contains("fallback-resource: ghe.example.com/${{ github.repository_owner }}\n"),
1031            "{wf}"
1032        );
1033        // Only ever next to the qualified form, and once.
1034        for wf in [
1035            render_workflow(&cfg(), CHECKOUT, &a),
1036            render_required_workflow(&cfg(), CHECKOUT, &a, &keyring).unwrap(),
1037        ] {
1038            assert_eq!(wf.matches("fallback-resource:").count(), 1, "{wf}");
1039            assert!(wf.contains(FALLBACK_LINES), "{wf}");
1040            assert!(!wf.contains("resource-format: legacy"));
1041        }
1042    }
1043
1044    #[test]
1045    fn the_transport_input_is_written_only_when_pinned() {
1046        // The default writes nothing, so existing workflows do not drift.
1047        let wf = render_workflow(&cfg(), CHECKOUT, &spec().resource);
1048        assert!(!wf.contains("transport:"), "{wf}");
1049        let keyring = b"k".to_vec();
1050        let rw = render_required_workflow(&cfg(), CHECKOUT, &spec().resource, &keyring).unwrap();
1051        assert!(!rw.contains("transport:"));
1052
1053        let pinned = cfg().with_verify_trust_transport(vgi_forge::VerifyTransport::Https);
1054        let wf = render_workflow(&pinned, CHECKOUT, &spec().resource);
1055        assert!(
1056            wf.contains("vtc-did: 'did:webvh:vtc'\n          transport: https\n"),
1057            "{wf}"
1058        );
1059        let rw = render_required_workflow(&pinned, CHECKOUT, &spec().resource, &keyring).unwrap();
1060        assert!(rw.contains("          transport: https\n"), "{rw}");
1061    }
1062
1063    #[test]
1064    fn the_workflow_is_pinned_qualified_and_unguarded() {
1065        let wf = render_workflow(&cfg(), CHECKOUT, &spec().resource);
1066        assert!(wf.contains("registry-did: 'did:webvh:reg'\n"));
1067        assert!(wf.contains("vtc-did: 'did:webvh:vtc'\n"));
1068        assert!(
1069            !wf.contains("${{ vars"),
1070            "no repository-overridable variables"
1071        );
1072        assert!(wf.contains("    name: 'Verify commit trust'\n"));
1073        assert!(wf.contains(FALLBACK_LINES), "{wf}");
1074        assert!(wf.contains("  merge_group:\n"));
1075        assert!(wf.contains(
1076            "range: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha \
1077             || format('origin/{0}', github.base_ref) }}..HEAD"
1078        ));
1079        assert!(wf.contains(&format!("verify-trust@{SHA}")));
1080        assert!(wf.contains("uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"));
1081        assert!(!wf.contains("if:"));
1082        let mut quoted = cfg();
1083        quoted.required_check = "it's".into();
1084        assert!(render_workflow(&quoted, "a/b@x", &spec().resource).contains("name: 'it''s'"));
1085    }
1086
1087    #[test]
1088    fn bad_config_fails_before_any_step_runs() {
1089        for guard in [
1090            owner_review(),
1091            CheckGuard::SoloOwner,
1092            CheckGuard::RequiredWorkflow,
1093        ] {
1094            let plan = |c: &VgiConfig| github_plan(&spec(), c, CHECKOUT, &guard);
1095            let mut c = cfg();
1096            c.verify_trust_action =
1097                "OpenVTC/verifiable-git-infrastructure/.github/actions/verify-trust@v0.5.0".into();
1098            assert!(plan(&c).unwrap_err().to_string().contains("pinned"));
1099
1100            let mut c = cfg();
1101            c.platform_keyring = None;
1102            assert!(plan(&c).unwrap_err().to_string().contains("web-flow"));
1103
1104            let c = cfg().with_platform_keyring("-----BEGIN PGP PRIVATE KEY BLOCK-----");
1105            assert!(plan(&c).unwrap_err().to_string().contains("PRIVATE"));
1106
1107            let mut c = cfg();
1108            c.vtc_did = "did:web:x\n  evil: true".into();
1109            assert!(plan(&c).is_err());
1110
1111            let mut c = cfg();
1112            c.verify_trust_version = "v1\nx".into();
1113            assert!(plan(&c).is_err());
1114
1115            assert!(plan(&cfg().with_extra_file("../x", "")).is_err());
1116            assert!(plan(&cfg().with_extra_file(WORKFLOW_PATH, "")).is_err());
1117
1118            assert!(github_plan(&spec(), &cfg(), "actions/checkout@v4", &guard).is_err());
1119            let ns = RepoSpec::new(Resource::parse("github.com/acme").unwrap());
1120            assert!(github_plan(&ns, &cfg(), CHECKOUT, &guard).is_err());
1121        }
1122    }
1123}