Merge branch 'worktree-agent-a3abfcce648e87dca'
| 14 | 14 | branches: [main] | |
| 15 | 15 | workflow_dispatch: | |
| 16 | 16 | ||
| 17 | + | # Its token only reads: it checks the code out and nothing more. | |
| 18 | + | permissions: | |
| 19 | + | contents: read | |
| 20 | + | ||
| 17 | 21 | concurrency: | |
| 18 | 22 | group: ci-${{ github.ref }} | |
| 19 | 23 | cancel-in-progress: true |
| 43 | 43 | type: boolean | |
| 44 | 44 | default: false | |
| 45 | 45 | ||
| 46 | + | # Its token only reads: deploying uses CLOUDFLARE_API_TOKEN, and g1t | |
| 47 | + | # records the deployments itself. | |
| 48 | + | permissions: | |
| 49 | + | contents: read | |
| 50 | + | ||
| 46 | 51 | # One deploy at a time, and never one cut off halfway: the next waits. | |
| 47 | 52 | concurrency: | |
| 48 | 53 | group: deploy-production |
| 34 | 34 | type: boolean | |
| 35 | 35 | default: false | |
| 36 | 36 | ||
| 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 | + | ||
| 37 | 44 | concurrency: | |
| 38 | 45 | group: runner-base | |
| 39 | 46 | cancel-in-progress: false |
| 14 | 14 | tags: ["runner-v*"] | |
| 15 | 15 | workflow_dispatch: | |
| 16 | 16 | ||
| 17 | + | permissions: | |
| 18 | + | contents: read | |
| 19 | + | ||
| 17 | 20 | concurrency: | |
| 18 | 21 | group: runner-release | |
| 19 | 22 | cancel-in-progress: false | |
| 79 | 82 | runs-on: [self-hosted, docker] | |
| 80 | 83 | environment: production | |
| 81 | 84 | timeout-minutes: 30 | |
| 85 | + | # Its token pushes the image to g1t's registry. | |
| 86 | + | permissions: | |
| 87 | + | contents: read | |
| 88 | + | packages: write | |
| 82 | 89 | steps: | |
| 83 | 90 | - uses: actions/checkout@v5 | |
| 84 | 91 | - uses: actions/download-artifact@v4 |
| 270 | 270 | scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 271 | 271 | legacy, | |
| 272 | 272 | name: None, | |
| 273 | + | ..TokenAccess::default() | |
| 273 | 274 | })), | |
| 274 | 275 | ..User::default() | |
| 275 | 276 | } |
| 607 | 607 | scopes: preset.scopes().map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 608 | 608 | legacy: false, | |
| 609 | 609 | name: None, | |
| 610 | + | ..TokenAccess::default() | |
| 610 | 611 | }; | |
| 611 | 612 | for preset in [Preset::ReadOnly, Preset::Agent] { | |
| 612 | 613 | let access = token(preset); |
| 164 | 164 | } | |
| 165 | 165 | (_, what) if what == "cache" || what.starts_with("cache/") => { | |
| 166 | 166 | 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 | |
| 168 | 168 | } | |
| 169 | 169 | _ => error(404, "No such endpoint."), | |
| 170 | 170 | } | |
| 204 | 204 | #[allow(clippy::too_many_arguments)] | |
| 205 | 205 | async fn cache( | |
| 206 | 206 | mut request: Request, | |
| 207 | − | kv: &KvStore, | |
| 208 | 207 | bucket: &Bucket, | |
| 209 | 208 | services: &Services, | |
| 210 | 209 | method: &str, | |
| 215 | 214 | ) -> Result<Response> { | |
| 216 | 215 | let parts: Vec<&str> = what.split('/').collect(); | |
| 217 | 216 | 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(); | |
| 218 | 220 | match (method, parts.as_slice()) { | |
| 219 | 221 | ("GET", ["cache"]) => { | |
| 220 | 222 | let key = query(&request, "key").unwrap_or_default(); | |
| 223 | 225 | let found: Outcome<Option<CacheHit>> = g1t_kit::call( | |
| 224 | 226 | &services.actions, | |
| 225 | 227 | "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()) }, | |
| 227 | 229 | ) | |
| 228 | 230 | .await?; | |
| 229 | 231 | let found = match refused(found) { | |
| 241 | 243 | headers.set("content-type", "application/octet-stream")?; | |
| 242 | 244 | return Ok(response); | |
| 243 | 245 | } | |
| 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.") | |
| 246 | 249 | } | |
| 247 | 250 | // Older runners send a whole entry of at most 60 MB at once. | |
| 248 | 251 | ("PUT", ["cache"]) => { | |
| 254 | 257 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 255 | 258 | &services.actions, | |
| 256 | 259 | "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()) }, | |
| 258 | 261 | ) | |
| 259 | 262 | .await?; | |
| 260 | 263 | let reserved = match reserved { | |
| 277 | 280 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 278 | 281 | &services.actions, | |
| 279 | 282 | "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()) }, | |
| 281 | 284 | ) | |
| 282 | 285 | .await?; | |
| 283 | 286 | let reserved = match refused(reserved) { | |
| 581 | 584 | } | |
| 582 | 585 | ||
| 583 | 586 | /// 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 | − | ||
| 603 | 587 | /// Someone who can see the run downloading one of its artifacts. | |
| 604 | 588 | pub async fn download(env: &Env, services: &Services, viewer: &g1t_contracts::Viewer, owner: &str, repo: &str, run: &str, name: &str) -> Result<Response> { | |
| 605 | 589 | let seen: Outcome<Value> = g1t_kit::call( |
| 75 | 75 | 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.", | |
| 76 | 76 | 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.", | |
| 77 | 77 | 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.", | |
| 80 | 80 | } | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 258 | 258 | } else { | |
| 259 | 259 | args.insert("viewer".into(), serde_json::to_value(viewer)?); | |
| 260 | 260 | } | |
| 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 | + | } | |
| 262 | 271 | } | |
| 263 | 272 | ||
| 264 | 273 | #[cfg(test)] |
| 20 | 20 | mod openapi; | |
| 21 | 21 | mod pins; | |
| 22 | 22 | mod projects; | |
| 23 | + | mod protection; | |
| 23 | 24 | mod operations; | |
| 24 | 25 | mod renamed; | |
| 25 | 26 | #[cfg(test)] | |
| 72 | 73 | /// workflow file, GitHub's contexts and event, and where to check out, all | |
| 73 | 74 | /// passed through as they are. | |
| 74 | 75 | const JOB_SPEC_AS_GIVEN: &[&str] = &[ | |
| 75 | − | "spec", "workflow", "github", "event", "contexts", "checkout", | |
| 76 | + | "spec", "workflow", "github", "event", "contexts", "checkout", "permissions", | |
| 76 | 77 | ]; | |
| 77 | 78 | ||
| 78 | 79 | /// Puts the toolkit's variables in a job's spec: its runtime token, where | |
| 646 | 647 | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => { | |
| 647 | 648 | let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect(); | |
| 648 | 649 | 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 | + | } | |
| 649 | 658 | return blobs::download(env, &services, &viewer, owner, repo, run, name).await; | |
| 650 | 659 | } | |
| 651 | 660 | } |
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::artifacts::ArtifactsOp; | |
| 12 | 12 | use crate::deployments::DeploymentsOp; | |
| 13 | + | use crate::protection::ProtectionOp; | |
| 13 | 14 | use crate::operations::Op; | |
| 14 | 15 | use crate::checks::ChecksOp; | |
| 15 | 16 | use crate::rules::RulesOp; | |
| 390 | 391 | ], | |
| 391 | 392 | ), | |
| 392 | 393 | ( | |
| 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 | + | ( | |
| 393 | 412 | "Secrets and variables", | |
| 394 | 413 | "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.", | |
| 395 | 414 | &[ | |
| 641 | 660 | Op::Checks(op) => op.title(), | |
| 642 | 661 | Op::About(op) => op.title(), | |
| 643 | 662 | Op::Deployments(op) => op.title(), | |
| 663 | + | Op::Protection(op) => op.title(), | |
| 644 | 664 | Op::Artifacts(op) => op.title(), | |
| 645 | 665 | } | |
| 646 | 666 | } |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::artifacts::ArtifactsOp; | |
| 32 | 32 | use crate::deployments::DeploymentsOp; | |
| 33 | + | use crate::protection::ProtectionOp; | |
| 33 | 34 | use crate::rules::RulesOp; | |
| 34 | 35 | use crate::security::SecurityOp; | |
| 35 | 36 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| 284 | 285 | About(AboutOp), | |
| 285 | 286 | /// Deployments wherever they run, and environments: deployments.rs. | |
| 286 | 287 | Deployments(DeploymentsOp), | |
| 288 | + | /// Environments' protection rules, approving runs, the token's default | |
| 289 | + | /// permissions and repository dispatch: protection.rs. | |
| 290 | + | Protection(ProtectionOp), | |
| 287 | 291 | /// Workflow run artifacts, and how long they are kept: artifacts.rs. | |
| 288 | 292 | Artifacts(ArtifactsOp), | |
| 289 | 293 | } | |
| 648 | 652 | } | |
| 649 | 653 | ||
| 650 | 654 | impl Op { | |
| 651 | − | pub const ALL: [Op; 264] = [ | |
| 655 | + | pub const ALL: [Op; 276] = [ | |
| 652 | 656 | Op::Whoami, | |
| 653 | 657 | Op::GetWorkspace, | |
| 654 | 658 | Op::CreateWorkspace, | |
| 913 | 917 | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 914 | 918 | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 915 | 919 | 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), | |
| 916 | 932 | ]; | |
| 917 | 933 | ||
| 918 | 934 | pub fn by_name(name: &str) -> Option<Op> { | |
| 1106 | 1122 | Op::Checks(op) => op.name(), | |
| 1107 | 1123 | Op::About(op) => op.name(), | |
| 1108 | 1124 | Op::Deployments(op) => op.name(), | |
| 1125 | + | Op::Protection(op) => op.name(), | |
| 1109 | 1126 | Op::Artifacts(op) => op.name(), | |
| 1110 | 1127 | } | |
| 1111 | 1128 | } | |
| 1620 | 1637 | Op::Checks(op) => op.description(), | |
| 1621 | 1638 | Op::About(op) => op.description(), | |
| 1622 | 1639 | Op::Deployments(op) => op.description(), | |
| 1640 | + | Op::Protection(op) => op.description(), | |
| 1623 | 1641 | Op::Artifacts(op) => op.description(), | |
| 1624 | 1642 | } | |
| 1625 | 1643 | } | |
| 2992 | 3010 | Op::Checks(op) => op.input(), | |
| 2993 | 3011 | Op::About(op) => op.input(), | |
| 2994 | 3012 | Op::Deployments(op) => op.input(), | |
| 3013 | + | Op::Protection(op) => op.input(), | |
| 2995 | 3014 | Op::Artifacts(op) => op.input(), | |
| 2996 | 3015 | } | |
| 2997 | 3016 | } | |
| 3039 | 3058 | | DeploymentsOp::ListEnvironments | |
| 3040 | 3059 | | DeploymentsOp::GetEnvironment | |
| 3041 | 3060 | ) | |
| 3061 | + | | Op::Protection( | |
| 3062 | + | ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval | |
| 3063 | + | ) | |
| 3042 | 3064 | ) | |
| 3043 | 3065 | } | |
| 3044 | 3066 | ||
| 3058 | 3080 | if let Op::Security(op) = self { | |
| 3059 | 3081 | return op.needs_repo(); | |
| 3060 | 3082 | } | |
| 3083 | + | if let Op::Protection(op) = self { | |
| 3084 | + | return op.needs_repo(); | |
| 3085 | + | } | |
| 3061 | 3086 | !matches!( | |
| 3062 | 3087 | self, | |
| 3063 | 3088 | Op::Whoami | |
| 5056 | 5081 | Op::Checks(op) => crate::checks::run(op, services, viewer, input).await, | |
| 5057 | 5082 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5058 | 5083 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5084 | + | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5059 | 5085 | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5060 | 5086 | Op::ReopenSecurityAlert => { | |
| 5061 | 5087 | let changed: Outcome<AlertChange> = call( |
| 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 | + | } |
| 9919 | 9919 | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9920 | 9920 | } | |
| 9921 | 9921 | }, | |
| 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 | + | }, | |
| 9922 | 10195 | "get_languages": { | |
| 9923 | 10196 | "response": { | |
| 9924 | 10197 | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", |
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::artifacts::ArtifactsOp; | |
| 7 | 7 | use crate::deployments::DeploymentsOp; | |
| 8 | + | use crate::protection::ProtectionOp; | |
| 8 | 9 | use crate::operations::Op; | |
| 9 | 10 | use crate::checks::ChecksOp; | |
| 10 | 11 | use crate::rules::RulesOp; | |
| 324 | 325 | route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]), | |
| 325 | 326 | route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]), | |
| 326 | 327 | 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 | + | ), | |
| 327 | 371 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 328 | 372 | route( | |
| 329 | 373 | "POST", |
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::artifacts::ArtifactsOp; | |
| 23 | 23 | use crate::deployments::DeploymentsOp; | |
| 24 | + | use crate::protection::ProtectionOp; | |
| 24 | 25 | use crate::operations::Op; | |
| 25 | 26 | use crate::checks::ChecksOp; | |
| 26 | 27 | use crate::rules::RulesOp; | |
| 201 | 202 | Tool { | |
| 202 | 203 | name: "workflow", | |
| 203 | 204 | 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.", | |
| 205 | 206 | default_action: None, | |
| 206 | 207 | actions: &[ | |
| 207 | 208 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| 237 | 238 | a("deployment_statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), "A deployment's statuses, newest first"), | |
| 238 | 239 | a("create_deployment_status", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), "Report where a deployment is: in_progress, success, failure"), | |
| 239 | 240 | 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"), | |
| 241 | 254 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 242 | 255 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 243 | 256 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
| 707 | 720 | scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 708 | 721 | legacy: false, | |
| 709 | 722 | name: None, | |
| 723 | + | ..TokenAccess::default() | |
| 710 | 724 | } | |
| 711 | 725 | } | |
| 712 | 726 |
| 257 | 257 | entry under 128 MB in one request, and g1t takes at most 100 MB in one | |
| 258 | 258 | request, as for [pushes](#pushes-up-to-100-mb-each). The step warns and | |
| 259 | 259 | 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. | |
| 263 | 262 | ||
| 264 | 263 | See [Not yet](/guides/actions/#not-yet). **Status.** Planned. | |
| 265 | 264 |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.