Skip to content

Commit

Merge branch 'worktree-agent-a3abfcce648e87dca'

syntaqxcommitted Parentse5722c748f70a7Browse files
92 files+1052−360/92 viewed
+4−0
1414 branches: [main]
1515 workflow_dispatch:
1616
17+# Its token only reads: it checks the code out and nothing more.
18+permissions:
19+ contents: read
20+
1721 concurrency:
1822 group: ci-${{ github.ref }}
1923 cancel-in-progress: true
+5−0
4343 type: boolean
4444 default: false
4545
46+# Its token only reads: deploying uses CLOUDFLARE_API_TOKEN, and g1t
47+# records the deployments itself.
48+permissions:
49+ contents: read
50+
4651 # One deploy at a time, and never one cut off halfway: the next waits.
4752 concurrency:
4853 group: deploy-production
+7−0
3434 type: boolean
3535 default: false
3636
37+# Its token pushes the branch with base.json and opens the pull request.
38+# What a job's token does starts no workflows, so that pull request's
39+# checks start when someone pushes to it or runs CI by hand.
40+permissions:
41+ contents: write
42+ pull-requests: write
43+
3744 concurrency:
3845 group: runner-base
3946 cancel-in-progress: false
+7−0
1414 tags: ["runner-v*"]
1515 workflow_dispatch:
1616
17+permissions:
18+ contents: read
19+
1720 concurrency:
1821 group: runner-release
1922 cancel-in-progress: false
7982 runs-on: [self-hosted, docker]
8083 environment: production
8184 timeout-minutes: 30
85+ # Its token pushes the image to g1t's registry.
86+ permissions:
87+ contents: read
88+ packages: write
8289 steps:
8390 - uses: actions/checkout@v5
8491 - uses: actions/download-artifact@v4
+1−0
270270 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
271271 legacy,
272272 name: None,
273+ ..TokenAccess::default()
273274 })),
274275 ..User::default()
275276 }
+1−0
607607 scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
608608 legacy: false,
609609 name: None,
610+ ..TokenAccess::default()
610611 };
611612 for preset in [Preset::ReadOnly, Preset::Agent] {
612613 let access = token(preset);
+10−26
164164 }
165165 (_, what) if what == "cache" || what.starts_with("cache/") => {
166166 let bucket = env.bucket("ACTIONS_CACHE")?;
167− cache(request, &kv, &bucket, services, method, job, &token, &repo, what).await
167+ cache(request, &bucket, services, method, job, &token, &repo, what).await
168168 }
169169 _ => error(404, "No such endpoint."),
170170 }
204204 #[allow(clippy::too_many_arguments)]
205205 async fn cache(
206206 mut request: Request,
207− kv: &KvStore,
208207 bucket: &Bucket,
209208 services: &Services,
210209 method: &str,
215214 ) -> Result<Response> {
216215 let parts: Vec<&str> = what.split('/').collect();
217216 let upload_id = query(&request, "upload").unwrap_or_default();
217+ // The hash of the entry's paths and compression; runners from before
218+ // it was sent send none.
219+ let version = query(&request, "version").unwrap_or_default();
218220 match (method, parts.as_slice()) {
219221 ("GET", ["cache"]) => {
220222 let key = query(&request, "key").unwrap_or_default();
223225 let found: Outcome<Option<CacheHit>> = g1t_kit::call(
224226 &services.actions,
225227 "cache_lookup",
226− &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore: restore.clone(), version: None },
228+ &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore, version: Some(version.clone()).filter(|v| !v.is_empty()) },
227229 )
228230 .await?;
229231 let found = match refused(found) {
241243 headers.set("content-type", "application/octet-stream")?;
242244 return Ok(response);
243245 }
244− // Entries saved in KV before the cache moved to R2.
245− kv_lookup(kv, repo, &key, &restore).await
246+ // Entries kept in KV before the cache moved to R2 had no scope,
247+ // so they are never restored.
248+ error(404, "Nothing cached under those keys.")
246249 }
247250 // Older runners send a whole entry of at most 60 MB at once.
248251 ("PUT", ["cache"]) => {
254257 let reserved: Outcome<CacheReservation> = g1t_kit::call(
255258 &services.actions,
256259 "cache_reserve",
257− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: None },
260+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: Some(version.clone()).filter(|v| !v.is_empty()) },
258261 )
259262 .await?;
260263 let reserved = match reserved {
277280 let reserved: Outcome<CacheReservation> = g1t_kit::call(
278281 &services.actions,
279282 "cache_reserve",
280− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: None },
283+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: Some(version.clone()).filter(|v| !v.is_empty()) },
281284 )
282285 .await?;
283286 let reserved = match refused(reserved) {
581584 }
582585
583586 /// An entry saved in KV before the cache moved to R2, by key or restore key.
584−async fn kv_lookup(kv: &KvStore, repo: &str, key: &str, restore: &[String]) -> Result<Response> {
585− // The exact key, else the newest entry under each restore key.
586− if let Some(bytes) = get(kv, &format!("c/{repo}/{key}")).await? {
587− let mut response = Response::from_bytes(bytes)?;
588− response.headers_mut().set("x-g1t-key", key)?;
589− return Ok(response);
590− }
591− for prefix in restore {
592− if let Some((base, meta)) = list(kv, &format!("c/{repo}/{prefix}")).await?.into_iter().next()
593− && let Some(bytes) = get(kv, &base).await?
594− {
595− let mut response = Response::from_bytes(bytes)?;
596− response.headers_mut().set("x-g1t-key", &meta.name)?;
597− return Ok(response);
598− }
599− }
600− error(404, "Nothing cached under those keys.")
601−}
602−
603587 /// Someone who can see the run downloading one of its artifacts.
604588 pub async fn download(env: &Env, services: &Services, viewer: &g1t_contracts::Viewer, owner: &str, repo: &str, run: &str, name: &str) -> Result<Response> {
605589 let seen: Outcome<Value> = g1t_kit::call(
+12−3
7575 DeploymentsOp::CreateDeployment => "Report a deployment of a commit to an environment, from any CI or script. ref is the branch, tag or commit deployed; sha is resolved from it unless you give the whole commit id. environment is production unless you say (any name up to 255 characters, such as staging or review/feature-x; names are matched without regard to case, and the first spelling is kept). task is deploy unless you say; payload is any JSON object, returned as given. production_environment is true for an environment named production unless you say; transient_environment marks one that goes away, such as a review app. Its first status is state (queued unless you say), with environment_url and log_url. Each status also shows on the commit as the check `deploy / <environment>`, which a ruleset's required_deployments rule can require. Needs the Write role. Returns the deployment with its statuses.",
7676 DeploymentsOp::ListDeploymentStatuses => "List a deployment's statuses, newest first: each with its state, description, environment_url, log_url, creator and created_at. A g1t.page build's are read from the build itself. Needs the Read role.",
7777 DeploymentsOp::CreateDeploymentStatus => "Add a status to a reported deployment: state (queued, in_progress, success, failure, error or inactive), description, environment_url (where it is served) and log_url (where its output can be read). The deployment takes its state, and any address it gives. A success with auto_inactive (true unless you say) makes the environment's older successful deployments inactive. The commit's `deploy / <environment>` check follows: pending while queued or in progress, then success, failure or error. A g1t.page build's statuses come from the build and cannot be added to. Needs the Write role.",
78− DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at. total_count counts deployments across every environment. Needs the Read role.",
79− DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments. A name with slashes is URL-encoded in the path. Needs the Read role.",
78+ DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed, and after them those with protection rules but no deployment yet. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at, and, when it has rules, protection_rules (required_reviewers, wait_timer, branch_policy), deployment_branch_policy, branch_policies and can_admins_bypass. total_count counts deployments across every environment. Needs the Read role.",
79+ DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments and its protection rules (see update_environment). An environment with rules but no deployment yet is found too. A name with slashes is URL-encoded in the path. Needs the Read role.",
8080 }
8181 }
8282
258258 } else {
259259 args.insert("viewer".into(), serde_json::to_value(viewer)?);
260260 }
261− g1t_kit::call(&services.deployments, method, &Value::Object(args)).await
261+ let answered: Outcome<Value> = g1t_kit::call(&services.deployments, method, &Value::Object(args)).await?;
262+ // Environments carry their protection rules, kept by the actions service.
263+ match op {
264+ DeploymentsOp::ListEnvironments => crate::protection::with_protection(services, viewer, input, answered, None).await,
265+ DeploymentsOp::GetEnvironment => {
266+ let name = input["environment"].as_str().unwrap_or_default().trim().to_owned();
267+ crate::protection::with_protection(services, viewer, input, answered, Some(&name)).await
268+ }
269+ _ => Ok(answered),
270+ }
262271 }
263272
264273 #[cfg(test)]
+10−1
2020 mod openapi;
2121 mod pins;
2222 mod projects;
23+mod protection;
2324 mod operations;
2425 mod renamed;
2526 #[cfg(test)]
7273 /// workflow file, GitHub's contexts and event, and where to check out, all
7374 /// passed through as they are.
7475 const JOB_SPEC_AS_GIVEN: &[&str] = &[
75− "spec", "workflow", "github", "event", "contexts", "checkout",
76+ "spec", "workflow", "github", "event", "contexts", "checkout", "permissions",
7677 ];
7778
7879 /// Puts the toolkit's variables in a job's spec: its runtime token, where
646647 ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => {
647648 let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect();
648649 if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() {
650+ // A workflow job's token reaches its own repository only.
651+ if viewer
652+ .as_ref()
653+ .and_then(|user| user.token.as_deref())
654+ .is_some_and(|token| !token.reaches(&format!("{owner}/{repo}")))
655+ {
656+ return fail(FailureCode::NotFound, "No such run.");
657+ }
649658 return blobs::download(env, &services, &viewer, owner, repo, run, name).await;
650659 }
651660 }
+20−0
1010 use crate::about::AboutOp;
1111 use crate::artifacts::ArtifactsOp;
1212 use crate::deployments::DeploymentsOp;
13+use crate::protection::ProtectionOp;
1314 use crate::operations::Op;
1415 use crate::checks::ChecksOp;
1516 use crate::rules::RulesOp;
390391 ],
391392 ),
392393 (
394+ "Run protection",
395+ "What keeps workflow runs safe: environments' protection rules (required reviewers, a wait timer, which branches may deploy) and the reviews of the jobs they hold, approving a pull request's run from outside, what a job's token gets when its workflow writes no `permissions:`, and repository_dispatch, which a job's own token may send.",
396+ &[
397+ Op::Protection(ProtectionOp::UpdateEnvironment),
398+ Op::Protection(ProtectionOp::DeleteEnvironment),
399+ Op::Protection(ProtectionOp::GetPendingDeployments),
400+ Op::Protection(ProtectionOp::ReviewPendingDeployments),
401+ Op::Protection(ProtectionOp::ApproveWorkflowRun),
402+ Op::Protection(ProtectionOp::GetWorkflowPermissions),
403+ Op::Protection(ProtectionOp::SetWorkflowPermissions),
404+ Op::Protection(ProtectionOp::GetForkPrApproval),
405+ Op::Protection(ProtectionOp::SetForkPrApproval),
406+ Op::Protection(ProtectionOp::CreateRepositoryDispatch),
407+ Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
408+ Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
409+ ],
410+ ),
411+ (
393412 "Secrets and variables",
394413 "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.",
395414 &[
641660 Op::Checks(op) => op.title(),
642661 Op::About(op) => op.title(),
643662 Op::Deployments(op) => op.title(),
663+ Op::Protection(op) => op.title(),
644664 Op::Artifacts(op) => op.title(),
645665 }
646666 }
+27−1
3030 use crate::about::AboutOp;
3131 use crate::artifacts::ArtifactsOp;
3232 use crate::deployments::DeploymentsOp;
33+use crate::protection::ProtectionOp;
3334 use crate::rules::RulesOp;
3435 use crate::security::SecurityOp;
3536 use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel};
284285 About(AboutOp),
285286 /// Deployments wherever they run, and environments: deployments.rs.
286287 Deployments(DeploymentsOp),
288+ /// Environments' protection rules, approving runs, the token's default
289+ /// permissions and repository dispatch: protection.rs.
290+ Protection(ProtectionOp),
287291 /// Workflow run artifacts, and how long they are kept: artifacts.rs.
288292 Artifacts(ArtifactsOp),
289293 }
648652 }
649653
650654 impl Op {
651− pub const ALL: [Op; 264] = [
655+ pub const ALL: [Op; 276] = [
652656 Op::Whoami,
653657 Op::GetWorkspace,
654658 Op::CreateWorkspace,
913917 Op::Artifacts(ArtifactsOp::DeleteArtifact),
914918 Op::Artifacts(ArtifactsOp::GetArtifactRetention),
915919 Op::Artifacts(ArtifactsOp::SetArtifactRetention),
920+ Op::Protection(ProtectionOp::UpdateEnvironment),
921+ Op::Protection(ProtectionOp::DeleteEnvironment),
922+ Op::Protection(ProtectionOp::GetPendingDeployments),
923+ Op::Protection(ProtectionOp::ReviewPendingDeployments),
924+ Op::Protection(ProtectionOp::ApproveWorkflowRun),
925+ Op::Protection(ProtectionOp::GetWorkflowPermissions),
926+ Op::Protection(ProtectionOp::SetWorkflowPermissions),
927+ Op::Protection(ProtectionOp::GetForkPrApproval),
928+ Op::Protection(ProtectionOp::SetForkPrApproval),
929+ Op::Protection(ProtectionOp::CreateRepositoryDispatch),
930+ Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
931+ Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
916932 ];
917933
918934 pub fn by_name(name: &str) -> Option<Op> {
11061122 Op::Checks(op) => op.name(),
11071123 Op::About(op) => op.name(),
11081124 Op::Deployments(op) => op.name(),
1125+ Op::Protection(op) => op.name(),
11091126 Op::Artifacts(op) => op.name(),
11101127 }
11111128 }
16201637 Op::Checks(op) => op.description(),
16211638 Op::About(op) => op.description(),
16221639 Op::Deployments(op) => op.description(),
1640+ Op::Protection(op) => op.description(),
16231641 Op::Artifacts(op) => op.description(),
16241642 }
16251643 }
29923010 Op::Checks(op) => op.input(),
29933011 Op::About(op) => op.input(),
29943012 Op::Deployments(op) => op.input(),
3013+ Op::Protection(op) => op.input(),
29953014 Op::Artifacts(op) => op.input(),
29963015 }
29973016 }
30393058 | DeploymentsOp::ListEnvironments
30403059 | DeploymentsOp::GetEnvironment
30413060 )
3061+ | Op::Protection(
3062+ ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval
3063+ )
30423064 )
30433065 }
30443066
30583080 if let Op::Security(op) = self {
30593081 return op.needs_repo();
30603082 }
3083+ if let Op::Protection(op) = self {
3084+ return op.needs_repo();
3085+ }
30613086 !matches!(
30623087 self,
30633088 Op::Whoami
50565081 Op::Checks(op) => crate::checks::run(op, services, viewer, input).await,
50575082 Op::About(op) => crate::about::run(op, services, viewer, input).await,
50585083 Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
5084+ Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
50595085 Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
50605086 Op::ReopenSecurityAlert => {
50615087 let changed: Outcome<AlertChange> = call(
+613−0
1+//! Keeping workflow runs safe, over REST and MCP: environments' protection
2+//! rules, the reviews of the jobs they hold, approving a pull request's
3+//! run from outside, what a job's token gets when its workflow names no
4+//! `permissions:`, which pull requests' runs wait for approval, and
5+//! `repository_dispatch`. The actions service decides and keeps all of it
6+//! (services/actions/src/protection.rs); these shape requests and answers
7+//! as the standard Actions REST API does.
8+
9+use g1t_contracts::{FailureCode, Outcome, Viewer};
10+use serde_json::{Map, Value, json};
11+use worker::Result;
12+
13+use crate::operations::{Services, repo_path};
14+
15+/// One operation.
16+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17+pub enum ProtectionOp {
18+ UpdateEnvironment,
19+ DeleteEnvironment,
20+ GetPendingDeployments,
21+ ReviewPendingDeployments,
22+ ApproveWorkflowRun,
23+ GetWorkflowPermissions,
24+ SetWorkflowPermissions,
25+ GetForkPrApproval,
26+ SetForkPrApproval,
27+ CreateRepositoryDispatch,
28+ GetWorkspaceWorkflowPermissions,
29+ SetWorkspaceWorkflowPermissions,
30+}
31+
32+impl ProtectionOp {
33+ /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test
34+ /// checks against this.
35+ #[cfg(test)]
36+ pub const ALL: [ProtectionOp; 12] = [
37+ ProtectionOp::UpdateEnvironment,
38+ ProtectionOp::DeleteEnvironment,
39+ ProtectionOp::GetPendingDeployments,
40+ ProtectionOp::ReviewPendingDeployments,
41+ ProtectionOp::ApproveWorkflowRun,
42+ ProtectionOp::GetWorkflowPermissions,
43+ ProtectionOp::SetWorkflowPermissions,
44+ ProtectionOp::GetForkPrApproval,
45+ ProtectionOp::SetForkPrApproval,
46+ ProtectionOp::CreateRepositoryDispatch,
47+ ProtectionOp::GetWorkspaceWorkflowPermissions,
48+ ProtectionOp::SetWorkspaceWorkflowPermissions,
49+ ];
50+
51+ pub fn name(self) -> &'static str {
52+ match self {
53+ ProtectionOp::UpdateEnvironment => "update_environment",
54+ ProtectionOp::DeleteEnvironment => "delete_environment",
55+ ProtectionOp::GetPendingDeployments => "get_pending_deployments",
56+ ProtectionOp::ReviewPendingDeployments => "review_pending_deployments",
57+ ProtectionOp::ApproveWorkflowRun => "approve_workflow_run",
58+ ProtectionOp::GetWorkflowPermissions => "get_workflow_permissions",
59+ ProtectionOp::SetWorkflowPermissions => "set_workflow_permissions",
60+ ProtectionOp::GetForkPrApproval => "get_fork_pr_approval",
61+ ProtectionOp::SetForkPrApproval => "set_fork_pr_approval",
62+ ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch",
63+ ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions",
64+ ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions",
65+ }
66+ }
67+
68+ /// Whether it is about one repository, named by `repo`; the rest are a
69+ /// workspace's.
70+ pub fn needs_repo(self) -> bool {
71+ !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions)
72+ }
73+
74+ pub fn title(self) -> &'static str {
75+ match self {
76+ ProtectionOp::UpdateEnvironment => "Create or update an environment's protection rules",
77+ ProtectionOp::DeleteEnvironment => "Delete an environment's protection rules",
78+ ProtectionOp::GetPendingDeployments => "Get a run's pending deployments",
79+ ProtectionOp::ReviewPendingDeployments => "Review a run's pending deployments",
80+ ProtectionOp::ApproveWorkflowRun => "Approve a workflow run",
81+ ProtectionOp::GetWorkflowPermissions => "Get the default workflow permissions",
82+ ProtectionOp::SetWorkflowPermissions => "Set the default workflow permissions",
83+ ProtectionOp::GetForkPrApproval => "Get the approval policy for outside pull requests",
84+ ProtectionOp::SetForkPrApproval => "Set the approval policy for outside pull requests",
85+ ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event",
86+ ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions",
87+ ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions",
88+ }
89+ }
90+
91+ pub fn description(self) -> &'static str {
92+ match self {
93+ ProtectionOp::UpdateEnvironment => "Create an environment's protection rules, or change them; fields left out stay as they are. A job that names the environment with `environment:` waits, once its needs are done, until the rules let it through, and only then gets the environment's secrets. reviewers: up to 6, each {\"type\": \"User\" or \"Team\", \"name\": a username or a team's slug} (id is read as the name too); a job waits until one of them approves it. prevent_self_review: whoever started the run may not approve it. wait_timer: minutes each job waits, 0 to 43200. deployment_branch_policy: null lets every branch deploy; {\"protected_branches\": true} only branches the repository's rules protect (the default branch included); {\"custom_branch_policies\": true} only the branches and tags in branch_policies, each {\"name\": a pattern such as release/*, \"type\": \"branch\" or \"tag\"}. can_admins_bypass (true unless you say): admins may approve without being reviewers, which also skips the wait. The environment's name is up to 40 letters, digits, - and _, matched without regard to case. Needs the Admin role. Returns the environment with its protection_rules.",
94+ ProtectionOp::DeleteEnvironment => "Delete an environment's protection rules: its jobs run without waiting from then on. Its secrets, variables and deployments stay. Needs the Admin role.",
95+ ProtectionOp::GetPendingDeployments => "The environments whose protection rules hold a run's jobs, this attempt: each with the environment's name, state (waiting, approved or rejected), wait_timer and wait_until (when its timer lets its jobs start), its reviewers, the jobs it holds, who reviewed it and their comment, and current_user_can_approve. Needs the Read role.",
96+ ProtectionOp::ReviewPendingDeployments => "Approve or reject the jobs a run's environments hold. environment_names names them (every waiting one if left out; environment_ids is read as names too); state is approved or rejected; comment is kept with the review. Only one of the environment's reviewers may, or an admin when can_admins_bypass is on, which also skips the wait timer; with prevent_self_review, not whoever started the run. A rejected environment's jobs fail. A workflow job's own token cannot review. Returns the pending deployments as they stand.",
97+ ProtectionOp::ApproveWorkflowRun => "Let a run of a pull request from outside start: it waits as action_required, by the repository's approval policy (get_fork_pr_approval), until someone with the Write role approves it. A workflow job's own token cannot approve. Returns the run.",
98+ ProtectionOp::GetWorkflowPermissions => "What a job's G1T_TOKEN (GITHUB_TOKEN) may do when its workflow and job write no `permissions:`: default_workflow_permissions is read (contents and packages read) or write (every permission). Unless the repository chose (default_chosen), a repository made before restricted tokens has write and a newer one its workspace's default; it is never more than the workspace's max_workflow_permissions. can_approve_pull_request_reviews says whether its jobs may open and approve pull requests (off unless chosen, and only where the workspace allows it). Needs the Read role.",
99+ ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read, write (refused where the workspace's maximum is read) or inherit (back to the workspace's default, or write for a repository made before restricted tokens), and can_approve_pull_request_reviews, \"Allow g1t Actions to create and approve pull requests\" (refused where the workspace does not allow it). Workflows that write `permissions:` get what they write either way, and a pull request's run from outside gets read-only. Needs the Admin role.",
100+ ProtectionOp::GetForkPrApproval => "Which pull requests' runs wait for someone with the Write role to approve them before anything runs (approve_workflow_run): approval_policy is first_time_contributors (a pull request from someone outside the workspace who has not had one merged here), outside_contributors (the default: also everyone outside who cannot push here) or all_external_contributors (everyone outside the workspace, outside collaborators included). Members never wait, nor does g1t's own work. Needs the Read role.",
101+ ProtectionOp::SetForkPrApproval => "Set approval_policy: first_time_contributors, outside_contributors or all_external_contributors. Needs the Admin role.",
102+ ProtectionOp::GetWorkspaceWorkflowPermissions => "A workspace's policy for its repositories' job tokens: default_workflow_permissions (read, the default, or write) is what a repository made from now on gets until it chooses; max_workflow_permissions (write, the default, or read) is the most any repository's default may be, so read holds every repository to read-only; can_approve_pull_request_reviews (off by default) lets its repositories allow jobs to open and approve pull requests. Members only.",
103+ ProtectionOp::SetWorkspaceWorkflowPermissions => "Change a workspace's default_workflow_permissions, max_workflow_permissions and can_approve_pull_request_reviews; fields left out stay as they are. A maximum of read makes the default read too. Owners only.",
104+ ProtectionOp::CreateRepositoryDispatch => "Start the default branch's workflows that run `on: repository_dispatch` for event_type (those listing it under types, or with none). client_payload, a JSON object of at most 10 properties and 64 KB, is github.event.client_payload; github.event.action is event_type. A workflow job's own token may send one: with workflow_dispatch, it is how one workflow starts another. Needs the Write role (code:write). Returns how many runs started.",
105+ }
106+ }
107+
108+ /// Whether it changes anything (the caller is its actor).
109+ pub fn writes(self) -> bool {
110+ !matches!(
111+ self,
112+ ProtectionOp::GetPendingDeployments
113+ | ProtectionOp::GetWorkflowPermissions
114+ | ProtectionOp::GetForkPrApproval
115+ | ProtectionOp::GetWorkspaceWorkflowPermissions
116+ )
117+ }
118+
119+ pub fn input(self) -> Value {
120+ let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
121+ let run = json!({ "type": "string", "description": "The run's id, run_…." });
122+ let workspace = json!({ "type": "string", "description": "The workspace's name, e.g. \"acme\"." });
123+ let environment = json!({ "type": "string", "description": "The environment's name, such as production." });
124+ let (properties, required): (Value, &[&str]) = match self {
125+ ProtectionOp::UpdateEnvironment => (
126+ json!({
127+ "repo": repo,
128+ "environment": environment,
129+ "wait_timer": { "type": "integer", "description": "Minutes each job waits before it may start, 0 to 43200." },
130+ "prevent_self_review": { "type": "boolean", "description": "Whoever started a run may not approve its jobs." },
131+ "reviewers": {
132+ "type": ["array", "null"],
133+ "description": "Up to 6 people or teams who may approve its jobs; empty for none.",
134+ "items": {
135+ "type": "object",
136+ "properties": {
137+ "type": { "type": "string", "enum": ["User", "Team"] },
138+ "name": { "type": "string", "description": "A username, or a team's slug in the repository's workspace." },
139+ },
140+ },
141+ },
142+ "deployment_branch_policy": {
143+ "type": ["object", "null"],
144+ "description": "null: every branch may deploy. protected_branches: only protected ones. custom_branch_policies: only those in branch_policies.",
145+ "properties": {
146+ "protected_branches": { "type": "boolean" },
147+ "custom_branch_policies": { "type": "boolean" },
148+ },
149+ },
150+ "branch_policies": {
151+ "type": "array",
152+ "description": "With custom_branch_policies: the branches and tags that may deploy, at most 50.",
153+ "items": {
154+ "type": "object",
155+ "properties": {
156+ "name": { "type": "string", "description": "A pattern, such as main, release/* or v*." },
157+ "type": { "type": "string", "enum": ["branch", "tag"] },
158+ },
159+ },
160+ },
161+ "can_admins_bypass": { "type": "boolean", "description": "Admins may approve without being reviewers, skipping the wait. True unless you say." },
162+ }),
163+ &["repo", "environment"],
164+ ),
165+ ProtectionOp::DeleteEnvironment => (json!({ "repo": repo, "environment": environment }), &["repo", "environment"]),
166+ ProtectionOp::GetPendingDeployments | ProtectionOp::ApproveWorkflowRun => (json!({ "repo": repo, "id": run }), &["repo", "id"]),
167+ ProtectionOp::ReviewPendingDeployments => (
168+ json!({
169+ "repo": repo,
170+ "id": run,
171+ "environment_names": { "type": "array", "items": { "type": "string" }, "description": "The environments to review; every waiting one if left out." },
172+ "state": { "type": "string", "enum": ["approved", "rejected"] },
173+ "comment": { "type": "string", "description": "Why, kept with the review." },
174+ }),
175+ &["repo", "id", "state"],
176+ ),
177+ ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval => (json!({ "repo": repo }), &["repo"]),
178+ ProtectionOp::SetWorkflowPermissions => (
179+ json!({
180+ "repo": repo,
181+ "default_workflow_permissions": { "type": "string", "enum": ["read", "write", "inherit"] },
182+ "can_approve_pull_request_reviews": { "type": "boolean", "description": "Allow g1t Actions to create and approve pull requests." },
183+ }),
184+ &["repo"],
185+ ),
186+ ProtectionOp::GetWorkspaceWorkflowPermissions => (json!({ "workspace": workspace }), &["workspace"]),
187+ ProtectionOp::SetWorkspaceWorkflowPermissions => (
188+ json!({
189+ "workspace": workspace,
190+ "default_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "What new repositories get." },
191+ "max_workflow_permissions": { "type": "string", "enum": ["read", "write"], "description": "The most any repository's default may be." },
192+ "can_approve_pull_request_reviews": { "type": "boolean", "description": "Let repositories allow jobs to open and approve pull requests." },
193+ }),
194+ &["workspace"],
195+ ),
196+ ProtectionOp::SetForkPrApproval => (
197+ json!({
198+ "repo": repo,
199+ "approval_policy": { "type": "string", "enum": ["first_time_contributors", "outside_contributors", "all_external_contributors"] },
200+ }),
201+ &["repo", "approval_policy"],
202+ ),
203+ ProtectionOp::CreateRepositoryDispatch => (
204+ json!({
205+ "repo": repo,
206+ "event_type": { "type": "string", "description": "What happened, 1 to 100 characters; workflows choose it with `types:`." },
207+ "client_payload": { "type": "object", "description": "Anything the workflows should read, as github.event.client_payload." },
208+ }),
209+ &["repo", "event_type"],
210+ ),
211+ };
212+ json!({ "type": "object", "properties": properties, "required": required })
213+ }
214+}
215+
216+fn text(input: &Value, key: &str) -> Option<String> {
217+ match &input[key] {
218+ Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
219+ Value::Number(number) => Some(number.to_string()),
220+ _ => None,
221+ }
222+}
223+
224+fn flag(input: &Value, key: &str) -> Option<bool> {
225+ match &input[key] {
226+ Value::Bool(value) => Some(*value),
227+ Value::String(text) => match text.trim() {
228+ "true" | "1" => Some(true),
229+ "false" | "0" => Some(false),
230+ _ => None,
231+ },
232+ _ => None,
233+ }
234+}
235+
236+/// The actions service's arguments for an environment's change, from a
237+/// request shaped as the standard environments API is.
238+pub(crate) fn environment_change(input: &Value) -> std::result::Result<Map<String, Value>, String> {
239+ let mut out = Map::new();
240+ if let Some(minutes) = input.get("wait_timer").filter(|v| !v.is_null()) {
241+ let minutes = minutes.as_u64().or_else(|| minutes.as_str().and_then(|s| s.trim().parse().ok())).ok_or("wait_timer is a number of minutes.")?;
242+ out.insert("waitMinutes".into(), minutes.into());
243+ }
244+ if let Some(value) = flag(input, "prevent_self_review") {
245+ out.insert("preventSelfReview".into(), value.into());
246+ }
247+ if let Some(value) = flag(input, "can_admins_bypass") {
248+ out.insert("adminsBypass".into(), value.into());
249+ }
250+ match input.get("reviewers") {
251+ None => {}
252+ Some(Value::Null) => {
253+ out.insert("reviewers".into(), json!([]));
254+ }
255+ Some(Value::Array(given)) => {
256+ let mut reviewers = Vec::new();
257+ for reviewer in given {
258+ let kind = reviewer["type"].as_str().unwrap_or("User").to_ascii_lowercase();
259+ let name = text(reviewer, "name").or_else(|| text(reviewer, "id")).or_else(|| text(reviewer, "login")).or_else(|| text(reviewer, "slug"));
260+ let Some(name) = name else { return Err("Each reviewer has a name: a username or a team's slug.".to_owned()) };
261+ reviewers.push(json!({ "type": kind, "name": name }));
262+ }
263+ out.insert("reviewers".into(), Value::Array(reviewers));
264+ }
265+ Some(_) => return Err("reviewers is a list of {\"type\", \"name\"}.".to_owned()),
266+ }
267+ match input.get("deployment_branch_policy") {
268+ None => {}
269+ Some(Value::Null) => {
270+ out.insert("branchPolicy".into(), "all".into());
271+ }
272+ Some(policy @ Value::Object(_)) => {
273+ let protected = flag(policy, "protected_branches") == Some(true);
274+ let custom = flag(policy, "custom_branch_policies") == Some(true);
275+ let chosen = match (protected, custom) {
276+ (true, true) => return Err("deployment_branch_policy is protected_branches or custom_branch_policies, not both.".to_owned()),
277+ (true, false) => "protected",
278+ (false, true) => "selected",
279+ (false, false) => "all",
280+ };
281+ out.insert("branchPolicy".into(), chosen.into());
282+ }
283+ Some(_) => return Err("deployment_branch_policy is an object, or null.".to_owned()),
284+ }
285+ if let Some(Value::Array(patterns)) = input.get("branch_policies") {
286+ let patterns: Vec<Value> = patterns
287+ .iter()
288+ .map(|pattern| json!({ "name": pattern["name"].as_str().unwrap_or_default(), "type": pattern["type"].as_str().unwrap_or("branch") }))
289+ .collect();
290+ out.insert("branchPatterns".into(), Value::Array(patterns));
291+ }
292+ Ok(out)
293+}
294+
295+/// An environment as the actions service keeps it (camelCase), in the
296+/// standard shape: `protection_rules`, `deployment_branch_policy` and
297+/// `can_admins_bypass`, with g1t's `branch_policies` beside them.
298+pub(crate) fn environment_view(env: &Value) -> Value {
299+ let reviewers: Vec<Value> = env["reviewers"]
300+ .as_array()
301+ .map(|list| {
302+ list.iter()
303+ .map(|r| match r["type"].as_str() {
304+ Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }),
305+ _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }),
306+ })
307+ .collect()
308+ })
309+ .unwrap_or_default();
310+ let mut rules = Vec::new();
311+ if !reviewers.is_empty() {
312+ rules.push(json!({ "type": "required_reviewers", "prevent_self_review": env["preventSelfReview"], "reviewers": reviewers }));
313+ }
314+ if env["waitMinutes"].as_u64().unwrap_or(0) > 0 {
315+ rules.push(json!({ "type": "wait_timer", "wait_timer": env["waitMinutes"] }));
316+ }
317+ let policy = env["branchPolicy"].as_str().unwrap_or("all");
318+ if policy != "all" {
319+ rules.push(json!({ "type": "branch_policy" }));
320+ }
321+ json!({
322+ "name": env["name"],
323+ "protection_rules": rules,
324+ "deployment_branch_policy": match policy {
325+ "protected" => json!({ "protected_branches": true, "custom_branch_policies": false }),
326+ "selected" => json!({ "protected_branches": false, "custom_branch_policies": true }),
327+ _ => Value::Null,
328+ },
329+ "branch_policies": env["branchPatterns"],
330+ "can_admins_bypass": env["adminsBypass"],
331+ "protected": env["protected"],
332+ "updated_at": env["updatedAt"],
333+ "updated_by": env["updatedBy"],
334+ })
335+}
336+
337+/// A pending deployment, in the standard shape.
338+fn pending_view(pending: &Value) -> Value {
339+ let reviewers: Vec<Value> = pending["reviewers"]
340+ .as_array()
341+ .map(|list| {
342+ list.iter()
343+ .map(|r| match r["type"].as_str() {
344+ Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }),
345+ _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }),
346+ })
347+ .collect()
348+ })
349+ .unwrap_or_default();
350+ json!({
351+ "environment": { "name": pending["environment"] },
352+ "state": pending["state"],
353+ "needs_review": pending["needsReview"],
354+ "wait_until": pending["waitUntil"],
355+ "current_user_can_approve": pending["canReview"],
356+ "reviewers": reviewers,
357+ "jobs": pending["jobs"],
358+ "reviewed_by": pending["reviewedBy"],
359+ "comment": pending["comment"],
360+ "reviewed_at": pending["reviewedAt"],
361+ })
362+}
363+
364+fn map<T>(outcome: Outcome<T>, view: impl FnOnce(T) -> Value) -> Outcome<Value> {
365+ match outcome {
366+ Outcome::Ok(value) => Outcome::Ok(view(value)),
367+ Outcome::Fail(refused) => Outcome::Fail(refused),
368+ }
369+}
370+
371+/// The protection rules of the environments `listed` (the deployments
372+/// service's answer to `list_environments` or `get_environment`) added to
373+/// it, and environments with rules but no deployments yet added to a list.
374+pub(crate) async fn with_protection(services: &Services, viewer: &Viewer, input: &Value, listed: Outcome<Value>, one: Option<&str>) -> Result<Outcome<Value>> {
375+ let Some(repo) = repo_path(input) else { return Ok(listed) };
376+ let mut args = json!({ "viewer": viewer, "repo": repo });
377+ if let Some(name) = one {
378+ args["name"] = json!(name);
379+ }
380+ let rules: Outcome<Vec<Value>> = g1t_kit::call(&services.actions, "environments", &args).await.unwrap_or(Outcome::Ok(Vec::new()));
381+ let rules = match rules {
382+ Outcome::Ok(rules) => rules,
383+ Outcome::Fail(_) => return Ok(listed),
384+ };
385+ let protection = |name: &str| rules.iter().find(|env| env["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))).map(environment_view);
386+ let add = |env: &mut Value| {
387+ if let Some(view) = env["name"].as_str().and_then(protection) {
388+ for key in ["protection_rules", "deployment_branch_policy", "branch_policies", "can_admins_bypass"] {
389+ env[key] = view[key].clone();
390+ }
391+ }
392+ };
393+ Ok(match (listed, one) {
394+ (Outcome::Ok(mut env), Some(_)) => {
395+ add(&mut env);
396+ Outcome::Ok(env)
397+ }
398+ // Never deployed, but protected: still an environment.
399+ (Outcome::Fail(refused), Some(name)) => match protection(name).filter(|view| view["protected"] == true) {
400+ Some(view) => Outcome::Ok(view),
401+ None => Outcome::Fail(refused),
402+ },
403+ (Outcome::Ok(mut list), None) => {
404+ if let Some(environments) = list["environments"].as_array_mut() {
405+ for env in environments.iter_mut() {
406+ add(env);
407+ }
408+ for env in rules.iter().filter(|env| env["protected"] == true) {
409+ let name = env["name"].as_str().unwrap_or_default();
410+ if !environments.iter().any(|known| known["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))) {
411+ environments.push(environment_view(env));
412+ }
413+ }
414+ }
415+ Outcome::Ok(list)
416+ }
417+ (failed, None) => failed,
418+ })
419+}
420+
421+/// A workspace's policy, in the standard shape.
422+fn workspace_view(settings: &Value) -> Value {
423+ json!({
424+ "default_workflow_permissions": settings["defaultPermissions"],
425+ "max_workflow_permissions": settings["maxPermissions"],
426+ "can_approve_pull_request_reviews": settings["canApprovePullRequests"],
427+ })
428+}
429+
430+pub async fn run(op: ProtectionOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
431+ if op.writes() && viewer.is_none() {
432+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
433+ }
434+ let actor = || viewer.clone().unwrap_or_default();
435+ let actions = &services.actions;
436+ if !op.needs_repo() {
437+ let Some(workspace) = text(input, "workspace") else {
438+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
439+ };
440+ let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions {
441+ g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await?
442+ } else {
443+ g1t_kit::call(
444+ actions,
445+ "set_workspace_actions_settings",
446+ &json!({
447+ "actor": actor(),
448+ "workspace": workspace,
449+ "defaultPermissions": text(input, "default_workflow_permissions"),
450+ "maxPermissions": text(input, "max_workflow_permissions"),
451+ "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"),
452+ }),
453+ )
454+ .await?
455+ };
456+ return Ok(map(settings, |s| workspace_view(&s)));
457+ }
458+ let Some(repo) = repo_path(input) else {
459+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
460+ };
461+ let id = text(input, "id").unwrap_or_default();
462+ let environment = text(input, "environment").unwrap_or_default();
463+ Ok(match op {
464+ ProtectionOp::UpdateEnvironment => {
465+ let mut args = match environment_change(input) {
466+ Ok(args) => args,
467+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
468+ };
469+ args.insert("actor".into(), serde_json::to_value(actor())?);
470+ args.insert("repo".into(), serde_json::to_value(&repo)?);
471+ args.insert("name".into(), environment.into());
472+ let saved: Outcome<Value> = g1t_kit::call(actions, "set_environment", &Value::Object(args)).await?;
473+ map(saved, |env| environment_view(&env))
474+ }
475+ ProtectionOp::DeleteEnvironment => {
476+ let removed: Outcome<bool> =
477+ g1t_kit::call(actions, "delete_environment", &json!({ "actor": actor(), "repo": repo, "name": environment })).await?;
478+ map(removed, |removed| json!({ "deleted": removed }))
479+ }
480+ ProtectionOp::GetPendingDeployments => {
481+ let pending: Outcome<Vec<Value>> = g1t_kit::call(actions, "pending_deployments", &json!({ "viewer": viewer, "repo": repo, "id": id })).await?;
482+ map(pending, |list| Value::Array(list.iter().map(pending_view).collect()))
483+ }
484+ ProtectionOp::ReviewPendingDeployments => {
485+ let names: Vec<String> = ["environment_names", "environments", "environment_ids"]
486+ .iter()
487+ .find_map(|key| input[*key].as_array())
488+ .map(|list| list.iter().filter_map(|v| v.as_str().map(str::to_owned).or_else(|| v.as_u64().map(|n| n.to_string()))).collect())
489+ .unwrap_or_default();
490+ let reviewed: Outcome<Vec<Value>> = g1t_kit::call(
491+ actions,
492+ "review_deployments",
493+ &json!({
494+ "actor": actor(),
495+ "repo": repo,
496+ "id": id,
497+ "environments": names,
498+ "state": text(input, "state").unwrap_or_default(),
499+ "comment": text(input, "comment"),
500+ }),
501+ )
502+ .await?;
503+ map(reviewed, |list| Value::Array(list.iter().map(pending_view).collect()))
504+ }
505+ ProtectionOp::ApproveWorkflowRun => g1t_kit::call(actions, "approve_run", &json!({ "actor": actor(), "repo": repo, "id": id })).await?,
506+ ProtectionOp::GetWorkflowPermissions | ProtectionOp::SetWorkflowPermissions => {
507+ let settings: Outcome<Value> = if op == ProtectionOp::GetWorkflowPermissions {
508+ g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await?
509+ } else {
510+ g1t_kit::call(
511+ actions,
512+ "set_actions_settings",
513+ &json!({
514+ "actor": actor(),
515+ "repo": repo,
516+ "defaultPermissions": text(input, "default_workflow_permissions"),
517+ "canApprovePullRequests": flag(input, "can_approve_pull_request_reviews"),
518+ }),
519+ )
520+ .await?
521+ };
522+ map(settings, |s| {
523+ json!({
524+ "default_workflow_permissions": s["defaultPermissions"],
525+ "default_chosen": s["defaultChosen"],
526+ "max_workflow_permissions": s["maxPermissions"],
527+ "can_approve_pull_request_reviews": s["canApprovePullRequests"],
528+ })
529+ })
530+ }
531+ ProtectionOp::GetForkPrApproval | ProtectionOp::SetForkPrApproval => {
532+ let settings: Outcome<Value> = if op == ProtectionOp::GetForkPrApproval {
533+ g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await?
534+ } else {
535+ g1t_kit::call(
536+ actions,
537+ "set_actions_settings",
538+ &json!({ "actor": actor(), "repo": repo, "approvalPolicy": text(input, "approval_policy").unwrap_or_default() }),
539+ )
540+ .await?
541+ };
542+ map(settings, |s| json!({ "approval_policy": s["approvalPolicy"] }))
543+ }
544+ ProtectionOp::CreateRepositoryDispatch => {
545+ let started: Outcome<u32> = g1t_kit::call(
546+ actions,
547+ "repository_dispatch",
548+ &json!({
549+ "actor": actor(),
550+ "repo": repo,
551+ "eventType": text(input, "event_type").unwrap_or_default(),
552+ "clientPayload": input.get("client_payload").cloned().unwrap_or(Value::Null),
553+ }),
554+ )
555+ .await?;
556+ map(started, |runs| json!({ "runs": runs }))
557+ }
558+ // Answered above, before a repository is read.
559+ ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions => {
560+ Outcome::fail(FailureCode::Invalid, "Name the workspace.")
561+ }
562+ })
563+}
564+
565+#[cfg(test)]
566+mod tests {
567+ use super::*;
568+
569+ #[test]
570+ fn an_environment_change_reads_the_standard_shape() {
571+ let args = environment_change(&json!({
572+ "wait_timer": 30,
573+ "prevent_self_review": true,
574+ "reviewers": [{ "type": "User", "id": "ada" }, { "type": "Team", "name": "deployers" }],
575+ "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true },
576+ "branch_policies": [{ "name": "release/*", "type": "branch" }],
577+ }))
578+ .unwrap();
579+ assert_eq!(args["waitMinutes"], 30);
580+ assert_eq!(args["preventSelfReview"], true);
581+ assert_eq!(args["reviewers"], json!([{ "type": "user", "name": "ada" }, { "type": "team", "name": "deployers" }]));
582+ assert_eq!(args["branchPolicy"], "selected");
583+ assert_eq!(args["branchPatterns"], json!([{ "name": "release/*", "type": "branch" }]));
584+ // Left out stays; null clears.
585+ let cleared = environment_change(&json!({ "deployment_branch_policy": null, "reviewers": null })).unwrap();
586+ assert_eq!(cleared["branchPolicy"], "all");
587+ assert_eq!(cleared["reviewers"], json!([]));
588+ assert!(!environment_change(&json!({})).unwrap().contains_key("waitMinutes"));
589+ assert!(environment_change(&json!({ "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": true } })).is_err());
590+ }
591+
592+ #[test]
593+ fn an_environment_reads_as_the_standard_shape() {
594+ let view = environment_view(&json!({
595+ "name": "production", "reviewers": [{ "type": "user", "name": "ada" }], "preventSelfReview": true,
596+ "waitMinutes": 10, "branchPolicy": "protected", "branchPatterns": [], "adminsBypass": false, "protected": true,
597+ }));
598+ let types: Vec<&str> = view["protection_rules"].as_array().unwrap().iter().map(|r| r["type"].as_str().unwrap()).collect();
599+ assert_eq!(types, ["required_reviewers", "wait_timer", "branch_policy"]);
600+ assert_eq!(view["protection_rules"][0]["reviewers"][0]["reviewer"]["login"], "ada");
601+ assert_eq!(view["deployment_branch_policy"]["protected_branches"], true);
602+ assert_eq!(view["can_admins_bypass"], false);
603+ }
604+
605+ #[test]
606+ fn each_operation_is_described_with_a_schema() {
607+ for op in ProtectionOp::ALL {
608+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
609+ let needs = if op.needs_repo() { "repo" } else { "workspace" };
610+ assert!(op.input()["required"].as_array().unwrap().contains(&json!(needs)), "{}", op.name());
611+ }
612+ }
613+}
+273−0
99199919 "updated_at": "2026-10-08T01:13:52.101Z"
99209920 }
99219921 },
9922+ "update_environment": {
9923+ "params": {
9924+ "owner": "flagon-io",
9925+ "name": "g1t",
9926+ "environment": "production"
9927+ },
9928+ "request": {
9929+ "wait_timer": 10,
9930+ "prevent_self_review": true,
9931+ "reviewers": [
9932+ {
9933+ "type": "User",
9934+ "name": "syntaqx"
9935+ },
9936+ {
9937+ "type": "Team",
9938+ "name": "deployers"
9939+ }
9940+ ],
9941+ "deployment_branch_policy": {
9942+ "protected_branches": true,
9943+ "custom_branch_policies": false
9944+ }
9945+ },
9946+ "response": {
9947+ "name": "production",
9948+ "protection_rules": [
9949+ {
9950+ "type": "required_reviewers",
9951+ "prevent_self_review": true,
9952+ "reviewers": [
9953+ {
9954+ "type": "User",
9955+ "reviewer": {
9956+ "login": "syntaqx"
9957+ }
9958+ },
9959+ {
9960+ "type": "Team",
9961+ "reviewer": {
9962+ "slug": "deployers"
9963+ }
9964+ }
9965+ ]
9966+ },
9967+ {
9968+ "type": "wait_timer",
9969+ "wait_timer": 10
9970+ },
9971+ {
9972+ "type": "branch_policy"
9973+ }
9974+ ],
9975+ "deployment_branch_policy": {
9976+ "protected_branches": true,
9977+ "custom_branch_policies": false
9978+ },
9979+ "branch_policies": [],
9980+ "can_admins_bypass": true,
9981+ "protected": true,
9982+ "updated_at": "2026-10-08T09:12:44.103Z",
9983+ "updated_by": "syntaqx"
9984+ },
9985+ "notes": "Fields left out stay as they are. A job with `environment: production` now waits for syntaqx or someone in deployers to approve it, then ten minutes, and runs only on a protected branch."
9986+ },
9987+ "delete_environment": {
9988+ "params": {
9989+ "owner": "flagon-io",
9990+ "name": "g1t",
9991+ "environment": "staging"
9992+ },
9993+ "response": {
9994+ "deleted": true
9995+ }
9996+ },
9997+ "get_pending_deployments": {
9998+ "params": {
9999+ "owner": "flagon-io",
10000+ "name": "g1t",
10001+ "id": "run_01kq9b3d5f7h9k1n3q5s7u9w1y"
10002+ },
10003+ "response": [
10004+ {
10005+ "environment": {
10006+ "name": "production"
10007+ },
10008+ "state": "waiting",
10009+ "needs_review": true,
10010+ "wait_until": "2026-10-08T09:31:02.551Z",
10011+ "current_user_can_approve": true,
10012+ "reviewers": [
10013+ {
10014+ "type": "User",
10015+ "reviewer": {
10016+ "login": "syntaqx"
10017+ }
10018+ },
10019+ {
10020+ "type": "Team",
10021+ "reviewer": {
10022+ "slug": "deployers"
10023+ }
10024+ }
10025+ ],
10026+ "jobs": [
10027+ "Deploy the core services"
10028+ ],
10029+ "reviewed_by": null,
10030+ "comment": null,
10031+ "reviewed_at": null
10032+ }
10033+ ]
10034+ },
10035+ "review_pending_deployments": {
10036+ "params": {
10037+ "owner": "flagon-io",
10038+ "name": "g1t",
10039+ "id": "run_01kq9b3d5f7h9k1n3q5s7u9w1y"
10040+ },
10041+ "request": {
10042+ "environment_names": [
10043+ "production"
10044+ ],
10045+ "state": "approved",
10046+ "comment": "Release notes checked."
10047+ },
10048+ "response": [
10049+ {
10050+ "environment": {
10051+ "name": "production"
10052+ },
10053+ "state": "approved",
10054+ "needs_review": true,
10055+ "wait_until": "2026-10-08T09:31:02.551Z",
10056+ "current_user_can_approve": false,
10057+ "reviewers": [
10058+ {
10059+ "type": "User",
10060+ "reviewer": {
10061+ "login": "syntaqx"
10062+ }
10063+ },
10064+ {
10065+ "type": "Team",
10066+ "reviewer": {
10067+ "slug": "deployers"
10068+ }
10069+ }
10070+ ],
10071+ "jobs": [
10072+ "Deploy the core services"
10073+ ],
10074+ "reviewed_by": "syntaqx",
10075+ "comment": "Release notes checked.",
10076+ "reviewed_at": "2026-10-08T09:22:15.871Z"
10077+ }
10078+ ],
10079+ "notes": "The jobs still wait for the wait timer, until `wait_until`, unless an admin approved past the rules."
10080+ },
10081+ "approve_workflow_run": {
10082+ "params": {
10083+ "owner": "flagon-io",
10084+ "name": "g1t",
10085+ "id": "run_01kq9a2c4e6g8j0m2p4r6t8v0x"
10086+ },
10087+ "response": {
10088+ "id": "run_01kq9a2c4e6g8j0m2p4r6t8v0x",
10089+ "workflow_id": "wfl_01kpw2c5d8e1f4g7h0j3k6m9np",
10090+ "path": ".g1t/workflows/ci.yml",
10091+ "name": "CI",
10092+ "title": "Fix a typo in the README",
10093+ "number": 41,
10094+ "attempt": 1,
10095+ "event": "pull_request",
10096+ "ref": "refs/pull/88/merge",
10097+ "sha": "4c1e7a9b2d5f8e0a3c6b9d2f5a8c1e4b7d0f3a6c",
10098+ "pull": 88,
10099+ "status": "queued",
10100+ "conclusion": null,
10101+ "error": null,
10102+ "actor": "octo-fan",
10103+ "created_at": "2026-10-08T08:02:11.004Z",
10104+ "started_at": null,
10105+ "finished_at": null
10106+ }
10107+ },
10108+ "get_workflow_permissions": {
10109+ "params": {
10110+ "owner": "flagon-io",
10111+ "name": "g1t"
10112+ },
10113+ "response": {
10114+ "default_workflow_permissions": "read",
10115+ "default_chosen": true,
10116+ "max_workflow_permissions": "write",
10117+ "can_approve_pull_request_reviews": false
10118+ }
10119+ },
10120+ "set_workflow_permissions": {
10121+ "params": {
10122+ "owner": "flagon-io",
10123+ "name": "g1t"
10124+ },
10125+ "request": {
10126+ "default_workflow_permissions": "write"
10127+ },
10128+ "response": {
10129+ "default_workflow_permissions": "write",
10130+ "default_chosen": true,
10131+ "max_workflow_permissions": "write",
10132+ "can_approve_pull_request_reviews": false
10133+ }
10134+ },
10135+ "get_fork_pr_approval": {
10136+ "params": {
10137+ "owner": "flagon-io",
10138+ "name": "g1t"
10139+ },
10140+ "response": {
10141+ "approval_policy": "outside_contributors"
10142+ }
10143+ },
10144+ "set_fork_pr_approval": {
10145+ "params": {
10146+ "owner": "flagon-io",
10147+ "name": "g1t"
10148+ },
10149+ "request": {
10150+ "approval_policy": "all_external_contributors"
10151+ },
10152+ "response": {
10153+ "approval_policy": "all_external_contributors"
10154+ }
10155+ },
10156+ "create_repository_dispatch": {
10157+ "params": {
10158+ "owner": "flagon-io",
10159+ "name": "g1t"
10160+ },
10161+ "request": {
10162+ "event_type": "docs-published",
10163+ "client_payload": {
10164+ "version": "2026.10.08"
10165+ }
10166+ },
10167+ "response": {
10168+ "runs": 1
10169+ }
10170+ },
10171+ "get_workspace_workflow_permissions": {
10172+ "params": {
10173+ "workspace": "flagon-io"
10174+ },
10175+ "response": {
10176+ "default_workflow_permissions": "read",
10177+ "max_workflow_permissions": "write",
10178+ "can_approve_pull_request_reviews": false
10179+ }
10180+ },
10181+ "set_workspace_workflow_permissions": {
10182+ "params": {
10183+ "workspace": "flagon-io"
10184+ },
10185+ "request": {
10186+ "max_workflow_permissions": "read"
10187+ },
10188+ "response": {
10189+ "default_workflow_permissions": "read",
10190+ "max_workflow_permissions": "read",
10191+ "can_approve_pull_request_reviews": false
10192+ },
10193+ "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too."
10194+ },
992210195 "get_languages": {
992310196 "response": {
992410197 "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
+44−0
55 use crate::about::AboutOp;
66 use crate::artifacts::ArtifactsOp;
77 use crate::deployments::DeploymentsOp;
8+use crate::protection::ProtectionOp;
89 use crate::operations::Op;
910 use crate::checks::ChecksOp;
1011 use crate::rules::RulesOp;
324325 route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]),
325326 route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]),
326327 route("GET", "/repos/:owner/:name/environments/:environment", Op::Deployments(DeploymentsOp::GetEnvironment), &[]),
328+ // Environments' protection rules, and the runs they hold.
329+ route("PUT", "/repos/:owner/:name/environments/:environment", Op::Protection(ProtectionOp::UpdateEnvironment), &[]),
330+ route("DELETE", "/repos/:owner/:name/environments/:environment", Op::Protection(ProtectionOp::DeleteEnvironment), &[]),
331+ route(
332+ "GET",
333+ "/repos/:owner/:name/actions/runs/:id/pending_deployments",
334+ Op::Protection(ProtectionOp::GetPendingDeployments),
335+ &[],
336+ ),
337+ route(
338+ "POST",
339+ "/repos/:owner/:name/actions/runs/:id/pending_deployments",
340+ Op::Protection(ProtectionOp::ReviewPendingDeployments),
341+ &[],
342+ ),
343+ route("POST", "/repos/:owner/:name/actions/runs/:id/approve", Op::Protection(ProtectionOp::ApproveWorkflowRun), &[]),
344+ route("GET", "/repos/:owner/:name/actions/permissions/workflow", Op::Protection(ProtectionOp::GetWorkflowPermissions), &[]),
345+ route("PUT", "/repos/:owner/:name/actions/permissions/workflow", Op::Protection(ProtectionOp::SetWorkflowPermissions), &[]),
346+ route(
347+ "GET",
348+ "/repos/:owner/:name/actions/permissions/fork-pr-contributor-approval",
349+ Op::Protection(ProtectionOp::GetForkPrApproval),
350+ &[],
351+ ),
352+ route(
353+ "PUT",
354+ "/repos/:owner/:name/actions/permissions/fork-pr-contributor-approval",
355+ Op::Protection(ProtectionOp::SetForkPrApproval),
356+ &[],
357+ ),
358+ route("POST", "/repos/:owner/:name/dispatches", Op::Protection(ProtectionOp::CreateRepositoryDispatch), &[]),
359+ route(
360+ "GET",
361+ "/workspaces/:workspace/actions/permissions/workflow",
362+ Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
363+ &[],
364+ ),
365+ route(
366+ "PUT",
367+ "/workspaces/:workspace/actions/permissions/workflow",
368+ Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
369+ &[],
370+ ),
327371 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
328372 route(
329373 "POST",
+16−2
2121 use crate::about::AboutOp;
2222 use crate::artifacts::ArtifactsOp;
2323 use crate::deployments::DeploymentsOp;
24+use crate::protection::ProtectionOp;
2425 use crate::operations::Op;
2526 use crate::checks::ChecksOp;
2627 use crate::rules::RulesOp;
201202 Tool {
202203 name: "workflow",
203204 title: "Workflows",
204− description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
205+ description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions and repository dispatch. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
205206 default_action: None,
206207 actions: &[
207208 a("list", Op::ListWorkflows, "Workflows on the default branch"),
237238 a("deployment_statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), "A deployment's statuses, newest first"),
238239 a("create_deployment_status", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), "Report where a deployment is: in_progress, success, failure"),
239240 a("list_environments", Op::Deployments(DeploymentsOp::ListEnvironments), "Environments with their current and latest deployments"),
240− a("get_environment", Op::Deployments(DeploymentsOp::GetEnvironment), "One environment by name"),
241+ a("get_environment", Op::Deployments(DeploymentsOp::GetEnvironment), "One environment by name, with its protection rules"),
242+ a("update_environment", Op::Protection(ProtectionOp::UpdateEnvironment), "Set an environment's reviewers, wait timer and branches"),
243+ a("delete_environment", Op::Protection(ProtectionOp::DeleteEnvironment), "Remove an environment's protection rules"),
244+ a("pending_deployments", Op::Protection(ProtectionOp::GetPendingDeployments), "The environments holding a run's jobs"),
245+ a("review_deployments", Op::Protection(ProtectionOp::ReviewPendingDeployments), "Approve or reject a run's jobs for its environments"),
246+ a("approve_run", Op::Protection(ProtectionOp::ApproveWorkflowRun), "Let a run of a pull request from outside start"),
247+ a("get_permissions", Op::Protection(ProtectionOp::GetWorkflowPermissions), "What a job's token gets without `permissions:`"),
248+ a("set_permissions", Op::Protection(ProtectionOp::SetWorkflowPermissions), "Set it: read or write"),
249+ a("get_approval_policy", Op::Protection(ProtectionOp::GetForkPrApproval), "Which pull requests' runs wait for approval"),
250+ a("set_approval_policy", Op::Protection(ProtectionOp::SetForkPrApproval), "Set which pull requests' runs wait for approval"),
251+ a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"),
252+ a("get_workspace_permissions", Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), "A workspace's default and maximum token permissions"),
253+ a("set_workspace_permissions", Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), "Set them, and whether jobs may open pull requests"),
241254 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
242255 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
243256 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
707720 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
708721 legacy: false,
709722 name: None,
723+ ..TokenAccess::default()
710724 }
711725 }
712726
+2−3
257257 entry under 128 MB in one request, and g1t takes at most 100 MB in one
258258 request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and
259259 the job goes on; smaller and larger entries are saved.
260−- Environments' protection rules: required reviewers, wait timers and branch
261− limits. A job with `environment:` gets that environment's values and runs
262− without waiting.
260+- `on: delete`: deleting a branch or tag starts no workflows. New branches
261+ and tags start `create` and `push` workflows.
263262
264263 See [Not yet](/guides/actions/#not-yet). **Status.** Planned.
265264
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.