Skip to content

Commit

Actions: keep workflow runs safe

A job's G1T_TOKEN is minted per job, reaches its repository only, holds the scopes its permissions: give (read-only by default and for pull requests from outside), is revoked when the job ends, and is audited as the job; what it changes starts no workflows. Environments get protection rules (reviewers, wait timer, branch policy, admin bypass) that hold jobs before their secrets are read. Pull requests from outside wait for approval by policy. The cache is scoped by ref and versioned. Masking covers multi-line, base64 and JSON forms and withholds outputs. A job's own concurrency, create and repository_dispatch are honoured; on: delete says it never runs. Docs, OpenAPI, REST and MCP updated.

syntaqxcommitted Parent859f150Browse files
89 files+924−360/89 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
4141 type: boolean
4242 default: false
4343
44+# Its token only reads: deploying uses CLOUDFLARE_API_TOKEN, and g1t
45+# records the deployments itself.
46+permissions:
47+ contents: read
48+
4449 # One deploy at a time, and never one cut off halfway: the next waits.
4550 concurrency:
4651 group: deploy-production
+7−0
3030 type: boolean
3131 default: false
3232
33+# Its token pushes the branch with base.json and opens the pull request.
34+# What a job's token does starts no workflows, so that pull request's
35+# checks start when someone pushes to it or runs CI by hand.
36+permissions:
37+ contents: write
38+ pull-requests: write
39+
3340 concurrency:
3441 group: runner-base
3542 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
7881 runs-on: [self-hosted, docker]
7982 environment: production
8083 timeout-minutes: 30
84+ # Its token pushes the image to g1t's registry.
85+ permissions:
86+ contents: read
87+ packages: write
8188 steps:
8289 - uses: actions/checkout@v5
8390 - 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
199199 }
200200 (_, what) if what == "cache" || what.starts_with("cache/") => {
201201 let bucket = env.bucket("ACTIONS_CACHE")?;
202− cache(request, &kv, &bucket, services, method, job, &token, &repo, what).await
202+ cache(request, &bucket, services, method, job, &token, &repo, what).await
203203 }
204204 _ => error(404, "No such endpoint."),
205205 }
239239 #[allow(clippy::too_many_arguments)]
240240 async fn cache(
241241 mut request: Request,
242− kv: &KvStore,
243242 bucket: &Bucket,
244243 services: &Services,
245244 method: &str,
250249 ) -> Result<Response> {
251250 let parts: Vec<&str> = what.split('/').collect();
252251 let upload_id = query(&request, "upload").unwrap_or_default();
252+ // The hash of the entry's paths and compression; runners from before
253+ // it was sent send none.
254+ let version = query(&request, "version").unwrap_or_default();
253255 match (method, parts.as_slice()) {
254256 ("GET", ["cache"]) => {
255257 let key = query(&request, "key").unwrap_or_default();
258260 let found: Outcome<Option<CacheHit>> = g1t_kit::call(
259261 &services.actions,
260262 "cache_lookup",
261− &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore: restore.clone() },
263+ &CacheLookupArgs { job: job.to_owned(), token: token.to_owned(), key: key.clone(), restore, version: version.clone() },
262264 )
263265 .await?;
264266 let found = match refused(found) {
276278 headers.set("content-type", "application/octet-stream")?;
277279 return Ok(response);
278280 }
279− // Entries saved in KV before the cache moved to R2.
280− kv_lookup(kv, repo, &key, &restore).await
281+ // Entries kept in KV before the cache moved to R2 had no scope,
282+ // so they are never restored.
283+ error(404, "Nothing cached under those keys.")
281284 }
282285 // Older runners send a whole entry of at most 60 MB at once.
283286 ("PUT", ["cache"]) => {
289292 let reserved: Outcome<CacheReservation> = g1t_kit::call(
290293 &services.actions,
291294 "cache_reserve",
292− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64 },
295+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size: bytes.len() as u64, version: version.clone() },
293296 )
294297 .await?;
295298 let reserved = match reserved {
312315 let reserved: Outcome<CacheReservation> = g1t_kit::call(
313316 &services.actions,
314317 "cache_reserve",
315− &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size },
318+ &CacheReserveArgs { job: job.to_owned(), token: token.to_owned(), key, size, version: version.clone() },
316319 )
317320 .await?;
318321 let reserved = match refused(reserved) {
396399 }
397400
398401 /// An entry saved in KV before the cache moved to R2, by key or restore key.
399−async fn kv_lookup(kv: &KvStore, repo: &str, key: &str, restore: &[String]) -> Result<Response> {
400− // The exact key, else the newest entry under each restore key.
401− if let Some(bytes) = get(kv, &format!("c/{repo}/{key}")).await? {
402− let mut response = Response::from_bytes(bytes)?;
403− response.headers_mut().set("x-g1t-key", key)?;
404− return Ok(response);
405− }
406− for prefix in restore {
407− if let Some((base, meta)) = list(kv, &format!("c/{repo}/{prefix}")).await?.into_iter().next()
408− && let Some(bytes) = get(kv, &base).await?
409− {
410− let mut response = Response::from_bytes(bytes)?;
411− response.headers_mut().set("x-g1t-key", &meta.name)?;
412− return Ok(response);
413− }
414− }
415− error(404, "Nothing cached under those keys.")
416−}
417−
418402 /// Someone who can see the run downloading one of its artifacts.
419403 pub async fn download(env: &Env, services: &Services, viewer: &g1t_contracts::Viewer, owner: &str, repo: &str, run: &str, name: &str) -> Result<Response> {
420404 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
1818 mod openapi;
1919 mod pins;
2020 mod projects;
21+mod protection;
2122 mod operations;
2223 mod renamed;
2324 #[cfg(test)]
6970 /// workflow file, GitHub's contexts and event, and where to check out, all
7071 /// passed through as they are.
7172 const JOB_SPEC_AS_GIVEN: &[&str] = &[
72− "spec", "workflow", "github", "event", "contexts", "checkout",
73+ "spec", "workflow", "github", "event", "contexts", "checkout", "permissions",
7374 ];
7475
7576 /// An error in the shape every endpoint uses.
606607 ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => {
607608 let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect();
608609 if let [owner, repo, "actions", "runs", run, "artifacts", rest @ ..] = parts.as_slice() {
610+ // A workflow job's token reaches its own repository only.
611+ if viewer
612+ .as_ref()
613+ .and_then(|user| user.token.as_deref())
614+ .is_some_and(|token| !token.reaches(&format!("{owner}/{repo}")))
615+ {
616+ return fail(FailureCode::NotFound, "No such run.");
617+ }
609618 return match rest {
610619 [] => {
611620 let seen: Outcome<Value> = g1t_kit::call(
+18−0
99
1010 use crate::about::AboutOp;
1111 use crate::deployments::DeploymentsOp;
12+use crate::protection::ProtectionOp;
1213 use crate::operations::Op;
1314 use crate::checks::ChecksOp;
1415 use crate::rules::RulesOp;
382383 ],
383384 ),
384385 (
386+ "Run protection",
387+ "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.",
388+ &[
389+ Op::Protection(ProtectionOp::UpdateEnvironment),
390+ Op::Protection(ProtectionOp::DeleteEnvironment),
391+ Op::Protection(ProtectionOp::GetPendingDeployments),
392+ Op::Protection(ProtectionOp::ReviewPendingDeployments),
393+ Op::Protection(ProtectionOp::ApproveWorkflowRun),
394+ Op::Protection(ProtectionOp::GetWorkflowPermissions),
395+ Op::Protection(ProtectionOp::SetWorkflowPermissions),
396+ Op::Protection(ProtectionOp::GetForkPrApproval),
397+ Op::Protection(ProtectionOp::SetForkPrApproval),
398+ Op::Protection(ProtectionOp::CreateRepositoryDispatch),
399+ ],
400+ ),
401+ (
385402 "Secrets and variables",
386403 "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.",
387404 &[
633650 Op::Checks(op) => op.title(),
634651 Op::About(op) => op.title(),
635652 Op::Deployments(op) => op.title(),
653+ Op::Protection(op) => op.title(),
636654 }
637655 }
638656
+22−1
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
3131 use crate::deployments::DeploymentsOp;
32+use crate::protection::ProtectionOp;
3233 use crate::rules::RulesOp;
3334 use crate::security::SecurityOp;
3435 use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel};
283284 About(AboutOp),
284285 /// Deployments wherever they run, and environments: deployments.rs.
285286 Deployments(DeploymentsOp),
287+ /// Environments' protection rules, approving runs, the token's default
288+ /// permissions and repository dispatch: protection.rs.
289+ Protection(ProtectionOp),
286290 }
287291
288292 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
645649 }
646650
647651 impl Op {
648− pub const ALL: [Op; 257] = [
652+ pub const ALL: [Op; 267] = [
649653 Op::Whoami,
650654 Op::GetWorkspace,
651655 Op::CreateWorkspace,
903907 Op::Deployments(DeploymentsOp::CreateDeploymentStatus),
904908 Op::Deployments(DeploymentsOp::ListEnvironments),
905909 Op::Deployments(DeploymentsOp::GetEnvironment),
910+ Op::Protection(ProtectionOp::UpdateEnvironment),
911+ Op::Protection(ProtectionOp::DeleteEnvironment),
912+ Op::Protection(ProtectionOp::GetPendingDeployments),
913+ Op::Protection(ProtectionOp::ReviewPendingDeployments),
914+ Op::Protection(ProtectionOp::ApproveWorkflowRun),
915+ Op::Protection(ProtectionOp::GetWorkflowPermissions),
916+ Op::Protection(ProtectionOp::SetWorkflowPermissions),
917+ Op::Protection(ProtectionOp::GetForkPrApproval),
918+ Op::Protection(ProtectionOp::SetForkPrApproval),
919+ Op::Protection(ProtectionOp::CreateRepositoryDispatch),
906920 ];
907921
908922 pub fn by_name(name: &str) -> Option<Op> {
10961110 Op::Checks(op) => op.name(),
10971111 Op::About(op) => op.name(),
10981112 Op::Deployments(op) => op.name(),
1113+ Op::Protection(op) => op.name(),
10991114 }
11001115 }
11011116
16091624 Op::Checks(op) => op.description(),
16101625 Op::About(op) => op.description(),
16111626 Op::Deployments(op) => op.description(),
1627+ Op::Protection(op) => op.description(),
16121628 }
16131629 }
16141630
29802996 Op::Checks(op) => op.input(),
29812997 Op::About(op) => op.input(),
29822998 Op::Deployments(op) => op.input(),
2999+ Op::Protection(op) => op.input(),
29833000 }
29843001 }
29853002
30223039 | DeploymentsOp::ListEnvironments
30233040 | DeploymentsOp::GetEnvironment
30243041 )
3042+ | Op::Protection(
3043+ ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval
3044+ )
30253045 )
30263046 }
30273047
50395059 Op::Checks(op) => crate::checks::run(op, services, viewer, input).await,
50405060 Op::About(op) => crate::about::run(op, services, viewer, input).await,
50415061 Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
5062+ Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
50425063 Op::ReopenSecurityAlert => {
50435064 let changed: Outcome<AlertChange> = call(
50445065 &services.security,
+531−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+}
29+
30+impl ProtectionOp {
31+ /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test
32+ /// checks against this.
33+ #[cfg(test)]
34+ pub const ALL: [ProtectionOp; 10] = [
35+ ProtectionOp::UpdateEnvironment,
36+ ProtectionOp::DeleteEnvironment,
37+ ProtectionOp::GetPendingDeployments,
38+ ProtectionOp::ReviewPendingDeployments,
39+ ProtectionOp::ApproveWorkflowRun,
40+ ProtectionOp::GetWorkflowPermissions,
41+ ProtectionOp::SetWorkflowPermissions,
42+ ProtectionOp::GetForkPrApproval,
43+ ProtectionOp::SetForkPrApproval,
44+ ProtectionOp::CreateRepositoryDispatch,
45+ ];
46+
47+ pub fn name(self) -> &'static str {
48+ match self {
49+ ProtectionOp::UpdateEnvironment => "update_environment",
50+ ProtectionOp::DeleteEnvironment => "delete_environment",
51+ ProtectionOp::GetPendingDeployments => "get_pending_deployments",
52+ ProtectionOp::ReviewPendingDeployments => "review_pending_deployments",
53+ ProtectionOp::ApproveWorkflowRun => "approve_workflow_run",
54+ ProtectionOp::GetWorkflowPermissions => "get_workflow_permissions",
55+ ProtectionOp::SetWorkflowPermissions => "set_workflow_permissions",
56+ ProtectionOp::GetForkPrApproval => "get_fork_pr_approval",
57+ ProtectionOp::SetForkPrApproval => "set_fork_pr_approval",
58+ ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch",
59+ }
60+ }
61+
62+ pub fn title(self) -> &'static str {
63+ match self {
64+ ProtectionOp::UpdateEnvironment => "Create or update an environment's protection rules",
65+ ProtectionOp::DeleteEnvironment => "Delete an environment's protection rules",
66+ ProtectionOp::GetPendingDeployments => "Get a run's pending deployments",
67+ ProtectionOp::ReviewPendingDeployments => "Review a run's pending deployments",
68+ ProtectionOp::ApproveWorkflowRun => "Approve a workflow run",
69+ ProtectionOp::GetWorkflowPermissions => "Get the default workflow permissions",
70+ ProtectionOp::SetWorkflowPermissions => "Set the default workflow permissions",
71+ ProtectionOp::GetForkPrApproval => "Get the approval policy for outside pull requests",
72+ ProtectionOp::SetForkPrApproval => "Set the approval policy for outside pull requests",
73+ ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event",
74+ }
75+ }
76+
77+ pub fn description(self) -> &'static str {
78+ match self {
79+ 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.",
80+ 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.",
81+ 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.",
82+ 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.",
83+ 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.",
84+ 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; the default) or write (every permission). can_approve_pull_request_reviews is always false: a job's token never approves pull requests. Needs the Read role.",
85+ ProtectionOp::SetWorkflowPermissions => "Set default_workflow_permissions to read or write. 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.",
86+ 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.",
87+ ProtectionOp::SetForkPrApproval => "Set approval_policy: first_time_contributors, outside_contributors or all_external_contributors. Needs the Admin role.",
88+ 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.",
89+ }
90+ }
91+
92+ /// Whether it changes anything (the caller is its actor).
93+ pub fn writes(self) -> bool {
94+ !matches!(self, ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval)
95+ }
96+
97+ pub fn input(self) -> Value {
98+ let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
99+ let run = json!({ "type": "string", "description": "The run's id, run_…." });
100+ let environment = json!({ "type": "string", "description": "The environment's name, such as production." });
101+ let (properties, required): (Value, &[&str]) = match self {
102+ ProtectionOp::UpdateEnvironment => (
103+ json!({
104+ "repo": repo,
105+ "environment": environment,
106+ "wait_timer": { "type": "integer", "description": "Minutes each job waits before it may start, 0 to 43200." },
107+ "prevent_self_review": { "type": "boolean", "description": "Whoever started a run may not approve its jobs." },
108+ "reviewers": {
109+ "type": ["array", "null"],
110+ "description": "Up to 6 people or teams who may approve its jobs; empty for none.",
111+ "items": {
112+ "type": "object",
113+ "properties": {
114+ "type": { "type": "string", "enum": ["User", "Team"] },
115+ "name": { "type": "string", "description": "A username, or a team's slug in the repository's workspace." },
116+ },
117+ },
118+ },
119+ "deployment_branch_policy": {
120+ "type": ["object", "null"],
121+ "description": "null: every branch may deploy. protected_branches: only protected ones. custom_branch_policies: only those in branch_policies.",
122+ "properties": {
123+ "protected_branches": { "type": "boolean" },
124+ "custom_branch_policies": { "type": "boolean" },
125+ },
126+ },
127+ "branch_policies": {
128+ "type": "array",
129+ "description": "With custom_branch_policies: the branches and tags that may deploy, at most 50.",
130+ "items": {
131+ "type": "object",
132+ "properties": {
133+ "name": { "type": "string", "description": "A pattern, such as main, release/* or v*." },
134+ "type": { "type": "string", "enum": ["branch", "tag"] },
135+ },
136+ },
137+ },
138+ "can_admins_bypass": { "type": "boolean", "description": "Admins may approve without being reviewers, skipping the wait. True unless you say." },
139+ }),
140+ &["repo", "environment"],
141+ ),
142+ ProtectionOp::DeleteEnvironment => (json!({ "repo": repo, "environment": environment }), &["repo", "environment"]),
143+ ProtectionOp::GetPendingDeployments | ProtectionOp::ApproveWorkflowRun => (json!({ "repo": repo, "id": run }), &["repo", "id"]),
144+ ProtectionOp::ReviewPendingDeployments => (
145+ json!({
146+ "repo": repo,
147+ "id": run,
148+ "environment_names": { "type": "array", "items": { "type": "string" }, "description": "The environments to review; every waiting one if left out." },
149+ "state": { "type": "string", "enum": ["approved", "rejected"] },
150+ "comment": { "type": "string", "description": "Why, kept with the review." },
151+ }),
152+ &["repo", "id", "state"],
153+ ),
154+ ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval => (json!({ "repo": repo }), &["repo"]),
155+ ProtectionOp::SetWorkflowPermissions => (
156+ json!({
157+ "repo": repo,
158+ "default_workflow_permissions": { "type": "string", "enum": ["read", "write"] },
159+ }),
160+ &["repo", "default_workflow_permissions"],
161+ ),
162+ ProtectionOp::SetForkPrApproval => (
163+ json!({
164+ "repo": repo,
165+ "approval_policy": { "type": "string", "enum": ["first_time_contributors", "outside_contributors", "all_external_contributors"] },
166+ }),
167+ &["repo", "approval_policy"],
168+ ),
169+ ProtectionOp::CreateRepositoryDispatch => (
170+ json!({
171+ "repo": repo,
172+ "event_type": { "type": "string", "description": "What happened, 1 to 100 characters; workflows choose it with `types:`." },
173+ "client_payload": { "type": "object", "description": "Anything the workflows should read, as github.event.client_payload." },
174+ }),
175+ &["repo", "event_type"],
176+ ),
177+ };
178+ json!({ "type": "object", "properties": properties, "required": required })
179+ }
180+}
181+
182+fn text(input: &Value, key: &str) -> Option<String> {
183+ match &input[key] {
184+ Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
185+ Value::Number(number) => Some(number.to_string()),
186+ _ => None,
187+ }
188+}
189+
190+fn flag(input: &Value, key: &str) -> Option<bool> {
191+ match &input[key] {
192+ Value::Bool(value) => Some(*value),
193+ Value::String(text) => match text.trim() {
194+ "true" | "1" => Some(true),
195+ "false" | "0" => Some(false),
196+ _ => None,
197+ },
198+ _ => None,
199+ }
200+}
201+
202+/// The actions service's arguments for an environment's change, from a
203+/// request shaped as the standard environments API is.
204+pub(crate) fn environment_change(input: &Value) -> std::result::Result<Map<String, Value>, String> {
205+ let mut out = Map::new();
206+ if let Some(minutes) = input.get("wait_timer").filter(|v| !v.is_null()) {
207+ 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.")?;
208+ out.insert("waitMinutes".into(), minutes.into());
209+ }
210+ if let Some(value) = flag(input, "prevent_self_review") {
211+ out.insert("preventSelfReview".into(), value.into());
212+ }
213+ if let Some(value) = flag(input, "can_admins_bypass") {
214+ out.insert("adminsBypass".into(), value.into());
215+ }
216+ match input.get("reviewers") {
217+ None => {}
218+ Some(Value::Null) => {
219+ out.insert("reviewers".into(), json!([]));
220+ }
221+ Some(Value::Array(given)) => {
222+ let mut reviewers = Vec::new();
223+ for reviewer in given {
224+ let kind = reviewer["type"].as_str().unwrap_or("User").to_ascii_lowercase();
225+ let name = text(reviewer, "name").or_else(|| text(reviewer, "id")).or_else(|| text(reviewer, "login")).or_else(|| text(reviewer, "slug"));
226+ let Some(name) = name else { return Err("Each reviewer has a name: a username or a team's slug.".to_owned()) };
227+ reviewers.push(json!({ "type": kind, "name": name }));
228+ }
229+ out.insert("reviewers".into(), Value::Array(reviewers));
230+ }
231+ Some(_) => return Err("reviewers is a list of {\"type\", \"name\"}.".to_owned()),
232+ }
233+ match input.get("deployment_branch_policy") {
234+ None => {}
235+ Some(Value::Null) => {
236+ out.insert("branchPolicy".into(), "all".into());
237+ }
238+ Some(policy @ Value::Object(_)) => {
239+ let protected = flag(policy, "protected_branches") == Some(true);
240+ let custom = flag(policy, "custom_branch_policies") == Some(true);
241+ let chosen = match (protected, custom) {
242+ (true, true) => return Err("deployment_branch_policy is protected_branches or custom_branch_policies, not both.".to_owned()),
243+ (true, false) => "protected",
244+ (false, true) => "selected",
245+ (false, false) => "all",
246+ };
247+ out.insert("branchPolicy".into(), chosen.into());
248+ }
249+ Some(_) => return Err("deployment_branch_policy is an object, or null.".to_owned()),
250+ }
251+ if let Some(Value::Array(patterns)) = input.get("branch_policies") {
252+ let patterns: Vec<Value> = patterns
253+ .iter()
254+ .map(|pattern| json!({ "name": pattern["name"].as_str().unwrap_or_default(), "type": pattern["type"].as_str().unwrap_or("branch") }))
255+ .collect();
256+ out.insert("branchPatterns".into(), Value::Array(patterns));
257+ }
258+ Ok(out)
259+}
260+
261+/// An environment as the actions service keeps it (camelCase), in the
262+/// standard shape: `protection_rules`, `deployment_branch_policy` and
263+/// `can_admins_bypass`, with g1t's `branch_policies` beside them.
264+pub(crate) fn environment_view(env: &Value) -> Value {
265+ let reviewers: Vec<Value> = env["reviewers"]
266+ .as_array()
267+ .map(|list| {
268+ list.iter()
269+ .map(|r| match r["type"].as_str() {
270+ Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }),
271+ _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }),
272+ })
273+ .collect()
274+ })
275+ .unwrap_or_default();
276+ let mut rules = Vec::new();
277+ if !reviewers.is_empty() {
278+ rules.push(json!({ "type": "required_reviewers", "prevent_self_review": env["preventSelfReview"], "reviewers": reviewers }));
279+ }
280+ if env["waitMinutes"].as_u64().unwrap_or(0) > 0 {
281+ rules.push(json!({ "type": "wait_timer", "wait_timer": env["waitMinutes"] }));
282+ }
283+ let policy = env["branchPolicy"].as_str().unwrap_or("all");
284+ if policy != "all" {
285+ rules.push(json!({ "type": "branch_policy" }));
286+ }
287+ json!({
288+ "name": env["name"],
289+ "protection_rules": rules,
290+ "deployment_branch_policy": match policy {
291+ "protected" => json!({ "protected_branches": true, "custom_branch_policies": false }),
292+ "selected" => json!({ "protected_branches": false, "custom_branch_policies": true }),
293+ _ => Value::Null,
294+ },
295+ "branch_policies": env["branchPatterns"],
296+ "can_admins_bypass": env["adminsBypass"],
297+ "protected": env["protected"],
298+ "updated_at": env["updatedAt"],
299+ "updated_by": env["updatedBy"],
300+ })
301+}
302+
303+/// A pending deployment, in the standard shape.
304+fn pending_view(pending: &Value) -> Value {
305+ let reviewers: Vec<Value> = pending["reviewers"]
306+ .as_array()
307+ .map(|list| {
308+ list.iter()
309+ .map(|r| match r["type"].as_str() {
310+ Some("team") => json!({ "type": "Team", "reviewer": { "slug": r["name"] } }),
311+ _ => json!({ "type": "User", "reviewer": { "login": r["name"] } }),
312+ })
313+ .collect()
314+ })
315+ .unwrap_or_default();
316+ json!({
317+ "environment": { "name": pending["environment"] },
318+ "state": pending["state"],
319+ "needs_review": pending["needsReview"],
320+ "wait_until": pending["waitUntil"],
321+ "current_user_can_approve": pending["canReview"],
322+ "reviewers": reviewers,
323+ "jobs": pending["jobs"],
324+ "reviewed_by": pending["reviewedBy"],
325+ "comment": pending["comment"],
326+ "reviewed_at": pending["reviewedAt"],
327+ })
328+}
329+
330+fn map<T>(outcome: Outcome<T>, view: impl FnOnce(T) -> Value) -> Outcome<Value> {
331+ match outcome {
332+ Outcome::Ok(value) => Outcome::Ok(view(value)),
333+ Outcome::Fail(refused) => Outcome::Fail(refused),
334+ }
335+}
336+
337+/// The protection rules of the environments `listed` (the deployments
338+/// service's answer to `list_environments` or `get_environment`) added to
339+/// it, and environments with rules but no deployments yet added to a list.
340+pub(crate) async fn with_protection(services: &Services, viewer: &Viewer, input: &Value, listed: Outcome<Value>, one: Option<&str>) -> Result<Outcome<Value>> {
341+ let Some(repo) = repo_path(input) else { return Ok(listed) };
342+ let mut args = json!({ "viewer": viewer, "repo": repo });
343+ if let Some(name) = one {
344+ args["name"] = json!(name);
345+ }
346+ let rules: Outcome<Vec<Value>> = g1t_kit::call(&services.actions, "environments", &args).await.unwrap_or(Outcome::Ok(Vec::new()));
347+ let rules = match rules {
348+ Outcome::Ok(rules) => rules,
349+ Outcome::Fail(_) => return Ok(listed),
350+ };
351+ 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);
352+ let add = |env: &mut Value| {
353+ if let Some(view) = env["name"].as_str().and_then(protection) {
354+ for key in ["protection_rules", "deployment_branch_policy", "branch_policies", "can_admins_bypass"] {
355+ env[key] = view[key].clone();
356+ }
357+ }
358+ };
359+ Ok(match (listed, one) {
360+ (Outcome::Ok(mut env), Some(_)) => {
361+ add(&mut env);
362+ Outcome::Ok(env)
363+ }
364+ // Never deployed, but protected: still an environment.
365+ (Outcome::Fail(refused), Some(name)) => match protection(name).filter(|view| view["protected"] == true) {
366+ Some(view) => Outcome::Ok(view),
367+ None => Outcome::Fail(refused),
368+ },
369+ (Outcome::Ok(mut list), None) => {
370+ if let Some(environments) = list["environments"].as_array_mut() {
371+ for env in environments.iter_mut() {
372+ add(env);
373+ }
374+ for env in rules.iter().filter(|env| env["protected"] == true) {
375+ let name = env["name"].as_str().unwrap_or_default();
376+ if !environments.iter().any(|known| known["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(name))) {
377+ environments.push(environment_view(env));
378+ }
379+ }
380+ }
381+ Outcome::Ok(list)
382+ }
383+ (failed, None) => failed,
384+ })
385+}
386+
387+pub async fn run(op: ProtectionOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
388+ let Some(repo) = repo_path(input) else {
389+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
390+ };
391+ if op.writes() && viewer.is_none() {
392+ return Ok(Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."));
393+ }
394+ let actor = || viewer.clone().unwrap_or_default();
395+ let actions = &services.actions;
396+ let id = text(input, "id").unwrap_or_default();
397+ let environment = text(input, "environment").unwrap_or_default();
398+ Ok(match op {
399+ ProtectionOp::UpdateEnvironment => {
400+ let mut args = match environment_change(input) {
401+ Ok(args) => args,
402+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
403+ };
404+ args.insert("actor".into(), serde_json::to_value(actor())?);
405+ args.insert("repo".into(), serde_json::to_value(&repo)?);
406+ args.insert("name".into(), environment.into());
407+ let saved: Outcome<Value> = g1t_kit::call(actions, "set_environment", &Value::Object(args)).await?;
408+ map(saved, |env| environment_view(&env))
409+ }
410+ ProtectionOp::DeleteEnvironment => {
411+ let removed: Outcome<bool> =
412+ g1t_kit::call(actions, "delete_environment", &json!({ "actor": actor(), "repo": repo, "name": environment })).await?;
413+ map(removed, |removed| json!({ "deleted": removed }))
414+ }
415+ ProtectionOp::GetPendingDeployments => {
416+ let pending: Outcome<Vec<Value>> = g1t_kit::call(actions, "pending_deployments", &json!({ "viewer": viewer, "repo": repo, "id": id })).await?;
417+ map(pending, |list| Value::Array(list.iter().map(pending_view).collect()))
418+ }
419+ ProtectionOp::ReviewPendingDeployments => {
420+ let names: Vec<String> = ["environment_names", "environments", "environment_ids"]
421+ .iter()
422+ .find_map(|key| input[*key].as_array())
423+ .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())
424+ .unwrap_or_default();
425+ let reviewed: Outcome<Vec<Value>> = g1t_kit::call(
426+ actions,
427+ "review_deployments",
428+ &json!({
429+ "actor": actor(),
430+ "repo": repo,
431+ "id": id,
432+ "environments": names,
433+ "state": text(input, "state").unwrap_or_default(),
434+ "comment": text(input, "comment"),
435+ }),
436+ )
437+ .await?;
438+ map(reviewed, |list| Value::Array(list.iter().map(pending_view).collect()))
439+ }
440+ ProtectionOp::ApproveWorkflowRun => g1t_kit::call(actions, "approve_run", &json!({ "actor": actor(), "repo": repo, "id": id })).await?,
441+ ProtectionOp::GetWorkflowPermissions | ProtectionOp::SetWorkflowPermissions => {
442+ let settings: Outcome<Value> = if op == ProtectionOp::GetWorkflowPermissions {
443+ g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await?
444+ } else {
445+ g1t_kit::call(
446+ actions,
447+ "set_actions_settings",
448+ &json!({ "actor": actor(), "repo": repo, "defaultPermissions": text(input, "default_workflow_permissions").unwrap_or_default() }),
449+ )
450+ .await?
451+ };
452+ map(settings, |s| json!({ "default_workflow_permissions": s["defaultPermissions"], "can_approve_pull_request_reviews": false }))
453+ }
454+ ProtectionOp::GetForkPrApproval | ProtectionOp::SetForkPrApproval => {
455+ let settings: Outcome<Value> = if op == ProtectionOp::GetForkPrApproval {
456+ g1t_kit::call(actions, "actions_settings", &json!({ "viewer": viewer, "repo": repo })).await?
457+ } else {
458+ g1t_kit::call(
459+ actions,
460+ "set_actions_settings",
461+ &json!({ "actor": actor(), "repo": repo, "approvalPolicy": text(input, "approval_policy").unwrap_or_default() }),
462+ )
463+ .await?
464+ };
465+ map(settings, |s| json!({ "approval_policy": s["approvalPolicy"] }))
466+ }
467+ ProtectionOp::CreateRepositoryDispatch => {
468+ let started: Outcome<u32> = g1t_kit::call(
469+ actions,
470+ "repository_dispatch",
471+ &json!({
472+ "actor": actor(),
473+ "repo": repo,
474+ "eventType": text(input, "event_type").unwrap_or_default(),
475+ "clientPayload": input.get("client_payload").cloned().unwrap_or(Value::Null),
476+ }),
477+ )
478+ .await?;
479+ map(started, |runs| json!({ "runs": runs }))
480+ }
481+ })
482+}
483+
484+#[cfg(test)]
485+mod tests {
486+ use super::*;
487+
488+ #[test]
489+ fn an_environment_change_reads_the_standard_shape() {
490+ let args = environment_change(&json!({
491+ "wait_timer": 30,
492+ "prevent_self_review": true,
493+ "reviewers": [{ "type": "User", "id": "ada" }, { "type": "Team", "name": "deployers" }],
494+ "deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true },
495+ "branch_policies": [{ "name": "release/*", "type": "branch" }],
496+ }))
497+ .unwrap();
498+ assert_eq!(args["waitMinutes"], 30);
499+ assert_eq!(args["preventSelfReview"], true);
500+ assert_eq!(args["reviewers"], json!([{ "type": "user", "name": "ada" }, { "type": "team", "name": "deployers" }]));
501+ assert_eq!(args["branchPolicy"], "selected");
502+ assert_eq!(args["branchPatterns"], json!([{ "name": "release/*", "type": "branch" }]));
503+ // Left out stays; null clears.
504+ let cleared = environment_change(&json!({ "deployment_branch_policy": null, "reviewers": null })).unwrap();
505+ assert_eq!(cleared["branchPolicy"], "all");
506+ assert_eq!(cleared["reviewers"], json!([]));
507+ assert!(!environment_change(&json!({})).unwrap().contains_key("waitMinutes"));
508+ assert!(environment_change(&json!({ "deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": true } })).is_err());
509+ }
510+
511+ #[test]
512+ fn an_environment_reads_as_the_standard_shape() {
513+ let view = environment_view(&json!({
514+ "name": "production", "reviewers": [{ "type": "user", "name": "ada" }], "preventSelfReview": true,
515+ "waitMinutes": 10, "branchPolicy": "protected", "branchPatterns": [], "adminsBypass": false, "protected": true,
516+ }));
517+ let types: Vec<&str> = view["protection_rules"].as_array().unwrap().iter().map(|r| r["type"].as_str().unwrap()).collect();
518+ assert_eq!(types, ["required_reviewers", "wait_timer", "branch_policy"]);
519+ assert_eq!(view["protection_rules"][0]["reviewers"][0]["reviewer"]["login"], "ada");
520+ assert_eq!(view["deployment_branch_policy"]["protected_branches"], true);
521+ assert_eq!(view["can_admins_bypass"], false);
522+ }
523+
524+ #[test]
525+ fn each_operation_is_described_with_a_schema() {
526+ for op in ProtectionOp::ALL {
527+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
528+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
529+ }
530+ }
531+}
+245−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+ "can_approve_pull_request_reviews": false
10116+ }
10117+ },
10118+ "set_workflow_permissions": {
10119+ "params": {
10120+ "owner": "flagon-io",
10121+ "name": "g1t"
10122+ },
10123+ "request": {
10124+ "default_workflow_permissions": "write"
10125+ },
10126+ "response": {
10127+ "default_workflow_permissions": "write",
10128+ "can_approve_pull_request_reviews": false
10129+ }
10130+ },
10131+ "get_fork_pr_approval": {
10132+ "params": {
10133+ "owner": "flagon-io",
10134+ "name": "g1t"
10135+ },
10136+ "response": {
10137+ "approval_policy": "outside_contributors"
10138+ }
10139+ },
10140+ "set_fork_pr_approval": {
10141+ "params": {
10142+ "owner": "flagon-io",
10143+ "name": "g1t"
10144+ },
10145+ "request": {
10146+ "approval_policy": "all_external_contributors"
10147+ },
10148+ "response": {
10149+ "approval_policy": "all_external_contributors"
10150+ }
10151+ },
10152+ "create_repository_dispatch": {
10153+ "params": {
10154+ "owner": "flagon-io",
10155+ "name": "g1t"
10156+ },
10157+ "request": {
10158+ "event_type": "docs-published",
10159+ "client_payload": {
10160+ "version": "2026.10.08"
10161+ }
10162+ },
10163+ "response": {
10164+ "runs": 1
10165+ }
10166+ },
992210167 "get_languages": {
992310168 "response": {
992410169 "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
+32−0
44
55 use crate::about::AboutOp;
66 use crate::deployments::DeploymentsOp;
7+use crate::protection::ProtectionOp;
78 use crate::operations::Op;
89 use crate::checks::ChecksOp;
910 use crate::rules::RulesOp;
323324 route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]),
324325 route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]),
325326 route("GET", "/repos/:owner/:name/environments/:environment", Op::Deployments(DeploymentsOp::GetEnvironment), &[]),
327+ // Environments' protection rules, and the runs they hold.
328+ route("PUT", "/repos/:owner/:name/environments/:environment", Op::Protection(ProtectionOp::UpdateEnvironment), &[]),
329+ route("DELETE", "/repos/:owner/:name/environments/:environment", Op::Protection(ProtectionOp::DeleteEnvironment), &[]),
330+ route(
331+ "GET",
332+ "/repos/:owner/:name/actions/runs/:id/pending_deployments",
333+ Op::Protection(ProtectionOp::GetPendingDeployments),
334+ &[],
335+ ),
336+ route(
337+ "POST",
338+ "/repos/:owner/:name/actions/runs/:id/pending_deployments",
339+ Op::Protection(ProtectionOp::ReviewPendingDeployments),
340+ &[],
341+ ),
342+ route("POST", "/repos/:owner/:name/actions/runs/:id/approve", Op::Protection(ProtectionOp::ApproveWorkflowRun), &[]),
343+ route("GET", "/repos/:owner/:name/actions/permissions/workflow", Op::Protection(ProtectionOp::GetWorkflowPermissions), &[]),
344+ route("PUT", "/repos/:owner/:name/actions/permissions/workflow", Op::Protection(ProtectionOp::SetWorkflowPermissions), &[]),
345+ route(
346+ "GET",
347+ "/repos/:owner/:name/actions/permissions/fork-pr-contributor-approval",
348+ Op::Protection(ProtectionOp::GetForkPrApproval),
349+ &[],
350+ ),
351+ route(
352+ "PUT",
353+ "/repos/:owner/:name/actions/permissions/fork-pr-contributor-approval",
354+ Op::Protection(ProtectionOp::SetForkPrApproval),
355+ &[],
356+ ),
357+ route("POST", "/repos/:owner/:name/dispatches", Op::Protection(ProtectionOp::CreateRepositoryDispatch), &[]),
326358 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
327359 route(
328360 "POST",
+14−2
2020
2121 use crate::about::AboutOp;
2222 use crate::deployments::DeploymentsOp;
23+use crate::protection::ProtectionOp;
2324 use crate::operations::Op;
2425 use crate::checks::ChecksOp;
2526 use crate::rules::RulesOp;
200201 Tool {
201202 name: "workflow",
202203 title: "Workflows",
203− description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. 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.",
204+ description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. 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.",
204205 default_action: None,
205206 actions: &[
206207 a("list", Op::ListWorkflows, "Workflows on the default branch"),
229230 a("deployment_statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), "A deployment's statuses, newest first"),
230231 a("create_deployment_status", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), "Report where a deployment is: in_progress, success, failure"),
231232 a("list_environments", Op::Deployments(DeploymentsOp::ListEnvironments), "Environments with their current and latest deployments"),
232− a("get_environment", Op::Deployments(DeploymentsOp::GetEnvironment), "One environment by name"),
233+ a("get_environment", Op::Deployments(DeploymentsOp::GetEnvironment), "One environment by name, with its protection rules"),
234+ a("update_environment", Op::Protection(ProtectionOp::UpdateEnvironment), "Set an environment's reviewers, wait timer and branches"),
235+ a("delete_environment", Op::Protection(ProtectionOp::DeleteEnvironment), "Remove an environment's protection rules"),
236+ a("pending_deployments", Op::Protection(ProtectionOp::GetPendingDeployments), "The environments holding a run's jobs"),
237+ a("review_deployments", Op::Protection(ProtectionOp::ReviewPendingDeployments), "Approve or reject a run's jobs for its environments"),
238+ a("approve_run", Op::Protection(ProtectionOp::ApproveWorkflowRun), "Let a run of a pull request from outside start"),
239+ a("get_permissions", Op::Protection(ProtectionOp::GetWorkflowPermissions), "What a job's token gets without `permissions:`"),
240+ a("set_permissions", Op::Protection(ProtectionOp::SetWorkflowPermissions), "Set it: read or write"),
241+ a("get_approval_policy", Op::Protection(ProtectionOp::GetForkPrApproval), "Which pull requests' runs wait for approval"),
242+ a("set_approval_policy", Op::Protection(ProtectionOp::SetForkPrApproval), "Set which pull requests' runs wait for approval"),
243+ a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"),
233244 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
234245 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
235246 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
699710 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
700711 legacy: false,
701712 name: None,
713+ ..TokenAccess::default()
702714 }
703715 }
704716
+5−3
232232 work.
233233 - Actions that cache through the hosted toolkit's own cache service, such as
234234 `setup-node` with `cache: npm`. They run without it; use `actions/cache`.
235−- Environments' protection rules: required reviewers, wait timers and branch
236− limits. A job with `environment:` gets that environment's values and runs
237− without waiting.
235+- `on: delete`: deleting a branch or tag starts no workflows. New branches
236+ and tags start `create` and `push` workflows.
237+- OIDC tokens for jobs (`permissions: id-token: write`). Keep cloud
238+ credentials in [secrets](/guides/secrets-and-variables/), and protect them
239+ with an [environment's rules](/guides/actions/#environments).
238240
239241 See [Not yet](/guides/actions/#not-yet). **Status.** Planned.
240242
+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.