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.
| 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 |
| 41 | 41 | type: boolean | |
| 42 | 42 | default: false | |
| 43 | 43 | ||
| 44 | + | # Its token only reads: deploying uses CLOUDFLARE_API_TOKEN, and g1t | |
| 45 | + | # records the deployments itself. | |
| 46 | + | permissions: | |
| 47 | + | contents: read | |
| 48 | + | ||
| 44 | 49 | # One deploy at a time, and never one cut off halfway: the next waits. | |
| 45 | 50 | concurrency: | |
| 46 | 51 | group: deploy-production |
| 30 | 30 | type: boolean | |
| 31 | 31 | default: false | |
| 32 | 32 | ||
| 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 | + | ||
| 33 | 40 | concurrency: | |
| 34 | 41 | group: runner-base | |
| 35 | 42 | 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 | |
| ⋯ | |||
| 78 | 81 | runs-on: [self-hosted, docker] | |
| 79 | 82 | environment: production | |
| 80 | 83 | timeout-minutes: 30 | |
| 84 | + | # Its token pushes the image to g1t's registry. | |
| 85 | + | permissions: | |
| 86 | + | contents: read | |
| 87 | + | packages: write | |
| 81 | 88 | steps: | |
| 82 | 89 | - uses: actions/checkout@v5 | |
| 83 | 90 | - 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); |
| 199 | 199 | } | |
| 200 | 200 | (_, what) if what == "cache" || what.starts_with("cache/") => { | |
| 201 | 201 | 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 | |
| 203 | 203 | } | |
| 204 | 204 | _ => error(404, "No such endpoint."), | |
| 205 | 205 | } | |
| ⋯ | |||
| 239 | 239 | #[allow(clippy::too_many_arguments)] | |
| 240 | 240 | async fn cache( | |
| 241 | 241 | mut request: Request, | |
| 242 | − | kv: &KvStore, | |
| 243 | 242 | bucket: &Bucket, | |
| 244 | 243 | services: &Services, | |
| 245 | 244 | method: &str, | |
| ⋯ | |||
| 250 | 249 | ) -> Result<Response> { | |
| 251 | 250 | let parts: Vec<&str> = what.split('/').collect(); | |
| 252 | 251 | 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(); | |
| 253 | 255 | match (method, parts.as_slice()) { | |
| 254 | 256 | ("GET", ["cache"]) => { | |
| 255 | 257 | let key = query(&request, "key").unwrap_or_default(); | |
| ⋯ | |||
| 258 | 260 | let found: Outcome<Option<CacheHit>> = g1t_kit::call( | |
| 259 | 261 | &services.actions, | |
| 260 | 262 | "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() }, | |
| 262 | 264 | ) | |
| 263 | 265 | .await?; | |
| 264 | 266 | let found = match refused(found) { | |
| ⋯ | |||
| 276 | 278 | headers.set("content-type", "application/octet-stream")?; | |
| 277 | 279 | return Ok(response); | |
| 278 | 280 | } | |
| 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.") | |
| 281 | 284 | } | |
| 282 | 285 | // Older runners send a whole entry of at most 60 MB at once. | |
| 283 | 286 | ("PUT", ["cache"]) => { | |
| ⋯ | |||
| 289 | 292 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 290 | 293 | &services.actions, | |
| 291 | 294 | "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() }, | |
| 293 | 296 | ) | |
| 294 | 297 | .await?; | |
| 295 | 298 | let reserved = match reserved { | |
| ⋯ | |||
| 312 | 315 | let reserved: Outcome<CacheReservation> = g1t_kit::call( | |
| 313 | 316 | &services.actions, | |
| 314 | 317 | "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() }, | |
| 316 | 319 | ) | |
| 317 | 320 | .await?; | |
| 318 | 321 | let reserved = match refused(reserved) { | |
| ⋯ | |||
| 396 | 399 | } | |
| 397 | 400 | ||
| 398 | 401 | /// 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 | − | ||
| 418 | 402 | /// Someone who can see the run downloading one of its artifacts. | |
| 419 | 403 | pub async fn download(env: &Env, services: &Services, viewer: &g1t_contracts::Viewer, owner: &str, repo: &str, run: &str, name: &str) -> Result<Response> { | |
| 420 | 404 | 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)] | |
| 18 | 18 | mod openapi; | |
| 19 | 19 | mod pins; | |
| 20 | 20 | mod projects; | |
| 21 | + | mod protection; | |
| 21 | 22 | mod operations; | |
| 22 | 23 | mod renamed; | |
| 23 | 24 | #[cfg(test)] | |
| ⋯ | |||
| 69 | 70 | /// workflow file, GitHub's contexts and event, and where to check out, all | |
| 70 | 71 | /// passed through as they are. | |
| 71 | 72 | const JOB_SPEC_AS_GIVEN: &[&str] = &[ | |
| 72 | − | "spec", "workflow", "github", "event", "contexts", "checkout", | |
| 73 | + | "spec", "workflow", "github", "event", "contexts", "checkout", "permissions", | |
| 73 | 74 | ]; | |
| 74 | 75 | ||
| 75 | 76 | /// An error in the shape every endpoint uses. | |
| ⋯ | |||
| 606 | 607 | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts") => { | |
| 607 | 608 | let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect(); | |
| 608 | 609 | 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 | + | } | |
| 609 | 618 | return match rest { | |
| 610 | 619 | [] => { | |
| 611 | 620 | let seen: Outcome<Value> = g1t_kit::call( | |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::deployments::DeploymentsOp; | |
| 12 | + | use crate::protection::ProtectionOp; | |
| 12 | 13 | use crate::operations::Op; | |
| 13 | 14 | use crate::checks::ChecksOp; | |
| 14 | 15 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 382 | 383 | ], | |
| 383 | 384 | ), | |
| 384 | 385 | ( | |
| 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 | + | ( | |
| 385 | 402 | "Secrets and variables", | |
| 386 | 403 | "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.", | |
| 387 | 404 | &[ | |
| ⋯ | |||
| 633 | 650 | Op::Checks(op) => op.title(), | |
| 634 | 651 | Op::About(op) => op.title(), | |
| 635 | 652 | Op::Deployments(op) => op.title(), | |
| 653 | + | Op::Protection(op) => op.title(), | |
| 636 | 654 | } | |
| 637 | 655 | } | |
| 638 | 656 | ||
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::deployments::DeploymentsOp; | |
| 32 | + | use crate::protection::ProtectionOp; | |
| 32 | 33 | use crate::rules::RulesOp; | |
| 33 | 34 | use crate::security::SecurityOp; | |
| 34 | 35 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| ⋯ | |||
| 283 | 284 | About(AboutOp), | |
| 284 | 285 | /// Deployments wherever they run, and environments: deployments.rs. | |
| 285 | 286 | Deployments(DeploymentsOp), | |
| 287 | + | /// Environments' protection rules, approving runs, the token's default | |
| 288 | + | /// permissions and repository dispatch: protection.rs. | |
| 289 | + | Protection(ProtectionOp), | |
| 286 | 290 | } | |
| 287 | 291 | ||
| 288 | 292 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 645 | 649 | } | |
| 646 | 650 | ||
| 647 | 651 | impl Op { | |
| 648 | − | pub const ALL: [Op; 257] = [ | |
| 652 | + | pub const ALL: [Op; 267] = [ | |
| 649 | 653 | Op::Whoami, | |
| 650 | 654 | Op::GetWorkspace, | |
| 651 | 655 | Op::CreateWorkspace, | |
| ⋯ | |||
| 903 | 907 | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 904 | 908 | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 905 | 909 | 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), | |
| 906 | 920 | ]; | |
| 907 | 921 | ||
| 908 | 922 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1096 | 1110 | Op::Checks(op) => op.name(), | |
| 1097 | 1111 | Op::About(op) => op.name(), | |
| 1098 | 1112 | Op::Deployments(op) => op.name(), | |
| 1113 | + | Op::Protection(op) => op.name(), | |
| 1099 | 1114 | } | |
| 1100 | 1115 | } | |
| 1101 | 1116 | ||
| ⋯ | |||
| 1609 | 1624 | Op::Checks(op) => op.description(), | |
| 1610 | 1625 | Op::About(op) => op.description(), | |
| 1611 | 1626 | Op::Deployments(op) => op.description(), | |
| 1627 | + | Op::Protection(op) => op.description(), | |
| 1612 | 1628 | } | |
| 1613 | 1629 | } | |
| 1614 | 1630 | ||
| ⋯ | |||
| 2980 | 2996 | Op::Checks(op) => op.input(), | |
| 2981 | 2997 | Op::About(op) => op.input(), | |
| 2982 | 2998 | Op::Deployments(op) => op.input(), | |
| 2999 | + | Op::Protection(op) => op.input(), | |
| 2983 | 3000 | } | |
| 2984 | 3001 | } | |
| 2985 | 3002 | ||
| ⋯ | |||
| 3022 | 3039 | | DeploymentsOp::ListEnvironments | |
| 3023 | 3040 | | DeploymentsOp::GetEnvironment | |
| 3024 | 3041 | ) | |
| 3042 | + | | Op::Protection( | |
| 3043 | + | ProtectionOp::GetPendingDeployments | ProtectionOp::GetWorkflowPermissions | ProtectionOp::GetForkPrApproval | |
| 3044 | + | ) | |
| 3025 | 3045 | ) | |
| 3026 | 3046 | } | |
| 3027 | 3047 | ||
| ⋯ | |||
| 5039 | 5059 | Op::Checks(op) => crate::checks::run(op, services, viewer, input).await, | |
| 5040 | 5060 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 5041 | 5061 | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 5062 | + | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5042 | 5063 | Op::ReopenSecurityAlert => { | |
| 5043 | 5064 | let changed: Outcome<AlertChange> = call( | |
| 5044 | 5065 | &services.security, | |
| 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 | + | } |
| 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 | + | "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 | + | }, | |
| 9922 | 10167 | "get_languages": { | |
| 9923 | 10168 | "response": { | |
| 9924 | 10169 | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::deployments::DeploymentsOp; | |
| 7 | + | use crate::protection::ProtectionOp; | |
| 7 | 8 | use crate::operations::Op; | |
| 8 | 9 | use crate::checks::ChecksOp; | |
| 9 | 10 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 323 | 324 | route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]), | |
| 324 | 325 | route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]), | |
| 325 | 326 | 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), &[]), | |
| 326 | 358 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 327 | 359 | route( | |
| 328 | 360 | "POST", | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::deployments::DeploymentsOp; | |
| 23 | + | use crate::protection::ProtectionOp; | |
| 23 | 24 | use crate::operations::Op; | |
| 24 | 25 | use crate::checks::ChecksOp; | |
| 25 | 26 | use crate::rules::RulesOp; | |
| ⋯ | |||
| 200 | 201 | Tool { | |
| 201 | 202 | name: "workflow", | |
| 202 | 203 | 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.", | |
| 204 | 205 | default_action: None, | |
| 205 | 206 | actions: &[ | |
| 206 | 207 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| ⋯ | |||
| 229 | 230 | a("deployment_statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), "A deployment's statuses, newest first"), | |
| 230 | 231 | a("create_deployment_status", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), "Report where a deployment is: in_progress, success, failure"), | |
| 231 | 232 | 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"), | |
| 233 | 244 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 234 | 245 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 235 | 246 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
| ⋯ | |||
| 699 | 710 | scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 700 | 711 | legacy: false, | |
| 701 | 712 | name: None, | |
| 713 | + | ..TokenAccess::default() | |
| 702 | 714 | } | |
| 703 | 715 | } | |
| 704 | 716 | ||
| 232 | 232 | work. | |
| 233 | 233 | - Actions that cache through the hosted toolkit's own cache service, such as | |
| 234 | 234 | `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). | |
| 238 | 240 | ||
| 239 | 241 | See [Not yet](/guides/actions/#not-yet). **Status.** Planned. | |
| 240 | 242 |
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.