Skip to content

Commit

Merge main: Deployments panel in the About, project homepage, both sides' operations

syntaqxcommitted Parentscacc9019bf501aBrowse files
98 files+1856−200/98 viewed
+77−0
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
+24−5
88 # core, edge, front the units of each stage, in jobs that share a build;
99 # a stage starts only when the one before it succeeded
1010 #
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+#
1119 # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the
1220 # variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's
1321 # workflow-only domains for deploy.yml in production (Settings, Guardrails),
6169 name: Plan
6270 needs: check
6371 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
6576 timeout-minutes: 15
6677 outputs:
6778 migrate: ${{ steps.plan.outputs.migrate }}
96107 needs: plan
97108 if: ${{ needs.plan.outputs.migrate == 'true' && inputs.dry_run != true }}
98109 runs-on: ubuntu-latest
99− environment: production
110+ environment:
111+ name: production
112+ url: https://g1t.sh
100113 timeout-minutes: 20
101114 steps:
102115 - uses: actions/checkout@v5
115128 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
116129 # Rust builds get 4 vCPUs; everything else the standard machine.
117130 runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
118− environment: production
131+ environment:
132+ name: production
133+ url: https://g1t.sh
119134 timeout-minutes: 60
120135 strategy:
121136 # A deploy cut off halfway is worse than one that finishes: the other
182197 needs: [plan, migrate, core]
183198 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
184199 runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
185− environment: production
200+ environment:
201+ name: production
202+ url: https://g1t.sh
186203 timeout-minutes: 60
187204 strategy:
188205 fail-fast: false
195212 needs: [plan, migrate, core, edge]
196213 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
197214 runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
198− environment: production
215+ environment:
216+ name: production
217+ url: https://g1t.sh
199218 timeout-minutes: 60
200219 strategy:
201220 fail-fast: false
+297−0
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+}
+2−0
1010 mod audit;
1111 mod billing;
1212 mod blobs;
13+mod deployments;
1314 mod mcp;
1415 mod notifications;
1516 mod oauth;
1617 mod openapi;
1718 mod pins;
19+mod projects;
1820 mod operations;
1921 mod renamed;
2022 #[cfg(test)]
+23−0
88 use serde_json::{Map, Value, json};
99
1010 use crate::about::AboutOp;
11+use crate::deployments::DeploymentsOp;
1112 use crate::operations::Op;
1213 use crate::rules::RulesOp;
1314 use crate::security::SecurityOp;
4748 &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects],
4849 ),
4950 (
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+ (
5056 "Workspaces",
5157 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
5258 &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
344350 ],
345351 ),
346352 (
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+ (
347366 "Secrets and variables",
348367 "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.",
349368 &[
568587 Op::PinProject => "Pin a project",
569588 Op::UnpinProject => "Unpin a project",
570589 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",
571593 Op::ListTeams => "List teams",
572594 Op::GetTeam => "Get a team",
573595 Op::CreateTeam => "Create a team",
588610 Op::Security(op) => op.title(),
589611 Op::Rules(op) => op.title(),
590612 Op::About(op) => op.title(),
613+ Op::Deployments(op) => op.title(),
591614 }
592615 }
593616
+97−1
2727
2828 use crate::alerts::{AlertKind, SecurityAlert};
2929 use crate::about::AboutOp;
30+use crate::deployments::DeploymentsOp;
3031 use crate::rules::RulesOp;
3132 use crate::security::SecurityOp;
3233 use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel};
5657 pub security: Fetcher,
5758 /// Projects: a person's pinned ones.
5859 pub projects: Fetcher,
60+ /// Deployments wherever they run, and environments.
61+ pub deployments: Fetcher,
5962 /// Where the request came in, for its audit entries.
6063 pub audit: crate::audit::AuditContext,
6164 /// Set for a request made with an agent's token: all it may do.
8083 search: env.service("SEARCH")?,
8184 security: env.service("SECURITY")?,
8285 projects: env.service("PROJECTS")?,
86+ deployments: env.service("DEPLOYMENTS")?,
8387 scope: None,
8488 audit: crate::audit::AuditContext::default(),
8589 addresses: crate::addresses::Addresses::from_env(env),
239243 PinProject,
240244 UnpinProject,
241245 ReorderPinnedProjects,
246+ ListProjects,
247+ GetProject,
248+ UpdateProject,
242249 ListTeams,
243250 GetTeam,
244251 CreateTeam,
270277 Rules(RulesOp),
271278 /// A repository's languages, contributors, license, stars and releases: about.rs.
272279 About(AboutOp),
280+ /// Deployments wherever they run, and environments: deployments.rs.
281+ Deployments(DeploymentsOp),
273282 }
274283
275284 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
632641 }
633642
634643 impl Op {
635− pub const ALL: [Op; 234] = [
644+ pub const ALL: [Op; 244] = [
636645 Op::Whoami,
637646 Op::GetWorkspace,
638647 Op::CreateWorkspace,
783792 Op::PinProject,
784793 Op::UnpinProject,
785794 Op::ReorderPinnedProjects,
795+ Op::ListProjects,
796+ Op::GetProject,
797+ Op::UpdateProject,
786798 Op::ListTeams,
787799 Op::GetTeam,
788800 Op::CreateTeam,
867879 Op::About(AboutOp::CreateRelease),
868880 Op::About(AboutOp::UpdateRelease),
869881 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),
870889 ];
871890
872891 pub fn by_name(name: &str) -> Option<Op> {
10261045 Op::PinProject => "pin_project",
10271046 Op::UnpinProject => "unpin_project",
10281047 Op::ReorderPinnedProjects => "reorder_pinned_projects",
1048+ Op::ListProjects => "list_projects",
1049+ Op::GetProject => "get_project",
1050+ Op::UpdateProject => "update_project",
10291051 Op::ListTeams => "list_teams",
10301052 Op::GetTeam => "get_team",
10311053 Op::CreateTeam => "create_team",
10541076 Op::Security(op) => op.name(),
10551077 Op::Rules(op) => op.name(),
10561078 Op::About(op) => op.name(),
1079+ Op::Deployments(op) => op.name(),
10571080 }
10581081 }
10591082
14751498 Op::ReorderPinnedProjects => {
14761499 "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."
14771500 }
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+ }
14781510 Op::ListTeams => {
14791511 "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."
14801512 }
15531585 Op::Security(op) => op.description(),
15541586 Op::Rules(op) => op.description(),
15551587 Op::About(op) => op.description(),
1588+ Op::Deployments(op) => op.description(),
15561589 }
15571590 }
15581591
26972730 }),
26982731 &["workspace", "projects"],
26992732 ),
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+ ),
27002777 Op::ListTeams => object(
27012778 json!({
27022779 "workspace": workspace_schema(),
28642941 Op::Security(op) => op.input(),
28652942 Op::Rules(op) => op.input(),
28662943 Op::About(op) => op.input(),
2944+ Op::Deployments(op) => op.input(),
28672945 }
28682946 }
28692947
28922970 | Op::ListCheckNames
28932971 | Op::GetMergeQueue
28942972 | Op::GetCodeownersErrors
2973+ | Op::ListProjects
2974+ | Op::GetProject
28952975 | 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+ )
28962983 )
28972984 }
28982985
29833070 | Op::PinProject
29843071 | Op::UnpinProject
29853072 | Op::ReorderPinnedProjects
3073+ | Op::ListProjects
3074+ | Op::GetProject
3075+ | Op::UpdateProject
29863076 | Op::ListTeams
29873077 | Op::GetTeam
29883078 | Op::CreateTeam
48744964 Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => {
48754965 crate::pins::run(self, services, viewer, input).await
48764966 }
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+ }
48774972 // The security suite: the security service decides, this gives
48784973 // each answer its public shape.
48794974 Op::Security(op) => crate::security::run(op, services, viewer, input).await,
48804975 Op::Rules(op) => crate::rules::run(op, services, viewer, input).await,
48814976 Op::About(op) => crate::about::run(op, services, viewer, input).await,
4977+ Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
48824978 Op::ReopenSecurityAlert => {
48834979 let changed: Outcome<AlertChange> = call(
48844980 &services.security,
+262−0
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+}
+577−0
63176317 ],
63186318 "notes": "Name every pinned project once; anything else is refused with `422 invalid`."
63196319 },
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+ },
63206541 "list_secret_scanning_alerts": {
63216542 "params": {
63226543 "owner": "flagon-io",
92799500 }
92809501 }
92819502 },
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+ },
92829859 "get_languages": {
92839860 "response": {
92849861 "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
+2−0
109109 }
110110 // Built by the API itself.
111111 Op::Rules(RulesOp::DeleteRepoRuleset | RulesOp::DeleteWorkspaceRuleset) => return as_is,
112+ // Deployments travel in `snake_case` between services too.
113+ Op::Deployments(_) => return as_is,
112114 // Built by the API itself, in `snake_case`.
113115 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
114116 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
+22−0
33 use serde_json::{Map, Value};
44
55 use crate::about::AboutOp;
6+use crate::deployments::DeploymentsOp;
67 use crate::operations::Op;
78 use crate::rules::RulesOp;
89 use crate::security::SecurityOp;
109110 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
110111 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
111112 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, &[]),
112117 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
113118 route(
114119 "GET",
281286 Op::Rules(RulesOp::ListWorkspaceRuleEvaluations),
282287 &[("ruleset_id", "ruleset_id"), ("verdict", "verdict"), ("problems_only", "problems_only"), ("before", "before"), ("limit", "limit")],
283288 ),
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), &[]),
284302 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
285303 route(
286304 "POST",
880898 if let Some(branch) = param("branch") {
881899 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
882900 }
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+ }
883905 if let Some(label) = param("label") {
884906 input.insert("label".to_owned(), Value::String(percent_decoded(label)));
885907 }
+13−2
1919 use serde_json::{Map, Value, json};
2020
2121 use crate::about::AboutOp;
22+use crate::deployments::DeploymentsOp;
2223 use crate::operations::Op;
2324 use crate::rules::RulesOp;
2425 use crate::security::SecurityOp;
198199 Tool {
199200 name: "workflow",
200201 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.",
202203 default_action: None,
203204 actions: &[
204205 a("list", Op::ListWorkflows, "Workflows on the default branch"),
209210 a("cancel", Op::CancelWorkflowRun, "Cancel a run"),
210211 a("rerun", Op::RerunWorkflowRun, "Run a finished run again"),
211212 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"),
212220 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
213221 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
214222 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
291299 Tool {
292300 name: "workspace",
293301 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.",
295303 default_action: None,
296304 actions: &[
297305 a("get", Op::GetWorkspace, "A workspace's details and settings"),
307315 a("test_integration", Op::TestIntegration, "Check its credentials"),
308316 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
309317 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"),
310321 a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"),
311322 a("pin_project", Op::PinProject, "Pin a project, at a position or the end"),
312323 a("unpin_project", Op::UnpinProject, "Unpin a project"),
+1−0
7474 items: [
7575 { label: 'Projects', slug: 'guides/projects' },
7676 { label: 'Deployments', slug: 'guides/deployments' },
77+ { label: 'Deployments API', slug: 'guides/deployments-api' },
7778 { label: 'Packages', slug: 'guides/packages' },
7879 { label: 'Container images', slug: 'guides/containers' },
7980 { label: 'npm', slug: 'guides/npm' },
+7−3
4242 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. |
4343 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::` | The same: errors and warnings become annotations on the run. |
4444 | `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. |
4646 | `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. |
4747 | `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). |
4848
6666 - **Environments' protection rules** (required reviewers, wait timers,
6767 branch limits). A job with `environment:` gets that environment's
6868 [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`.
7072
7173 Why each of these is missing, and what to use instead, is on
7274 [What g1t can't do yet](/about/limitations/#actions-and-runners).
264266 Secrets are read as `${{ secrets.KEY }}` and config as `${{ vars.KEY }}`,
265267 from the rows under **Settings → Secrets and variables** that are
266268 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
268272 [Secrets and variables](/guides/secrets-and-variables/) for how rows,
269273 environments and the workspace's rows work.
270274
+4−1
321321 | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` |
322322 | Agents | `agents:run` |
323323 | Workflows | `workflows:read`, `workflows:write` |
324+| Deployments | `deployments:read`, `deployments:write` |
324325 | Memory & search | `memory:read`, `memory:write` |
325326 | Account | `account:read`, `account:write` |
326327 | Notifications | `notifications:read`, `notifications:write` |
354355 | `agents:run` | Put g1t to work and message it, which uses the workspace's money |
355356 | `workflows:read` | Read workflows, runs and logs |
356357 | `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 |
357360 | `memory:read` | Recall memory and search the workspace's context |
358361 | `memory:write` | Save memory for the next agent |
359362 | `account:read` | Read your email addresses, invites, invitations, pinned projects and stars |
411414 | --- | --- |
412415 | Read only | Every `read` scope. Changes nothing. |
413416 | 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. |
415418 | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. |
416419
417420 Admin scopes change things that are hard to undo, or decide who can reach
+431−0
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.
+17−8
4141 is built again from the new default branch. An
4242 [archived](/guides/managing-repositories/) repository's apps keep serving.
4343
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+
4450 An app runs only while it answers a request. One nobody visits runs
4551 nothing and costs nothing, and the next visit wakes it in milliseconds.
4652
4753 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
5056 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
5157 them off again.
5258
6672 Production starts building at once from the default branch. Every pull
6773 request opened or pushed to from then on gets a preview.
6874
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.
7584
7685 While payments on g1t are in test mode, no real card is charged: use the
7786 test card `4242 4242 4242 4242` with any future date and any code.
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.