Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

2 commits

139 files+2608−170/139 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
+356−0
1+//! A repository's About over REST and MCP: its languages, contributors and
2+//! license (read from the default branch in the background and kept by
3+//! commit), stars, and releases. The repos service decides who may see
4+//! and change each (`g1t_contracts::about`).
5+
6+use g1t_contracts::about::*;
7+use g1t_contracts::repos::RepoPath;
8+use g1t_contracts::{FailureCode, Outcome, Viewer};
9+use serde::Serialize;
10+use serde::de::DeserializeOwned;
11+use serde_json::{Value, json};
12+use worker::Result;
13+
14+use crate::operations::Services;
15+
16+/// One operation on a repository's About, stars or releases.
17+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18+pub enum AboutOp {
19+ GetLanguages,
20+ ListContributors,
21+ GetLicense,
22+ ListStargazers,
23+ ListStarred,
24+ CheckStarred,
25+ Star,
26+ Unstar,
27+ ListReleases,
28+ GetLatestRelease,
29+ GetReleaseByTag,
30+ GetRelease,
31+ CreateRelease,
32+ UpdateRelease,
33+ DeleteRelease,
34+}
35+
36+impl AboutOp {
37+ /// Every one: `Op::ALL` lists each as `Op::About(…)`, which a test checks.
38+ #[cfg(test)]
39+ pub const ALL: [AboutOp; 15] = [
40+ AboutOp::GetLanguages,
41+ AboutOp::ListContributors,
42+ AboutOp::GetLicense,
43+ AboutOp::ListStargazers,
44+ AboutOp::ListStarred,
45+ AboutOp::CheckStarred,
46+ AboutOp::Star,
47+ AboutOp::Unstar,
48+ AboutOp::ListReleases,
49+ AboutOp::GetLatestRelease,
50+ AboutOp::GetReleaseByTag,
51+ AboutOp::GetRelease,
52+ AboutOp::CreateRelease,
53+ AboutOp::UpdateRelease,
54+ AboutOp::DeleteRelease,
55+ ];
56+
57+ pub fn name(self) -> &'static str {
58+ match self {
59+ AboutOp::GetLanguages => "get_languages",
60+ AboutOp::ListContributors => "list_contributors",
61+ AboutOp::GetLicense => "get_license",
62+ AboutOp::ListStargazers => "list_stargazers",
63+ AboutOp::ListStarred => "list_starred",
64+ AboutOp::CheckStarred => "check_starred",
65+ AboutOp::Star => "star_repo",
66+ AboutOp::Unstar => "unstar_repo",
67+ AboutOp::ListReleases => "list_releases",
68+ AboutOp::GetLatestRelease => "get_latest_release",
69+ AboutOp::GetReleaseByTag => "get_release_by_tag",
70+ AboutOp::GetRelease => "get_release",
71+ AboutOp::CreateRelease => "create_release",
72+ AboutOp::UpdateRelease => "update_release",
73+ AboutOp::DeleteRelease => "delete_release",
74+ }
75+ }
76+
77+ /// For the API reference: "List a repository's languages".
78+ pub fn title(self) -> &'static str {
79+ match self {
80+ AboutOp::GetLanguages => "Get a repository's languages",
81+ AboutOp::ListContributors => "List a repository's contributors",
82+ AboutOp::GetLicense => "Get a repository's license",
83+ AboutOp::ListStargazers => "List who starred a repository",
84+ AboutOp::ListStarred => "List repositories you starred",
85+ AboutOp::CheckStarred => "Check whether you starred a repository",
86+ AboutOp::Star => "Star a repository",
87+ AboutOp::Unstar => "Unstar a repository",
88+ AboutOp::ListReleases => "List releases",
89+ AboutOp::GetLatestRelease => "Get the latest release",
90+ AboutOp::GetReleaseByTag => "Get a release by its tag",
91+ AboutOp::GetRelease => "Get a release",
92+ AboutOp::CreateRelease => "Create a release",
93+ AboutOp::UpdateRelease => "Update a release",
94+ AboutOp::DeleteRelease => "Delete a release",
95+ }
96+ }
97+
98+ pub fn description(self) -> &'static str {
99+ match self {
100+ AboutOp::GetLanguages => "The languages a repository's default branch is written in, by bytes, largest first: each with its name, color, bytes and percent. Programming and markup languages count; data (JSON, YAML) and prose (Markdown) do not, nor do vendored, generated and documentation files, unless the repository's .gitattributes says otherwise (linguist-vendored, linguist-generated, linguist-documentation, linguist-language, linguist-detectable). Worked out in the background for the default branch's head and kept by commit: commit says which commit the answer is for, and pending is true while the first is worked out (ask again in a few seconds). partial is true when the repository was too large to read in full.",
101+ AboutOp::ListContributors => "Everyone whose commits are on a repository's default branch, most commits first: each with kind (user, matched to an account by an address they confirmed or their noreply address; g1t, g1t itself; author, anyone else, by the name on their commits), name, username and avatar for a user, commits, first_at, last_at, and weeks (commits per week, for the 100 most active). total is how many there are (at most 500 are listed), commits how many were read (the newest 3,000), and weeks the repository's commits by week, oldest first. Worked out in the background and kept by commit, as for get_languages.",
102+ AboutOp::GetLicense => "The license in a repository's LICENSE file (or LICENCE, COPYING, UNLICENSE, with or without an extension) on its default branch: its spdx_id (null when the text is not one g1t recognizes), name (\"Other\" then) and path. Not found when it has none, or before the default branch has been read the first time.",
103+ AboutOp::ListStargazers => "Who starred a repository, newest first: each username, avatar and starred_at. 100 a page; page from 1.",
104+ AboutOp::ListStarred => "The repositories you starred that you can still see, newest first, at most 100: each repository, when you starred it (starred_at) and how many stars it has.",
105+ AboutOp::CheckStarred => "Whether you starred a repository (starred), and how many people have (stars).",
106+ AboutOp::Star => "Star a repository you can see. Starring one you starred already changes nothing. Returns starred and the count of stars. People only: a workspace's or an agent's token cannot.",
107+ AboutOp::Unstar => "Take back your star from a repository. Returns starred (false) and the count of stars.",
108+ AboutOp::ListReleases => "A repository's releases, newest first, at most 100: each with its id (rel_…), tag_name, target (the commit the tag named), name, body (Markdown notes), draft, prerelease, author, created_at, published_at and latest (the newest published release that is neither a draft nor a prerelease). Drafts are listed only to those with the Write role.",
109+ AboutOp::GetLatestRelease => "The latest release: the newest published release that is neither a draft nor a prerelease. Not found when there is none.",
110+ AboutOp::GetReleaseByTag => "The release of one tag. A tag with slashes is URL-encoded in the path.",
111+ AboutOp::GetRelease => "One release by its id (rel_…), with its tag, target commit, title, notes and whether it is a draft, a prerelease or the latest. A draft is found only by those with the Write role.",
112+ AboutOp::CreateRelease => "Publish a release of a tag, with a title (release_name; name in the answer) and notes (body, Markdown). A tag that does not exist yet is made at target (a branch or commit; the default branch when left out), as a lightweight tag, under the repository's tag rulesets. draft keeps it from everyone without the Write role until it is published; prerelease marks it not ready for everyone, so it is never the latest. One release per tag. Needs the Write role.",
113+ AboutOp::UpdateRelease => "Change a release's name, body, draft or prerelease; fields left out stay as they are, and an empty release_name clears it. Setting draft to false publishes it (published_at is set the first time). Needs the Write role.",
114+ AboutOp::DeleteRelease => "Delete a release. Its tag stays: delete that with git (git push origin :refs/tags/<tag>). Needs the Write role.",
115+ }
116+ }
117+
118+ /// Whether the operation is about one repository named by `repo`.
119+ pub fn needs_repo(self) -> bool {
120+ self != AboutOp::ListStarred
121+ }
122+
123+ /// Whether an anonymous caller may use it, on a public repository.
124+ pub fn anonymous(self) -> bool {
125+ matches!(
126+ self,
127+ AboutOp::GetLanguages
128+ | AboutOp::ListContributors
129+ | AboutOp::GetLicense
130+ | AboutOp::ListStargazers
131+ | AboutOp::ListReleases
132+ | AboutOp::GetLatestRelease
133+ | AboutOp::GetReleaseByTag
134+ | AboutOp::GetRelease
135+ )
136+ }
137+
138+ /// Whether it is about the caller's own stars: nobody else's business,
139+ /// so not audited.
140+ pub fn personal(self) -> bool {
141+ matches!(self, AboutOp::ListStarred | AboutOp::CheckStarred | AboutOp::Star | AboutOp::Unstar)
142+ }
143+
144+ pub fn input(self) -> Value {
145+ let repo = || json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." });
146+ let id = || json!({ "type": "string", "description": "The release's id: rel_…" });
147+ let release_fields = |mut properties: Value| {
148+ properties["release_name"] = json!({ "type": "string", "description": "Its title (name in the answer), at most 200 characters; under a repository's address `name` is the repository's. The tag's name is shown when it has none." });
149+ properties["body"] = json!({ "type": "string", "description": "Its notes, Markdown, at most 125,000 characters." });
150+ properties["draft"] = json!({ "type": "boolean", "description": "Seen only by those with the Write role until published." });
151+ properties["prerelease"] = json!({ "type": "boolean", "description": "Not ready for everyone: never the latest release." });
152+ properties
153+ };
154+ let (properties, required): (Value, &[&str]) = match self {
155+ AboutOp::GetLanguages
156+ | AboutOp::ListContributors
157+ | AboutOp::GetLicense
158+ | AboutOp::CheckStarred
159+ | AboutOp::Star
160+ | AboutOp::Unstar
161+ | AboutOp::ListReleases
162+ | AboutOp::GetLatestRelease => (json!({ "repo": repo() }), &["repo"]),
163+ AboutOp::ListStargazers => (
164+ json!({ "repo": repo(), "page": { "type": "integer", "description": "The page, from 1; 100 a page." } }),
165+ &["repo"],
166+ ),
167+ AboutOp::ListStarred => (json!({}), &[]),
168+ AboutOp::GetReleaseByTag => (
169+ json!({ "repo": repo(), "tag": { "type": "string", "description": "The tag's name, such as v1.2.0." } }),
170+ &["repo", "tag"],
171+ ),
172+ AboutOp::GetRelease | AboutOp::DeleteRelease => (json!({ "repo": repo(), "id": id() }), &["repo", "id"]),
173+ AboutOp::CreateRelease => (
174+ release_fields(json!({
175+ "repo": repo(),
176+ "tag_name": { "type": "string", "description": "The tag to release, such as v1.2.0. Made at target when it does not exist yet." },
177+ "target": { "type": "string", "description": "A branch or commit to make a new tag at. The default branch when left out; ignored for a tag that exists." },
178+ })),
179+ &["repo", "tag_name"],
180+ ),
181+ AboutOp::UpdateRelease => (release_fields(json!({ "repo": repo(), "id": id() })), &["repo", "id"]),
182+ };
183+ let mut schema = json!({ "type": "object", "properties": properties });
184+ if !required.is_empty() {
185+ schema["required"] = json!(required);
186+ }
187+ schema
188+ }
189+}
190+
191+fn text(input: &Value, key: &str) -> Option<String> {
192+ input[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned)
193+}
194+
195+/// A string that may be given empty, to clear it.
196+fn given(input: &Value, key: &str) -> Option<String> {
197+ input[key].as_str().map(str::to_owned)
198+}
199+
200+fn flag(input: &Value, key: &str) -> Option<bool> {
201+ match &input[key] {
202+ Value::Bool(value) => Some(*value),
203+ Value::String(text) => match text.trim() {
204+ "true" | "1" => Some(true),
205+ "false" | "0" => Some(false),
206+ _ => None,
207+ },
208+ _ => None,
209+ }
210+}
211+
212+fn number(input: &Value, key: &str) -> Option<u32> {
213+ match &input[key] {
214+ Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
215+ Value::String(digits) => digits.trim().parse().ok(),
216+ _ => None,
217+ }
218+}
219+
220+async fn call<A: Serialize, T: DeserializeOwned>(services: &Services, method: &str, args: &A) -> Result<Outcome<T>> {
221+ g1t_kit::call(&services.repos, method, args).await
222+}
223+
224+fn ok<T: Serialize>(value: &T) -> Result<Outcome<Value>> {
225+ Ok(Outcome::Ok(serde_json::to_value(value)?))
226+}
227+
228+fn out<T: Serialize>(outcome: Outcome<T>) -> Result<Outcome<Value>> {
229+ match outcome {
230+ Outcome::Ok(value) => ok(&value),
231+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
232+ }
233+}
234+
235+pub async fn run(op: AboutOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
236+ let actor = || viewer.clone().unwrap_or_default();
237+ if op == AboutOp::ListStarred {
238+ let username = actor().username;
239+ let starred: Vec<StarredRepo> = g1t_kit::call(&services.repos, "starred", &StarredArgs { username, viewer: viewer.clone() }).await?;
240+ return ok(&starred);
241+ }
242+ let Some(path) = crate::operations::repo_path(input) else {
243+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
244+ };
245+ let view = |path: RepoPath| RepoViewArgs { path, viewer: viewer.clone() };
246+ let release = |path: RepoPath, id: Option<String>, tag: Option<String>, latest: bool| ReleaseArgs { path, viewer: viewer.clone(), id, tag, latest };
247+ match op {
248+ AboutOp::GetLanguages => out(call::<_, Languages>(services, "languages", &view(path)).await?),
249+ AboutOp::ListContributors => out(call::<_, Contributors>(services, "contributors", &view(path)).await?),
250+ AboutOp::GetLicense => match call::<_, Option<License>>(services, "license", &view(path)).await? {
251+ Outcome::Ok(Some(license)) => ok(&license),
252+ Outcome::Ok(None) => Ok(Outcome::fail(
253+ FailureCode::NotFound,
254+ "No license file was found on the default branch. A repository just pushed to is read in the background: try again in a moment.",
255+ )),
256+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
257+ },
258+ AboutOp::ListStargazers => {
259+ out(call::<_, Vec<Stargazer>>(services, "stargazers", &StargazersArgs { path, viewer: viewer.clone(), page: number(input, "page") }).await?)
260+ }
261+ AboutOp::CheckStarred => out(call::<_, Stars>(services, "stars", &view(path)).await?),
262+ AboutOp::Star | AboutOp::Unstar => {
263+ out(call::<_, Stars>(services, "star", &StarArgs { path, actor: actor(), starred: op == AboutOp::Star }).await?)
264+ }
265+ AboutOp::ListReleases => out(call::<_, Vec<Release>>(services, "releases", &view(path)).await?),
266+ AboutOp::GetLatestRelease => out(call::<_, Release>(services, "release", &release(path, None, None, true)).await?),
267+ AboutOp::GetReleaseByTag => {
268+ let Some(tag) = text(input, "tag") else {
269+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the tag."));
270+ };
271+ out(call::<_, Release>(services, "release", &release(path, None, Some(tag), false)).await?)
272+ }
273+ AboutOp::GetRelease => {
274+ let Some(id) = text(input, "id") else {
275+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id."));
276+ };
277+ out(call::<_, Release>(services, "release", &release(path, Some(id), None, false)).await?)
278+ }
279+ AboutOp::CreateRelease => {
280+ let Some(tag_name) = text(input, "tag_name") else {
281+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the tag to release: tag_name."));
282+ };
283+ let args = CreateReleaseArgs {
284+ path,
285+ actor: actor(),
286+ tag_name,
287+ target: text(input, "target"),
288+ name: text(input, "release_name"),
289+ body: given(input, "body"),
290+ draft: flag(input, "draft").unwrap_or(false),
291+ prerelease: flag(input, "prerelease").unwrap_or(false),
292+ };
293+ out(call::<_, Release>(services, "create_release", &args).await?)
294+ }
295+ AboutOp::UpdateRelease => {
296+ let Some(id) = text(input, "id") else {
297+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id."));
298+ };
299+ let args = UpdateReleaseArgs {
300+ path,
301+ actor: actor(),
302+ id,
303+ name: given(input, "release_name"),
304+ body: given(input, "body"),
305+ draft: flag(input, "draft"),
306+ prerelease: flag(input, "prerelease"),
307+ };
308+ out(call::<_, Release>(services, "update_release", &args).await?)
309+ }
310+ AboutOp::DeleteRelease => {
311+ let Some(id) = text(input, "id") else {
312+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the release's id."));
313+ };
314+ match call::<_, bool>(services, "delete_release", &DeleteReleaseArgs { path, actor: actor(), id }).await? {
315+ Outcome::Ok(deleted) => ok(&json!({ "deleted": deleted })),
316+ Outcome::Fail(failure) => Ok(Outcome::Fail(failure)),
317+ }
318+ }
319+ AboutOp::ListStarred => unreachable!("answered above"),
320+ }
321+}
322+
323+#[cfg(test)]
324+mod tests {
325+ use super::*;
326+
327+ #[test]
328+ fn each_operation_is_described_with_a_schema() {
329+ for op in AboutOp::ALL {
330+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
331+ let schema = op.input();
332+ assert_eq!(schema["type"], "object");
333+ if op.needs_repo() {
334+ assert!(schema["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name());
335+ }
336+ }
337+ }
338+
339+ #[test]
340+ fn every_operation_is_one_of_the_api_s() {
341+ for op in AboutOp::ALL {
342+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::About(op)), "{}", op.name());
343+ }
344+ }
345+
346+ #[test]
347+ fn inputs_are_read_loosely() {
348+ let input = json!({ "draft": "true", "prerelease": false, "page": "2", "name": "" });
349+ assert_eq!(flag(&input, "draft"), Some(true));
350+ assert_eq!(flag(&input, "prerelease"), Some(false));
351+ assert_eq!(flag(&input, "missing"), None);
352+ assert_eq!(number(&input, "page"), Some(2));
353+ assert_eq!(given(&input, "name").as_deref(), Some(""), "an empty release_name clears it");
354+ assert_eq!(text(&input, "name"), None);
355+ }
356+}
+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+}
+3−0
44 //! operations (see [`operations::Op`]), which call the services that own
55 //! the data. This Worker holds none.
66
7+mod about;
78 mod addresses;
89 mod alerts;
910 mod audit;
1011 mod billing;
1112 mod blobs;
13+mod deployments;
1214 mod mcp;
1315 mod notifications;
1416 mod oauth;
1517 mod openapi;
1618 mod pins;
19+mod projects;
1720 mod operations;
1821 mod renamed;
1922 #[cfg(test)]
+54−0
77 use g1t_contracts::scopes::scope_for;
88 use serde_json::{Map, Value, json};
99
10+use crate::about::AboutOp;
11+use crate::deployments::DeploymentsOp;
1012 use crate::operations::Op;
1113 use crate::rules::RulesOp;
1214 use crate::security::SecurityOp;
4648 &[Op::ListPinnedProjects, Op::PinProject, Op::UnpinProject, Op::ReorderPinnedProjects],
4749 ),
4850 (
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+ (
4956 "Workspaces",
5057 "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.",
5158 &[Op::GetWorkspace, Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace],
102109 ],
103110 ),
104111 (
112+ "Repository insights",
113+ "What a repository's default branch says about it, read in the background and kept by commit: the languages it is written in, who made it, and its license.",
114+ &[Op::About(AboutOp::GetLanguages), Op::About(AboutOp::ListContributors), Op::About(AboutOp::GetLicense)],
115+ ),
116+ (
117+ "Stars",
118+ "Starring a repository, to keep it and to say you like it: who starred one, and what you starred.",
119+ &[
120+ Op::About(AboutOp::ListStargazers),
121+ Op::About(AboutOp::ListStarred),
122+ Op::About(AboutOp::CheckStarred),
123+ Op::About(AboutOp::Star),
124+ Op::About(AboutOp::Unstar),
125+ ],
126+ ),
127+ (
128+ "Releases",
129+ "A release is a tag published with a title and notes. The latest is the newest published one that is neither a draft nor a prerelease.",
130+ &[
131+ Op::About(AboutOp::ListReleases),
132+ Op::About(AboutOp::CreateRelease),
133+ Op::About(AboutOp::GetLatestRelease),
134+ Op::About(AboutOp::GetReleaseByTag),
135+ Op::About(AboutOp::GetRelease),
136+ Op::About(AboutOp::UpdateRelease),
137+ Op::About(AboutOp::DeleteRelease),
138+ ],
139+ ),
140+ (
105141 "Access",
106142 "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, and a workspace's base permission.",
107143 &[
314350 ],
315351 ),
316352 (
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+ (
317366 "Secrets and variables",
318367 "Values that workflows and deployments read, per repository or for a whole workspace, with a row per environment.",
319368 &[
538587 Op::PinProject => "Pin a project",
539588 Op::UnpinProject => "Unpin a project",
540589 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",
541593 Op::ListTeams => "List teams",
542594 Op::GetTeam => "Get a team",
543595 Op::CreateTeam => "Create a team",
557609 Op::GetCodeownersErrors => "List CODEOWNERS errors",
558610 Op::Security(op) => op.title(),
559611 Op::Rules(op) => op.title(),
612+ Op::About(op) => op.title(),
613+ Op::Deployments(op) => op.title(),
560614 }
561615 }
562616
+128−1
2626 };
2727
2828 use crate::alerts::{AlertKind, SecurityAlert};
29+use crate::about::AboutOp;
30+use crate::deployments::DeploymentsOp;
2931 use crate::rules::RulesOp;
3032 use crate::security::SecurityOp;
3133 use g1t_contracts::inbox::{Reason, Severity, WATCH_EVENTS, WatchLevel};
5557 pub security: Fetcher,
5658 /// Projects: a person's pinned ones.
5759 pub projects: Fetcher,
60+ /// Deployments wherever they run, and environments.
61+ pub deployments: Fetcher,
5862 /// Where the request came in, for its audit entries.
5963 pub audit: crate::audit::AuditContext,
6064 /// Set for a request made with an agent's token: all it may do.
7983 search: env.service("SEARCH")?,
8084 security: env.service("SECURITY")?,
8185 projects: env.service("PROJECTS")?,
86+ deployments: env.service("DEPLOYMENTS")?,
8287 scope: None,
8388 audit: crate::audit::AuditContext::default(),
8489 addresses: crate::addresses::Addresses::from_env(env),
238243 PinProject,
239244 UnpinProject,
240245 ReorderPinnedProjects,
246+ ListProjects,
247+ GetProject,
248+ UpdateProject,
241249 ListTeams,
242250 GetTeam,
243251 CreateTeam,
267275 Security(SecurityOp),
268276 /// Rulesets: rules.rs.
269277 Rules(RulesOp),
278+ /// A repository's languages, contributors, license, stars and releases: about.rs.
279+ About(AboutOp),
280+ /// Deployments wherever they run, and environments: deployments.rs.
281+ Deployments(DeploymentsOp),
270282 }
271283
272284 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
629641 }
630642
631643 impl Op {
632− pub const ALL: [Op; 219] = [
644+ pub const ALL: [Op; 244] = [
633645 Op::Whoami,
634646 Op::GetWorkspace,
635647 Op::CreateWorkspace,
780792 Op::PinProject,
781793 Op::UnpinProject,
782794 Op::ReorderPinnedProjects,
795+ Op::ListProjects,
796+ Op::GetProject,
797+ Op::UpdateProject,
783798 Op::ListTeams,
784799 Op::GetTeam,
785800 Op::CreateTeam,
849864 Op::Rules(RulesOp::UpdateWorkspaceRuleset),
850865 Op::Rules(RulesOp::DeleteWorkspaceRuleset),
851866 Op::Rules(RulesOp::ListWorkspaceRuleEvaluations),
867+ Op::About(AboutOp::GetLanguages),
868+ Op::About(AboutOp::ListContributors),
869+ Op::About(AboutOp::GetLicense),
870+ Op::About(AboutOp::ListStargazers),
871+ Op::About(AboutOp::ListStarred),
872+ Op::About(AboutOp::CheckStarred),
873+ Op::About(AboutOp::Star),
874+ Op::About(AboutOp::Unstar),
875+ Op::About(AboutOp::ListReleases),
876+ Op::About(AboutOp::GetLatestRelease),
877+ Op::About(AboutOp::GetReleaseByTag),
878+ Op::About(AboutOp::GetRelease),
879+ Op::About(AboutOp::CreateRelease),
880+ Op::About(AboutOp::UpdateRelease),
881+ Op::About(AboutOp::DeleteRelease),
882+ Op::Deployments(DeploymentsOp::ListDeployments),
883+ Op::Deployments(DeploymentsOp::CreateDeployment),
884+ Op::Deployments(DeploymentsOp::GetDeployment),
885+ Op::Deployments(DeploymentsOp::ListDeploymentStatuses),
886+ Op::Deployments(DeploymentsOp::CreateDeploymentStatus),
887+ Op::Deployments(DeploymentsOp::ListEnvironments),
888+ Op::Deployments(DeploymentsOp::GetEnvironment),
852889 ];
853890
854891 pub fn by_name(name: &str) -> Option<Op> {
10081045 Op::PinProject => "pin_project",
10091046 Op::UnpinProject => "unpin_project",
10101047 Op::ReorderPinnedProjects => "reorder_pinned_projects",
1048+ Op::ListProjects => "list_projects",
1049+ Op::GetProject => "get_project",
1050+ Op::UpdateProject => "update_project",
10111051 Op::ListTeams => "list_teams",
10121052 Op::GetTeam => "get_team",
10131053 Op::CreateTeam => "create_team",
10351075 Op::GetCodeownersErrors => "get_codeowners_errors",
10361076 Op::Security(op) => op.name(),
10371077 Op::Rules(op) => op.name(),
1078+ Op::About(op) => op.name(),
1079+ Op::Deployments(op) => op.name(),
10381080 }
10391081 }
10401082
14561498 Op::ReorderPinnedProjects => {
14571499 "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."
14581500 }
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+ }
14591510 Op::ListTeams => {
14601511 "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."
14611512 }
15331584 }
15341585 Op::Security(op) => op.description(),
15351586 Op::Rules(op) => op.description(),
1587+ Op::About(op) => op.description(),
1588+ Op::Deployments(op) => op.description(),
15361589 }
15371590 }
15381591
26772730 }),
26782731 &["workspace", "projects"],
26792732 ),
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+ ),
26802777 Op::ListTeams => object(
26812778 json!({
26822779 "workspace": workspace_schema(),
28432940 ),
28442941 Op::Security(op) => op.input(),
28452942 Op::Rules(op) => op.input(),
2943+ Op::About(op) => op.input(),
2944+ Op::Deployments(op) => op.input(),
28462945 }
28472946 }
28482947
28492948 /// Whether the operation refuses an anonymous caller outright.
28502949 pub(crate) fn needs_user(self) -> bool {
2950+ if let Op::About(op) = self {
2951+ return !op.anonymous();
2952+ }
28512953 !matches!(
28522954 self,
28532955 Op::ListRepos
28682970 | Op::ListCheckNames
28692971 | Op::GetMergeQueue
28702972 | Op::GetCodeownersErrors
2973+ | Op::ListProjects
2974+ | Op::GetProject
28712975 | 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+ )
28722983 )
28732984 }
28742985
28822993 if let Op::Rules(op) = self {
28832994 return op.needs_repo();
28842995 }
2996+ if let Op::About(op) = self {
2997+ return op.needs_repo();
2998+ }
28852999 if let Op::Security(op) = self {
28863000 return op.needs_repo();
28873001 }
29563070 | Op::PinProject
29573071 | Op::UnpinProject
29583072 | Op::ReorderPinnedProjects
3073+ | Op::ListProjects
3074+ | Op::GetProject
3075+ | Op::UpdateProject
29593076 | Op::ListTeams
29603077 | Op::GetTeam
29613078 | Op::CreateTeam
29853102 /// subscriptions and watching) or their pins. Nobody else's business,
29863103 /// so not audited.
29873104 pub(crate) fn personal(self) -> bool {
3105+ if let Op::About(op) = self {
3106+ return op.personal();
3107+ }
29883108 matches!(
29893109 self,
29903110 Op::ListNotifications
48444964 Op::ListPinnedProjects | Op::PinProject | Op::UnpinProject | Op::ReorderPinnedProjects => {
48454965 crate::pins::run(self, services, viewer, input).await
48464966 }
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+ }
48474972 // The security suite: the security service decides, this gives
48484973 // each answer its public shape.
48494974 Op::Security(op) => crate::security::run(op, services, viewer, input).await,
48504975 Op::Rules(op) => crate::rules::run(op, services, viewer, input).await,
4976+ Op::About(op) => crate::about::run(op, services, viewer, input).await,
4977+ Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
48514978 Op::ReopenSecurityAlert => {
48524979 let changed: Outcome<AlertChange> = call(
48534980 &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+}
+886−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",
92789499 ]
92799500 }
92809501 }
9502+ },
9503+ "list_deployments": {
9504+ "params": {
9505+ "owner": "flagon-io",
9506+ "name": "g1t"
9507+ },
9508+ "query": {
9509+ "environment": "production",
9510+ "per_page": "2"
9511+ },
9512+ "response": {
9513+ "deployments": [
9514+ {
9515+ "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9516+ "environment": "production",
9517+ "ref": "main",
9518+ "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7",
9519+ "task": "deploy",
9520+ "description": "Deploy",
9521+ "payload": {},
9522+ "transient_environment": false,
9523+ "production_environment": true,
9524+ "state": "success",
9525+ "environment_url": "https://g1t.sh",
9526+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9527+ "creator": "syntaqx",
9528+ "source": "actions",
9529+ "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9530+ "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9531+ "project": null,
9532+ "number": null,
9533+ "created_at": "2026-10-07T18:02:11.204Z",
9534+ "updated_at": "2026-10-07T18:19:47.881Z"
9535+ }
9536+ ],
9537+ "total_count": 304,
9538+ "page": 1,
9539+ "per_page": 2
9540+ },
9541+ "notes": "Every deployment of the repository in one list, wherever it ran: `source` is `api` for those reported with [`POST /repos/{owner}/{name}/deployments`](/reference/api/deployments/create-deployment/), `actions` for those a g1t Actions job with an `environment:` made, and `g1t_page` for [g1t.page](/guides/deployments/) builds, whose ids start `dpl_`. `state` is the latest status's. See [Deployments API](/guides/deployments-api/)."
9542+ },
9543+ "create_deployment": {
9544+ "params": {
9545+ "owner": "flagon-io",
9546+ "name": "g1t"
9547+ },
9548+ "request": {
9549+ "ref": "main",
9550+ "environment": "staging",
9551+ "description": "Deployed by the release pipeline",
9552+ "payload": {
9553+ "pipeline": 4182,
9554+ "region": "us-east"
9555+ },
9556+ "state": "in_progress",
9557+ "log_url": "https://ci.example.com/pipelines/4182"
9558+ },
9559+ "response": {
9560+ "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v",
9561+ "environment": "staging",
9562+ "ref": "main",
9563+ "sha": "4b8d0f2a6c1e3579bd02468ace13579bdf02468a",
9564+ "task": "deploy",
9565+ "description": "Deployed by the release pipeline",
9566+ "payload": {
9567+ "pipeline": 4182,
9568+ "region": "us-east"
9569+ },
9570+ "transient_environment": false,
9571+ "production_environment": false,
9572+ "state": "in_progress",
9573+ "environment_url": null,
9574+ "log_url": "https://ci.example.com/pipelines/4182",
9575+ "creator": "flagon-io",
9576+ "source": "api",
9577+ "run_id": null,
9578+ "run_url": null,
9579+ "project": null,
9580+ "number": null,
9581+ "created_at": "2026-10-06T21:40:03.512Z",
9582+ "updated_at": "2026-10-06T21:40:03.512Z",
9583+ "statuses": [
9584+ {
9585+ "id": "dst_01kq7z9a1d4f6h8k0m2p4r6t8v",
9586+ "deployment_id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v",
9587+ "state": "in_progress",
9588+ "description": "Deployed by the release pipeline",
9589+ "environment_url": null,
9590+ "log_url": "https://ci.example.com/pipelines/4182",
9591+ "creator": "flagon-io",
9592+ "created_at": "2026-10-06T21:40:03.512Z"
9593+ }
9594+ ]
9595+ },
9596+ "notes": "Report from any CI with an access token that has `deployments:write` (the CI preset has it) and the Write role. Then report each step with [`POST …/deployments/{id}/statuses`](/reference/api/deployments/create-deployment-status/). Each status sets the check `deploy / <environment>` on the commit. A g1t Actions job with an `environment:` does all of this itself. See [Deployments API](/guides/deployments-api/)."
9597+ },
9598+ "get_deployment": {
9599+ "params": {
9600+ "owner": "flagon-io",
9601+ "name": "g1t",
9602+ "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m"
9603+ },
9604+ "response": {
9605+ "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9606+ "environment": "production",
9607+ "ref": "main",
9608+ "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7",
9609+ "task": "deploy",
9610+ "description": "Deploy",
9611+ "payload": {},
9612+ "transient_environment": false,
9613+ "production_environment": true,
9614+ "state": "success",
9615+ "environment_url": "https://g1t.sh",
9616+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9617+ "creator": "syntaqx",
9618+ "source": "actions",
9619+ "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9620+ "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9621+ "project": null,
9622+ "number": null,
9623+ "created_at": "2026-10-07T18:02:11.204Z",
9624+ "updated_at": "2026-10-07T18:19:47.881Z",
9625+ "statuses": [
9626+ {
9627+ "id": "dst_01kq8m3t5w0a2c4e6g8j0m2p4r",
9628+ "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9629+ "state": "in_progress",
9630+ "description": "Deploy is deploying",
9631+ "environment_url": "https://g1t.sh",
9632+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9633+ "creator": "syntaqx",
9634+ "created_at": "2026-10-07T18:02:11.204Z"
9635+ },
9636+ {
9637+ "id": "dst_01kq8n4v6x8z0b2d4f6h8k0m2p",
9638+ "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9639+ "state": "success",
9640+ "description": "Deploy deployed",
9641+ "environment_url": "https://g1t.sh",
9642+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9643+ "creator": "syntaqx",
9644+ "created_at": "2026-10-07T18:19:47.881Z"
9645+ }
9646+ ]
9647+ },
9648+ "notes": "`statuses` are oldest first. A g1t.page build's (`dpl_…`) are read from the build: queued, building, how it ended, and `inactive` once a newer build replaced it or it was taken down."
9649+ },
9650+ "list_deployment_statuses": {
9651+ "params": {
9652+ "owner": "flagon-io",
9653+ "name": "g1t",
9654+ "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m"
9655+ },
9656+ "response": [
9657+ {
9658+ "id": "dst_01kq8n4v6x8z0b2d4f6h8k0m2p",
9659+ "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9660+ "state": "success",
9661+ "description": "Deploy deployed",
9662+ "environment_url": "https://g1t.sh",
9663+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9664+ "creator": "syntaqx",
9665+ "created_at": "2026-10-07T18:19:47.881Z"
9666+ },
9667+ {
9668+ "id": "dst_01kq8m3t5w0a2c4e6g8j0m2p4r",
9669+ "deployment_id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9670+ "state": "in_progress",
9671+ "description": "Deploy is deploying",
9672+ "environment_url": "https://g1t.sh",
9673+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9674+ "creator": "syntaqx",
9675+ "created_at": "2026-10-07T18:02:11.204Z"
9676+ }
9677+ ]
9678+ },
9679+ "create_deployment_status": {
9680+ "params": {
9681+ "owner": "flagon-io",
9682+ "name": "g1t",
9683+ "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v"
9684+ },
9685+ "request": {
9686+ "state": "failure",
9687+ "description": "Smoke tests failed",
9688+ "log_url": "https://ci.example.com/pipelines/4182"
9689+ },
9690+ "response": {
9691+ "id": "dst_01kq7zc2e4g6j8m0p2r4t6v8x0",
9692+ "deployment_id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v",
9693+ "state": "failure",
9694+ "description": "Smoke tests failed",
9695+ "environment_url": null,
9696+ "log_url": "https://ci.example.com/pipelines/4182",
9697+ "creator": "flagon-io",
9698+ "created_at": "2026-10-06T21:44:58.020Z"
9699+ },
9700+ "notes": "`queued` and `in_progress` set the commit's `deploy / <environment>` check pending; `success`, `failure` and `error` settle it; `inactive` leaves it. A success makes the environment's older successful deployments `inactive` unless `auto_inactive` is false. A g1t.page build (`dpl_…`) answers `409`: its statuses come from the build."
9701+ },
9702+ "list_environments": {
9703+ "params": {
9704+ "owner": "flagon-io",
9705+ "name": "g1t"
9706+ },
9707+ "response": {
9708+ "total_count": 304,
9709+ "environments": [
9710+ {
9711+ "name": "production",
9712+ "url": "https://g1t.sh",
9713+ "production_environment": true,
9714+ "transient_environment": false,
9715+ "deployments_count": 281,
9716+ "latest": {
9717+ "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9718+ "environment": "production",
9719+ "ref": "main",
9720+ "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7",
9721+ "task": "deploy",
9722+ "description": "Deploy",
9723+ "payload": {},
9724+ "transient_environment": false,
9725+ "production_environment": true,
9726+ "state": "success",
9727+ "environment_url": "https://g1t.sh",
9728+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9729+ "creator": "syntaqx",
9730+ "source": "actions",
9731+ "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9732+ "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9733+ "project": null,
9734+ "number": null,
9735+ "created_at": "2026-10-07T18:02:11.204Z",
9736+ "updated_at": "2026-10-07T18:19:47.881Z"
9737+ },
9738+ "current": {
9739+ "id": "dep_01kq8m3t5v7x9z1b3d5f7h9k2m",
9740+ "environment": "production",
9741+ "ref": "main",
9742+ "sha": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7",
9743+ "task": "deploy",
9744+ "description": "Deploy",
9745+ "payload": {},
9746+ "transient_environment": false,
9747+ "production_environment": true,
9748+ "state": "success",
9749+ "environment_url": "https://g1t.sh",
9750+ "log_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9751+ "creator": "syntaqx",
9752+ "source": "actions",
9753+ "run_id": "run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9754+ "run_url": "https://g1t.sh/flagon-io/g1t/actions/runs/run_01kq8m2r4t6v8x0z2b4d6f8h0k",
9755+ "project": null,
9756+ "number": null,
9757+ "created_at": "2026-10-07T18:02:11.204Z",
9758+ "updated_at": "2026-10-07T18:19:47.881Z"
9759+ },
9760+ "updated_at": "2026-10-07T18:19:47.881Z"
9761+ },
9762+ {
9763+ "name": "staging",
9764+ "url": null,
9765+ "production_environment": false,
9766+ "transient_environment": false,
9767+ "deployments_count": 19,
9768+ "latest": {
9769+ "id": "dep_01kq7z9a1c3e5g7j9m1p3r5t7v",
9770+ "environment": "staging",
9771+ "ref": "main",
9772+ "sha": "4b8d0f2a6c1e3579bd02468ace13579bdf02468a",
9773+ "task": "deploy",
9774+ "description": "Deployed by the release pipeline",
9775+ "payload": {
9776+ "pipeline": 4182,
9777+ "region": "us-east"
9778+ },
9779+ "transient_environment": false,
9780+ "production_environment": false,
9781+ "state": "failure",
9782+ "environment_url": null,
9783+ "log_url": "https://ci.example.com/pipelines/4182",
9784+ "creator": "flagon-io",
9785+ "source": "api",
9786+ "run_id": null,
9787+ "run_url": null,
9788+ "project": null,
9789+ "number": null,
9790+ "created_at": "2026-10-06T21:40:03.512Z",
9791+ "updated_at": "2026-10-06T21:44:58.020Z"
9792+ },
9793+ "current": null,
9794+ "updated_at": "2026-10-06T21:44:58.020Z"
9795+ }
9796+ ]
9797+ },
9798+ "notes": "Production comes first, then the most recently deployed. `latest` is the newest deployment whatever its state; `current` is the newest that succeeded and is still active, and `url` is where it is served."
9799+ },
9800+ "get_environment": {
9801+ "params": {
9802+ "owner": "flagon-io",
9803+ "name": "docs",
9804+ "environment": "preview"
9805+ },
9806+ "response": {
9807+ "name": "preview",
9808+ "url": "https://docs-git-docs-deployments-flagon-io.g1t.page",
9809+ "production_environment": false,
9810+ "transient_environment": true,
9811+ "deployments_count": 87,
9812+ "latest": {
9813+ "id": "dpl_01kq8p2b4d6f8h0k2m4p6r8t0v",
9814+ "environment": "preview",
9815+ "ref": "docs-deployments",
9816+ "sha": "e2f4a6c8b0d1e3f5a7c9b1d3e5f7a9c1b3d5e7f9",
9817+ "task": "deploy",
9818+ "description": "Preview of docs-deployments on g1t.page",
9819+ "payload": {},
9820+ "transient_environment": true,
9821+ "production_environment": false,
9822+ "state": "success",
9823+ "environment_url": "https://docs-git-docs-deployments-flagon-io.g1t.page",
9824+ "log_url": "https://g1t.sh/flagon-io/docs/deployments/dpl_01kq8p2b4d6f8h0k2m4p6r8t0v",
9825+ "creator": "syntaqx",
9826+ "source": "g1t_page",
9827+ "run_id": null,
9828+ "run_url": null,
9829+ "project": "docs",
9830+ "number": 412,
9831+ "created_at": "2026-10-08T01:12:44.630Z",
9832+ "updated_at": "2026-10-08T01:13:52.101Z"
9833+ },
9834+ "current": {
9835+ "id": "dpl_01kq8p2b4d6f8h0k2m4p6r8t0v",
9836+ "environment": "preview",
9837+ "ref": "docs-deployments",
9838+ "sha": "e2f4a6c8b0d1e3f5a7c9b1d3e5f7a9c1b3d5e7f9",
9839+ "task": "deploy",
9840+ "description": "Preview of docs-deployments on g1t.page",
9841+ "payload": {},
9842+ "transient_environment": true,
9843+ "production_environment": false,
9844+ "state": "success",
9845+ "environment_url": "https://docs-git-docs-deployments-flagon-io.g1t.page",
9846+ "log_url": "https://g1t.sh/flagon-io/docs/deployments/dpl_01kq8p2b4d6f8h0k2m4p6r8t0v",
9847+ "creator": "syntaqx",
9848+ "source": "g1t_page",
9849+ "run_id": null,
9850+ "run_url": null,
9851+ "project": "docs",
9852+ "number": 412,
9853+ "created_at": "2026-10-08T01:12:44.630Z",
9854+ "updated_at": "2026-10-08T01:13:52.101Z"
9855+ },
9856+ "updated_at": "2026-10-08T01:13:52.101Z"
9857+ }
9858+ },
9859+ "get_languages": {
9860+ "response": {
9861+ "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
9862+ "commit": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
9863+ "computed_at": "2026-10-07T09:14:03.000Z",
9864+ "pending": false,
9865+ "partial": false,
9866+ "languages": [
9867+ {
9868+ "name": "Rust",
9869+ "color": "#dea584",
9870+ "bytes": 184213,
9871+ "percent": 71.4
9872+ },
9873+ {
9874+ "name": "TypeScript",
9875+ "color": "#3178c6",
9876+ "bytes": 61022,
9877+ "percent": 23.7
9878+ },
9879+ {
9880+ "name": "Shell",
9881+ "color": "#89e051",
9882+ "bytes": 12650,
9883+ "percent": 4.9
9884+ }
9885+ ]
9886+ },
9887+ "notes": "While `pending` is true, `languages` is empty: the default branch is being read for the first time. When `commit` is not `head`, the answer is for an older commit while the newer one is read."
9888+ },
9889+ "list_contributors": {
9890+ "response": {
9891+ "head": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
9892+ "commit": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
9893+ "computed_at": "2026-10-07T09:14:03.000Z",
9894+ "pending": false,
9895+ "partial": false,
9896+ "total": 3,
9897+ "commits": 126,
9898+ "contributors": [
9899+ {
9900+ "kind": "g1t",
9901+ "name": "g1t",
9902+ "username": null,
9903+ "avatar": null,
9904+ "commits": 71,
9905+ "first_at": "2026-09-29T08:00:00.000Z",
9906+ "last_at": "2026-10-07T08:41:00.000Z",
9907+ "weeks": [
9908+ {
9909+ "week": "2026-09-28",
9910+ "commits": 40
9911+ },
9912+ {
9913+ "week": "2026-10-05",
9914+ "commits": 31
9915+ }
9916+ ]
9917+ },
9918+ {
9919+ "kind": "user",
9920+ "name": "ada",
9921+ "username": "ada",
9922+ "avatar": "5f2b8c1d9e7a3f6b4c0d2e8a1b9c7d5e3f1a0b2c4d6e8f0a1b3c5d7e9f0a2b4c",
9923+ "commits": 52,
9924+ "first_at": "2026-09-28T14:11:52.000Z",
9925+ "last_at": "2026-10-06T16:02:11.000Z",
9926+ "weeks": [
9927+ {
9928+ "week": "2026-09-28",
9929+ "commits": 30
9930+ },
9931+ {
9932+ "week": "2026-10-05",
9933+ "commits": 22
9934+ }
9935+ ]
9936+ },
9937+ {
9938+ "kind": "author",
9939+ "name": "Sam Okafor",
9940+ "username": null,
9941+ "avatar": null,
9942+ "commits": 3,
9943+ "first_at": "2026-10-02T11:20:00.000Z",
9944+ "last_at": "2026-10-03T09:05:00.000Z",
9945+ "weeks": [
9946+ {
9947+ "week": "2026-09-28",
9948+ "commits": 3
9949+ }
9950+ ]
9951+ }
9952+ ],
9953+ "weeks": [
9954+ {
9955+ "week": "2026-09-28",
9956+ "commits": 73
9957+ },
9958+ {
9959+ "week": "2026-10-05",
9960+ "commits": 53
9961+ }
9962+ ]
9963+ },
9964+ "notes": "Read from the newest 3,000 commits of the default branch. A person's addresses count as one when they confirmed them on their account; their noreply address counts too."
9965+ },
9966+ "get_license": {
9967+ "response": {
9968+ "spdx_id": "MIT",
9969+ "name": "MIT License",
9970+ "path": "LICENSE"
9971+ }
9972+ },
9973+ "list_stargazers": {
9974+ "query": {
9975+ "page": 1
9976+ },
9977+ "response": [
9978+ {
9979+ "username": "ada",
9980+ "avatar": null,
9981+ "starred_at": "2026-10-06T18:30:00.000Z"
9982+ },
9983+ {
9984+ "username": "sam",
9985+ "avatar": null,
9986+ "starred_at": "2026-10-02T09:12:00.000Z"
9987+ }
9988+ ]
9989+ },
9990+ "list_starred": {
9991+ "response": [
9992+ {
9993+ "repo": {
9994+ "id": "rep_01m3m5q6p0e2qaw6mmjahk0qrr",
9995+ "namespace": "flagon-io",
9996+ "name": "hello",
9997+ "description": "A tiny service that says hello.",
9998+ "is_private": false,
9999+ "owner_id": "usr_01kkntcg1eeb98j62xjm7eh09p",
10000+ "default_branch": "main",
10001+ "fork_of": null,
10002+ "protected": true,
10003+ "created_at": "2026-09-28T14:11:52.640Z",
10004+ "topics": [
10005+ "cli"
10006+ ],
10007+ "website": "https://hello.g1t.page",
10008+ "archived_at": null
10009+ },
10010+ "starred_at": "2026-10-06T18:30:00.000Z",
10011+ "stars": 12
10012+ }
10013+ ]
10014+ },
10015+ "check_starred": {
10016+ "response": {
10017+ "starred": true,
10018+ "stars": 12
10019+ }
10020+ },
10021+ "star_repo": {
10022+ "response": {
10023+ "starred": true,
10024+ "stars": 13
10025+ }
10026+ },
10027+ "unstar_repo": {
10028+ "response": {
10029+ "starred": false,
10030+ "stars": 12
10031+ }
10032+ },
10033+ "list_releases": {
10034+ "response": [
10035+ {
10036+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7",
10037+ "tag_name": "v1.2.0",
10038+ "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
10039+ "name": "Greetings by name",
10040+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)",
10041+ "draft": false,
10042+ "prerelease": false,
10043+ "author": "ada",
10044+ "created_at": "2026-10-06T16:02:11.000Z",
10045+ "published_at": "2026-10-06T16:02:11.000Z",
10046+ "latest": true
10047+ },
10048+ {
10049+ "id": "rel_01m3x8q2w4e6r8t0y2u4i6o8p0",
10050+ "tag_name": "v1.1.0",
10051+ "target": "3b2a19f8e7d6c5b4a392817069f3c2a1b7e5d4c3",
10052+ "name": "First release",
10053+ "body": "The first release.",
10054+ "draft": false,
10055+ "prerelease": false,
10056+ "author": "ada",
10057+ "created_at": "2026-09-30T10:00:00.000Z",
10058+ "published_at": "2026-09-30T10:00:00.000Z",
10059+ "latest": false
10060+ }
10061+ ],
10062+ "notes": "Drafts are listed only to those with the Write role; their `published_at` is null."
10063+ },
10064+ "get_latest_release": {
10065+ "response": {
10066+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7",
10067+ "tag_name": "v1.2.0",
10068+ "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
10069+ "name": "Greetings by name",
10070+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)",
10071+ "draft": false,
10072+ "prerelease": false,
10073+ "author": "ada",
10074+ "created_at": "2026-10-06T16:02:11.000Z",
10075+ "published_at": "2026-10-06T16:02:11.000Z",
10076+ "latest": true
10077+ }
10078+ },
10079+ "get_release_by_tag": {
10080+ "params": {
10081+ "tag": "v1.2.0"
10082+ },
10083+ "response": {
10084+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7",
10085+ "tag_name": "v1.2.0",
10086+ "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
10087+ "name": "Greetings by name",
10088+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)",
10089+ "draft": false,
10090+ "prerelease": false,
10091+ "author": "ada",
10092+ "created_at": "2026-10-06T16:02:11.000Z",
10093+ "published_at": "2026-10-06T16:02:11.000Z",
10094+ "latest": true
10095+ }
10096+ },
10097+ "get_release": {
10098+ "params": {
10099+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7"
10100+ },
10101+ "response": {
10102+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7",
10103+ "tag_name": "v1.2.0",
10104+ "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
10105+ "name": "Greetings by name",
10106+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)",
10107+ "draft": false,
10108+ "prerelease": false,
10109+ "author": "ada",
10110+ "created_at": "2026-10-06T16:02:11.000Z",
10111+ "published_at": "2026-10-06T16:02:11.000Z",
10112+ "latest": true
10113+ }
10114+ },
10115+ "create_release": {
10116+ "request": {
10117+ "tag_name": "v1.2.0",
10118+ "target": "main",
10119+ "release_name": "Greetings by name",
10120+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)",
10121+ "draft": false,
10122+ "prerelease": false
10123+ },
10124+ "response": {
10125+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7",
10126+ "tag_name": "v1.2.0",
10127+ "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
10128+ "name": "Greetings by name",
10129+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)",
10130+ "draft": false,
10131+ "prerelease": false,
10132+ "author": "ada",
10133+ "created_at": "2026-10-06T16:02:11.000Z",
10134+ "published_at": "2026-10-06T16:02:11.000Z",
10135+ "latest": true
10136+ },
10137+ "notes": "A tag that already exists is released as it is, and `target` is ignored. One release per tag: releasing a tag again is refused with `conflict`."
10138+ },
10139+ "update_release": {
10140+ "params": {
10141+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7"
10142+ },
10143+ "request": {
10144+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)\n- Docs for `--name`"
10145+ },
10146+ "response": {
10147+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7",
10148+ "tag_name": "v1.2.0",
10149+ "target": "9f3c2a1b7e5d4c3b2a19f8e7d6c5b4a39281706f",
10150+ "name": "Greetings by name",
10151+ "body": "## What changed\n\n- `hello` greets the caller by name (#12)\n- Faster start-up (#15)\n- Docs for `--name`",
10152+ "draft": false,
10153+ "prerelease": false,
10154+ "author": "ada",
10155+ "created_at": "2026-10-06T16:02:11.000Z",
10156+ "published_at": "2026-10-06T16:02:11.000Z",
10157+ "latest": true
10158+ }
10159+ },
10160+ "delete_release": {
10161+ "params": {
10162+ "id": "rel_01m4a2b7c9d3e5f7g9h1j3k5m7"
10163+ },
10164+ "response": {
10165+ "deleted": true
10166+ }
928110167 }
928210168 }
+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 => {
+41−0
22
33 use serde_json::{Map, Value};
44
5+use crate::about::AboutOp;
6+use crate::deployments::DeploymentsOp;
57 use crate::operations::Op;
68 use crate::rules::RulesOp;
79 use crate::security::SecurityOp;
8284 route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]),
8385 route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]),
8486 route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]),
87+ // Stars: yours, and who starred a repository.
88+ route("GET", "/user/starred", Op::About(AboutOp::ListStarred), &[]),
89+ route("GET", "/user/starred/:owner/:name", Op::About(AboutOp::CheckStarred), &[]),
90+ route("PUT", "/user/starred/:owner/:name", Op::About(AboutOp::Star), &[]),
91+ route("DELETE", "/user/starred/:owner/:name", Op::About(AboutOp::Unstar), &[]),
92+ route("GET", "/repos/:owner/:name/stargazers", Op::About(AboutOp::ListStargazers), &[("page", "page")]),
93+ // What the default branch says about a repository, kept by commit.
94+ route("GET", "/repos/:owner/:name/languages", Op::About(AboutOp::GetLanguages), &[]),
95+ route("GET", "/repos/:owner/:name/contributors", Op::About(AboutOp::ListContributors), &[]),
96+ route("GET", "/repos/:owner/:name/license", Op::About(AboutOp::GetLicense), &[]),
97+ // Releases: `latest` and `tags/…` before an id.
98+ route("GET", "/repos/:owner/:name/releases", Op::About(AboutOp::ListReleases), &[]),
99+ route("POST", "/repos/:owner/:name/releases", Op::About(AboutOp::CreateRelease), &[]),
100+ route("GET", "/repos/:owner/:name/releases/latest", Op::About(AboutOp::GetLatestRelease), &[]),
101+ route("GET", "/repos/:owner/:name/releases/tags/:tag", Op::About(AboutOp::GetReleaseByTag), &[]),
102+ route("GET", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::GetRelease), &[]),
103+ route("PATCH", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::UpdateRelease), &[]),
104+ route("DELETE", "/repos/:owner/:name/releases/:id", Op::About(AboutOp::DeleteRelease), &[]),
85105 // Your pinned projects in a workspace, in your order.
86106 route("GET", "/user/pinned_projects/:workspace", Op::ListPinnedProjects, &[]),
87107 route("PUT", "/user/pinned_projects/:workspace", Op::ReorderPinnedProjects, &[]),
90110 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
91111 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
92112 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, &[]),
93117 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
94118 route(
95119 "GET",
262286 Op::Rules(RulesOp::ListWorkspaceRuleEvaluations),
263287 &[("ruleset_id", "ruleset_id"), ("verdict", "verdict"), ("problems_only", "problems_only"), ("before", "before"), ("limit", "limit")],
264288 ),
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), &[]),
265302 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
266303 route(
267304 "POST",
861898 if let Some(branch) = param("branch") {
862899 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
863900 }
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+ }
864905 if let Some(label) = param("label") {
865906 input.insert("label".to_owned(), Value::String(percent_decoded(label)));
866907 }
+30−3
1818 use g1t_contracts::scopes::{Level, NO_SCOPE, TokenAccess, scope_for};
1919 use serde_json::{Map, Value, json};
2020
21+use crate::about::AboutOp;
22+use crate::deployments::DeploymentsOp;
2123 use crate::operations::Op;
2224 use crate::rules::RulesOp;
2325 use crate::security::SecurityOp;
5961 Tool {
6062 name: "repository",
6163 title: "Repositories",
62− description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, and see and dismiss their security alerts (secrets and vulnerable dependencies). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
64+ description: "Repositories: find, read and create them, change their settings and rulesets (what may happen to branches and tags, and what a pull request needs to merge), check their CODEOWNERS file, manage their labels and milestones, see and dismiss their security alerts (secrets and vulnerable dependencies), read what their default branch says (languages, contributors, license), star them, and publish releases. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.",
6365 default_action: None,
6466 actions: &[
6567 a("list", Op::ListRepos, "Repositories you can see"),
8890 a("update_milestone", Op::UpdateMilestone, "Change a milestone's title, description, due date or state"),
8991 a("delete_milestone", Op::DeleteMilestone, "Delete a milestone"),
9092 a("list_events", Op::ListEvents, "Timeline: pushes, issues, pull requests, comments"),
93+ a("languages", Op::About(AboutOp::GetLanguages), "Its languages by bytes, with colors and percentages"),
94+ a("contributors", Op::About(AboutOp::ListContributors), "Who made it: commits per person, agent and author, by week"),
95+ a("license", Op::About(AboutOp::GetLicense), "The license its LICENSE file holds"),
96+ a("stargazers", Op::About(AboutOp::ListStargazers), "Who starred it"),
97+ a("starred", Op::About(AboutOp::CheckStarred), "Whether you starred it, and how many have"),
98+ a("star", Op::About(AboutOp::Star), "Star it"),
99+ a("unstar", Op::About(AboutOp::Unstar), "Take your star back"),
100+ a("list_starred", Op::About(AboutOp::ListStarred), "Repositories you starred"),
101+ a("list_releases", Op::About(AboutOp::ListReleases), "Releases, newest first"),
102+ a("latest_release", Op::About(AboutOp::GetLatestRelease), "The latest release"),
103+ a("get_release", Op::About(AboutOp::GetRelease), "One release by id"),
104+ a("get_release_by_tag", Op::About(AboutOp::GetReleaseByTag), "The release of a tag"),
105+ a("create_release", Op::About(AboutOp::CreateRelease), "Publish a release of a tag, making the tag if needed"),
106+ a("update_release", Op::About(AboutOp::UpdateRelease), "Change a release's title, notes, draft or prerelease"),
107+ a("delete_release", Op::About(AboutOp::DeleteRelease), "Delete a release; its tag stays"),
91108 a("rename_branch", Op::RenameBranch, "Rename a branch"),
92109 a("rename", Op::RenameRepo, "Rename it; old addresses redirect"),
93110 a("transfer", Op::TransferRepo, "Move it to another workspace you own"),
182199 Tool {
183200 name: "workflow",
184201 title: "Workflows",
185− 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.",
186203 default_action: None,
187204 actions: &[
188205 a("list", Op::ListWorkflows, "Workflows on the default branch"),
193210 a("cancel", Op::CancelWorkflowRun, "Cancel a run"),
194211 a("rerun", Op::RerunWorkflowRun, "Run a finished run again"),
195212 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"),
196220 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
197221 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
198222 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
275299 Tool {
276300 name: "workspace",
277301 title: "Workspaces",
278− 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.",
279303 default_action: None,
280304 actions: &[
281305 a("get", Op::GetWorkspace, "A workspace's details and settings"),
291315 a("test_integration", Op::TestIntegration, "Check its credentials"),
292316 a("get_model_routes", Op::GetModelRoutes, "Where each kind of work's model requests go"),
293317 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"),
294321 a("list_pinned_projects", Op::ListPinnedProjects, "Your pinned projects in it, in your order"),
295322 a("pin_project", Op::PinProject, "Pin a project, at a position or the end"),
296323 a("unpin_project", Op::UnpinProject, "Unpin a project"),
+2−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' },
144145 { label: 'Access and roles', slug: 'guides/access-and-roles' },
145146 { label: 'Teams', slug: 'guides/teams' },
146147 { label: 'Managing a repository', slug: 'guides/managing-repositories' },
148+ { label: 'Releases', slug: 'guides/releases' },
147149 { label: 'Transferring a repository', slug: 'guides/transferring-repositories' },
148150 { label: 'Audit log', slug: 'guides/audit-log' },
149151 { label: 'Usage and billing', slug: 'guides/usage-and-billing' },
+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
+8−5
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` |
337338
338339 | Scope | What it lets a token do |
339340 | --- | --- |
340−| `repo:read` | See repositories, their settings, labels, timelines and security alerts, and search |
341−| `repo:write` | Create repositories, rename branches and change how pull requests merge |
341+| `repo:read` | See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search |
342+| `repo:write` | Create repositories, rename branches, change how pull requests merge and publish releases |
342343 | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts |
343344 | `code:read` | Clone and fetch private repositories with git |
344345 | `code:write` | Push commits with git |
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 |
359−| `account:read` | Read your email addresses, invites, invitations and pinned projects |
360−| `account:write` | Change your email addresses, make invites, answer invitations and pin projects |
362+| `account:read` | Read your email addresses, invites, invitations, pinned projects and stars |
363+| `account:write` | Change your email addresses, make invites, answer invitations, pin projects and star repositories |
361364 | `notifications:read` | See your [inbox](/guides/inbox/), its threads, and what you subscribe to and watch |
362365 | `notifications:write` | Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories |
363366 | `workspace:read` | Read workspace settings, invites, integrations, model routes and [teams](/guides/teams/) |
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.
+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.

+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.