Merge main: Deployments panel in the About, project homepage, both sides' operations
| 1 | + | # Tests every pull request into main, so main can always be deployed. The | |
| 2 | + | # "Default branch" ruleset requires this workflow ("CI") to pass before a | |
| 3 | + | # pull request merges; people may still push to main directly, agents | |
| 4 | + | # may not. Deploy (deploy.yml) ships what lands on main. | |
| 5 | + | # | |
| 6 | + | # rust every crate's tests, natively | |
| 7 | + | # typescript type checks and tests of the apps and TS services, the | |
| 8 | + | # deploy and ops scripts, and the deploy manifest | |
| 9 | + | # build the site, sudo and the docs build as they deploy | |
| 10 | + | name: CI | |
| 11 | + | ||
| 12 | + | on: | |
| 13 | + | pull_request: | |
| 14 | + | branches: [main] | |
| 15 | + | workflow_dispatch: | |
| 16 | + | ||
| 17 | + | concurrency: | |
| 18 | + | group: ci-${{ github.ref }} | |
| 19 | + | cancel-in-progress: true | |
| 20 | + | ||
| 21 | + | env: | |
| 22 | + | CARGO_TERM_COLOR: never | |
| 23 | + | WRANGLER_SEND_METRICS: "false" | |
| 24 | + | ||
| 25 | + | jobs: | |
| 26 | + | rust: | |
| 27 | + | name: Rust | |
| 28 | + | runs-on: g1t-4core | |
| 29 | + | timeout-minutes: 45 | |
| 30 | + | steps: | |
| 31 | + | - uses: actions/checkout@v5 | |
| 32 | + | - name: Cache crates | |
| 33 | + | uses: actions/cache@v4 | |
| 34 | + | with: | |
| 35 | + | path: ~/.cargo/registry/cache | |
| 36 | + | key: cargo-crates-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} | |
| 37 | + | restore-keys: cargo-crates-${{ runner.os }}- | |
| 38 | + | - name: Cache the test build | |
| 39 | + | uses: actions/cache@v4 | |
| 40 | + | with: | |
| 41 | + | path: | | |
| 42 | + | target/debug | |
| 43 | + | !target/debug/incremental | |
| 44 | + | key: cargo-test-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 45 | + | restore-keys: cargo-test-${{ runner.os }}- | |
| 46 | + | - name: Tests | |
| 47 | + | run: cargo test --workspace --locked --quiet | |
| 48 | + | ||
| 49 | + | typescript: | |
| 50 | + | name: TypeScript | |
| 51 | + | runs-on: ubuntu-latest | |
| 52 | + | timeout-minutes: 30 | |
| 53 | + | steps: | |
| 54 | + | - uses: actions/checkout@v5 | |
| 55 | + | - name: Install | |
| 56 | + | run: npm ci --no-audit --no-fund | |
| 57 | + | - name: Type checks | |
| 58 | + | run: npm run typecheck | |
| 59 | + | - name: Tests | |
| 60 | + | run: npm test --workspaces --if-present | |
| 61 | + | - name: The deploy tool's tests | |
| 62 | + | run: npm run test:deploy | |
| 63 | + | - name: The ops scripts' tests | |
| 64 | + | run: npm run test:ops | |
| 65 | + | - name: The manifest matches every wrangler.jsonc | |
| 66 | + | run: node scripts/deploy.mjs manifest --check | |
| 67 | + | ||
| 68 | + | build: | |
| 69 | + | name: Build | |
| 70 | + | runs-on: ubuntu-latest | |
| 71 | + | timeout-minutes: 30 | |
| 72 | + | steps: | |
| 73 | + | - uses: actions/checkout@v5 | |
| 74 | + | - name: Install | |
| 75 | + | run: npm ci --no-audit --no-fund | |
| 76 | + | - name: The site, sudo and the docs | |
| 77 | + | run: node scripts/deploy.mjs build --only web,sudo,docs |
| 8 | 8 | # core, edge, front the units of each stage, in jobs that share a build; | |
| 9 | 9 | # a stage starts only when the one before it succeeded | |
| 10 | 10 | # | |
| 11 | + | # Each run that deploys is one production deployment of g1t.sh, made by the | |
| 12 | + | # jobs that name `environment: production` (one per run, however many jobs): | |
| 13 | + | # in progress when the first starts, then a success or a failure when the | |
| 14 | + | # run ends. It shows on the project's Deployments page and as the commit's | |
| 15 | + | # `deploy / production` check. The plan job reads production's secrets | |
| 16 | + | # with `deployment: false`, so a dry run or a change that deploys nothing | |
| 17 | + | # makes no deployment. | |
| 18 | + | # | |
| 11 | 19 | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the | |
| 12 | 20 | # variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's | |
| 13 | 21 | # workflow-only domains for deploy.yml in production (Settings, Guardrails), | |
| ⋯ | |||
| 61 | 69 | name: Plan | |
| 62 | 70 | needs: check | |
| 63 | 71 | runs-on: ubuntu-latest | |
| 64 | − | environment: production | |
| 72 | + | # Production's secrets, without a deployment: planning deploys nothing. | |
| 73 | + | environment: | |
| 74 | + | name: production | |
| 75 | + | deployment: false | |
| 65 | 76 | timeout-minutes: 15 | |
| 66 | 77 | outputs: | |
| 67 | 78 | migrate: ${{ steps.plan.outputs.migrate }} | |
| ⋯ | |||
| 96 | 107 | needs: plan | |
| 97 | 108 | if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }} | |
| 98 | 109 | runs-on: ubuntu-latest | |
| 99 | − | environment: production | |
| 110 | + | environment: | |
| 111 | + | name: production | |
| 112 | + | url: https://g1t.sh | |
| 100 | 113 | timeout-minutes: 20 | |
| 101 | 114 | steps: | |
| 102 | 115 | - uses: actions/checkout@v5 | |
| ⋯ | |||
| 115 | 128 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| 116 | 129 | # Rust builds get 4 vCPUs; everything else the standard machine. | |
| 117 | 130 | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 118 | − | environment: production | |
| 131 | + | environment: | |
| 132 | + | name: production | |
| 133 | + | url: https://g1t.sh | |
| 119 | 134 | timeout-minutes: 60 | |
| 120 | 135 | strategy: | |
| 121 | 136 | # A deploy cut off halfway is worse than one that finishes: the other | |
| ⋯ | |||
| 182 | 197 | needs: [plan, migrate, core] | |
| 183 | 198 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 184 | 199 | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 185 | − | environment: production | |
| 200 | + | environment: | |
| 201 | + | name: production | |
| 202 | + | url: https://g1t.sh | |
| 186 | 203 | timeout-minutes: 60 | |
| 187 | 204 | strategy: | |
| 188 | 205 | fail-fast: false | |
| ⋯ | |||
| 195 | 212 | needs: [plan, migrate, core, edge] | |
| 196 | 213 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 197 | 214 | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 198 | − | environment: production | |
| 215 | + | environment: | |
| 216 | + | name: production | |
| 217 | + | url: https://g1t.sh | |
| 199 | 218 | timeout-minutes: 60 | |
| 200 | 219 | strategy: | |
| 201 | 220 | fail-fast: false | |
| 1 | + | //! Deployments over REST and MCP: a repository's deployments wherever they | |
| 2 | + | //! run, their statuses, and its environments. | |
| 3 | + | //! | |
| 4 | + | //! Any CI reports a deployment and its statuses here; a g1t Actions job | |
| 5 | + | //! with an `environment:` makes them itself, and g1t.page builds are read | |
| 6 | + | //! in alongside. The deployments service decides who may see and report | |
| 7 | + | //! them (Read to see, Write to report) and keeps them; deployments travel | |
| 8 | + | //! in `snake_case` between services too, so a request's fields reach it | |
| 9 | + | //! as they are, and a deployment's `payload` comes back as it was given. | |
| 10 | + | ||
| 11 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 12 | + | use serde_json::{Map, Value, json}; | |
| 13 | + | use worker::Result; | |
| 14 | + | ||
| 15 | + | use crate::operations::{Services, repo_path}; | |
| 16 | + | ||
| 17 | + | /// One operation on deployments. | |
| 18 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 19 | + | pub enum DeploymentsOp { | |
| 20 | + | ListDeployments, | |
| 21 | + | GetDeployment, | |
| 22 | + | CreateDeployment, | |
| 23 | + | ListDeploymentStatuses, | |
| 24 | + | CreateDeploymentStatus, | |
| 25 | + | ListEnvironments, | |
| 26 | + | GetEnvironment, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// The states a deployment's status can have. | |
| 30 | + | const STATES: [&str; 6] = ["queued", "in_progress", "success", "failure", "error", "inactive"]; | |
| 31 | + | ||
| 32 | + | impl DeploymentsOp { | |
| 33 | + | /// Every one: `Op::ALL` lists each as `Op::Deployments(…)`, which a | |
| 34 | + | /// test checks against this. | |
| 35 | + | #[cfg(test)] | |
| 36 | + | pub const ALL: [DeploymentsOp; 7] = [ | |
| 37 | + | DeploymentsOp::ListDeployments, | |
| 38 | + | DeploymentsOp::GetDeployment, | |
| 39 | + | DeploymentsOp::CreateDeployment, | |
| 40 | + | DeploymentsOp::ListDeploymentStatuses, | |
| 41 | + | DeploymentsOp::CreateDeploymentStatus, | |
| 42 | + | DeploymentsOp::ListEnvironments, | |
| 43 | + | DeploymentsOp::GetEnvironment, | |
| 44 | + | ]; | |
| 45 | + | ||
| 46 | + | pub fn name(self) -> &'static str { | |
| 47 | + | match self { | |
| 48 | + | DeploymentsOp::ListDeployments => "list_deployments", | |
| 49 | + | DeploymentsOp::GetDeployment => "get_deployment", | |
| 50 | + | DeploymentsOp::CreateDeployment => "create_deployment", | |
| 51 | + | DeploymentsOp::ListDeploymentStatuses => "list_deployment_statuses", | |
| 52 | + | DeploymentsOp::CreateDeploymentStatus => "create_deployment_status", | |
| 53 | + | DeploymentsOp::ListEnvironments => "list_environments", | |
| 54 | + | DeploymentsOp::GetEnvironment => "get_environment", | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// For the API reference: "List deployments". | |
| 59 | + | pub fn title(self) -> &'static str { | |
| 60 | + | match self { | |
| 61 | + | DeploymentsOp::ListDeployments => "List deployments", | |
| 62 | + | DeploymentsOp::GetDeployment => "Get a deployment", | |
| 63 | + | DeploymentsOp::CreateDeployment => "Create a deployment", | |
| 64 | + | DeploymentsOp::ListDeploymentStatuses => "List deployment statuses", | |
| 65 | + | DeploymentsOp::CreateDeploymentStatus => "Create a deployment status", | |
| 66 | + | DeploymentsOp::ListEnvironments => "List environments", | |
| 67 | + | DeploymentsOp::GetEnvironment => "Get an environment", | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | pub fn description(self) -> &'static str { | |
| 72 | + | match self { | |
| 73 | + | DeploymentsOp::ListDeployments => "List a repository's deployments wherever they run, newest first: those reported through this API, those g1t Actions made for jobs with an `environment:`, and g1t.page builds (production and previews). Each has its environment, ref and sha, task, description, payload, transient_environment and production_environment, its latest state (queued, in_progress, success, failure, error or inactive), environment_url and log_url, creator, and source (api, actions or g1t_page), with run_id and run_url for g1t Actions and project and number for g1t.page. Filter by environment, ref, sha (or a prefix), task, state, source and creator; page with page and per_page (30 by default, at most 100). total_count counts every match. Needs the Read role; a public repository's are open to anyone.", | |
| 74 | + | DeploymentsOp::GetDeployment => "Get one deployment by id (dep_… for a reported one, dpl_… for a g1t.page build), with every status it has had, oldest first. Needs the Read role.", | |
| 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 | + | 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 | + | 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.", | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Whether it changes anything. | |
| 84 | + | pub fn writes(self) -> bool { | |
| 85 | + | matches!(self, DeploymentsOp::CreateDeployment | DeploymentsOp::CreateDeploymentStatus) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | pub fn input(self) -> Value { | |
| 89 | + | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 90 | + | let id = json!({ "type": "string", "description": "The deployment's id: dep_… for a reported one, dpl_… for a g1t.page build." }); | |
| 91 | + | let state = |what: &str| json!({ "type": "string", "enum": STATES, "description": what }); | |
| 92 | + | let address = |what: &str| json!({ "type": "string", "description": what }); | |
| 93 | + | let (properties, required): (Value, &[&str]) = match self { | |
| 94 | + | DeploymentsOp::ListDeployments => ( | |
| 95 | + | json!({ | |
| 96 | + | "repo": repo, | |
| 97 | + | "environment": { "type": "string", "description": "Only this environment's, matched without regard to case." }, | |
| 98 | + | "ref": { "type": "string", "description": "Only deployments of this branch, tag or commit as it was given." }, | |
| 99 | + | "sha": { "type": "string", "description": "Only deployments of this commit, or of commits starting with it." }, | |
| 100 | + | "task": { "type": "string", "description": "Only this task's, such as deploy." }, | |
| 101 | + | "state": state("Only deployments whose latest status has this state."), | |
| 102 | + | "source": { "type": "string", "enum": ["api", "actions", "g1t_page"], "description": "Only those reported through the API, made by g1t Actions, or built on g1t.page." }, | |
| 103 | + | "creator": { "type": "string", "description": "Only those this username (or g1t) made." }, | |
| 104 | + | "page": { "type": "integer", "description": "Which page, from 1." }, | |
| 105 | + | "per_page": { "type": "integer", "description": "How many a page holds, 1 to 100; 30 by default." }, | |
| 106 | + | }), | |
| 107 | + | &["repo"], | |
| 108 | + | ), | |
| 109 | + | DeploymentsOp::GetDeployment | DeploymentsOp::ListDeploymentStatuses => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 110 | + | DeploymentsOp::CreateDeployment => ( | |
| 111 | + | json!({ | |
| 112 | + | "repo": repo, | |
| 113 | + | "ref": { "type": "string", "description": "The branch, tag or commit deployed, such as main or v1.4.0." }, | |
| 114 | + | "sha": { "type": "string", "description": "The commit deployed; resolved from ref when left out." }, | |
| 115 | + | "environment": { "type": "string", "description": "Where it went, such as production, staging or review/feature-x; production unless you say." }, | |
| 116 | + | "task": { "type": "string", "description": "What kind of deployment, such as deploy or deploy:migrations; deploy unless you say." }, | |
| 117 | + | "description": { "type": "string", "description": "A short note, at most 1,000 characters." }, | |
| 118 | + | "payload": { "type": "object", "description": "Anything else to keep with it, as a JSON object (a JSON string of one is read too), at most 64 KB. Returned as given." }, | |
| 119 | + | "production_environment": { "type": "boolean", "description": "Whether people use this environment directly. True for production unless you say." }, | |
| 120 | + | "transient_environment": { "type": "boolean", "description": "Whether the environment goes away, such as a review app. False unless you say." }, | |
| 121 | + | "state": state("Its first status: queued unless you say. Report in_progress, then success or failure, as it goes."), | |
| 122 | + | "environment_url": address("Where it is served, an http(s) address."), | |
| 123 | + | "log_url": address("Where its output can be read, an http(s) address."), | |
| 124 | + | }), | |
| 125 | + | &["repo", "ref"], | |
| 126 | + | ), | |
| 127 | + | DeploymentsOp::CreateDeploymentStatus => ( | |
| 128 | + | json!({ | |
| 129 | + | "repo": repo, | |
| 130 | + | "id": id, | |
| 131 | + | "state": state("Where it is now."), | |
| 132 | + | "description": { "type": "string", "description": "A short note, at most 1,000 characters." }, | |
| 133 | + | "environment_url": address("Where it is served, an http(s) address."), | |
| 134 | + | "log_url": address("Where its output can be read, an http(s) address."), | |
| 135 | + | "auto_inactive": { "type": "boolean", "description": "On a success, make the environment's older successful deployments inactive. True unless you say." }, | |
| 136 | + | }), | |
| 137 | + | &["repo", "id", "state"], | |
| 138 | + | ), | |
| 139 | + | DeploymentsOp::ListEnvironments => (json!({ "repo": repo }), &["repo"]), | |
| 140 | + | DeploymentsOp::GetEnvironment => ( | |
| 141 | + | json!({ | |
| 142 | + | "repo": repo, | |
| 143 | + | "environment": { "type": "string", "description": "The environment's name, such as production." }, | |
| 144 | + | }), | |
| 145 | + | &["repo", "environment"], | |
| 146 | + | ), | |
| 147 | + | }; | |
| 148 | + | json!({ "type": "object", "properties": properties, "required": required }) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// The fields of `input` named in `keys` that were given, as they were. | |
| 153 | + | fn given(input: &Value, keys: &[&str]) -> Map<String, Value> { | |
| 154 | + | keys.iter() | |
| 155 | + | .filter_map(|key| input.get(*key).filter(|value| !value.is_null()).map(|value| ((*key).to_owned(), value.clone()))) | |
| 156 | + | .collect() | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// A query parameter's number, given as a number or as text. | |
| 160 | + | fn number(input: &Value, key: &str) -> Option<u64> { | |
| 161 | + | match &input[key] { | |
| 162 | + | Value::Number(number) => number.as_u64(), | |
| 163 | + | Value::String(digits) => digits.trim().parse().ok(), | |
| 164 | + | _ => None, | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// A flag given as a boolean or as text. | |
| 169 | + | fn flag(input: &Value, key: &str) -> Option<bool> { | |
| 170 | + | match &input[key] { | |
| 171 | + | Value::Bool(value) => Some(*value), | |
| 172 | + | Value::String(text) => match text.trim() { | |
| 173 | + | "true" | "1" => Some(true), | |
| 174 | + | "false" | "0" => Some(false), | |
| 175 | + | _ => None, | |
| 176 | + | }, | |
| 177 | + | _ => None, | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// The arguments the deployments service takes for `op`, from the | |
| 182 | + | /// operation's input; `Err` says what is missing or wrong. | |
| 183 | + | pub(crate) fn args(op: DeploymentsOp, input: &Value) -> std::result::Result<(&'static str, Map<String, Value>), String> { | |
| 184 | + | let mut out = Map::new(); | |
| 185 | + | let repo = repo_path(input).ok_or("Give the repository as \"owner/name\".")?; | |
| 186 | + | out.insert("repo".into(), json!({ "namespace": repo.namespace, "name": repo.name })); | |
| 187 | + | let id = || input["id"].as_str().map(str::trim).filter(|id| !id.is_empty()).map(str::to_owned).ok_or("Give the deployment's id."); | |
| 188 | + | if let Some(state) = input["state"].as_str() | |
| 189 | + | && !STATES.contains(&state) | |
| 190 | + | { | |
| 191 | + | return Err(format!("{state} is not a state: use queued, in_progress, success, failure, error or inactive.")); | |
| 192 | + | } | |
| 193 | + | let method = match op { | |
| 194 | + | DeploymentsOp::ListDeployments => { | |
| 195 | + | out.extend(given(input, &["environment", "ref", "sha", "task", "state", "source", "creator"])); | |
| 196 | + | if let Some(page) = number(input, "page") { | |
| 197 | + | out.insert("page".into(), page.into()); | |
| 198 | + | } | |
| 199 | + | if let Some(per_page) = number(input, "per_page") { | |
| 200 | + | out.insert("per_page".into(), per_page.into()); | |
| 201 | + | } | |
| 202 | + | "list_deployments" | |
| 203 | + | } | |
| 204 | + | DeploymentsOp::GetDeployment => { | |
| 205 | + | out.insert("id".into(), id()?.into()); | |
| 206 | + | "get_deployment" | |
| 207 | + | } | |
| 208 | + | DeploymentsOp::ListDeploymentStatuses => { | |
| 209 | + | out.insert("id".into(), id()?.into()); | |
| 210 | + | "list_deployment_statuses" | |
| 211 | + | } | |
| 212 | + | DeploymentsOp::CreateDeployment => { | |
| 213 | + | if input["ref"].as_str().is_none_or(|text| text.trim().is_empty()) && input["sha"].as_str().is_none() { | |
| 214 | + | return Err("Give the ref deployed: a branch, a tag or a commit.".to_owned()); | |
| 215 | + | } | |
| 216 | + | out.extend(given( | |
| 217 | + | input, | |
| 218 | + | &["ref", "sha", "environment", "task", "description", "payload", "state", "environment_url", "log_url"], | |
| 219 | + | )); | |
| 220 | + | for key in ["production_environment", "transient_environment"] { | |
| 221 | + | if let Some(value) = flag(input, key) { | |
| 222 | + | out.insert(key.into(), value.into()); | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | "create_deployment" | |
| 226 | + | } | |
| 227 | + | DeploymentsOp::CreateDeploymentStatus => { | |
| 228 | + | out.insert("id".into(), id()?.into()); | |
| 229 | + | if input["state"].as_str().is_none() { | |
| 230 | + | return Err("Give the status's state: queued, in_progress, success, failure, error or inactive.".to_owned()); | |
| 231 | + | } | |
| 232 | + | out.extend(given(input, &["state", "description", "environment_url", "log_url"])); | |
| 233 | + | if let Some(value) = flag(input, "auto_inactive") { | |
| 234 | + | out.insert("auto_inactive".into(), value.into()); | |
| 235 | + | } | |
| 236 | + | "create_deployment_status" | |
| 237 | + | } | |
| 238 | + | DeploymentsOp::ListEnvironments => "list_environments", | |
| 239 | + | DeploymentsOp::GetEnvironment => { | |
| 240 | + | let name = input["environment"].as_str().map(str::trim).filter(|name| !name.is_empty()).ok_or("Name the environment.")?; | |
| 241 | + | out.insert("name".into(), name.into()); | |
| 242 | + | "get_environment" | |
| 243 | + | } | |
| 244 | + | }; | |
| 245 | + | Ok((method, out)) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | pub async fn run(op: DeploymentsOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 249 | + | let (method, mut args) = match args(op, input) { | |
| 250 | + | Ok(found) => found, | |
| 251 | + | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), | |
| 252 | + | }; | |
| 253 | + | if op.writes() { | |
| 254 | + | let Some(actor) = viewer else { | |
| 255 | + | return Ok(Outcome::fail(FailureCode::Unauthenticated, "Reporting a deployment needs a g1t access token.")); | |
| 256 | + | }; | |
| 257 | + | args.insert("actor".into(), serde_json::to_value(actor)?); | |
| 258 | + | } else { | |
| 259 | + | args.insert("viewer".into(), serde_json::to_value(viewer)?); | |
| 260 | + | } | |
| 261 | + | g1t_kit::call(&services.deployments, method, &Value::Object(args)).await | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | #[cfg(test)] | |
| 265 | + | mod tests { | |
| 266 | + | use super::*; | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn a_request_becomes_the_services_arguments() { | |
| 270 | + | let (method, args) = super::args( | |
| 271 | + | DeploymentsOp::CreateDeployment, | |
| 272 | + | &json!({ "repo": "acme/web", "ref": "main", "environment": "staging", "payload": { "buildId": 7 }, "production_environment": "false", "ignored": 1 }), | |
| 273 | + | ) | |
| 274 | + | .unwrap(); | |
| 275 | + | assert_eq!(method, "create_deployment"); | |
| 276 | + | assert_eq!(args["repo"], json!({ "namespace": "acme", "name": "web" })); | |
| 277 | + | assert_eq!(args["payload"], json!({ "buildId": 7 }), "a payload passes through as given"); | |
| 278 | + | assert_eq!(args["production_environment"], json!(false)); | |
| 279 | + | assert!(!args.contains_key("ignored")); | |
| 280 | + | let (method, args) = super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "page": "2", "state": "failure" })).unwrap(); | |
| 281 | + | assert_eq!(method, "list_deployments"); | |
| 282 | + | assert_eq!(args["page"], json!(2)); | |
| 283 | + | assert!(super::args(DeploymentsOp::ListDeployments, &json!({ "repo": "acme/web", "state": "done" })).is_err()); | |
| 284 | + | assert!(super::args(DeploymentsOp::CreateDeployment, &json!({ "repo": "acme/web" })).is_err()); | |
| 285 | + | assert!(super::args(DeploymentsOp::CreateDeploymentStatus, &json!({ "repo": "acme/web", "id": "dep_1" })).is_err()); | |
| 286 | + | let (method, args) = super::args(DeploymentsOp::GetEnvironment, &json!({ "repo": "acme/web", "environment": "review/x" })).unwrap(); | |
| 287 | + | assert_eq!((method, args["name"].clone()), ("get_environment", json!("review/x"))); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn each_operation_is_described_with_a_schema() { | |
| 292 | + | for op in DeploymentsOp::ALL { | |
| 293 | + | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 294 | + | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } |
| 10 | 10 | mod audit; | |
| 11 | 11 | mod billing; | |
| 12 | 12 | mod blobs; | |
| 13 | + | mod deployments; | |
| 13 | 14 | mod mcp; | |
| 14 | 15 | mod notifications; | |
| 15 | 16 | mod oauth; | |
| 16 | 17 | mod openapi; | |
| 17 | 18 | mod pins; | |
| 19 | + | mod projects; | |
| 18 | 20 | mod operations; | |
| 19 | 21 | mod renamed; | |
| 20 | 22 | #[cfg(test)] |
| 8 | 8 | use serde_json::{Map, Value, json}; | |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | + | use crate::deployments::DeploymentsOp; | |
| 11 | 12 | use crate::operations::Op; | |
| 12 | 13 | use crate::rules::RulesOp; | |
| 13 | 14 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 47 | 48 | &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects], | |
| 48 | 49 | ), | |
| 49 | 50 | ( | |
| 51 | + | "Projects", | |
| 52 | + | "A project is what a workspace builds and runs, from a repository or a root directory in one. Each says what it is, where it runs and where to find it: its homepage, docs and other links.", | |
| 53 | + | &[Op::ListProjects, Op::GetProject, Op::UpdateProject], | |
| 54 | + | ), | |
| 55 | + | ( | |
| 50 | 56 | "Workspaces", | |
| 51 | 57 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 52 | 58 | &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| ⋯ | |||
| 344 | 350 | ], | |
| 345 | 351 | ), | |
| 346 | 352 | ( | |
| 353 | + | "Deployments", | |
| 354 | + | "A repository's deployments wherever they run: reported from any CI with these routes, made by g1t Actions jobs with an `environment:`, or built on g1t.page. Each has statuses, shows on its commit as the check `deploy / <environment>`, and belongs to an environment.", | |
| 355 | + | &[ | |
| 356 | + | Op::Deployments(DeploymentsOp::ListDeployments), | |
| 357 | + | Op::Deployments(DeploymentsOp::CreateDeployment), | |
| 358 | + | Op::Deployments(DeploymentsOp::GetDeployment), | |
| 359 | + | Op::Deployments(DeploymentsOp::ListDeploymentStatuses), | |
| 360 | + | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 361 | + | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 362 | + | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 363 | + | ], | |
| 364 | + | ), | |
| 365 | + | ( | |
| 347 | 366 | "Secrets and variables", | |
| 348 | 367 | "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.", | |
| 349 | 368 | &[ | |
| ⋯ | |||
| 568 | 587 | Op::PinProject => "Pin a project", | |
| 569 | 588 | Op::UnpinProject => "Unpin a project", | |
| 570 | 589 | Op::ReorderPinnedProjects => "Reorder your pinned projects", | |
| 590 | + | Op::ListProjects => "List a workspace's projects", | |
| 591 | + | Op::GetProject => "Get a project", | |
| 592 | + | Op::UpdateProject => "Update a project", | |
| 571 | 593 | Op::ListTeams => "List teams", | |
| 572 | 594 | Op::GetTeam => "Get a team", | |
| 573 | 595 | Op::CreateTeam => "Create a team", | |
| ⋯ | |||
| 588 | 610 | Op::Security(op) => op.title(), | |
| 589 | 611 | Op::Rules(op) => op.title(), | |
| 590 | 612 | Op::About(op) => op.title(), | |
| 613 | + | Op::Deployments(op) => op.title(), | |
| 591 | 614 | } | |
| 592 | 615 | } | |
| 593 | 616 | ||
| 27 | 27 | ||
| 28 | 28 | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 29 | 29 | use crate::about::AboutOp; | |
| 30 | + | use crate::deployments::DeploymentsOp; | |
| 30 | 31 | use crate::rules::RulesOp; | |
| 31 | 32 | use crate::security::SecurityOp; | |
| 32 | 33 | use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel}; | |
| ⋯ | |||
| 56 | 57 | pub security: Fetcher, | |
| 57 | 58 | /// Projects: a person's pinned ones. | |
| 58 | 59 | pub projects: Fetcher, | |
| 60 | + | /// Deployments wherever they run, and environments. | |
| 61 | + | pub deployments: Fetcher, | |
| 59 | 62 | /// Where the request came in, for its audit entries. | |
| 60 | 63 | pub audit: crate::audit::AuditContext, | |
| 61 | 64 | /// Set for a request made with an agent's token: all it may do. | |
| ⋯ | |||
| 80 | 83 | search: env.service("SEARCH")?, | |
| 81 | 84 | security: env.service("SECURITY")?, | |
| 82 | 85 | projects: env.service("PROJECTS")?, | |
| 86 | + | deployments: env.service("DEPLOYMENTS")?, | |
| 83 | 87 | scope: None, | |
| 84 | 88 | audit: crate::audit::AuditContext::default(), | |
| 85 | 89 | addresses: crate::addresses::Addresses::from_env(env), | |
| ⋯ | |||
| 239 | 243 | PinProject, | |
| 240 | 244 | UnpinProject, | |
| 241 | 245 | ReorderPinnedProjects, | |
| 246 | + | ListProjects, | |
| 247 | + | GetProject, | |
| 248 | + | UpdateProject, | |
| 242 | 249 | ListTeams, | |
| 243 | 250 | GetTeam, | |
| 244 | 251 | CreateTeam, | |
| ⋯ | |||
| 270 | 277 | Rules(RulesOp), | |
| 271 | 278 | /// A repository's languages, contributors, license, stars and releases: about.rs. | |
| 272 | 279 | About(AboutOp), | |
| 280 | + | /// Deployments wherever they run, and environments: deployments.rs. | |
| 281 | + | Deployments(DeploymentsOp), | |
| 273 | 282 | } | |
| 274 | 283 | ||
| 275 | 284 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 632 | 641 | } | |
| 633 | 642 | ||
| 634 | 643 | impl Op { | |
| 635 | − | pub const ALL: [Op; 234] = [ | |
| 644 | + | pub const ALL: [Op; 244] = [ | |
| 636 | 645 | Op::Whoami, | |
| 637 | 646 | Op::GetWorkspace, | |
| 638 | 647 | Op::CreateWorkspace, | |
| ⋯ | |||
| 783 | 792 | Op::PinProject, | |
| 784 | 793 | Op::UnpinProject, | |
| 785 | 794 | Op::ReorderPinnedProjects, | |
| 795 | + | Op::ListProjects, | |
| 796 | + | Op::GetProject, | |
| 797 | + | Op::UpdateProject, | |
| 786 | 798 | Op::ListTeams, | |
| 787 | 799 | Op::GetTeam, | |
| 788 | 800 | Op::CreateTeam, | |
| ⋯ | |||
| 867 | 879 | Op::About(AboutOp::CreateRelease), | |
| 868 | 880 | Op::About(AboutOp::UpdateRelease), | |
| 869 | 881 | Op::About(AboutOp::DeleteRelease), | |
| 882 | + | Op::Deployments(DeploymentsOp::ListDeployments), | |
| 883 | + | Op::Deployments(DeploymentsOp::CreateDeployment), | |
| 884 | + | Op::Deployments(DeploymentsOp::GetDeployment), | |
| 885 | + | Op::Deployments(DeploymentsOp::ListDeploymentStatuses), | |
| 886 | + | Op::Deployments(DeploymentsOp::CreateDeploymentStatus), | |
| 887 | + | Op::Deployments(DeploymentsOp::ListEnvironments), | |
| 888 | + | Op::Deployments(DeploymentsOp::GetEnvironment), | |
| 870 | 889 | ]; | |
| 871 | 890 | ||
| 872 | 891 | pub fn by_name(name: &str) -> Option<Op> { | |
| ⋯ | |||
| 1026 | 1045 | Op::PinProject => "pin_project", | |
| 1027 | 1046 | Op::UnpinProject => "unpin_project", | |
| 1028 | 1047 | Op::ReorderPinnedProjects => "reorder_pinned_projects", | |
| 1048 | + | Op::ListProjects => "list_projects", | |
| 1049 | + | Op::GetProject => "get_project", | |
| 1050 | + | Op::UpdateProject => "update_project", | |
| 1029 | 1051 | Op::ListTeams => "list_teams", | |
| 1030 | 1052 | Op::GetTeam => "get_team", | |
| 1031 | 1053 | Op::CreateTeam => "create_team", | |
| ⋯ | |||
| 1054 | 1076 | Op::Security(op) => op.name(), | |
| 1055 | 1077 | Op::Rules(op) => op.name(), | |
| 1056 | 1078 | Op::About(op) => op.name(), | |
| 1079 | + | Op::Deployments(op) => op.name(), | |
| 1057 | 1080 | } | |
| 1058 | 1081 | } | |
| 1059 | 1082 | ||
| ⋯ | |||
| 1475 | 1498 | Op::ReorderPinnedProjects => { | |
| 1476 | 1499 | "Put your pins in a workspace in a new order: `projects` names every pinned project's slug, once, in the order you want them. Returns your pins, in order." | |
| 1477 | 1500 | } | |
| 1501 | + | Op::ListProjects => { | |
| 1502 | + | "A workspace's projects that you can see, by name. A project is what a workspace builds and runs, from a repository or a root directory in one; every repository has a project of its own name. Each has what it is (`kind`: app, library, tool, docs or other) and why (`kind_reason`), where it runs (`runs`: `g1t` when g1t deploys it, `elsewhere` when it is deployed by other means, at `production_url`), and its `links`." | |
| 1503 | + | } | |
| 1504 | + | Op::GetProject => { | |
| 1505 | + | "A project: what it is (`kind`, and `kind_reason` saying why), where it runs (`runs` and `production_url`), what you set and what detection decides (`setting` and `detected`), its repository and `root_dir`, and its homepage, docs and other `links`. A private repository's project is found only by those who can see the repository." | |
| 1506 | + | } | |
| 1507 | + | Op::UpdateProject => { | |
| 1508 | + | "Change a project: its name, description, root directory, what it is, where it runs and its links. Only what you give changes. kind auto and runs auto leave each to detection. Setting runs makes it an app unless it is docs; making it a library, tool or other while Deployments are on is refused, so turn Deployments off first. Give description or homepage as null or \"\" to follow the repository's again, and production_url or docs_url as null or \"\" to clear it. links replaces its other links: at most 10, each a label of up to 40 characters and an http or https address (https:// is added when you leave the scheme out). Needs the Maintain role or higher on its repository." | |
| 1509 | + | } | |
| 1478 | 1510 | Op::ListTeams => { | |
| 1479 | 1511 | "A workspace's teams that you can see, yours first, then by name. A team is a group of the workspace's members, given roles on repositories together, mentioned as @workspace/team and asked to review together. A secret team is seen only by its own people and the workspace's owners. Each team has its `slug`, `name`, `description`, `visibility` (`visible` or `secret`), `parent`, whether its people are notified when it is mentioned (`notify`), its `review_assignment`, how many people, repositories and child teams it has (`members_count`, `repos_count`, `child_teams_count`), your own `viewer_role` in it, and whether you may change it (`can_manage`). `query` narrows them by name or slug. Members of the workspace only." | |
| 1480 | 1512 | } | |
| ⋯ | |||
| 1553 | 1585 | Op::Security(op) => op.description(), | |
| 1554 | 1586 | Op::Rules(op) => op.description(), | |
| 1555 | 1587 | Op::About(op) => op.description(), | |
| 1588 | + | Op::Deployments(op) => op.description(), | |
| 1556 | 1589 | } | |
| 1557 | 1590 | } | |
| 1558 | 1591 | ||
| ⋯ | |||
| 2697 | 2730 | }), | |
| 2698 | 2731 | &["workspace", "projects"], | |
| 2699 | 2732 | ), | |
| 2733 | + | Op::ListProjects => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 2734 | + | Op::GetProject => object( | |
| 2735 | + | json!({ | |
| 2736 | + | "workspace": workspace_schema(), | |
| 2737 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2738 | + | }), | |
| 2739 | + | &["workspace", "project"], | |
| 2740 | + | ), | |
| 2741 | + | Op::UpdateProject => object( | |
| 2742 | + | json!({ | |
| 2743 | + | "workspace": workspace_schema(), | |
| 2744 | + | "project": { "type": "string", "description": "The project's slug, as in g1t.sh/{workspace}/{project}." }, | |
| 2745 | + | "name": { "type": "string", "description": "Its name." }, | |
| 2746 | + | "description": { "type": ["string", "null"], "description": "Its own description. null or \"\" follows its repository's again." }, | |
| 2747 | + | "root_dir": { "type": "string", "description": "Where in the repository it lives, such as apps/web; \"\" for the whole repository." }, | |
| 2748 | + | "kind": { | |
| 2749 | + | "type": "string", | |
| 2750 | + | "enum": ["auto", "app", "library", "tool", "docs", "other"], | |
| 2751 | + | "description": "What it is. auto leaves it to detection. A library, tool or other runs nowhere.", | |
| 2752 | + | }, | |
| 2753 | + | "runs": { | |
| 2754 | + | "type": "string", | |
| 2755 | + | "enum": ["auto", "g1t", "elsewhere"], | |
| 2756 | + | "description": "Where it runs: g1t when g1t deploys it, elsewhere when it is deployed by other means. auto leaves it to Deployments.", | |
| 2757 | + | }, | |
| 2758 | + | "production_url": { "type": ["string", "null"], "description": "Production's address when it runs elsewhere. null or \"\" clears it." }, | |
| 2759 | + | "homepage": { "type": ["string", "null"], "description": "Its homepage. null or \"\" follows its repository's website again." }, | |
| 2760 | + | "docs_url": { "type": ["string", "null"], "description": "Where its documentation is read. null or \"\" clears it." }, | |
| 2761 | + | "links": { | |
| 2762 | + | "type": "array", | |
| 2763 | + | "maxItems": 10, | |
| 2764 | + | "items": { | |
| 2765 | + | "type": "object", | |
| 2766 | + | "properties": { | |
| 2767 | + | "label": { "type": "string", "maxLength": 40 }, | |
| 2768 | + | "url": { "type": "string", "description": "An http or https address; https:// is added when you leave the scheme out." }, | |
| 2769 | + | }, | |
| 2770 | + | "required": ["label", "url"], | |
| 2771 | + | }, | |
| 2772 | + | "description": "Its other links, replacing the ones it has. [] removes them all.", | |
| 2773 | + | }, | |
| 2774 | + | }), | |
| 2775 | + | &["workspace", "project"], | |
| 2776 | + | ), | |
| 2700 | 2777 | Op::ListTeams => object( | |
| 2701 | 2778 | json!({ | |
| 2702 | 2779 | "workspace": workspace_schema(), | |
| ⋯ | |||
| 2864 | 2941 | Op::Security(op) => op.input(), | |
| 2865 | 2942 | Op::Rules(op) => op.input(), | |
| 2866 | 2943 | Op::About(op) => op.input(), | |
| 2944 | + | Op::Deployments(op) => op.input(), | |
| 2867 | 2945 | } | |
| 2868 | 2946 | } | |
| 2869 | 2947 | ||
| ⋯ | |||
| 2892 | 2970 | | Op::ListCheckNames | |
| 2893 | 2971 | | Op::GetMergeQueue | |
| 2894 | 2972 | | Op::GetCodeownersErrors | |
| 2973 | + | | Op::ListProjects | |
| 2974 | + | | Op::GetProject | |
| 2895 | 2975 | | Op::Rules(RulesOp::ListRepoRulesets | RulesOp::GetRepoRuleset | RulesOp::GetBranchRules) | |
| 2976 | + | | Op::Deployments( | |
| 2977 | + | DeploymentsOp::ListDeployments | |
| 2978 | + | | DeploymentsOp::GetDeployment | |
| 2979 | + | | DeploymentsOp::ListDeploymentStatuses | |
| 2980 | + | | DeploymentsOp::ListEnvironments | |
| 2981 | + | | DeploymentsOp::GetEnvironment | |
| 2982 | + | ) | |
| 2896 | 2983 | ) | |
| 2897 | 2984 | } | |
| 2898 | 2985 | ||
| ⋯ | |||
| 2983 | 3070 | | Op::PinProject | |
| 2984 | 3071 | | Op::UnpinProject | |
| 2985 | 3072 | | Op::ReorderPinnedProjects | |
| 3073 | + | | Op::ListProjects | |
| 3074 | + | | Op::GetProject | |
| 3075 | + | | Op::UpdateProject | |
| 2986 | 3076 | | Op::ListTeams | |
| 2987 | 3077 | | Op::GetTeam | |
| 2988 | 3078 | | Op::CreateTeam | |
| ⋯ | |||
| 4874 | 4964 | Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => { | |
| 4875 | 4965 | crate::pins::run(self, services, viewer, input).await | |
| 4876 | 4966 | } | |
| 4967 | + | // What a project is, where it runs and its links: the projects | |
| 4968 | + | // service keeps them and decides who may change them. | |
| 4969 | + | Op::ListProjects | Op::GetProject | Op::UpdateProject => { | |
| 4970 | + | crate::projects::run(self, services, viewer, input).await | |
| 4971 | + | } | |
| 4877 | 4972 | // The security suite: the security service decides, this gives | |
| 4878 | 4973 | // each answer its public shape. | |
| 4879 | 4974 | Op::Security(op) => crate::security::run(op, services, viewer, input).await, | |
| 4880 | 4975 | Op::Rules(op) => crate::rules::run(op, services, viewer, input).await, | |
| 4881 | 4976 | Op::About(op) => crate::about::run(op, services, viewer, input).await, | |
| 4977 | + | Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await, | |
| 4882 | 4978 | Op::ReopenSecurityAlert => { | |
| 4883 | 4979 | let changed: Outcome<AlertChange> = call( | |
| 4884 | 4980 | &services.security, | |
| 1 | + | //! Projects: what a workspace builds and runs, where each runs, and its | |
| 2 | + | //! links. The projects service keeps them and decides who may see or change | |
| 3 | + | //! them; this is their public shape, in snake_case. | |
| 4 | + | ||
| 5 | + | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 6 | + | use serde_json::{Map, Value, json}; | |
| 7 | + | use worker::Result; | |
| 8 | + | ||
| 9 | + | use crate::operations::{Op, Services}; | |
| 10 | + | ||
| 11 | + | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 12 | + | Ok(Outcome::fail(code, message)) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | fn slug(input: &Value, key: &str) -> Option<String> { | |
| 16 | + | input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_lowercase) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// A reason a kind was decided, as the API shows it. | |
| 20 | + | fn reason_json(reason: &Value) -> Value { | |
| 21 | + | json!({ "by": reason["by"], "detail": reason["detail"] }) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// A project as the projects service answers (camelCase), as the API shows it. | |
| 25 | + | pub(crate) fn project_json(project: &Value, site: &str) -> Value { | |
| 26 | + | let workspace = project["workspace"].as_str().unwrap_or_default(); | |
| 27 | + | let slug = project["slug"].as_str().unwrap_or_default(); | |
| 28 | + | let source = &project["source"]; | |
| 29 | + | let repository = match (source["repo"]["namespace"].as_str(), source["repo"]["name"].as_str()) { | |
| 30 | + | (Some(namespace), Some(name)) => json!(format!("{namespace}/{name}")), | |
| 31 | + | _ => Value::Null, | |
| 32 | + | }; | |
| 33 | + | let links = &project["links"]; | |
| 34 | + | let custom: Vec<Value> = links["custom"] | |
| 35 | + | .as_array() | |
| 36 | + | .map(|items| items.iter().map(|link| json!({ "label": link["label"], "url": link["url"] })).collect()) | |
| 37 | + | .unwrap_or_default(); | |
| 38 | + | json!({ | |
| 39 | + | "id": project["id"], | |
| 40 | + | "workspace": workspace, | |
| 41 | + | "slug": slug, | |
| 42 | + | "name": project["name"], | |
| 43 | + | "description": project["description"], | |
| 44 | + | "description_inherited": project["descriptionInherited"].as_bool().unwrap_or(false), | |
| 45 | + | "url": format!("{}/{workspace}/{slug}", site.trim_end_matches('/')), | |
| 46 | + | "repository": repository, | |
| 47 | + | "root_dir": source["rootDir"].as_str().unwrap_or_default(), | |
| 48 | + | "default_branch": source["defaultBranch"], | |
| 49 | + | "private": project["private"], | |
| 50 | + | "archived": project["archived"], | |
| 51 | + | "primary": project["primary"], | |
| 52 | + | "kind": project["kind"], | |
| 53 | + | "kind_reason": reason_json(&project["kindReason"]), | |
| 54 | + | "runs": project["runs"], | |
| 55 | + | "production_url": project["productionUrl"], | |
| 56 | + | "setting": { "kind": project["setting"]["kind"], "runs": project["setting"]["runs"] }, | |
| 57 | + | "detected": { | |
| 58 | + | "kind": project["detected"]["kind"], | |
| 59 | + | "reason": reason_json(&project["detected"]["reason"]), | |
| 60 | + | }, | |
| 61 | + | "ecosystem": project["ecosystem"], | |
| 62 | + | "links": { | |
| 63 | + | "homepage": links["homepage"], | |
| 64 | + | "homepage_inherited": links["homepageInherited"].as_bool().unwrap_or(false), | |
| 65 | + | "docs": links["docs"], | |
| 66 | + | "custom": custom, | |
| 67 | + | }, | |
| 68 | + | "created_at": project["createdAt"], | |
| 69 | + | "updated_at": project["updatedAt"], | |
| 70 | + | "pushed_at": project["pushedAt"], | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// The fields a change may set: (input, service, whether null clears it). | |
| 75 | + | const CHANGES: &[(&str, &str, bool)] = &[ | |
| 76 | + | ("name", "name", false), | |
| 77 | + | ("description", "description", true), | |
| 78 | + | ("root_dir", "rootDir", false), | |
| 79 | + | ("kind", "kind", false), | |
| 80 | + | ("runs", "runs", false), | |
| 81 | + | ("production_url", "productionUrl", true), | |
| 82 | + | ("homepage", "homepage", true), | |
| 83 | + | ("docs_url", "docsUrl", true), | |
| 84 | + | ]; | |
| 85 | + | ||
| 86 | + | /// The change `input` asks for, in the service's spelling: only what it | |
| 87 | + | /// gives, with null passed on for what null clears. | |
| 88 | + | pub(crate) fn changes(input: &Value) -> std::result::Result<Value, String> { | |
| 89 | + | let mut out = Map::new(); | |
| 90 | + | for (key, field, clearable) in CHANGES { | |
| 91 | + | match input.get(*key) { | |
| 92 | + | None => {} | |
| 93 | + | Some(Value::Null) if *clearable => { | |
| 94 | + | out.insert((*field).to_owned(), Value::Null); | |
| 95 | + | } | |
| 96 | + | Some(Value::Null) => {} | |
| 97 | + | Some(Value::String(text)) => { | |
| 98 | + | out.insert((*field).to_owned(), json!(text)); | |
| 99 | + | } | |
| 100 | + | Some(_) => { | |
| 101 | + | let kind = if *clearable { "a string or null" } else { "a string" }; | |
| 102 | + | return Err(format!("{key} must be {kind}.")); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | match input.get("links") { | |
| 107 | + | None | Some(Value::Null) => {} | |
| 108 | + | Some(Value::Array(items)) => { | |
| 109 | + | let mut links = Vec::with_capacity(items.len()); | |
| 110 | + | for item in items { | |
| 111 | + | match (item["label"].as_str(), item["url"].as_str()) { | |
| 112 | + | (Some(label), Some(url)) => links.push(json!({ "label": label, "url": url })), | |
| 113 | + | _ => return Err("Each of links needs a label and a url.".to_owned()), | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | out.insert("links".to_owned(), Value::Array(links)); | |
| 117 | + | } | |
| 118 | + | Some(_) => return Err("links must be a list of { label, url }.".to_owned()), | |
| 119 | + | } | |
| 120 | + | Ok(Value::Object(out)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Runs one of the project operations. | |
| 124 | + | pub async fn run(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 125 | + | let Some(workspace) = slug(input, "workspace") else { | |
| 126 | + | return failed(FailureCode::Invalid, "Give the workspace's slug."); | |
| 127 | + | }; | |
| 128 | + | let site = services.addresses.site.as_str(); | |
| 129 | + | let one = |outcome: Outcome<Value>| -> Outcome<Value> { | |
| 130 | + | match outcome { | |
| 131 | + | Outcome::Ok(project) => Outcome::Ok(project_json(&project, site)), | |
| 132 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 133 | + | } | |
| 134 | + | }; | |
| 135 | + | if op == Op::ListProjects { | |
| 136 | + | let listed: Outcome<Vec<Value>> = | |
| 137 | + | g1t_kit::call(&services.projects, "list", &json!({ "workspace": workspace, "viewer": viewer })).await?; | |
| 138 | + | return Ok(match listed { | |
| 139 | + | Outcome::Ok(projects) => { | |
| 140 | + | Outcome::Ok(Value::Array(projects.iter().map(|project| project_json(project, site)).collect())) | |
| 141 | + | } | |
| 142 | + | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 143 | + | }); | |
| 144 | + | } | |
| 145 | + | let Some(project) = slug(input, "project") else { | |
| 146 | + | return failed(FailureCode::Invalid, "Give the project's slug."); | |
| 147 | + | }; | |
| 148 | + | match op { | |
| 149 | + | Op::GetProject => Ok(one( | |
| 150 | + | g1t_kit::call( | |
| 151 | + | &services.projects, | |
| 152 | + | "get", | |
| 153 | + | &json!({ "workspace": workspace, "slug": project, "viewer": viewer }), | |
| 154 | + | ) | |
| 155 | + | .await?, | |
| 156 | + | )), | |
| 157 | + | Op::UpdateProject => { | |
| 158 | + | let Some(actor) = viewer else { | |
| 159 | + | return failed(FailureCode::Unauthenticated, "This needs a g1t access token."); | |
| 160 | + | }; | |
| 161 | + | let changes = match changes(input) { | |
| 162 | + | Ok(changes) => changes, | |
| 163 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 164 | + | }; | |
| 165 | + | Ok(one( | |
| 166 | + | g1t_kit::call( | |
| 167 | + | &services.projects, | |
| 168 | + | "update", | |
| 169 | + | &json!({ "actor": actor, "workspace": workspace, "slug": project, "changes": changes }), | |
| 170 | + | ) | |
| 171 | + | .await?, | |
| 172 | + | )) | |
| 173 | + | } | |
| 174 | + | _ => failed(FailureCode::Invalid, "Not a project operation."), | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[cfg(test)] | |
| 179 | + | mod tests { | |
| 180 | + | use super::*; | |
| 181 | + | use g1t_kit::wire; | |
| 182 | + | ||
| 183 | + | fn project() -> Value { | |
| 184 | + | json!({ | |
| 185 | + | "id": "prj_1", "workspace": "flagon-io", "slug": "g1t", "name": "g1t", | |
| 186 | + | "description": "Git hosting for people and agents.", "descriptionInherited": true, | |
| 187 | + | "source": { | |
| 188 | + | "kind": "hosted", "repoId": "repo_1", "repo": { "namespace": "flagon-io", "name": "g1t" }, | |
| 189 | + | "rootDir": "", "defaultBranch": "main", | |
| 190 | + | }, | |
| 191 | + | "private": false, "archived": false, "primary": true, | |
| 192 | + | "setting": { "kind": "app", "runs": "elsewhere" }, | |
| 193 | + | "kind": "app", "kindReason": { "by": "set", "detail": "Set to an app." }, | |
| 194 | + | "runs": "elsewhere", "productionUrl": "https://g1t.sh", | |
| 195 | + | "detected": { "kind": "app", "reason": { "by": "files", "detail": "It has a wrangler.jsonc." } }, | |
| 196 | + | "ecosystem": null, | |
| 197 | + | "links": { | |
| 198 | + | "homepage": "https://g1t.sh", "homepageInherited": true, "docs": "https://docs.g1t.sh", | |
| 199 | + | "custom": [{ "label": "Status", "url": "https://status.g1t.sh" }], | |
| 200 | + | }, | |
| 201 | + | "createdBy": "usr_1", "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-10-07T10:00:00.000Z", | |
| 202 | + | "pushedAt": "2026-10-07T09:00:00.000Z", "activity": 12.5, | |
| 203 | + | }) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn a_project_is_snake_case_with_its_address() { | |
| 208 | + | let shown = project_json(&project(), "https://g1t.sh/"); | |
| 209 | + | assert!(wire::camel_case_keys(&shown).is_empty(), "{:?}", wire::camel_case_keys(&shown)); | |
| 210 | + | assert_eq!(shown["url"], "https://g1t.sh/flagon-io/g1t"); | |
| 211 | + | assert_eq!(shown["repository"], "flagon-io/g1t"); | |
| 212 | + | assert_eq!(shown["root_dir"], ""); | |
| 213 | + | assert_eq!(shown["default_branch"], "main"); | |
| 214 | + | assert_eq!(shown["description_inherited"], true); | |
| 215 | + | assert_eq!(shown["kind_reason"]["by"], "set"); | |
| 216 | + | assert_eq!(shown["production_url"], "https://g1t.sh"); | |
| 217 | + | assert_eq!(shown["setting"]["runs"], "elsewhere"); | |
| 218 | + | assert_eq!(shown["detected"]["reason"]["by"], "files"); | |
| 219 | + | assert_eq!(shown["links"]["homepage_inherited"], true); | |
| 220 | + | assert_eq!(shown["links"]["custom"][0]["label"], "Status"); | |
| 221 | + | assert_eq!(shown["pushed_at"], "2026-10-07T09:00:00.000Z"); | |
| 222 | + | // What is the service's own business stays there. | |
| 223 | + | assert!(shown.get("source").is_none()); | |
| 224 | + | assert!(shown.get("activity").is_none()); | |
| 225 | + | assert!(shown.get("created_by").is_none()); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn a_change_is_only_what_is_given_in_the_service_s_spelling() { | |
| 230 | + | let asked = json!({ | |
| 231 | + | "workspace": "flagon-io", "project": "g1t", | |
| 232 | + | "kind": "app", "runs": "elsewhere", "production_url": "https://g1t.sh", | |
| 233 | + | "root_dir": "apps/web", "docs_url": "docs.g1t.sh", | |
| 234 | + | "links": [{ "label": "Status", "url": "https://status.g1t.sh", "extra": 1 }], | |
| 235 | + | }); | |
| 236 | + | let sent = changes(&asked).unwrap(); | |
| 237 | + | assert_eq!( | |
| 238 | + | sent, | |
| 239 | + | json!({ | |
| 240 | + | "kind": "app", "runs": "elsewhere", "productionUrl": "https://g1t.sh", "rootDir": "apps/web", | |
| 241 | + | "docsUrl": "docs.g1t.sh", "links": [{ "label": "Status", "url": "https://status.g1t.sh" }], | |
| 242 | + | }) | |
| 243 | + | ); | |
| 244 | + | assert!(sent.get("workspace").is_none()); | |
| 245 | + | assert!(sent.get("name").is_none()); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn null_clears_what_it_can_and_absent_changes_nothing() { | |
| 250 | + | let sent = changes(&json!({ "description": null, "homepage": null, "production_url": null, "docs_url": null, "name": null, "kind": null })).unwrap(); | |
| 251 | + | assert_eq!(sent, json!({ "description": null, "homepage": null, "productionUrl": null, "docsUrl": null })); | |
| 252 | + | assert_eq!(changes(&json!({})).unwrap(), json!({})); | |
| 253 | + | assert_eq!(changes(&json!({ "links": [] })).unwrap(), json!({ "links": [] })); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | #[test] | |
| 257 | + | fn a_change_of_the_wrong_type_is_refused() { | |
| 258 | + | assert!(changes(&json!({ "kind": 3 })).is_err()); | |
| 259 | + | assert!(changes(&json!({ "links": "https://g1t.sh" })).is_err()); | |
| 260 | + | assert!(changes(&json!({ "links": [{ "url": "https://g1t.sh" }] })).is_err()); | |
| 261 | + | } | |
| 262 | + | } |
| 6317 | 6317 | ], | |
| 6318 | 6318 | "notes": "Name every pinned project once; anything else is refused with `422 invalid`." | |
| 6319 | 6319 | }, | |
| 6320 | + | "list_projects": { | |
| 6321 | + | "params": { | |
| 6322 | + | "workspace": "flagon-io" | |
| 6323 | + | }, | |
| 6324 | + | "response": [ | |
| 6325 | + | { | |
| 6326 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6327 | + | "workspace": "flagon-io", | |
| 6328 | + | "slug": "g1t", | |
| 6329 | + | "name": "g1t", | |
| 6330 | + | "description": "Git hosting where people and agents work together.", | |
| 6331 | + | "description_inherited": true, | |
| 6332 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6333 | + | "repository": "flagon-io/g1t", | |
| 6334 | + | "root_dir": "", | |
| 6335 | + | "default_branch": "main", | |
| 6336 | + | "private": false, | |
| 6337 | + | "archived": false, | |
| 6338 | + | "primary": true, | |
| 6339 | + | "kind": "app", | |
| 6340 | + | "kind_reason": { | |
| 6341 | + | "by": "set", | |
| 6342 | + | "detail": "Set to an app that runs elsewhere." | |
| 6343 | + | }, | |
| 6344 | + | "runs": "elsewhere", | |
| 6345 | + | "production_url": "https://g1t.sh", | |
| 6346 | + | "setting": { | |
| 6347 | + | "kind": "app", | |
| 6348 | + | "runs": "elsewhere" | |
| 6349 | + | }, | |
| 6350 | + | "detected": { | |
| 6351 | + | "kind": "app", | |
| 6352 | + | "reason": { | |
| 6353 | + | "by": "files", | |
| 6354 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6355 | + | } | |
| 6356 | + | }, | |
| 6357 | + | "ecosystem": null, | |
| 6358 | + | "links": { | |
| 6359 | + | "homepage": "https://g1t.sh", | |
| 6360 | + | "homepage_inherited": true, | |
| 6361 | + | "docs": "https://docs.g1t.sh", | |
| 6362 | + | "custom": [ | |
| 6363 | + | { | |
| 6364 | + | "label": "Status", | |
| 6365 | + | "url": "https://status.g1t.sh" | |
| 6366 | + | } | |
| 6367 | + | ] | |
| 6368 | + | }, | |
| 6369 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6370 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6371 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6372 | + | }, | |
| 6373 | + | { | |
| 6374 | + | "id": "prj_01kkp3n2a7e5v8sk3b6g9j4m1y", | |
| 6375 | + | "workspace": "flagon-io", | |
| 6376 | + | "slug": "hello", | |
| 6377 | + | "name": "hello", | |
| 6378 | + | "description": "Greets people from the command line.", | |
| 6379 | + | "description_inherited": false, | |
| 6380 | + | "url": "https://g1t.sh/flagon-io/hello", | |
| 6381 | + | "repository": "flagon-io/hello", | |
| 6382 | + | "root_dir": "", | |
| 6383 | + | "default_branch": "main", | |
| 6384 | + | "private": false, | |
| 6385 | + | "archived": false, | |
| 6386 | + | "primary": true, | |
| 6387 | + | "kind": "library", | |
| 6388 | + | "kind_reason": { | |
| 6389 | + | "by": "files", | |
| 6390 | + | "detail": "composer.json says it is a library." | |
| 6391 | + | }, | |
| 6392 | + | "runs": null, | |
| 6393 | + | "production_url": null, | |
| 6394 | + | "setting": { | |
| 6395 | + | "kind": null, | |
| 6396 | + | "runs": null | |
| 6397 | + | }, | |
| 6398 | + | "detected": { | |
| 6399 | + | "kind": "library", | |
| 6400 | + | "reason": { | |
| 6401 | + | "by": "files", | |
| 6402 | + | "detail": "composer.json says it is a library." | |
| 6403 | + | } | |
| 6404 | + | }, | |
| 6405 | + | "ecosystem": "composer", | |
| 6406 | + | "links": { | |
| 6407 | + | "homepage": null, | |
| 6408 | + | "homepage_inherited": false, | |
| 6409 | + | "docs": null, | |
| 6410 | + | "custom": [] | |
| 6411 | + | }, | |
| 6412 | + | "created_at": "2026-09-12T14:30:00.000Z", | |
| 6413 | + | "updated_at": "2026-10-01T09:12:00.000Z", | |
| 6414 | + | "pushed_at": null | |
| 6415 | + | } | |
| 6416 | + | ], | |
| 6417 | + | "notes": "By name. Projects whose repository you cannot see are left out; without a token you see public ones only. `kind` is what the project is, from `setting` where a person set it and from detection otherwise (`detected`, shown beside it); `kind_reason` says why. `runs` is null for what is not deployed. `homepage_inherited` is true while the homepage is its repository's website. `pushed_at` is null until its repository is pushed to. See [Projects](/guides/projects/)." | |
| 6418 | + | }, | |
| 6419 | + | "get_project": { | |
| 6420 | + | "params": { | |
| 6421 | + | "workspace": "flagon-io", | |
| 6422 | + | "project": "g1t" | |
| 6423 | + | }, | |
| 6424 | + | "response": { | |
| 6425 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6426 | + | "workspace": "flagon-io", | |
| 6427 | + | "slug": "g1t", | |
| 6428 | + | "name": "g1t", | |
| 6429 | + | "description": "Git hosting where people and agents work together.", | |
| 6430 | + | "description_inherited": true, | |
| 6431 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6432 | + | "repository": "flagon-io/g1t", | |
| 6433 | + | "root_dir": "", | |
| 6434 | + | "default_branch": "main", | |
| 6435 | + | "private": false, | |
| 6436 | + | "archived": false, | |
| 6437 | + | "primary": true, | |
| 6438 | + | "kind": "app", | |
| 6439 | + | "kind_reason": { | |
| 6440 | + | "by": "set", | |
| 6441 | + | "detail": "Set to an app that runs elsewhere." | |
| 6442 | + | }, | |
| 6443 | + | "runs": "elsewhere", | |
| 6444 | + | "production_url": "https://g1t.sh", | |
| 6445 | + | "setting": { | |
| 6446 | + | "kind": "app", | |
| 6447 | + | "runs": "elsewhere" | |
| 6448 | + | }, | |
| 6449 | + | "detected": { | |
| 6450 | + | "kind": "app", | |
| 6451 | + | "reason": { | |
| 6452 | + | "by": "files", | |
| 6453 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6454 | + | } | |
| 6455 | + | }, | |
| 6456 | + | "ecosystem": null, | |
| 6457 | + | "links": { | |
| 6458 | + | "homepage": "https://g1t.sh", | |
| 6459 | + | "homepage_inherited": true, | |
| 6460 | + | "docs": "https://docs.g1t.sh", | |
| 6461 | + | "custom": [ | |
| 6462 | + | { | |
| 6463 | + | "label": "Status", | |
| 6464 | + | "url": "https://status.g1t.sh" | |
| 6465 | + | } | |
| 6466 | + | ] | |
| 6467 | + | }, | |
| 6468 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6469 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6470 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6471 | + | }, | |
| 6472 | + | "notes": "`404` for a project that does not exist or whose repository you cannot see. `setting` holds what a person set, each part null while it is left to detection. `ecosystem` is where a library's files say it is published (`composer`, `npm`, `cargo`, `go` or `python`), or null. See [What a project is](/guides/projects/#what-a-project-is)." | |
| 6473 | + | }, | |
| 6474 | + | "update_project": { | |
| 6475 | + | "params": { | |
| 6476 | + | "workspace": "flagon-io", | |
| 6477 | + | "project": "g1t" | |
| 6478 | + | }, | |
| 6479 | + | "request": { | |
| 6480 | + | "kind": "app", | |
| 6481 | + | "runs": "elsewhere", | |
| 6482 | + | "production_url": "https://g1t.sh", | |
| 6483 | + | "docs_url": "https://docs.g1t.sh", | |
| 6484 | + | "links": [ | |
| 6485 | + | { | |
| 6486 | + | "label": "Status", | |
| 6487 | + | "url": "https://status.g1t.sh" | |
| 6488 | + | } | |
| 6489 | + | ] | |
| 6490 | + | }, | |
| 6491 | + | "response": { | |
| 6492 | + | "id": "prj_01kkp3m8w2f6t9qh4c7d1r5n0x", | |
| 6493 | + | "workspace": "flagon-io", | |
| 6494 | + | "slug": "g1t", | |
| 6495 | + | "name": "g1t", | |
| 6496 | + | "description": "Git hosting where people and agents work together.", | |
| 6497 | + | "description_inherited": true, | |
| 6498 | + | "url": "https://g1t.sh/flagon-io/g1t", | |
| 6499 | + | "repository": "flagon-io/g1t", | |
| 6500 | + | "root_dir": "", | |
| 6501 | + | "default_branch": "main", | |
| 6502 | + | "private": false, | |
| 6503 | + | "archived": false, | |
| 6504 | + | "primary": true, | |
| 6505 | + | "kind": "app", | |
| 6506 | + | "kind_reason": { | |
| 6507 | + | "by": "set", | |
| 6508 | + | "detail": "Set to an app that runs elsewhere." | |
| 6509 | + | }, | |
| 6510 | + | "runs": "elsewhere", | |
| 6511 | + | "production_url": "https://g1t.sh", | |
| 6512 | + | "setting": { | |
| 6513 | + | "kind": "app", | |
| 6514 | + | "runs": "elsewhere" | |
| 6515 | + | }, | |
| 6516 | + | "detected": { | |
| 6517 | + | "kind": "app", | |
| 6518 | + | "reason": { | |
| 6519 | + | "by": "files", | |
| 6520 | + | "detail": "wrangler.jsonc at its root makes it an app." | |
| 6521 | + | } | |
| 6522 | + | }, | |
| 6523 | + | "ecosystem": null, | |
| 6524 | + | "links": { | |
| 6525 | + | "homepage": "https://g1t.sh", | |
| 6526 | + | "homepage_inherited": true, | |
| 6527 | + | "docs": "https://docs.g1t.sh", | |
| 6528 | + | "custom": [ | |
| 6529 | + | { | |
| 6530 | + | "label": "Status", | |
| 6531 | + | "url": "https://status.g1t.sh" | |
| 6532 | + | } | |
| 6533 | + | ] | |
| 6534 | + | }, | |
| 6535 | + | "created_at": "2026-09-01T10:00:00.000Z", | |
| 6536 | + | "updated_at": "2026-10-07T11:20:00.000Z", | |
| 6537 | + | "pushed_at": "2026-10-07T10:00:00.000Z" | |
| 6538 | + | }, | |
| 6539 | + | "notes": "Only the fields given change. `kind` and `runs` take `auto` to go back to detection. Setting `runs` makes the project an app unless it is docs. Making it a `library`, `tool` or `other` while [Deployments](/guides/deployments/) are on is refused with `409 conflict`: turn them off first. `description` and `homepage` given as null or `\"\"` follow the repository's again; `production_url` and `docs_url` given as null or `\"\"` are cleared. `links` replaces the project's other links: at most 10, each with a `label` of up to 40 characters and an http or https `url` (`https://` is added when you leave the scheme out); anything else is refused with `422 invalid`. Needs the Maintain role or higher on the project's repository. Recorded in the [audit log](/guides/audit-log/). See [Settings](/guides/projects/#settings)." | |
| 6540 | + | }, | |
| 6320 | 6541 | "list_secret_scanning_alerts": { | |
| 6321 | 6542 | "params": { | |
| 6322 | 6543 | "owner": "flagon-io", | |
| ⋯ | |||
| 9279 | 9500 | } | |
| 9280 | 9501 | } | |
| 9281 | 9502 | }, | |
| 9503 | + | "list_deployments": { | |
| 9504 | + | "params": { | |
| 9505 | + | "owner": "flagon-io", | |
| 9506 | + | "name": "g1t" | |
| 9507 | + | }, | |
| 9508 | + | "query": { | |
| 9509 | + | "environment": "production", | |
| 9510 | + | "per_page": "2" | |
| 9511 | + | }, | |
| 9512 | + | "response": { | |
| 9513 | + | "deployments": [ | |
| 9514 | + | { | |
| 9515 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9516 | + | "environment": "production", | |
| 9517 | + | "ref": "main", | |
| 9518 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9519 | + | "task": "deploy", | |
| 9520 | + | "description": "Deploy", | |
| 9521 | + | "payload": {}, | |
| 9522 | + | "transient_environment": false, | |
| 9523 | + | "production_environment": true, | |
| 9524 | + | "state": "success", | |
| 9525 | + | "environment_url": "https://g1t.sh", | |
| 9526 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9527 | + | "creator": "syntaqx", | |
| 9528 | + | "source": "actions", | |
| 9529 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9530 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9531 | + | "project": null, | |
| 9532 | + | "number": null, | |
| 9533 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9534 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9535 | + | } | |
| 9536 | + | ], | |
| 9537 | + | "total_count": 304, | |
| 9538 | + | "page": 1, | |
| 9539 | + | "per_page": 2 | |
| 9540 | + | }, | |
| 9541 | + | "notes": "Every deployment of the repository in one list, wherever it ran: `source` is `api` for those reported with [`POST /repos/{owner}/{name}/deployments`](/reference/api/deployments/create-deployment/), `actions` for those a g1t Actions job with an `environment:` made, and `g1t_page` for [g1t.page](/guides/deployments/) builds, whose ids start `dpl_`. `state` is the latest status's. See [Deployments API](/guides/deployments-api/)." | |
| 9542 | + | }, | |
| 9543 | + | "create_deployment": { | |
| 9544 | + | "params": { | |
| 9545 | + | "owner": "flagon-io", | |
| 9546 | + | "name": "g1t" | |
| 9547 | + | }, | |
| 9548 | + | "request": { | |
| 9549 | + | "ref": "main", | |
| 9550 | + | "environment": "staging", | |
| 9551 | + | "description": "Deployed by the release pipeline", | |
| 9552 | + | "payload": { | |
| 9553 | + | "pipeline": 4182, | |
| 9554 | + | "region": "us-east" | |
| 9555 | + | }, | |
| 9556 | + | "state": "in_progress", | |
| 9557 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 9558 | + | }, | |
| 9559 | + | "response": { | |
| 9560 | + | "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9561 | + | "environment": "staging", | |
| 9562 | + | "ref": "main", | |
| 9563 | + | "sha": "4b8d0f2a6c1e3579bd02468ace13579bdf02468a", | |
| 9564 | + | "task": "deploy", | |
| 9565 | + | "description": "Deployed by the release pipeline", | |
| 9566 | + | "payload": { | |
| 9567 | + | "pipeline": 4182, | |
| 9568 | + | "region": "us-east" | |
| 9569 | + | }, | |
| 9570 | + | "transient_environment": false, | |
| 9571 | + | "production_environment": false, | |
| 9572 | + | "state": "in_progress", | |
| 9573 | + | "environment_url": null, | |
| 9574 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9575 | + | "creator": "flagon-io", | |
| 9576 | + | "source": "api", | |
| 9577 | + | "run_id": null, | |
| 9578 | + | "run_url": null, | |
| 9579 | + | "project": null, | |
| 9580 | + | "number": null, | |
| 9581 | + | "created_at": "2026-10-06T21:40:03.512Z", | |
| 9582 | + | "updated_at": "2026-10-06T21:40:03.512Z", | |
| 9583 | + | "statuses": [ | |
| 9584 | + | { | |
| 9585 | + | "id": "dst_01kq7z9a1d4f6h8k0m2p4r6t8v", | |
| 9586 | + | "deployment_id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9587 | + | "state": "in_progress", | |
| 9588 | + | "description": "Deployed by the release pipeline", | |
| 9589 | + | "environment_url": null, | |
| 9590 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9591 | + | "creator": "flagon-io", | |
| 9592 | + | "created_at": "2026-10-06T21:40:03.512Z" | |
| 9593 | + | } | |
| 9594 | + | ] | |
| 9595 | + | }, | |
| 9596 | + | "notes": "Report from any CI with an access token that has `deployments:write` (the CI preset has it) and the Write role. Then report each step with [`POST …/deployments/{id}/statuses`](/reference/api/deployments/create-deployment-status/). Each status sets the check `deploy / <environment>` on the commit. A g1t Actions job with an `environment:` does all of this itself. See [Deployments API](/guides/deployments-api/)." | |
| 9597 | + | }, | |
| 9598 | + | "get_deployment": { | |
| 9599 | + | "params": { | |
| 9600 | + | "owner": "flagon-io", | |
| 9601 | + | "name": "g1t", | |
| 9602 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m" | |
| 9603 | + | }, | |
| 9604 | + | "response": { | |
| 9605 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9606 | + | "environment": "production", | |
| 9607 | + | "ref": "main", | |
| 9608 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9609 | + | "task": "deploy", | |
| 9610 | + | "description": "Deploy", | |
| 9611 | + | "payload": {}, | |
| 9612 | + | "transient_environment": false, | |
| 9613 | + | "production_environment": true, | |
| 9614 | + | "state": "success", | |
| 9615 | + | "environment_url": "https://g1t.sh", | |
| 9616 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9617 | + | "creator": "syntaqx", | |
| 9618 | + | "source": "actions", | |
| 9619 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9620 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9621 | + | "project": null, | |
| 9622 | + | "number": null, | |
| 9623 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9624 | + | "updated_at": "2026-10-07T18:19:47.881Z", | |
| 9625 | + | "statuses": [ | |
| 9626 | + | { | |
| 9627 | + | "id": "dst_01kq8m3t5w0a2c4e6g8j0m2p4r", | |
| 9628 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9629 | + | "state": "in_progress", | |
| 9630 | + | "description": "Deploy is deploying", | |
| 9631 | + | "environment_url": "https://g1t.sh", | |
| 9632 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9633 | + | "creator": "syntaqx", | |
| 9634 | + | "created_at": "2026-10-07T18:02:11.204Z" | |
| 9635 | + | }, | |
| 9636 | + | { | |
| 9637 | + | "id": "dst_01kq8n4v6x8z0b2d4f6h8k0m2p", | |
| 9638 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9639 | + | "state": "success", | |
| 9640 | + | "description": "Deploy deployed", | |
| 9641 | + | "environment_url": "https://g1t.sh", | |
| 9642 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9643 | + | "creator": "syntaqx", | |
| 9644 | + | "created_at": "2026-10-07T18:19:47.881Z" | |
| 9645 | + | } | |
| 9646 | + | ] | |
| 9647 | + | }, | |
| 9648 | + | "notes": "`statuses` are oldest first. A g1t.page build's (`dpl_…`) are read from the build: queued, building, how it ended, and `inactive` once a newer build replaced it or it was taken down." | |
| 9649 | + | }, | |
| 9650 | + | "list_deployment_statuses": { | |
| 9651 | + | "params": { | |
| 9652 | + | "owner": "flagon-io", | |
| 9653 | + | "name": "g1t", | |
| 9654 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m" | |
| 9655 | + | }, | |
| 9656 | + | "response": [ | |
| 9657 | + | { | |
| 9658 | + | "id": "dst_01kq8n4v6x8z0b2d4f6h8k0m2p", | |
| 9659 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9660 | + | "state": "success", | |
| 9661 | + | "description": "Deploy deployed", | |
| 9662 | + | "environment_url": "https://g1t.sh", | |
| 9663 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9664 | + | "creator": "syntaqx", | |
| 9665 | + | "created_at": "2026-10-07T18:19:47.881Z" | |
| 9666 | + | }, | |
| 9667 | + | { | |
| 9668 | + | "id": "dst_01kq8m3t5w0a2c4e6g8j0m2p4r", | |
| 9669 | + | "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9670 | + | "state": "in_progress", | |
| 9671 | + | "description": "Deploy is deploying", | |
| 9672 | + | "environment_url": "https://g1t.sh", | |
| 9673 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9674 | + | "creator": "syntaqx", | |
| 9675 | + | "created_at": "2026-10-07T18:02:11.204Z" | |
| 9676 | + | } | |
| 9677 | + | ] | |
| 9678 | + | }, | |
| 9679 | + | "create_deployment_status": { | |
| 9680 | + | "params": { | |
| 9681 | + | "owner": "flagon-io", | |
| 9682 | + | "name": "g1t", | |
| 9683 | + | "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v" | |
| 9684 | + | }, | |
| 9685 | + | "request": { | |
| 9686 | + | "state": "failure", | |
| 9687 | + | "description": "Smoke tests failed", | |
| 9688 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 9689 | + | }, | |
| 9690 | + | "response": { | |
| 9691 | + | "id": "dst_01kq7zc2e4g6j8m0p2r4t6v8x0", | |
| 9692 | + | "deployment_id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9693 | + | "state": "failure", | |
| 9694 | + | "description": "Smoke tests failed", | |
| 9695 | + | "environment_url": null, | |
| 9696 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9697 | + | "creator": "flagon-io", | |
| 9698 | + | "created_at": "2026-10-06T21:44:58.020Z" | |
| 9699 | + | }, | |
| 9700 | + | "notes": "`queued` and `in_progress` set the commit's `deploy / <environment>` check pending; `success`, `failure` and `error` settle it; `inactive` leaves it. A success makes the environment's older successful deployments `inactive` unless `auto_inactive` is false. A g1t.page build (`dpl_…`) answers `409`: its statuses come from the build." | |
| 9701 | + | }, | |
| 9702 | + | "list_environments": { | |
| 9703 | + | "params": { | |
| 9704 | + | "owner": "flagon-io", | |
| 9705 | + | "name": "g1t" | |
| 9706 | + | }, | |
| 9707 | + | "response": { | |
| 9708 | + | "total_count": 304, | |
| 9709 | + | "environments": [ | |
| 9710 | + | { | |
| 9711 | + | "name": "production", | |
| 9712 | + | "url": "https://g1t.sh", | |
| 9713 | + | "production_environment": true, | |
| 9714 | + | "transient_environment": false, | |
| 9715 | + | "deployments_count": 281, | |
| 9716 | + | "latest": { | |
| 9717 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9718 | + | "environment": "production", | |
| 9719 | + | "ref": "main", | |
| 9720 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9721 | + | "task": "deploy", | |
| 9722 | + | "description": "Deploy", | |
| 9723 | + | "payload": {}, | |
| 9724 | + | "transient_environment": false, | |
| 9725 | + | "production_environment": true, | |
| 9726 | + | "state": "success", | |
| 9727 | + | "environment_url": "https://g1t.sh", | |
| 9728 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9729 | + | "creator": "syntaqx", | |
| 9730 | + | "source": "actions", | |
| 9731 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9732 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9733 | + | "project": null, | |
| 9734 | + | "number": null, | |
| 9735 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9736 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9737 | + | }, | |
| 9738 | + | "current": { | |
| 9739 | + | "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m", | |
| 9740 | + | "environment": "production", | |
| 9741 | + | "ref": "main", | |
| 9742 | + | "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7", | |
| 9743 | + | "task": "deploy", | |
| 9744 | + | "description": "Deploy", | |
| 9745 | + | "payload": {}, | |
| 9746 | + | "transient_environment": false, | |
| 9747 | + | "production_environment": true, | |
| 9748 | + | "state": "success", | |
| 9749 | + | "environment_url": "https://g1t.sh", | |
| 9750 | + | "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9751 | + | "creator": "syntaqx", | |
| 9752 | + | "source": "actions", | |
| 9753 | + | "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9754 | + | "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k", | |
| 9755 | + | "project": null, | |
| 9756 | + | "number": null, | |
| 9757 | + | "created_at": "2026-10-07T18:02:11.204Z", | |
| 9758 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9759 | + | }, | |
| 9760 | + | "updated_at": "2026-10-07T18:19:47.881Z" | |
| 9761 | + | }, | |
| 9762 | + | { | |
| 9763 | + | "name": "staging", | |
| 9764 | + | "url": null, | |
| 9765 | + | "production_environment": false, | |
| 9766 | + | "transient_environment": false, | |
| 9767 | + | "deployments_count": 19, | |
| 9768 | + | "latest": { | |
| 9769 | + | "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v", | |
| 9770 | + | "environment": "staging", | |
| 9771 | + | "ref": "main", | |
| 9772 | + | "sha": "4b8d0f2a6c1e3579bd02468ace13579bdf02468a", | |
| 9773 | + | "task": "deploy", | |
| 9774 | + | "description": "Deployed by the release pipeline", | |
| 9775 | + | "payload": { | |
| 9776 | + | "pipeline": 4182, | |
| 9777 | + | "region": "us-east" | |
| 9778 | + | }, | |
| 9779 | + | "transient_environment": false, | |
| 9780 | + | "production_environment": false, | |
| 9781 | + | "state": "failure", | |
| 9782 | + | "environment_url": null, | |
| 9783 | + | "log_url": "https://ci.example.com/pipelines/4182", | |
| 9784 | + | "creator": "flagon-io", | |
| 9785 | + | "source": "api", | |
| 9786 | + | "run_id": null, | |
| 9787 | + | "run_url": null, | |
| 9788 | + | "project": null, | |
| 9789 | + | "number": null, | |
| 9790 | + | "created_at": "2026-10-06T21:40:03.512Z", | |
| 9791 | + | "updated_at": "2026-10-06T21:44:58.020Z" | |
| 9792 | + | }, | |
| 9793 | + | "current": null, | |
| 9794 | + | "updated_at": "2026-10-06T21:44:58.020Z" | |
| 9795 | + | } | |
| 9796 | + | ] | |
| 9797 | + | }, | |
| 9798 | + | "notes": "Production comes first, then the most recently deployed. `latest` is the newest deployment whatever its state; `current` is the newest that succeeded and is still active, and `url` is where it is served." | |
| 9799 | + | }, | |
| 9800 | + | "get_environment": { | |
| 9801 | + | "params": { | |
| 9802 | + | "owner": "flagon-io", | |
| 9803 | + | "name": "docs", | |
| 9804 | + | "environment": "preview" | |
| 9805 | + | }, | |
| 9806 | + | "response": { | |
| 9807 | + | "name": "preview", | |
| 9808 | + | "url": "https://docs-git-docs-deployments-flagon-io.g1t.page", | |
| 9809 | + | "production_environment": false, | |
| 9810 | + | "transient_environment": true, | |
| 9811 | + | "deployments_count": 87, | |
| 9812 | + | "latest": { | |
| 9813 | + | "id": "dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9814 | + | "environment": "preview", | |
| 9815 | + | "ref": "docs-deployments", | |
| 9816 | + | "sha": "e2f4a6c8b0d1e3f5a7c9b1d3e5f7a9c1b3d5e7f9", | |
| 9817 | + | "task": "deploy", | |
| 9818 | + | "description": "Preview of docs-deployments on g1t.page", | |
| 9819 | + | "payload": {}, | |
| 9820 | + | "transient_environment": true, | |
| 9821 | + | "production_environment": false, | |
| 9822 | + | "state": "success", | |
| 9823 | + | "environment_url": "https://docs-git-docs-deployments-flagon-io.g1t.page", | |
| 9824 | + | "log_url": "https://g1t.sh/flagon-io/docs/deployments/dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9825 | + | "creator": "syntaqx", | |
| 9826 | + | "source": "g1t_page", | |
| 9827 | + | "run_id": null, | |
| 9828 | + | "run_url": null, | |
| 9829 | + | "project": "docs", | |
| 9830 | + | "number": 412, | |
| 9831 | + | "created_at": "2026-10-08T01:12:44.630Z", | |
| 9832 | + | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9833 | + | }, | |
| 9834 | + | "current": { | |
| 9835 | + | "id": "dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9836 | + | "environment": "preview", | |
| 9837 | + | "ref": "docs-deployments", | |
| 9838 | + | "sha": "e2f4a6c8b0d1e3f5a7c9b1d3e5f7a9c1b3d5e7f9", | |
| 9839 | + | "task": "deploy", | |
| 9840 | + | "description": "Preview of docs-deployments on g1t.page", | |
| 9841 | + | "payload": {}, | |
| 9842 | + | "transient_environment": true, | |
| 9843 | + | "production_environment": false, | |
| 9844 | + | "state": "success", | |
| 9845 | + | "environment_url": "https://docs-git-docs-deployments-flagon-io.g1t.page", | |
| 9846 | + | "log_url": "https://g1t.sh/flagon-io/docs/deployments/dpl_01kq8p2b4d6f8h0k2m4p6r8t0v", | |
| 9847 | + | "creator": "syntaqx", | |
| 9848 | + | "source": "g1t_page", | |
| 9849 | + | "run_id": null, | |
| 9850 | + | "run_url": null, | |
| 9851 | + | "project": "docs", | |
| 9852 | + | "number": 412, | |
| 9853 | + | "created_at": "2026-10-08T01:12:44.630Z", | |
| 9854 | + | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9855 | + | }, | |
| 9856 | + | "updated_at": "2026-10-08T01:13:52.101Z" | |
| 9857 | + | } | |
| 9858 | + | }, | |
| 9282 | 9859 | "get_languages": { | |
| 9283 | 9860 | "response": { | |
| 9284 | 9861 | "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f", | |
| 109 | 109 | } | |
| 110 | 110 | // Built by the API itself. | |
| 111 | 111 | Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is, | |
| 112 | + | // Deployments travel in `snake_case` between services too. | |
| 113 | + | Op::Deployments(_) => return as_is, | |
| 112 | 114 | // Built by the API itself, in `snake_case`. | |
| 113 | 115 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 114 | 116 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { |
| 3 | 3 | use serde_json::{Map, Value}; | |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | + | use crate::deployments::DeploymentsOp; | |
| 6 | 7 | use crate::operations::Op; | |
| 7 | 8 | use crate::rules::RulesOp; | |
| 8 | 9 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 109 | 110 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 110 | 111 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 111 | 112 | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), | |
| 113 | + | // A workspace's projects: what each is, where it runs, its links. | |
| 114 | + | route("GET", "/workspaces/:workspace/projects", Op::ListProjects, &[]), | |
| 115 | + | route("GET", "/workspaces/:workspace/projects/:project", Op::GetProject, &[]), | |
| 116 | + | route("PATCH", "/workspaces/:workspace/projects/:project", Op::UpdateProject, &[]), | |
| 112 | 117 | route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]), | |
| 113 | 118 | route( | |
| 114 | 119 | "GET", | |
| ⋯ | |||
| 281 | 286 | Op::Rules(RulesOp::ListWorkspaceRuleEvaluations), | |
| 282 | 287 | &[("ruleset_id", "ruleset_id"), ("verdict", "verdict"), ("problems_only", "problems_only"), ("before", "before"), ("limit", "limit")], | |
| 283 | 288 | ), | |
| 289 | + | // Deployments wherever they run, their statuses, and environments. | |
| 290 | + | route( | |
| 291 | + | "GET", | |
| 292 | + | "/repos/:owner/:name/deployments", | |
| 293 | + | Op::Deployments(DeploymentsOp::ListDeployments), | |
| 294 | + | &[("environment", "environment"), ("ref", "ref"), ("sha", "sha"), ("task", "task"), ("state", "state"), ("source", "source"), ("creator", "creator"), ("page", "page"), ("per_page", "per_page")], | |
| 295 | + | ), | |
| 296 | + | route("POST", "/repos/:owner/:name/deployments", Op::Deployments(DeploymentsOp::CreateDeployment), &[]), | |
| 297 | + | route("GET", "/repos/:owner/:name/deployments/:id", Op::Deployments(DeploymentsOp::GetDeployment), &[]), | |
| 298 | + | route("GET", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), &[]), | |
| 299 | + | route("POST", "/repos/:owner/:name/deployments/:id/statuses", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), &[]), | |
| 300 | + | route("GET", "/repos/:owner/:name/environments", Op::Deployments(DeploymentsOp::ListEnvironments), &[]), | |
| 301 | + | route("GET", "/repos/:owner/:name/environments/:environment", Op::Deployments(DeploymentsOp::GetEnvironment), &[]), | |
| 284 | 302 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 285 | 303 | route( | |
| 286 | 304 | "POST", | |
| ⋯ | |||
| 880 | 898 | if let Some(branch) = param("branch") { | |
| 881 | 899 | input.insert("branch".to_owned(), Value::String(percent_decoded(branch))); | |
| 882 | 900 | } | |
| 901 | + | // An environment's name may hold slashes and spaces, URL-encoded. | |
| 902 | + | if let Some(environment) = param("environment") { | |
| 903 | + | input.insert("environment".to_owned(), Value::String(percent_decoded(environment))); | |
| 904 | + | } | |
| 883 | 905 | if let Some(label) = param("label") { | |
| 884 | 906 | input.insert("label".to_owned(), Value::String(percent_decoded(label))); | |
| 885 | 907 | } | |
| 19 | 19 | use serde_json::{Map, Value, json}; | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | + | use crate::deployments::DeploymentsOp; | |
| 22 | 23 | use crate::operations::Op; | |
| 23 | 24 | use crate::rules::RulesOp; | |
| 24 | 25 | use crate::security::SecurityOp; | |
| ⋯ | |||
| 198 | 199 | Tool { | |
| 199 | 200 | name: "workflow", | |
| 200 | 201 | title: "Workflows", | |
| 201 | − | description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. 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.", | |
| 202 | + | 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. 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.", | |
| 202 | 203 | default_action: None, | |
| 203 | 204 | actions: &[ | |
| 204 | 205 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| ⋯ | |||
| 209 | 210 | a("cancel", Op::CancelWorkflowRun, "Cancel a run"), | |
| 210 | 211 | a("rerun", Op::RerunWorkflowRun, "Run a finished run again"), | |
| 211 | 212 | a("update", Op::UpdateWorkflow, "Turn a workflow on or off"), | |
| 213 | + | a("list_deployments", Op::Deployments(DeploymentsOp::ListDeployments), "Deployments wherever they run, newest first, filtered"), | |
| 214 | + | a("get_deployment", Op::Deployments(DeploymentsOp::GetDeployment), "One deployment with every status it has had"), | |
| 215 | + | a("create_deployment", Op::Deployments(DeploymentsOp::CreateDeployment), "Report a deployment of a ref to an environment"), | |
| 216 | + | a("deployment_statuses", Op::Deployments(DeploymentsOp::ListDeploymentStatuses), "A deployment's statuses, newest first"), | |
| 217 | + | a("create_deployment_status", Op::Deployments(DeploymentsOp::CreateDeploymentStatus), "Report where a deployment is: in_progress, success, failure"), | |
| 218 | + | a("list_environments", Op::Deployments(DeploymentsOp::ListEnvironments), "Environments with their current and latest deployments"), | |
| 219 | + | a("get_environment", Op::Deployments(DeploymentsOp::GetEnvironment), "One environment by name"), | |
| 212 | 220 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 213 | 221 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 214 | 222 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
| ⋯ | |||
| 291 | 299 | Tool { | |
| 292 | 300 | name: "workspace", | |
| 293 | 301 | title: "Workspaces", | |
| 294 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, set rulesets that hold across its repositories, and keep your own pinned projects at the top of its sidebar.", | |
| 302 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, connect integrations and model providers, set rulesets that hold across its repositories, read and change its projects (what each is, where it runs, its links), and keep your own pinned projects at the top of its sidebar.", | |
| 295 | 303 | default_action: None, | |
| 296 | 304 | actions: &[ | |
| 297 | 305 | a("get", Op::GetWorkspace, "A workspace's details and settings"), | |
| ⋯ | |||
| 307 | 315 | a("test_integration", Op::TestIntegration, "Check its credentials"), | |
| 308 | 316 | a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"), | |
| 309 | 317 | a("set_model_routes", Op::SetModelRoutes, "Replace them"), | |
| 318 | + | a("list_projects", Op::ListProjects, "Its projects you can see: what each is, where it runs, its links"), | |
| 319 | + | a("get_project", Op::GetProject, "One project"), | |
| 320 | + | a("update_project", Op::UpdateProject, "Change a project's name, description, kind, where it runs or its links"), | |
| 310 | 321 | a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"), | |
| 311 | 322 | a("pin_project", Op::PinProject, "Pin a project, at a position or the end"), | |
| 312 | 323 | a("unpin_project", Op::UnpinProject, "Unpin a project"), | |
| 74 | 74 | items: [ | |
| 75 | 75 | { label: 'Projects', slug: 'guides/projects' }, | |
| 76 | 76 | { label: 'Deployments', slug: 'guides/deployments' }, | |
| 77 | + | { label: 'Deployments API', slug: 'guides/deployments-api' }, | |
| 77 | 78 | { label: 'Packages', slug: 'guides/packages' }, | |
| 78 | 79 | { label: 'Container images', slug: 'guides/containers' }, | |
| 79 | 80 | { label: 'npm', slug: 'guides/npm' }, |
| 42 | 42 | | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. | | |
| 43 | 43 | | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. | | |
| 44 | 44 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is the workspace's own token for the run; `GITHUB_TOKEN` is its alias. | | |
| 45 | − | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work. | | |
| 45 | + | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. | | |
| 46 | 46 | | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. | | |
| 47 | 47 | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). | | |
| 48 | 48 | ||
| ⋯ | |||
| 66 | 66 | - **Environments' protection rules** (required reviewers, wait timers, | |
| 67 | 67 | branch limits). A job with `environment:` gets that environment's | |
| 68 | 68 | [values](/guides/secrets-and-variables/#a-value-per-environment), and runs | |
| 69 | − | without waiting. | |
| 69 | + | without waiting. It still records a | |
| 70 | + | [deployment](/guides/deployments-api/#deployments-from-g1t-actions) | |
| 71 | + | unless it says `deployment: false`. | |
| 70 | 72 | ||
| 71 | 73 | Why each of these is missing, and what to use instead, is on | |
| 72 | 74 | [What g1t can't do yet](/about/limitations/#actions-and-runners). | |
| ⋯ | |||
| 264 | 266 | Secrets are read as `${{ secrets.KEY }}` and config as `${{ vars.KEY }}`, | |
| 265 | 267 | from the rows under **Settings → Secrets and variables** that are | |
| 266 | 268 | available to Workflows. A job with `environment: production` reads each | |
| 267 | − | key's Production row; other jobs read the rows for all environments. See | |
| 269 | + | key's Production row; other jobs read the rows for all environments. A | |
| 270 | + | job with an `environment:` also makes a deployment to it; see | |
| 271 | + | [deployments from g1t Actions](/guides/deployments-api/#deployments-from-g1t-actions). See | |
| 268 | 272 | [Secrets and variables](/guides/secrets-and-variables/) for how rows, | |
| 269 | 273 | environments and the workspace's rows work. | |
| 270 | 274 | ||
| 321 | 321 | | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` | | |
| 322 | 322 | | Agents | `agents:run` | | |
| 323 | 323 | | Workflows | `workflows:read`, `workflows:write` | | |
| 324 | + | | Deployments | `deployments:read`, `deployments:write` | | |
| 324 | 325 | | Memory & search | `memory:read`, `memory:write` | | |
| 325 | 326 | | Account | `account:read`, `account:write` | | |
| 326 | 327 | | Notifications | `notifications:read`, `notifications:write` | | |
| ⋯ | |||
| 354 | 355 | | `agents:run` | Put g1t to work and message it, which uses the workspace's money | | |
| 355 | 356 | | `workflows:read` | Read workflows, runs and logs | | |
| 356 | 357 | | `workflows:write` | Run, cancel, rerun and turn workflows on or off | | |
| 358 | + | | `deployments:read` | See [deployments](/guides/deployments-api/), their statuses and environments | | |
| 359 | + | | `deployments:write` | Report deployments and their statuses, from any CI | | |
| 357 | 360 | | `memory:read` | Recall memory and search the workspace's context | | |
| 358 | 361 | | `memory:write` | Save memory for the next agent | | |
| 359 | 362 | | `account:read` | Read your email addresses, invites, invitations, pinned projects and stars | | |
| ⋯ | |||
| 411 | 414 | | --- | --- | | |
| 412 | 415 | | Read only | Every `read` scope. Changes nothing. | | |
| 413 | 416 | | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run`, `memory:write` and `notifications:write`. Reads everything, works on issues and pull requests, pushes code, puts g1t to work, and answers your inbox. No admin scope. | | |
| 414 | − | | CI | `repo:read`, `code:read`, `code:write`, `packages:read`, `packages:write`, `workflows:read` and `workflows:write`. Clones and pushes code, pushes and pulls packages, and runs workflows. | | |
| 417 | + | | CI | `repo:read`, `code:read`, `code:write`, `packages:read`, `packages:write`, `workflows:read`, `workflows:write`, `deployments:read` and `deployments:write`. Clones and pushes code, pushes and pulls packages, runs workflows and reports deployments. | | |
| 415 | 418 | | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. | | |
| 416 | 419 | ||
| 417 | 420 | Admin scopes change things that are hard to undo, or decide who can reach | |
| 1 | + | --- | |
| 2 | + | title: Deployments API | |
| 3 | + | description: Report deployments from any CI, see every deployment of a repository and its environments in one place, require them before a merge, and hear of them by webhook. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | A repository keeps one list of its deployments, wherever they ran. A | |
| 7 | + | deployment is one commit sent to one environment, such as `production` or | |
| 8 | + | `staging`. An environment is the place it went: a name, and the address it | |
| 9 | + | is served at. Each deployment has a list of statuses that say how it went, | |
| 10 | + | and its latest status is its state. | |
| 11 | + | ||
| 12 | + | Deployments reach that list three ways. Each deployment says which in its | |
| 13 | + | `source`: | |
| 14 | + | ||
| 15 | + | | `source` | Made by | Ids | | |
| 16 | + | | --- | --- | --- | | |
| 17 | + | | `api` | Any CI or script, through the routes on this page, with an access token. | `dep_…` | | |
| 18 | + | | `actions` | A [g1t Actions](/guides/actions/) job with an `environment:`, by itself. | `dep_…` | | |
| 19 | + | | `g1t_page` | A [g1t.page](/guides/deployments/) build of a project, to the `production` or `preview` environment. | `dpl_…` | | |
| 20 | + | ||
| 21 | + | A status's id starts `dst_`. Every deployment, whatever its source, shows | |
| 22 | + | on the repository's **Deployments** page, sets a check on its commit, and | |
| 23 | + | sends [webhooks](#webhooks). | |
| 24 | + | ||
| 25 | + | ## Report a deployment from any CI | |
| 26 | + | ||
| 27 | + | You report a deployment by creating it, then adding a status each time it | |
| 28 | + | moves on. The base URL is `https://api.g1t.sh`, and every request carries | |
| 29 | + | an access token as `Authorization: Bearer g1t_…`. | |
| 30 | + | ||
| 31 | + | ### Make a token | |
| 32 | + | ||
| 33 | + | 1. Open your **Settings → Access tokens**, or a workspace's | |
| 34 | + | **Settings → Access tokens** for a token that belongs to the workspace. | |
| 35 | + | 2. Give the token a name, such as `release-pipeline`. | |
| 36 | + | 3. Select the **CI** preset. It includes `deployments:read` and | |
| 37 | + | `deployments:write`. To report deployments and nothing else, tick only | |
| 38 | + | `deployments:write` under **Deployments**. | |
| 39 | + | 4. Choose an expiry and select **Create token**. Copy the token now: it is | |
| 40 | + | not shown again. | |
| 41 | + | 5. Store it in your CI as a secret named `G1T_TOKEN`. | |
| 42 | + | ||
| 43 | + | Reporting also needs the Write [role](/guides/access-and-roles/) on the | |
| 44 | + | repository. See [scopes](/guides/authentication/#scopes). | |
| 45 | + | ||
| 46 | + | ### Create the deployment | |
| 47 | + | ||
| 48 | + | `POST /repos/{owner}/{name}/deployments` creates a deployment. Give it | |
| 49 | + | `state: "in_progress"` when the deploy has started: | |
| 50 | + | ||
| 51 | + | ```sh | |
| 52 | + | curl -X POST https://api.g1t.sh/repos/acme/web/deployments \ | |
| 53 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 54 | + | -H "Content-Type: application/json" \ | |
| 55 | + | -d '{ | |
| 56 | + | "ref": "main", | |
| 57 | + | "environment": "staging", | |
| 58 | + | "description": "Deployed by the release pipeline", | |
| 59 | + | "state": "in_progress", | |
| 60 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 61 | + | }' | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | The answer is the deployment, with its first status in `statuses`. Keep | |
| 65 | + | its `id` for the next call. | |
| 66 | + | ||
| 67 | + | | Field | Default | | | |
| 68 | + | | --- | --- | --- | | |
| 69 | + | | `ref` | Required | The branch, tag or commit deployed, such as `main` or `v1.4.0`. | | |
| 70 | + | | `sha` | Resolved from `ref` | The commit deployed. Give the whole commit id to skip resolving `ref`. | | |
| 71 | + | | `environment` | `production` | 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. | | |
| 72 | + | | `task` | `deploy` | What kind of deployment, such as `deploy:migrations`. Up to 100 characters. | | |
| 73 | + | | `description` | None | A short note, up to 1,000 characters. | | |
| 74 | + | | `payload` | `{}` | Anything else to keep with it: a JSON object, or a JSON string of one, up to 64 KB. Returned as given. | | |
| 75 | + | | `production_environment` | `true` for `production`, else `false` | Whether people use this environment directly. | | |
| 76 | + | | `transient_environment` | `false` | Whether the environment goes away, such as a review app. | | |
| 77 | + | | `state` | `queued` | Its first status. See [statuses](#statuses). | | |
| 78 | + | | `environment_url` | None | Where it is served, an `http` or `https` address. | | |
| 79 | + | | `log_url` | None | Where its output can be read, an `http` or `https` address. | | |
| 80 | + | ||
| 81 | + | ### Report how it went | |
| 82 | + | ||
| 83 | + | `POST /repos/{owner}/{name}/deployments/{id}/statuses` adds a status. When | |
| 84 | + | the deploy succeeds, give the address it is served at: | |
| 85 | + | ||
| 86 | + | ```sh | |
| 87 | + | curl -X POST https://api.g1t.sh/repos/acme/web/deployments/dep_01kq7z9a1c3e5g7j9m1p3r5t7v/statuses \ | |
| 88 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 89 | + | -H "Content-Type: application/json" \ | |
| 90 | + | -d '{ | |
| 91 | + | "state": "success", | |
| 92 | + | "environment_url": "https://staging.example.com", | |
| 93 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 94 | + | }' | |
| 95 | + | ``` | |
| 96 | + | ||
| 97 | + | When it fails: | |
| 98 | + | ||
| 99 | + | ```sh | |
| 100 | + | curl -X POST https://api.g1t.sh/repos/acme/web/deployments/dep_01kq7z9a1c3e5g7j9m1p3r5t7v/statuses \ | |
| 101 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 102 | + | -H "Content-Type: application/json" \ | |
| 103 | + | -d '{ | |
| 104 | + | "state": "failure", | |
| 105 | + | "description": "Smoke tests failed", | |
| 106 | + | "log_url": "https://ci.example.com/pipelines/4182" | |
| 107 | + | }' | |
| 108 | + | ``` | |
| 109 | + | ||
| 110 | + | | Field | Default | | | |
| 111 | + | | --- | --- | --- | | |
| 112 | + | | `state` | Required | `queued`, `in_progress`, `success`, `failure`, `error` or `inactive`. | | |
| 113 | + | | `description` | None | A short note, up to 1,000 characters. | | |
| 114 | + | | `environment_url` | None | Where it is served, an `http` or `https` address. | | |
| 115 | + | | `log_url` | None | Where its output can be read, an `http` or `https` address. | | |
| 116 | + | | `auto_inactive` | `true` | On a `success`, give the environment's older successful deployments an `inactive` status. See [auto_inactive](#auto_inactive). | | |
| 117 | + | ||
| 118 | + | The deployment takes the status's state, and any address the status gives. | |
| 119 | + | ||
| 120 | + | ### A script for any CI | |
| 121 | + | ||
| 122 | + | This script wraps a deploy command. It needs `curl`, `jq`, and three | |
| 123 | + | variables: `G1T_TOKEN`, `G1T_REPO` (such as `acme/web`) and `GIT_COMMIT` | |
| 124 | + | (the commit being deployed). Change `ENVIRONMENT`, `ENVIRONMENT_URL` and | |
| 125 | + | the deploy command to yours. | |
| 126 | + | ||
| 127 | + | ```sh | |
| 128 | + | #!/bin/sh | |
| 129 | + | set -eu | |
| 130 | + | ||
| 131 | + | API="https://api.g1t.sh/repos/$G1T_REPO/deployments" | |
| 132 | + | ENVIRONMENT="staging" | |
| 133 | + | ENVIRONMENT_URL="https://staging.example.com" | |
| 134 | + | ||
| 135 | + | report() { | |
| 136 | + | curl -fsS -X POST "$1" \ | |
| 137 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 138 | + | -H "Content-Type: application/json" \ | |
| 139 | + | -d "$2" | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | # 1. Create the deployment, in progress. | |
| 143 | + | ID=$(report "$API" "$(jq -n \ | |
| 144 | + | --arg ref "$GIT_COMMIT" \ | |
| 145 | + | --arg environment "$ENVIRONMENT" \ | |
| 146 | + | '{ref: $ref, environment: $environment, state: "in_progress"}')" | jq -r .id) | |
| 147 | + | ||
| 148 | + | # 2. Deploy, then report how it went. | |
| 149 | + | if ./deploy.sh; then | |
| 150 | + | report "$API/$ID/statuses" "$(jq -n --arg url "$ENVIRONMENT_URL" \ | |
| 151 | + | '{state: "success", environment_url: $url}')" > /dev/null | |
| 152 | + | else | |
| 153 | + | report "$API/$ID/statuses" '{"state": "failure", "description": "The deploy command failed"}' > /dev/null | |
| 154 | + | exit 1 | |
| 155 | + | fi | |
| 156 | + | ``` | |
| 157 | + | ||
| 158 | + | Give `log_url` in both calls to link the deployment to your CI's page for | |
| 159 | + | the job. | |
| 160 | + | ||
| 161 | + | ## Statuses | |
| 162 | + | ||
| 163 | + | | State | Means | The commit's check | | |
| 164 | + | | --- | --- | --- | | |
| 165 | + | | `queued` | It is waiting to start. | Pending | | |
| 166 | + | | `in_progress` | It is deploying. | Pending | | |
| 167 | + | | `success` | It is live. | Success | | |
| 168 | + | | `failure` | It did not go live. | Failure | | |
| 169 | + | | `error` | Something went wrong around it, such as a cancelled run. | Error | | |
| 170 | + | | `inactive` | It is no longer what the environment serves. | Left as it was | | |
| 171 | + | ||
| 172 | + | A g1t.page build's statuses are read from the build itself: `queued`, then | |
| 173 | + | `in_progress` while it builds, then how it ended, and `inactive` once a | |
| 174 | + | newer build replaced it or it was taken down. You cannot add statuses to a | |
| 175 | + | g1t.page build: `POST …/deployments/dpl_…/statuses` answers `409`. | |
| 176 | + | ||
| 177 | + | ### auto_inactive | |
| 178 | + | ||
| 179 | + | An environment serves one deployment at a time. When a deployment | |
| 180 | + | succeeds, the environment's older successful deployments each get an | |
| 181 | + | `inactive` status, so only the newest stays current. To keep them as they | |
| 182 | + | are, for example when several deployments are live side by side, send | |
| 183 | + | `"auto_inactive": false` with the `success`. | |
| 184 | + | ||
| 185 | + | ## Environments | |
| 186 | + | ||
| 187 | + | An environment exists once something deploys to it. You do not create | |
| 188 | + | environments first. | |
| 189 | + | ||
| 190 | + | - **Production environments** are those people use directly. An | |
| 191 | + | environment named `production` is one unless the deployment says | |
| 192 | + | `"production_environment": false`. Set it to `true` for others, such as | |
| 193 | + | `live`. | |
| 194 | + | - **Transient environments** go away, such as a review app for one pull | |
| 195 | + | request. Set `"transient_environment": true` on their deployments. | |
| 196 | + | ||
| 197 | + | `GET /repos/{owner}/{name}/environments` lists them: `production` first, | |
| 198 | + | then other production environments, then the rest, most recently deployed | |
| 199 | + | first. Each has: | |
| 200 | + | ||
| 201 | + | | Field | | | |
| 202 | + | | --- | --- | | |
| 203 | + | | `name` | The environment's name, as first spelled. | | |
| 204 | + | | `url` | Where it is served: `current`'s `environment_url`. | | |
| 205 | + | | `production_environment`, `transient_environment` | As its deployments said. | | |
| 206 | + | | `deployments_count` | How many deployments went to it. | | |
| 207 | + | | `latest` | Its newest deployment, whatever its state. | | |
| 208 | + | | `current` | Its newest successful deployment that is still active. Null when none is. | | |
| 209 | + | | `updated_at` | When it last changed. | | |
| 210 | + | ||
| 211 | + | `total_count` counts deployments across every environment. | |
| 212 | + | `GET /repos/{owner}/{name}/environments/{environment}` returns one, matched | |
| 213 | + | without regard to case. URL-encode a name with slashes: | |
| 214 | + | `…/environments/review%2Ffeature-x`. | |
| 215 | + | ||
| 216 | + | ## Read deployments | |
| 217 | + | ||
| 218 | + | | Route | What it returns | | |
| 219 | + | | --- | --- | | |
| 220 | + | | `GET /repos/{owner}/{name}/deployments` | Deployments, newest first, as `{ deployments, total_count, page, per_page }`. | | |
| 221 | + | | `GET /repos/{owner}/{name}/deployments/{id}` | One deployment, with every status in `statuses`, oldest first. | | |
| 222 | + | | `GET /repos/{owner}/{name}/deployments/{id}/statuses` | Its statuses, newest first. | | |
| 223 | + | | `GET /repos/{owner}/{name}/environments` | The environments, as above. | | |
| 224 | + | | `GET /repos/{owner}/{name}/environments/{environment}` | One environment. | | |
| 225 | + | ||
| 226 | + | The list takes these filters: | |
| 227 | + | ||
| 228 | + | | Query | | | |
| 229 | + | | --- | --- | | |
| 230 | + | | `environment` | Only this environment's, matched without regard to case. | | |
| 231 | + | | `ref` | Only deployments of this branch, tag or commit, as it was given. | | |
| 232 | + | | `sha` | Only deployments of this commit, or of commits starting with it. | | |
| 233 | + | | `task` | Only this task's. | | |
| 234 | + | | `state` | Only deployments whose latest status has this state. | | |
| 235 | + | | `source` | `api`, `actions` or `g1t_page`. | | |
| 236 | + | | `creator` | Only those this username made, or `g1t`. | | |
| 237 | + | | `page`, `per_page` | Which page, from 1, and how many on it: 30 unless you say, at most 100. | | |
| 238 | + | ||
| 239 | + | ```sh | |
| 240 | + | curl "https://api.g1t.sh/repos/acme/web/deployments?environment=production&state=success&per_page=5" \ | |
| 241 | + | -H "Authorization: Bearer $G1T_TOKEN" | |
| 242 | + | ``` | |
| 243 | + | ||
| 244 | + | A deployment has these fields: | |
| 245 | + | ||
| 246 | + | | Field | | | |
| 247 | + | | --- | --- | | |
| 248 | + | | `id` | `dep_…`, or `dpl_…` for a g1t.page build. | | |
| 249 | + | | `environment`, `ref`, `sha`, `task`, `description`, `payload` | As reported. | | |
| 250 | + | | `production_environment`, `transient_environment` | As reported. | | |
| 251 | + | | `state` | Its latest status's state. | | |
| 252 | + | | `environment_url`, `log_url` | The latest addresses it was given. | | |
| 253 | + | | `creator` | The username of whoever reported it or started its run, or `g1t`. | | |
| 254 | + | | `source` | `api`, `actions` or `g1t_page`. | | |
| 255 | + | | `run_id`, `run_url` | The g1t Actions run that made it. Null for other sources. | | |
| 256 | + | | `project`, `number` | For a g1t.page build: the project, and for a preview its pull request's number. Null for other sources. | | |
| 257 | + | | `created_at`, `updated_at` | When it was made, and when it last changed. | | |
| 258 | + | ||
| 259 | + | Reading needs the `deployments:read` scope and the Read role. A public | |
| 260 | + | repository's deployments can be read by anyone. Reporting needs | |
| 261 | + | `deployments:write` and the Write role, and an | |
| 262 | + | [archived](/guides/managing-repositories/) repository refuses it with | |
| 263 | + | `409`. Every route, with its example, is in the | |
| 264 | + | [API reference](/reference/api/deployments/list-deployments/). | |
| 265 | + | ||
| 266 | + | ## The commit's check | |
| 267 | + | ||
| 268 | + | Every status sets a check on the deployment's commit, named | |
| 269 | + | `deploy / <environment>`, such as `deploy / staging`. It links to the | |
| 270 | + | deployment's page, `https://g1t.sh/{owner}/{name}/deployments/{id}`. The | |
| 271 | + | [statuses table](#statuses) says which state the check takes. A pull | |
| 272 | + | request whose head was deployed shows the check with its other checks. | |
| 273 | + | ||
| 274 | + | g1t.page builds keep their own check, `g1t / deploy`. See | |
| 275 | + | [previews of branches](/guides/deployments/#previews-of-branches). | |
| 276 | + | ||
| 277 | + | ### Require a deployment before merging | |
| 278 | + | ||
| 279 | + | A ruleset's **Require deployments to succeed** rule | |
| 280 | + | (`required_deployments`) holds a pull request until its head has deployed | |
| 281 | + | successfully to each environment it names. A successful | |
| 282 | + | `deploy / <environment>` check meets it, so any environment you report to, | |
| 283 | + | from anywhere, can be required: | |
| 284 | + | ||
| 285 | + | ```sh | |
| 286 | + | curl -X POST https://api.g1t.sh/repos/acme/web/rulesets \ | |
| 287 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 288 | + | -H "Content-Type: application/json" \ | |
| 289 | + | -d '{ | |
| 290 | + | "ruleset_name": "Staging first", | |
| 291 | + | "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"] } }, | |
| 292 | + | "rules": [ | |
| 293 | + | { "type": "required_deployments", "parameters": { "environments": ["staging"] } } | |
| 294 | + | ] | |
| 295 | + | }' | |
| 296 | + | ``` | |
| 297 | + | ||
| 298 | + | For this to work, your CI deploys each pull request's head to `staging` | |
| 299 | + | and reports it. See [rules](/guides/rules/#pull-requests-and-checks). | |
| 300 | + | ||
| 301 | + | ## Deployments from g1t Actions | |
| 302 | + | ||
| 303 | + | A [g1t Actions](/guides/actions/) job with an `environment:` makes a | |
| 304 | + | deployment by itself. You do not call the API. | |
| 305 | + | ||
| 306 | + | ```yaml | |
| 307 | + | jobs: | |
| 308 | + | deploy: | |
| 309 | + | runs-on: ubuntu-latest | |
| 310 | + | environment: production | |
| 311 | + | steps: | |
| 312 | + | - uses: actions/checkout@v4 | |
| 313 | + | - run: ./deploy.sh | |
| 314 | + | ``` | |
| 315 | + | ||
| 316 | + | Give the environment's address with `url`. Its expressions are filled in | |
| 317 | + | from the `github`, `inputs` and `matrix` contexts, and it must be an | |
| 318 | + | `http` or `https` address: | |
| 319 | + | ||
| 320 | + | ```yaml | |
| 321 | + | jobs: | |
| 322 | + | deploy: | |
| 323 | + | runs-on: ubuntu-latest | |
| 324 | + | environment: | |
| 325 | + | name: staging | |
| 326 | + | url: https://staging.example.com/${{ github.sha }} | |
| 327 | + | steps: | |
| 328 | + | - uses: actions/checkout@v4 | |
| 329 | + | - run: ./deploy.sh staging | |
| 330 | + | ``` | |
| 331 | + | ||
| 332 | + | A run makes one deployment per environment, however many of its jobs name | |
| 333 | + | it. A matrix that deploys to three regions makes one `production` | |
| 334 | + | deployment, not three: | |
| 335 | + | ||
| 336 | + | ```yaml | |
| 337 | + | jobs: | |
| 338 | + | deploy: | |
| 339 | + | runs-on: ubuntu-latest | |
| 340 | + | strategy: | |
| 341 | + | matrix: | |
| 342 | + | region: [us-east, eu-west, ap-south] | |
| 343 | + | environment: production | |
| 344 | + | steps: | |
| 345 | + | - uses: actions/checkout@v4 | |
| 346 | + | - run: ./deploy.sh ${{ matrix.region }} | |
| 347 | + | ``` | |
| 348 | + | ||
| 349 | + | How the deployment goes: | |
| 350 | + | ||
| 351 | + | 1. It is made, `in_progress`, when the first job that names the | |
| 352 | + | environment starts. | |
| 353 | + | 2. If one of those jobs fails, it is marked `failure` at once, unless the | |
| 354 | + | job has `continue-on-error`. | |
| 355 | + | 3. When the run finishes, it settles: `failure` if any of those jobs | |
| 356 | + | failed, `error` if the run was cancelled, and `success` otherwise. Jobs | |
| 357 | + | that were skipped do not count. If none of them ran, no deployment is | |
| 358 | + | made. | |
| 359 | + | ||
| 360 | + | Its `ref` is the run's branch, `sha` the run's commit, `creator` whoever | |
| 361 | + | started the run, and `log_url` and `run_url` the run's page. A run | |
| 362 | + | attempted again makes a deployment of its own. Jobs on | |
| 363 | + | [self-hosted runners](/guides/self-hosted-runners/) make deployments the | |
| 364 | + | same way. | |
| 365 | + | ||
| 366 | + | A job that needs an environment's | |
| 367 | + | [secrets and variables](/guides/secrets-and-variables/#a-value-per-environment) | |
| 368 | + | but does not deploy, such as one that plans a change, says | |
| 369 | + | `deployment: false`: | |
| 370 | + | ||
| 371 | + | ```yaml | |
| 372 | + | jobs: | |
| 373 | + | plan: | |
| 374 | + | runs-on: ubuntu-latest | |
| 375 | + | environment: | |
| 376 | + | name: production | |
| 377 | + | deployment: false | |
| 378 | + | steps: | |
| 379 | + | - uses: actions/checkout@v4 | |
| 380 | + | - run: ./plan.sh | |
| 381 | + | ``` | |
| 382 | + | ||
| 383 | + | A job's `G1T_TOKEN` has full access, so a step can also report deployments | |
| 384 | + | of its own with the API. | |
| 385 | + | ||
| 386 | + | ## Webhooks | |
| 387 | + | ||
| 388 | + | [Webhooks](/guides/webhooks/) send two events for deployments of every | |
| 389 | + | source, g1t.page builds included: | |
| 390 | + | ||
| 391 | + | | Event | When | `data` | | |
| 392 | + | | --- | --- | --- | | |
| 393 | + | | `deployment.created` | A deployment was made. | `repo_id`, `deployment` (without `payload`) | | |
| 394 | + | | `deployment_status.created` | A deployment got a status. | `repo_id`, `deployment` (without `payload`), `deployment_status` | | |
| 395 | + | ||
| 396 | + | `deployment.succeeded` and `deployment.failed` are sent for g1t.page builds | |
| 397 | + | only. | |
| 398 | + | ||
| 399 | + | ## MCP | |
| 400 | + | ||
| 401 | + | The [`workflow` tool](/reference/mcp/#workflow) has an action for each route: | |
| 402 | + | ||
| 403 | + | | Action | Route | | |
| 404 | + | | --- | --- | | |
| 405 | + | | `list_deployments` | `GET /repos/{owner}/{name}/deployments` | | |
| 406 | + | | `get_deployment` | `GET /repos/{owner}/{name}/deployments/{id}` | | |
| 407 | + | | `create_deployment` | `POST /repos/{owner}/{name}/deployments` | | |
| 408 | + | | `deployment_statuses` | `GET /repos/{owner}/{name}/deployments/{id}/statuses` | | |
| 409 | + | | `create_deployment_status` | `POST /repos/{owner}/{name}/deployments/{id}/statuses` | | |
| 410 | + | | `list_environments` | `GET /repos/{owner}/{name}/environments` | | |
| 411 | + | | `get_environment` | `GET /repos/{owner}/{name}/environments/{environment}` | | |
| 412 | + | ||
| 413 | + | They take the repository as `repo`, written `owner/name`, and the same | |
| 414 | + | fields as the routes. | |
| 415 | + | ||
| 416 | + | ## On the site | |
| 417 | + | ||
| 418 | + | - **The Deployments page**, `g1t.sh/<owner>/<project>/deployments`, shows | |
| 419 | + | a card for each environment: its state, address, commit, ref, who | |
| 420 | + | deployed it and from where, when, and how many deployments it has had. | |
| 421 | + | Below is every deployment, newest first, filtered by **Environment**, | |
| 422 | + | **State**, **Source**, **Creator** and **Ref**. Projects deployed to | |
| 423 | + | g1t.page manage their apps under **On g1t.page** on the same page. | |
| 424 | + | - **A deployment's page**, `g1t.sh/<owner>/<project>/deployments/<id>`, | |
| 425 | + | shows its statuses in order, links to its log and run, and its payload. | |
| 426 | + | - **The repository's code page** and **the project's overview** show a | |
| 427 | + | **Deployments** panel with how many there are, and each environment's | |
| 428 | + | latest deployment and when. It links to the Deployments page. | |
| 429 | + | - **The project's overview** shows a production environment deployed | |
| 430 | + | elsewhere in its production card: its address, state, commit and when it | |
| 431 | + | went up. |
| 41 | 41 | is built again from the new default branch. An | |
| 42 | 42 | [archived](/guides/managing-repositories/) repository's apps keep serving. | |
| 43 | 43 | ||
| 44 | + | Every build is also a deployment in the repository's one list of | |
| 45 | + | deployments, beside those reported from any CI and those g1t Actions | |
| 46 | + | jobs make, with the source `g1t_page` and the environment `production` or | |
| 47 | + | `preview`. See [Deployments API](/guides/deployments-api/) to read them, | |
| 48 | + | report your own, and hear of them by webhook. | |
| 49 | + | ||
| 44 | 50 | An app runs only while it answers a request. One nobody visits runs | |
| 45 | 51 | nothing and costs nothing, and the next visit wakes it in milliseconds. | |
| 46 | 52 | ||
| 47 | 53 | Deployments are part of the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), | |
| 48 | − | and are opt-in per project. A project with deployments off says | |
| 49 | − | **Deployments are off** on its overview, with **Turn on deployments** for | |
| 54 | + | and are opt-in per project. A project set to deploy on g1t, with | |
| 55 | + | deployments off, says **Deployments are off** on its overview, with **Turn on deployments** for | |
| 50 | 56 | people with the Admin [role](/guides/access-and-roles/) on its repository. Nothing builds or runs until you turn them on, and one click turns | |
| 51 | 57 | them off again. | |
| 52 | 58 | ||
| ⋯ | |||
| 66 | 72 | Production starts building at once from the default branch. Every pull | |
| 67 | 73 | request opened or pushed to from then on gets a preview. | |
| 68 | 74 | ||
| 69 | − | A project g1t takes for a library or a tool, such as a Composer package, | |
| 70 | − | does not offer to deploy on its overview; it shows its packages instead. | |
| 71 | − | Its **Deployments** page still turns them on, and doing so makes it an | |
| 72 | − | app. A project set to **Doesn't deploy** cannot have deployments turned | |
| 73 | − | on until the setting changes. See | |
| 74 | − | [apps and libraries](/guides/projects/#apps-and-libraries). | |
| 75 | + | Deployments are for projects g1t runs. Not every project is one: a | |
| 76 | + | library, a tool or documentation is published rather than deployed, and | |
| 77 | + | an app may be deployed by its own pipeline somewhere else. Only a project | |
| 78 | + | that is [an app or site deployed on g1t](/guides/projects/#what-a-project-is) | |
| 79 | + | is asked to turn deployments on. Any other project's **Deployments** page | |
| 80 | + | still turns them on; doing so makes it an app deployed on g1t, including | |
| 81 | + | one that was deployed elsewhere. A project set to be a library, a tool or | |
| 82 | + | something else cannot have deployments turned on until that setting | |
| 83 | + | changes. | |
| 75 | 84 | ||
| 76 | 85 | While payments on g1t are in test mode, no real card is charged: use the | |
| 77 | 86 | test card `4242 4242 4242 4242` with any future date and any code. | |
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.
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.