Skip to content

Commit

Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.

syntaqxcommitted Parents6ec3802d512d53Browse files
100 files+594−250/100 viewed
+3−1
4040 | `project` | The repository's one project with Deployments on | The project's slug. Needed when the repository has several. |
4141 | `root-dir` | The project's setting | Where the project is in the checkout. |
4242 | `build-command` | The project's setting, else what the build finds | What builds it. |
43−| `output-dir` | The project's setting, else the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` | The folder a static build writes. |
43+| `output-dir` | The project's setting, else the folder the framework writes, else the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` | The folder a static build writes. |
44+| `build-as` | The project's setting, else `auto` | What it is built as: `auto`, `static`, `workers` or `docker`. A container is built with the `docker` on the job's `PATH`. |
45+| `dockerfile` | The project's setting, else `Dockerfile` or `Containerfile` | The Dockerfile a container is built from, relative to the project. |
4446 | `sha` | The run's commit | The commit this is a build of, for its `g1t / deploy` check. |
4547 | `token` | `${{ github.token }}` | The job's own token. It needs `deployments: write`; any other token is refused. |
4648
+12−2
1212 required: false
1313 default: ""
1414 build-command:
15− description: What builds it. The project's own setting, else what the build finds (a Workers config, or the package's build script).
15+ description: What builds it. The project's own setting, else what the build finds (a Workers config, the package's build script, or the framework's own command).
1616 required: false
1717 default: ""
1818 output-dir:
19− description: The folder a static build writes. The project's own setting, else the first of dist, build, out, public, _site or .output/public.
19+ description: The folder a static build writes. The project's own setting, else the folder the framework writes, else the first of dist, build, out, public, _site or .output/public.
20+ required: false
21+ default: ""
22+ build-as:
23+ description: What it is built as, auto, static, workers or docker. The project's own setting unless you say; auto finds it from the project's files.
2024 required: false
2125 default: ""
26+ dockerfile:
27+ description: The Dockerfile a container is built from, relative to the project. The project's setting, else Dockerfile or Containerfile.
28+ required: false
29+ default: ""
2230 sha:
2331 description: The commit this is a build of, for the commit's g1t / deploy check. The run's commit unless you say.
2432 required: false
4957 INPUT_ROOT_DIR: ${{ inputs.root-dir }}
5058 INPUT_BUILD_COMMAND: ${{ inputs.build-command }}
5159 INPUT_OUTPUT_DIR: ${{ inputs.output-dir }}
60+ INPUT_BUILD_AS: ${{ inputs.build-as }}
61+ INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
5262 INPUT_SHA: ${{ inputs.sha }}
5363 INPUT_TOKEN: ${{ inputs.token }}
5464 run: bash "$GITHUB_ACTION_PATH/deploy.sh"
+2−0
9393 export ROOT_DIR="$(pick "${INPUT_ROOT_DIR:-}" root_dir)"
9494 export BUILD_COMMAND="$(pick "${INPUT_BUILD_COMMAND:-}" build_command)"
9595 export OUTPUT_DIR="$(pick "${INPUT_OUTPUT_DIR:-}" output_dir)"
96+export BUILD_AS="$(pick "${INPUT_BUILD_AS:-}" build_as)"
97+export DOCKERFILE="$(pick "${INPUT_DOCKERFILE:-}" dockerfile)"
9698 export BUILD_ENV="$(field build_env <<<"${answer}")"
9799 export BUILD_SECRETS="$(field build_secrets <<<"${answer}")"
98100 unset GIT_REMOTE
+38−4
1111 uses: g1t/detect@v1
1212 - if: steps.detect.outputs.kind == 'none'
1313 run: echo "::error::Nothing here would be served."
14+ - if: steps.detect.outputs.framework == 'hugo'
15+ run: echo "A Hugo site, served from ${{ steps.detect.outputs.output-dir }}"
1416 - if: steps.detect.outputs.install != ''
1517 run: ${{ steps.detect.outputs.install }}
1618 ```
2224 | `root-dir` | The checkout | Where the project is in the checkout. |
2325 | `build-command` | None | A build command of your own, as `g1t/deploy` takes it. |
2426 | `output-dir` | None | An output folder of your own, as `g1t/deploy` takes it. |
27+| `build-as` | `auto` | What it is built as: `auto`, `static`, `workers` or `docker`, as `g1t/deploy` takes it. |
28+| `dockerfile` | None | A Dockerfile's path of your own, as `g1t/deploy` takes it. |
2529
2630 ## Outputs
2731
2832 | Output | |
2933 | --- | --- |
30−| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`), `static` (a site a build writes), `html` (the files as they are) or `none`. |
31−| `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on. Empty without a `package.json`. |
32−| `build` | What builds it: your command, `npm run build` for a package with a build script, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
33−| `output-dir` | The folder served, relative to the project (`.` for the project itself): a Workers project's assets directory, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it. |
34+| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, or a framework's Cloudflare adapter), `static` (a site a build writes), `html` (the files as they are), `container` (a `Dockerfile`, a framework set up to need a server, or a server g1t writes a Dockerfile for) or `none` (nothing to serve). |
35+| `framework` | The framework it is built with, from the table below; for a container without one, `dockerfile`, or the server g1t writes a Dockerfile for (`node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`, `rack`, `laravel`, `php`). Empty when there is none. |
36+| `dockerfile` | A container's own Dockerfile, relative to the project. Empty when g1t writes one, and for anything that is not a container. |
37+| `port` | The port a container listens on: its Dockerfile's `EXPOSE`, else `8080`. |
38+| `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on, and `bundle install` for Jekyll. Empty when there is nothing to install. |
39+| `build` | What builds it: your command, the package's `build` script (`npm run build`), the framework's own command, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
40+| `output-dir` | The folder served, relative to the project (`.` for the project itself): yours, a Workers project's assets directory, the folder the framework writes, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it, as for a Cloudflare adapter's Worker. |
41+
42+When `kind` is `none`, the step's log says what to change or add, such as
43+a `Dockerfile`. [Docker deployments](https://docs.g1t.sh/guides/deployments/#docker-deployments)
44+has the order in which a project's kind is decided.
45+
46+## Frameworks
47+
48+| `framework` | Found by | Serves |
49+| --- | --- | --- |
50+| `vite`, `vite-react`, `vite-vue`, `vite-svelte`, `vite-solid`, `vite-preact` | `vite`, and the framework's Vite plugin | `dist`, or `build.outDir` |
51+| `astro` | `astro` | `dist`, or `outDir`; a Worker with `@astrojs/cloudflare` |
52+| `nextjs` | `next` | `out` with `output: 'export'`; a Worker with `@opennextjs/cloudflare` and a `wrangler.jsonc` |
53+| `sveltekit` | `@sveltejs/kit` | `build` with `adapter-static`; a Worker with `adapter-cloudflare` |
54+| `nuxt` | `nuxt` | `.output/public`, from `nuxi generate` |
55+| `react-router`, `remix` | `@react-router/dev`, `@remix-run/dev` | `build/client` with `ssr: false` |
56+| `gatsby` | `gatsby` | `public` |
57+| `docusaurus` | `@docusaurus/core` | `build` |
58+| `eleventy` | `@11ty/eleventy` | `_site` |
59+| `angular` | `@angular/core` and `angular.json` | `dist/<project>/browser` |
60+| `create-react-app` | `react-scripts` | `build` |
61+| `hugo` | `hugo.toml`, or Hugo's `config.toml` | `public` |
62+| `jekyll` | `_config.yml` and a `Gemfile` | `_site` |
63+| `mdbook` | `book.toml` | `book` |
64+
65+[What deploys](https://docs.g1t.sh/guides/deployments/#frameworks) has
66+each one's build command. Your `build-command` and `output-dir` win over
67+what is detected.
3468
3569 It runs `g1t-runner detect`, found or downloaded as `g1t/deploy` finds it.
+21−2
11 name: Detect a g1t.page project
2−description: Says what a g1t.page build of this checkout would find, without building anything. Its kind, how it installs, what builds it and where its output is.
2+description: Says what a g1t.page build of this checkout would find, without building anything. Its kind and framework, how it installs, what builds it and where its output is.
33 author: g1t
44
55 inputs:
1515 description: An output folder of your own, as g1t/deploy takes it.
1616 required: false
1717 default: ""
18+ build-as:
19+ description: What it is built as, auto, static, workers or docker, as g1t/deploy takes it.
20+ required: false
21+ default: ""
22+ dockerfile:
23+ description: A Dockerfile's path of your own, as g1t/deploy takes it.
24+ required: false
25+ default: ""
1826
1927 outputs:
2028 kind:
21− description: workers (a Wrangler config), static (a site a build writes), html (the files as they are) or none.
29+ description: workers (a Wrangler config, or a framework's Cloudflare adapter), static (a site a build writes), html (the files as they are), container (a Dockerfile, a framework set up to need a server, or a server g1t writes a Dockerfile for) or none (nothing to serve).
2230 value: ${{ steps.detect.outputs.kind }}
2331 install:
2432 description: How its dependencies are installed, such as npm ci. Empty without a package.json.
2937 output-dir:
3038 description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it.
3139 value: ${{ steps.detect.outputs.output-dir }}
40+ framework:
41+ description: The framework it is built with, such as vite, astro, nextjs, sveltekit or hugo; for a container without one, dockerfile or the server g1t writes a Dockerfile for (node, flask, rails…). Empty when there is none.
42+ value: ${{ steps.detect.outputs.framework }}
43+ dockerfile:
44+ description: A container's own Dockerfile, relative to the project. Empty when g1t writes one, or for anything else.
45+ value: ${{ steps.detect.outputs.dockerfile }}
46+ port:
47+ description: The port a container listens on, its Dockerfile's EXPOSE or 8080. Empty for anything else.
48+ value: ${{ steps.detect.outputs.port }}
3249
3350 runs:
3451 using: composite
3956 ROOT_DIR: ${{ inputs.root-dir }}
4057 BUILD_COMMAND: ${{ inputs.build-command }}
4158 OUTPUT_DIR: ${{ inputs.output-dir }}
59+ BUILD_AS: ${{ inputs.build-as }}
60+ DOCKERFILE: ${{ inputs.dockerfile }}
4261 run: |
4362 set -euo pipefail
4463 runner="$(bash "$GITHUB_ACTION_PATH/runner.sh")"
+136−0
1+//! A repository's files at a branch, tag or commit as one archive, at
2+//! GitHub's addresses: `GET /repos/{owner}/{repo}/tarball/{ref}` and
3+//! `/zipball/{ref}` (and without a ref, the default branch).
4+//!
5+//! As GitHub's redirect to codeload, these answer `302 Found` with a link to
6+//! the site's archive route (apps/web routes/repo/archive.ts) for the commit
7+//! the ref names, carrying a token the repos service made for whoever asked
8+//! (services/repos archive_links.rs). The link is good for 10 minutes and
9+//! needs no token of its own, so a client following the redirect, which
10+//! drops its `Authorization` header on the way, gets a private
11+//! repository's files too. The archive holds one folder,
12+//! `<owner>-<repo>-<short sha>/`, as GitHub's does: `actions/checkout`
13+//! downloads one this way in a job container without git, and expects it.
14+
15+use g1t_contracts::repos::{ArchiveLink, ArchiveLinkArgs};
16+use g1t_contracts::{FailureCode, Outcome, Viewer};
17+use serde_json::{Value, json};
18+use worker::Result;
19+
20+use crate::operations::{Services, repo_path};
21+
22+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23+pub enum ArchiveOp {
24+ DownloadTarball,
25+ DownloadZipball,
26+}
27+
28+impl ArchiveOp {
29+ #[cfg(test)]
30+ pub const ALL: [ArchiveOp; 2] = [ArchiveOp::DownloadTarball, ArchiveOp::DownloadZipball];
31+
32+ pub fn name(self) -> &'static str {
33+ match self {
34+ ArchiveOp::DownloadTarball => "download_tarball_archive",
35+ ArchiveOp::DownloadZipball => "download_zipball_archive",
36+ }
37+ }
38+
39+ /// For the API reference.
40+ pub fn title(self) -> &'static str {
41+ match self {
42+ ArchiveOp::DownloadTarball => "Download a repository archive (tar)",
43+ ArchiveOp::DownloadZipball => "Download a repository archive (zip)",
44+ }
45+ }
46+
47+ pub fn description(self) -> &'static str {
48+ match self {
49+ ArchiveOp::DownloadTarball => "A link to download a branch, tag or commit as a gzipped tar archive (the default branch without ref), good for 10 minutes and needing no token. Over REST, GET …/tarball/{ref} answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with commit and expires_at. The archive holds one folder, <owner>-<repo>-<first 7 of the commit>/, with executable files and symlinks as git has them and the commit in its pax header. Up to 10,000 files and 24 MB; clone a larger repository. Needs the Read role; a public repository's are open to anyone.",
50+ ArchiveOp::DownloadZipball => "A link to download a branch, tag or commit as a zip archive (the default branch without ref), good for 10 minutes and needing no token. Over REST, GET …/zipball/{ref} answers 302 with the link in Location, as GitHub does: `curl -L` follows it. Over MCP the link is returned as url, with commit and expires_at. The archive holds one folder, <owner>-<repo>-<first 7 of the commit>/. Up to 10,000 files and 24 MB; clone a larger repository. Needs the Read role; a public repository's are open to anyone.",
51+ }
52+ }
53+
54+ fn suffix(self) -> &'static str {
55+ match self {
56+ ArchiveOp::DownloadTarball => "tar.gz",
57+ ArchiveOp::DownloadZipball => "zip",
58+ }
59+ }
60+
61+ pub fn input(self) -> Value {
62+ json!({
63+ "type": "object",
64+ "properties": {
65+ "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." },
66+ "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out. A branch with slashes is sent URL-encoded as one path segment." },
67+ },
68+ "required": ["repo"],
69+ })
70+ }
71+}
72+
73+/// Where the site serves the archive of `link`.
74+pub fn archive_url(site: &str, link: &ArchiveLink, op: ArchiveOp) -> String {
75+ format!(
76+ "{site}/{}/{}/archive/{}.{}?token={}",
77+ link.repo.namespace,
78+ link.repo.name,
79+ link.commit,
80+ op.suffix(),
81+ link.token
82+ )
83+}
84+
85+pub async fn run(op: ArchiveOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
86+ let Some(path) = repo_path(input) else {
87+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\"."));
88+ };
89+ let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned);
90+ let found: Outcome<ArchiveLink> = g1t_kit::call(&services.repos, "archive_link", &ArchiveLinkArgs { path, viewer: viewer.clone(), git_ref }).await?;
91+ Ok(match found {
92+ Outcome::Ok(link) => Outcome::Ok(json!({
93+ "url": archive_url(&services.addresses.site, &link, op),
94+ "commit": link.commit,
95+ "expires_at": link.expires_at,
96+ })),
97+ Outcome::Fail(refused) => Outcome::Fail(refused),
98+ })
99+}
100+
101+#[cfg(test)]
102+mod tests {
103+ use super::*;
104+
105+ fn link() -> ArchiveLink {
106+ let repo: g1t_contracts::repos::Repo = serde_json::from_value(json!({
107+ "id": "repo_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": true,
108+ "ownerId": "ws_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-10-01T00:00:00.000Z",
109+ }))
110+ .unwrap();
111+ ArchiveLink { repo, commit: "4807077b296e6edbf410d55e72749d3e1170c291".into(), token: "abc_-123".into(), expires_at: "2026-10-12T00:10:00.000Z".into() }
112+ }
113+
114+ #[test]
115+ fn the_link_is_the_sites_archive_of_the_commit() {
116+ let link = link();
117+ assert_eq!(
118+ archive_url("https://g1t.sh", &link, ArchiveOp::DownloadTarball),
119+ "https://g1t.sh/acme/web/archive/4807077b296e6edbf410d55e72749d3e1170c291.tar.gz?token=abc_-123"
120+ );
121+ assert_eq!(
122+ archive_url("http://localhost:8787", &link, ArchiveOp::DownloadZipball),
123+ "http://localhost:8787/acme/web/archive/4807077b296e6edbf410d55e72749d3e1170c291.zip?token=abc_-123"
124+ );
125+ }
126+
127+ #[test]
128+ fn each_operation_is_described_with_a_schema() {
129+ for op in ArchiveOp::ALL {
130+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Archives(op)), "{}", op.name());
131+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
132+ assert_eq!(op.input()["required"], json!(["repo"]));
133+ assert_eq!(g1t_contracts::scopes::scope_for(op.name()), Some(g1t_contracts::scopes::Scope::CodeRead), "{}", op.name());
134+ }
135+ }
136+}
+1−1
8181 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.",
8282 DeploymentsOp::ListEnvironments => "List the environments a repository's deployments went to, those people use directly first (production by name before others), then the most recently deployed, and after them those with protection rules but no deployment yet. Each has its name, url (where its current deployment is served), production_environment, transient_environment, deployments_count, latest (its newest deployment, whatever its state), current (its newest successful deployment that is still active) and updated_at, and, when it has rules, protection_rules (required_reviewers, wait_timer, branch_policy), deployment_branch_policy, branch_policies and can_admins_bypass. total_count counts deployments across every environment. Needs the Read role.",
8383 DeploymentsOp::GetEnvironment => "Get one environment by name, matched without regard to case, with its current and latest deployments and its protection rules (see update_environment). An environment with rules but no deployment yet is found too. A name with slashes is URL-encoded in the path. Needs the Read role.",
84− DeploymentsOp::StartDeploymentBuild => "From a running g1t Actions job, open a g1t.page build of one of the repository's projects, which the job then builds in its own checkout with `g1t-runner deploy` (the g1t/deploy action does both). Only the job's own token (G1T_TOKEN) may call it, with `deployments: write` (or `pages: write`) in its `permissions:`. project is the project's slug; it may be left out when the repository has one project with Deployments on. The run decides what is built: a run on the project's production branch (a push, a dispatch or a schedule there) builds production, a pull request's run builds that pull request's preview, and a run on another branch builds that branch's preview; a tag's run is refused. sha is the commit built, the run's unless you say. A pull request from a fork or from someone without the Write role, and a run that had to be approved, get no build secrets. The workspace's plan must include Deployments and its spend limit must not be reached; the build's time is the job's Actions time and is not charged again. Returns deploy_id and token (DEPLOY_ID and DEPLOY_TOKEN for `g1t-runner deploy`, the token good for this build alone until it finishes), kind (production or preview), project, branch, number, commit, url (the app's address), trusted, the project's root_dir, build_command and output_dir, and build_env and build_secrets (the project's variables and secrets for builds of that kind). The commit's `g1t / deploy` check is pending until the build reports.",
84+ DeploymentsOp::StartDeploymentBuild => "From a running g1t Actions job, open a g1t.page build of one of the repository's projects, which the job then builds in its own checkout with `g1t-runner deploy` (the g1t/deploy action does both). Only the job's own token (G1T_TOKEN) may call it, with `deployments: write` (or `pages: write`) in its `permissions:`. project is the project's slug; it may be left out when the repository has one project with Deployments on. The run decides what is built: a run on the project's production branch (a push, a dispatch or a schedule there) builds production, a pull request's run builds that pull request's preview, and a run on another branch builds that branch's preview; a tag's run is refused. sha is the commit built, the run's unless you say. A pull request from a fork or from someone without the Write role, and a run that had to be approved, get no build secrets. The workspace's plan must include Deployments and its spend limit must not be reached; the build's time is the job's Actions time and is not charged again. Returns deploy_id and token (DEPLOY_ID and DEPLOY_TOKEN for `g1t-runner deploy`, the token good for this build alone until it finishes), kind (production or preview), project, branch, number, commit, url (the app's address), trusted, the project's root_dir, build_command and output_dir, build_as (auto, static, workers or docker) and dockerfile (its Dockerfile's path, or null to find `Dockerfile` or `Containerfile`), and build_env and build_secrets (the project's variables and secrets for builds of that kind). The commit's `g1t / deploy` check is pending until the build reports.",
8585 }
8686 }
8787
+12−6
66
77 mod about;
88 mod run_artifacts;
9+mod archives;
910 mod addresses;
1011 mod alerts;
1112 mod audit;
548549 }
549550
550551 // A sandbox building a deployment, reporting with its build's token,
551− // which is not a g1t token. The body goes through as it is: it can
552− // carry a Worker's bundled code.
552+ // which is not a g1t token, or a container app's Worker reporting how
553+ // long its container ran, with its own token. The body goes through as
554+ // it is: it can carry a Worker's bundled code.
553555 if method == "POST" && !on_mcp
554− && let Some(rest) = path.strip_prefix("/deployments/jobs/")
556+ && let Some(rest) = path
557+ .strip_prefix("/deployments/jobs/")
558+ .map(|rest| format!("jobs/{rest}"))
559+ .or_else(|| path.strip_prefix("/deployments/apps/").filter(|rest| rest.ends_with("/meter")).map(|rest| format!("apps/{rest}")))
555560 {
556− let target = format!("https://deployments/jobs/{rest}");
561+ let target = format!("https://deployments/{rest}");
557562 let body = request.bytes().await?;
558563 let headers = worker::Headers::new();
559564 headers.set("content-type", "application/json")?;
864869 return fail(FailureCode::NotFound, "No such endpoint.");
865870 };
866871 match audit::run(route.op, &services, &viewer, &input).await? {
867− // A download is a redirect to its signed link, as GitHub's is.
868− Outcome::Ok(value) if route.op == operations::Op::Artifacts(run_artifacts::ArtifactsOp::DownloadArtifact) => {
872+ // A download is a redirect to its signed link, as GitHub's is: an
873+ // artifact's, and a repository's tarball and zipball.
874+ Outcome::Ok(value) if route.op.redirects() => {
869875 match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) {
870876 Some(url) => Response::redirect_with_status(url, 302),
871877 None => reply(&value),
+9−1
99
1010 use crate::about::AboutOp;
1111 use crate::run_artifacts::ArtifactsOp;
12+use crate::archives::ArchiveOp;
1213 use crate::customize::CustomizeOp;
1314 use crate::deploy_keys::DeployKeysOp;
1415 use crate::mirrors::MirrorsOp;
114115 ),
115116 (
116117 "Repositories",
117− "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it.",
118+ "A repository, how it handles pull requests, and its timeline: renaming, archiving, moving and deleting it; and its files at a branch, tag or commit as a tarball or zipball.",
118119 &[
119120 Op::ListRepos,
120121 Op::CreateRepo,
121122 Op::GetRepo,
123+ Op::Archives(ArchiveOp::DownloadTarball),
124+ Op::Archives(ArchiveOp::DownloadZipball),
122125 Op::UpdateRepo,
123126 Op::RenameRepo,
124127 Op::RenameBranch,
498501 Op::Protection(ProtectionOp::CreateRepositoryDispatch),
499502 Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
500503 Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
504+ Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows),
505+ Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow),
506+ Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow),
507+ Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow),
501508 ],
502509 ),
503510 (
836843 Op::Protection(op) => op.title(),
837844 Op::Tokens(op) => op.title(),
838845 Op::Artifacts(op) => op.title(),
846+ Op::Archives(op) => op.title(),
839847 Op::DeployKeys(op) => op.title(),
840848 Op::Mirrors(op) => op.title(),
841849 Op::Packages(op) => op.title(),
+24−1
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
3131 use crate::run_artifacts::ArtifactsOp;
32+use crate::archives::ArchiveOp;
3233 use crate::customize::CustomizeOp;
3334 use crate::deploy_keys::DeployKeysOp;
3435 use crate::mirrors::MirrorsOp;
330331 Tokens(TokenOp),
331332 /// Workflow run artifacts, and how long they are kept: run_artifacts.rs.
332333 Artifacts(ArtifactsOp),
334+ /// A branch, tag or commit as a tarball or zipball: archives.rs.
335+ Archives(ArchiveOp),
333336 /// A repository's deploy keys: deploy_keys.rs.
334337 DeployKeys(DeployKeysOp),
335338 /// A repository's mirroring: its remotes, takeovers and hand-backs:
713716 }
714717
715718 impl Op {
716− pub const ALL: [Op; 382] = [
719+ pub const ALL: [Op; 388] = [
717720 Op::Whoami,
718721 Op::GetWorkspace,
719722 Op::CreateWorkspace,
10071010 Op::Artifacts(ArtifactsOp::DeleteArtifact),
10081011 Op::Artifacts(ArtifactsOp::GetArtifactRetention),
10091012 Op::Artifacts(ArtifactsOp::SetArtifactRetention),
1013+ Op::Archives(ArchiveOp::DownloadTarball),
1014+ Op::Archives(ArchiveOp::DownloadZipball),
10101015 Op::DeployKeys(DeployKeysOp::ListDeployKeys),
10111016 Op::DeployKeys(DeployKeysOp::GetDeployKey),
10121017 Op::DeployKeys(DeployKeysOp::CreateDeployKey),
10561061 Op::Protection(ProtectionOp::CreateRepositoryDispatch),
10571062 Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions),
10581063 Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
1064+ Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows),
1065+ Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow),
1066+ Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow),
1067+ Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow),
10591068 Op::Tokens(TokenOp::GetTokenPolicy),
10601069 Op::Tokens(TokenOp::SetTokenPolicy),
10611070 Op::Tokens(TokenOp::ListMemberTokens),
13121321 Op::Protection(op) => op.name(),
13131322 Op::Tokens(op) => op.name(),
13141323 Op::Artifacts(op) => op.name(),
1324+ Op::Archives(op) => op.name(),
13151325 Op::DeployKeys(op) => op.name(),
13161326 Op::Mirrors(op) => op.name(),
13171327 Op::Packages(op) => op.name(),
18911901 Op::Protection(op) => op.description(),
18921902 Op::Tokens(op) => op.description(),
18931903 Op::Artifacts(op) => op.description(),
1904+ Op::Archives(op) => op.description(),
18941905 Op::DeployKeys(op) => op.description(),
18951906 Op::Mirrors(op) => op.description(),
18961907 Op::Packages(op) => op.description(),
34133424 Op::Protection(op) => op.input(),
34143425 Op::Tokens(op) => op.input(),
34153426 Op::Artifacts(op) => op.input(),
3427+ Op::Archives(op) => op.input(),
34163428 Op::DeployKeys(op) => op.input(),
34173429 Op::Mirrors(op) => op.input(),
34183430 Op::Packages(op) => op.input(),
34213433 }
34223434 }
34233435
3436+ /// Whether REST answers with a redirect to the `url` it returns, as
3437+ /// GitHub's downloads do.
3438+ pub(crate) fn redirects(self) -> bool {
3439+ matches!(self, Op::Artifacts(ArtifactsOp::DownloadArtifact) | Op::Archives(_))
3440+ }
3441+
34243442 /// Whether the operation refuses an anonymous caller outright.
34253443 pub(crate) fn needs_user(self) -> bool {
34263444 // A public repository's checks are anyone's to read.
34343452 if let Op::Artifacts(op) = self {
34353453 return op.writes();
34363454 }
3455+ // And its archives.
3456+ if let Op::Archives(_) = self {
3457+ return false;
3458+ }
34373459 // So are public packages.
34383460 if let Op::Packages(op) = self {
34393461 return !op.anonymous();
58015823 Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
58025824 Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await,
58035825 Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await,
5826+ Op::Archives(op) => crate::archives::run(op, services, viewer, input).await,
58045827 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
58055828 Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
58065829 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
+151−6
2929 CreateRepositoryDispatch,
3030 GetWorkspaceWorkflowPermissions,
3131 SetWorkspaceWorkflowPermissions,
32+ ListWorkspaceManagedWorkflows,
33+ CreateWorkspaceManagedWorkflow,
34+ UpdateWorkspaceManagedWorkflow,
35+ DeleteWorkspaceManagedWorkflow,
3236 }
3337
3438 impl ProtectionOp {
3539 /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test
3640 /// checks against this.
3741 #[cfg(test)]
38− pub const ALL: [ProtectionOp; 14] = [
42+ pub const ALL: [ProtectionOp; 18] = [
3943 ProtectionOp::UpdateEnvironment,
4044 ProtectionOp::DeleteEnvironment,
4145 ProtectionOp::GetPendingDeployments,
5054 ProtectionOp::CreateRepositoryDispatch,
5155 ProtectionOp::GetWorkspaceWorkflowPermissions,
5256 ProtectionOp::SetWorkspaceWorkflowPermissions,
57+ ProtectionOp::ListWorkspaceManagedWorkflows,
58+ ProtectionOp::CreateWorkspaceManagedWorkflow,
59+ ProtectionOp::UpdateWorkspaceManagedWorkflow,
60+ ProtectionOp::DeleteWorkspaceManagedWorkflow,
5361 ];
5462
5563 pub fn name(self) -> &'static str {
6876 ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch",
6977 ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions",
7078 ProtectionOp::SetWorkspaceWorkflowPermissions => "set_workspace_workflow_permissions",
79+ ProtectionOp::ListWorkspaceManagedWorkflows => "list_workspace_managed_workflows",
80+ ProtectionOp::CreateWorkspaceManagedWorkflow => "create_workspace_managed_workflow",
81+ ProtectionOp::UpdateWorkspaceManagedWorkflow => "update_workspace_managed_workflow",
82+ ProtectionOp::DeleteWorkspaceManagedWorkflow => "delete_workspace_managed_workflow",
7183 }
7284 }
7385
7486 /// Whether it is about one repository, named by `repo`; the rest are a
7587 /// workspace's.
7688 pub fn needs_repo(self) -> bool {
77− !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions)
89+ !self.managed() && !matches!(self, ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions)
90+ }
91+
92+ /// Whether it is about a workspace's managed workflows.
93+ fn managed(self) -> bool {
94+ matches!(
95+ self,
96+ ProtectionOp::ListWorkspaceManagedWorkflows
97+ | ProtectionOp::CreateWorkspaceManagedWorkflow
98+ | ProtectionOp::UpdateWorkspaceManagedWorkflow
99+ | ProtectionOp::DeleteWorkspaceManagedWorkflow
100+ )
78101 }
79102
80103 pub fn title(self) -> &'static str {
93116 ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event",
94117 ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions",
95118 ProtectionOp::SetWorkspaceWorkflowPermissions => "Set a workspace's default workflow permissions",
119+ ProtectionOp::ListWorkspaceManagedWorkflows => "List the workflows a workspace manages",
120+ ProtectionOp::CreateWorkspaceManagedWorkflow => "Add a workflow a workspace manages",
121+ ProtectionOp::UpdateWorkspaceManagedWorkflow => "Update a workflow a workspace manages",
122+ ProtectionOp::DeleteWorkspaceManagedWorkflow => "Remove a workflow a workspace manages",
96123 }
97124 }
98125
111138 ProtectionOp::SetActionsAccess => "Set access_level: none, or organization to let the workspace's other private repositories use this repository's actions and reusable workflows (user is read as organization). Needs the Admin role.",
112139 ProtectionOp::GetWorkspaceWorkflowPermissions => "A workspace's policy for its repositories' job tokens: default_workflow_permissions (read, the default, or write) is what a repository made from now on gets until it chooses; max_workflow_permissions (write, the default, or read) is the most any repository's default may be, so read holds every repository to read-only; can_approve_pull_request_reviews (off by default) lets its repositories allow jobs to open and approve pull requests. Members only.",
113140 ProtectionOp::SetWorkspaceWorkflowPermissions => "Change a workspace's default_workflow_permissions, max_workflow_permissions and can_approve_pull_request_reviews; fields left out stay as they are. A maximum of read makes the default read too. Owners only.",
141+ ProtectionOp::ListWorkspaceManagedWorkflows => "The workflows a workspace's owners added to its repositories, g1t's required workflows: each runs in every repository it targets as that repository's own workflow (its token, its secrets, its checks), under the path managed/<name>.yml, from a copy of its source file taken when it was saved. Each has name, path, workflow_name (its `name:`), source (owner/repo/path@ref), source_sha (the commit it was copied at), source_private, mode, repositories, visibility, content (the copy that runs) and when and by whom it was last saved. Owners only.",
142+ ProtectionOp::CreateWorkspaceManagedWorkflow => "Add a workflow to a workspace's repositories. name (lowercase letters, digits and hyphens) makes its path managed/<name>.yml. source is a workflow file in one of the workspace's repositories, owner/repo/.g1t/workflows/file.yml@ref; g1t copies it at the commit ref names, and the copy runs until it is copied again (update_workspace_managed_workflow with refresh). mode is required (a repository's workflow of the same `name:` is set aside, and no one in the repository can turn it off, so its check can be required by a ruleset) or default (a repository's own workflow of the same name runs in its place, and the repository's admins can turn it off). repositories lists names and patterns (* matches any characters, ! excludes), read in order, the last match deciding; empty or left out is every repository. visibility is all (the default), public or private. A source in a private repository runs in private repositories only, so visibility must be private. Owners only. Returns the workflow.",
143+ ProtectionOp::UpdateWorkspaceManagedWorkflow => "Change a workflow a workspace manages: its source, mode, repositories or visibility; fields left out stay as they are. Giving source, or refresh: true, copies the source again at the commit its ref names now. Owners only. Returns the workflow.",
144+ ProtectionOp::DeleteWorkspaceManagedWorkflow => "Take a workflow out of every repository of the workspace. Its runs stay. Owners only.",
114145 ProtectionOp::CreateRepositoryDispatch => "Start the default branch's workflows that run `on: repository_dispatch` for event_type (those listing it under types, or with none). client_payload, a JSON object of at most 10 properties and 64 KB, is github.event.client_payload; github.event.action is event_type. A workflow job's own token may send one: with workflow_dispatch, it is how one workflow starts another. Needs the Write role (code:write). Returns how many runs started.",
115146 }
116147 }
124155 | ProtectionOp::GetForkPrApproval
125156 | ProtectionOp::GetActionsAccess
126157 | ProtectionOp::GetWorkspaceWorkflowPermissions
158+ | ProtectionOp::ListWorkspaceManagedWorkflows
127159 )
128160 }
129161
201233 }),
202234 &["repo"],
203235 ),
204− ProtectionOp::GetWorkspaceWorkflowPermissions => (json!({ "workspace": workspace }), &["workspace"]),
236+ ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::ListWorkspaceManagedWorkflows => (json!({ "workspace": workspace }), &["workspace"]),
237+ ProtectionOp::CreateWorkspaceManagedWorkflow => (
238+ json!({
239+ "workspace": workspace,
240+ "name": { "type": "string", "description": "Lowercase letters, digits and hyphens: its path is managed/<name>.yml." },
241+ "source": managed_source(),
242+ "mode": managed_mode(),
243+ "repositories": managed_repositories(),
244+ "visibility": managed_visibility(),
245+ }),
246+ &["workspace", "name", "source", "mode"],
247+ ),
248+ ProtectionOp::UpdateWorkspaceManagedWorkflow => (
249+ json!({
250+ "workspace": workspace,
251+ "workflow": managed_name(),
252+ "source": managed_source(),
253+ "mode": managed_mode(),
254+ "repositories": managed_repositories(),
255+ "visibility": managed_visibility(),
256+ "refresh": { "type": "boolean", "description": "Copy the source again, at the commit its ref names now." },
257+ }),
258+ &["workspace", "workflow"],
259+ ),
260+ ProtectionOp::DeleteWorkspaceManagedWorkflow => (json!({ "workspace": workspace, "workflow": managed_name() }), &["workspace", "workflow"]),
205261 ProtectionOp::SetWorkspaceWorkflowPermissions => (
206262 json!({
207263 "workspace": workspace,
231287 }
232288 }
233289
290+fn managed_name() -> Value {
291+ json!({ "type": "string", "description": "The managed workflow's name, as in managed/<name>.yml." })
292+}
293+
294+fn managed_source() -> Value {
295+ json!({ "type": "string", "description": "A workflow file in one of the workspace's repositories: owner/repo/.g1t/workflows/file.yml@ref." })
296+}
297+
298+fn managed_mode() -> Value {
299+ json!({ "type": "string", "enum": ["required", "default"], "description": "required: a repository cannot take its place or turn it off. default: a repository's workflow of the same name takes its place." })
300+}
301+
302+fn managed_repositories() -> Value {
303+ json!({ "type": "array", "items": { "type": "string" }, "description": "Repository names and patterns (* matches any characters, ! excludes); empty is every repository." })
304+}
305+
306+fn managed_visibility() -> Value {
307+ json!({ "type": "string", "enum": ["all", "public", "private"], "description": "Which repositories, by visibility. A private source needs private." })
308+}
309+
310+/// A workspace's managed workflow, in the API's shape.
311+fn managed_view(workflow: &Value) -> Value {
312+ json!({
313+ "name": workflow["name"],
314+ "path": workflow["path"],
315+ "workflow_name": workflow["workflowName"],
316+ "source": workflow["source"],
317+ "source_sha": workflow["sourceSha"],
318+ "source_private": workflow["sourcePrivate"],
319+ "mode": workflow["mode"],
320+ "repositories": workflow["repositories"],
321+ "visibility": workflow["visibility"],
322+ "content": workflow["content"],
323+ "created_at": workflow["createdAt"],
324+ "updated_at": workflow["updatedAt"],
325+ "updated_by": workflow["updatedBy"],
326+ })
327+}
328+
329+/// A workspace's managed workflows: listed, added, changed and removed.
330+async fn run_managed(op: ProtectionOp, actions: &worker::Fetcher, viewer: &Viewer, actor: g1t_contracts::User, workspace: String, input: &Value) -> Result<Outcome<Value>> {
331+ let strings = |key: &str| -> Option<Vec<String>> {
332+ input.get(key).and_then(Value::as_array).map(|items| items.iter().filter_map(Value::as_str).map(str::to_owned).collect())
333+ };
334+ Ok(match op {
335+ ProtectionOp::ListWorkspaceManagedWorkflows => {
336+ let listed: Outcome<Vec<Value>> = g1t_kit::call(actions, "workspace_managed_workflows", &json!({ "viewer": viewer, "workspace": workspace })).await?;
337+ map(listed, |list| json!({ "total_count": list.len(), "managed_workflows": list.iter().map(managed_view).collect::<Vec<_>>() }))
338+ }
339+ ProtectionOp::CreateWorkspaceManagedWorkflow | ProtectionOp::UpdateWorkspaceManagedWorkflow => {
340+ let create = op == ProtectionOp::CreateWorkspaceManagedWorkflow;
341+ let name = if create { text(input, "name") } else { text(input, "workflow") };
342+ let saved: Outcome<Value> = g1t_kit::call(
343+ actions,
344+ "set_workspace_managed_workflow",
345+ &json!({
346+ "actor": actor,
347+ "workspace": workspace,
348+ "name": name.unwrap_or_default(),
349+ "create": create,
350+ "source": text(input, "source"),
351+ "mode": text(input, "mode"),
352+ "repositories": strings("repositories"),
353+ "visibility": text(input, "visibility"),
354+ "refresh": flag(input, "refresh").unwrap_or(false),
355+ }),
356+ )
357+ .await?;
358+ map(saved, |workflow| managed_view(&workflow))
359+ }
360+ ProtectionOp::DeleteWorkspaceManagedWorkflow => {
361+ let removed: Outcome<bool> = g1t_kit::call(
362+ actions,
363+ "remove_workspace_managed_workflow",
364+ &json!({ "actor": actor, "workspace": workspace, "name": text(input, "workflow").unwrap_or_default() }),
365+ )
366+ .await?;
367+ map(removed, |removed| json!({ "deleted": removed }))
368+ }
369+ _ => Outcome::fail(FailureCode::Invalid, "Not a managed workflow operation."),
370+ })
371+}
372+
234373 fn text(input: &Value, key: &str) -> Option<String> {
235374 match &input[key] {
236375 Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()),
455594 let Some(workspace) = text(input, "workspace") else {
456595 return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace."));
457596 };
597+ if op.managed() {
598+ return run_managed(op, actions, viewer, actor(), workspace, input).await;
599+ }
458600 let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions {
459601 g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await?
460602 } else {
587729 map(started, |runs| json!({ "runs": runs }))
588730 }
589731 // Answered above, before a repository is read.
590− ProtectionOp::GetWorkspaceWorkflowPermissions | ProtectionOp::SetWorkspaceWorkflowPermissions => {
591− Outcome::fail(FailureCode::Invalid, "Name the workspace.")
592− }
732+ ProtectionOp::GetWorkspaceWorkflowPermissions
733+ | ProtectionOp::SetWorkspaceWorkflowPermissions
734+ | ProtectionOp::ListWorkspaceManagedWorkflows
735+ | ProtectionOp::CreateWorkspaceManagedWorkflow
736+ | ProtectionOp::UpdateWorkspaceManagedWorkflow
737+ | ProtectionOp::DeleteWorkspaceManagedWorkflow => Outcome::fail(FailureCode::Invalid, "Name the workspace."),
593738 })
594739 }
595740
+126−0
1188911889 "root_dir": "",
1189011890 "build_command": null,
1189111891 "output_dir": null,
11892+ "build_as": "auto",
11893+ "dockerfile": null,
1189211894 "build_env": {
1189311895 "PUBLIC_API_URL": "https://api.example.com"
1189411896 },
1225112253 },
1225212254 "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too."
1225312255 },
12256+ "list_workspace_managed_workflows": {
12257+ "params": {
12258+ "workspace": "flagon-io"
12259+ },
12260+ "response": {
12261+ "total_count": 1,
12262+ "managed_workflows": [
12263+ {
12264+ "name": "security-scan",
12265+ "path": "managed/security-scan.yml",
12266+ "workflow_name": "Security scan",
12267+ "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main",
12268+ "source_sha": "4f1c2a9e0b7d3c5a8e6f1b2d4c7a9e0f3b5d8c1a",
12269+ "source_private": false,
12270+ "mode": "required",
12271+ "repositories": [
12272+ "*",
12273+ "!sandbox-*"
12274+ ],
12275+ "visibility": "all",
12276+ "content": "name: Security scan\non: [push, pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: ./scripts/scan.sh\n",
12277+ "created_at": "2026-10-12T09:30:00Z",
12278+ "updated_at": "2026-10-12T09:30:00Z",
12279+ "updated_by": "ada"
12280+ }
12281+ ]
12282+ }
12283+ },
12284+ "create_workspace_managed_workflow": {
12285+ "params": {
12286+ "workspace": "flagon-io"
12287+ },
12288+ "request": {
12289+ "name": "security-scan",
12290+ "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main",
12291+ "mode": "required",
12292+ "repositories": [
12293+ "*",
12294+ "!sandbox-*"
12295+ ]
12296+ },
12297+ "response": {
12298+ "name": "security-scan",
12299+ "path": "managed/security-scan.yml",
12300+ "workflow_name": "Security scan",
12301+ "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main",
12302+ "source_sha": "4f1c2a9e0b7d3c5a8e6f1b2d4c7a9e0f3b5d8c1a",
12303+ "source_private": false,
12304+ "mode": "required",
12305+ "repositories": [
12306+ "*",
12307+ "!sandbox-*"
12308+ ],
12309+ "visibility": "all",
12310+ "content": "name: Security scan\non: [push, pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: ./scripts/scan.sh\n",
12311+ "created_at": "2026-10-12T09:30:00Z",
12312+ "updated_at": "2026-10-12T09:30:00Z",
12313+ "updated_by": "ada"
12314+ },
12315+ "notes": "g1t copies the source when you save it, and that copy runs in each repository the workflow targets, as the repository's own workflow, with its token and secrets. A source in a private repository runs in private repositories only: give visibility private."
12316+ },
12317+ "update_workspace_managed_workflow": {
12318+ "params": {
12319+ "workspace": "flagon-io",
12320+ "workflow": "security-scan"
12321+ },
12322+ "request": {
12323+ "refresh": true
12324+ },
12325+ "response": {
12326+ "name": "security-scan",
12327+ "path": "managed/security-scan.yml",
12328+ "workflow_name": "Security scan",
12329+ "source": "flagon-io/policies/.g1t/workflows/security-scan.yml@main",
12330+ "source_sha": "4f1c2a9e0b7d3c5a8e6f1b2d4c7a9e0f3b5d8c1a",
12331+ "source_private": false,
12332+ "mode": "required",
12333+ "repositories": [
12334+ "*",
12335+ "!sandbox-*"
12336+ ],
12337+ "visibility": "all",
12338+ "content": "name: Security scan\non: [push, pull_request]\njobs:\n scan:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: ./scripts/scan.sh\n",
12339+ "created_at": "2026-10-12T09:30:00Z",
12340+ "updated_at": "2026-10-12T09:30:00Z",
12341+ "updated_by": "ada"
12342+ },
12343+ "notes": "refresh copies the source again at the commit its ref names now. Fields left out stay as they are."
12344+ },
12345+ "delete_workspace_managed_workflow": {
12346+ "params": {
12347+ "workspace": "flagon-io",
12348+ "workflow": "security-scan"
12349+ },
12350+ "response": {
12351+ "deleted": true
12352+ }
12353+ },
1225412354 "get_token_policy": {
1225512355 "params": {
1225612356 "workspace": "flagon-io"
1323413334 },
1323513335 "notes": "Over REST the answer is `302 Found` with the link in `Location`, as GitHub's is, so `curl -L -o web-dist.zip …/zip` saves the file; the body above is what the MCP `workflow` tool's `download_artifact` returns. The link needs no token and stops working after 10 minutes."
1323613336 },
13337+ "download_tarball_archive": {
13338+ "params": {
13339+ "owner": "flagon-io",
13340+ "name": "hello",
13341+ "ref": "main"
13342+ },
13343+ "response": {
13344+ "url": "https://g1t.sh/flagon-io/hello/archive/7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7.tar.gz?token=…",
13345+ "commit": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7",
13346+ "expires_at": "2026-10-12T15:13:00.000Z"
13347+ },
13348+ "notes": "Over REST the answer is `302 Found` with the link in `Location`, as GitHub's is, so `curl -L -H \"Authorization: Bearer g1t_…\" -o hello.tar.gz https://api.g1t.sh/repos/flagon-io/hello/tarball/main` saves the file and `tar -xzf hello.tar.gz` makes one folder, `flagon-io-hello-7c1e9a4/`; the body above is what the MCP `repository` tool's `tarball` returns. The link is for the commit the ref named when you asked, needs no token and stops working after 10 minutes. `actions/checkout` uses this route in a job container without git."
13349+ },
13350+ "download_zipball_archive": {
13351+ "params": {
13352+ "owner": "flagon-io",
13353+ "name": "hello",
13354+ "ref": "v1.2.0"
13355+ },
13356+ "response": {
13357+ "url": "https://g1t.sh/flagon-io/hello/archive/7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7.zip?token=…",
13358+ "commit": "7c1e9a4b2d6f80135ac9e2b7d4f6a8c0e1b3d5f7",
13359+ "expires_at": "2026-10-12T15:13:00.000Z"
13360+ },
13361+ "notes": "Over REST the answer is `302 Found` with the link in `Location`, as GitHub's is, so `curl -L -o hello.zip …/zipball/v1.2.0` saves the file, with one folder, `flagon-io-hello-7c1e9a4/`; the body above is what the MCP `repository` tool's `zipball` returns. The link needs no token and stops working after 10 minutes."
13362+ },
1323713363 "delete_artifact": {
1323813364 "params": {
1323913365 "owner": "flagon-io",
+2−0
119119 Op::Deployments(_) => return as_is,
120120 // Artifacts are shaped by the API itself, in `snake_case`.
121121 Op::Artifacts(_) => return as_is,
122+ // So are archive links.
123+ Op::Archives(_) => return as_is,
122124 // Deploy keys travel in `snake_case` from identity.
123125 Op::DeployKeys(DeployKeysOp::ListDeployKeys) => return through::<Vec<g1t_contracts::deploy_keys::DeployKey>>(op, as_is),
124126 Op::DeployKeys(DeployKeysOp::GetDeployKey | DeployKeysOp::CreateDeployKey) => {
+48−0
44
55 use crate::about::AboutOp;
66 use crate::run_artifacts::ArtifactsOp;
7+use crate::archives::ArchiveOp;
78 use crate::customize::CustomizeOp;
89 use crate::deploy_keys::DeployKeysOp;
910 use crate::mirrors::MirrorsOp;
274275 ),
275276 route("POST", "/repos", Op::CreateRepo, &[]),
276277 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
278+ // A branch, tag or commit as one archive: a redirect to a link good for
279+ // ten minutes, as GitHub's go to codeload.
280+ route("GET", "/repos/:owner/:name/tarball/:ref", Op::Archives(ArchiveOp::DownloadTarball), &[]),
281+ route("GET", "/repos/:owner/:name/zipball/:ref", Op::Archives(ArchiveOp::DownloadZipball), &[]),
277282 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
278283 route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
279284 route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
423428 Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions),
424429 &[],
425430 ),
431+ route(
432+ "GET",
433+ "/workspaces/:workspace/actions/managed-workflows",
434+ Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows),
435+ &[],
436+ ),
437+ route(
438+ "POST",
439+ "/workspaces/:workspace/actions/managed-workflows",
440+ Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow),
441+ &[],
442+ ),
443+ route(
444+ "PATCH",
445+ "/workspaces/:workspace/actions/managed-workflows/:workflow",
446+ Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow),
447+ &[],
448+ ),
449+ route(
450+ "DELETE",
451+ "/workspaces/:workspace/actions/managed-workflows/:workflow",
452+ Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow),
453+ &[],
454+ ),
426455 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
427456 route(
428457 "POST",
14391468 }
14401469
14411470 #[test]
1471+ fn archives_are_at_githubs_addresses_and_redirect() {
1472+ let sha = "4807077b296e6edbf410d55e72749d3e1170c291";
1473+ let (route, input) = resolve("GET", &format!("/repos/acme/web/tarball/{sha}"), &[], Value::Null).unwrap();
1474+ assert_eq!(route.op, Op::Archives(ArchiveOp::DownloadTarball));
1475+ assert_eq!(input, json!({ "ref": sha, "repo": "acme/web" }));
1476+ // A branch with slashes, URL-encoded as octokit sends it.
1477+ let (route, input) = resolve("GET", "/repos/acme/web/zipball/release%2F1.x", &[], Value::Null).unwrap();
1478+ assert_eq!(route.op, Op::Archives(ArchiveOp::DownloadZipball));
1479+ assert_eq!(input, json!({ "ref": "release/1.x", "repo": "acme/web" }));
1480+ // Both answer with a redirect to their link, as the artifact download does.
1481+ assert!(route.op.redirects());
1482+ assert!(Op::Archives(ArchiveOp::DownloadTarball).redirects());
1483+ assert!(Op::Artifacts(ArtifactsOp::DownloadArtifact).redirects());
1484+ assert!(!Op::GetRepo.redirects());
1485+ // Anyone may ask for a public repository's.
1486+ assert!(!route.op.needs_user());
1487+ }
1488+
1489+ #[test]
14421490 fn query_parameters_are_renamed() {
14431491 let query = [
14441492 ("q".to_owned(), "parser".to_owned()),
+1−0
7070 args["version"] = json!(body["version"].as_str().unwrap_or_default());
7171 args["running"] = json!(body["running"].as_array().cloned().unwrap_or_default());
7272 args["wait_ms"] = json!(body["wait_ms"].as_u64().unwrap_or(0));
73+ args["isolation"] = json!(body["isolation"].as_str());
7374 "runner_poll"
7475 }
7576 "finished" => {
+8−1
2020
2121 use crate::about::AboutOp;
2222 use crate::run_artifacts::ArtifactsOp;
23+use crate::archives::ArchiveOp;
2324 use crate::customize::CustomizeOp;
2425 use crate::deploy_keys::DeployKeysOp;
2526 use crate::mirrors::MirrorsOp;
7576 actions: &[
7677 a("list", Op::ListRepos, "Repositories you can see"),
7778 a("get", Op::GetRepo, "One repository"),
79+ a("tarball", Op::Archives(ArchiveOp::DownloadTarball), "A 10-minute link to a branch, tag or commit as a .tar.gz"),
80+ a("zipball", Op::Archives(ArchiveOp::DownloadZipball), "A 10-minute link to a branch, tag or commit as a .zip"),
7881 a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL"),
7982 a("update", Op::UpdateRepo, "Change description, website, topics, default branch, protection"),
8083 a("get_settings", Op::GetRepoSettings, "How pull requests merge, and the default branch's protection as its rules stack"),
229232 Tool {
230233 name: "workflow",
231234 title: "Workflows",
232− description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions and repository dispatch. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
235+ description: "GitHub Actions workflows from .g1t/workflows: their runs, jobs and logs, and running, cancelling or rerunning them. Runs' artifacts: listing, a download link, deleting, and how long they are kept. Deployments wherever they run (reported from any CI, made by jobs with an `environment:`, or built on g1t.page), their statuses and environments, and reporting your own; environments' protection rules, approving or rejecting the jobs they hold, approving a pull request's run from outside, the token's default permissions, repository dispatch, and the workflows a workspace runs in its repositories without a file in them. Checks on commits: statuses, check runs (a g1t Actions job is one) and check suites, to read where a commit stands or report on it from CI or an integration. Also the self-hosted runners they run on: a workspace's (`workspace`) or a repository's own (`repo`), their groups, and where agent work runs.",
233236 default_action: None,
234237 actions: &[
235238 a("list", Op::ListWorkflows, "Workflows on the default branch"),
281284 a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"),
282285 a("get_workspace_permissions", Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), "A workspace's default and maximum token permissions"),
283286 a("set_workspace_permissions", Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), "Set them, and whether jobs may open pull requests"),
287+ a("list_managed_workflows", Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), "The workflows a workspace runs in its repositories"),
288+ a("add_managed_workflow", Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), "Run a workflow in the workspace's repositories, required or default"),
289+ a("update_managed_workflow", Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), "Change one's repositories or mode, or copy its source again"),
290+ a("remove_managed_workflow", Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), "Take one out of the workspace's repositories"),
284291 a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"),
285292 a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"),
286293 a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"),
+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.