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.
| 40 | 40 | | `project` | The repository's one project with Deployments on | The project's slug. Needed when the repository has several. | | |
| 41 | 41 | | `root-dir` | The project's setting | Where the project is in the checkout. | | |
| 42 | 42 | | `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. | | |
| 44 | 46 | | `sha` | The run's commit | The commit this is a build of, for its `g1t / deploy` check. | | |
| 45 | 47 | | `token` | `${{ github.token }}` | The job's own token. It needs `deployments: write`; any other token is refused. | | |
| 46 | 48 |
| 12 | 12 | required: false | |
| 13 | 13 | default: "" | |
| 14 | 14 | 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). | |
| 16 | 16 | required: false | |
| 17 | 17 | default: "" | |
| 18 | 18 | 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. | |
| 20 | 24 | required: false | |
| 21 | 25 | 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: "" | |
| 22 | 30 | sha: | |
| 23 | 31 | description: The commit this is a build of, for the commit's g1t / deploy check. The run's commit unless you say. | |
| 24 | 32 | required: false | |
| ⋯ | |||
| 49 | 57 | INPUT_ROOT_DIR: ${{ inputs.root-dir }} | |
| 50 | 58 | INPUT_BUILD_COMMAND: ${{ inputs.build-command }} | |
| 51 | 59 | INPUT_OUTPUT_DIR: ${{ inputs.output-dir }} | |
| 60 | + | INPUT_BUILD_AS: ${{ inputs.build-as }} | |
| 61 | + | INPUT_DOCKERFILE: ${{ inputs.dockerfile }} | |
| 52 | 62 | INPUT_SHA: ${{ inputs.sha }} | |
| 53 | 63 | INPUT_TOKEN: ${{ inputs.token }} | |
| 54 | 64 | run: bash "$GITHUB_ACTION_PATH/deploy.sh" | |
| 93 | 93 | export ROOT_DIR="$(pick "${INPUT_ROOT_DIR:-}" root_dir)" | |
| 94 | 94 | export BUILD_COMMAND="$(pick "${INPUT_BUILD_COMMAND:-}" build_command)" | |
| 95 | 95 | 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)" | |
| 96 | 98 | export BUILD_ENV="$(field build_env <<<"${answer}")" | |
| 97 | 99 | export BUILD_SECRETS="$(field build_secrets <<<"${answer}")" | |
| 98 | 100 | unset GIT_REMOTE |
| 11 | 11 | uses: g1t/detect@v1 | |
| 12 | 12 | - if: steps.detect.outputs.kind == 'none' | |
| 13 | 13 | 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 }}" | |
| 14 | 16 | - if: steps.detect.outputs.install != '' | |
| 15 | 17 | run: ${{ steps.detect.outputs.install }} | |
| 16 | 18 | ``` | |
| ⋯ | |||
| 22 | 24 | | `root-dir` | The checkout | Where the project is in the checkout. | | |
| 23 | 25 | | `build-command` | None | A build command of your own, as `g1t/deploy` takes it. | | |
| 24 | 26 | | `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. | | |
| 25 | 29 | ||
| 26 | 30 | ## Outputs | |
| 27 | 31 | ||
| 28 | 32 | | Output | | | |
| 29 | 33 | | --- | --- | | |
| 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. | |
| 34 | 68 | ||
| 35 | 69 | It runs `g1t-runner detect`, found or downloaded as `g1t/deploy` finds it. | |
| 1 | 1 | 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. | |
| 3 | 3 | author: g1t | |
| 4 | 4 | ||
| 5 | 5 | inputs: | |
| ⋯ | |||
| 15 | 15 | description: An output folder of your own, as g1t/deploy takes it. | |
| 16 | 16 | required: false | |
| 17 | 17 | 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: "" | |
| 18 | 26 | ||
| 19 | 27 | outputs: | |
| 20 | 28 | 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). | |
| 22 | 30 | value: ${{ steps.detect.outputs.kind }} | |
| 23 | 31 | install: | |
| 24 | 32 | description: How its dependencies are installed, such as npm ci. Empty without a package.json. | |
| ⋯ | |||
| 29 | 37 | output-dir: | |
| 30 | 38 | description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it. | |
| 31 | 39 | 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 }} | |
| 32 | 49 | ||
| 33 | 50 | runs: | |
| 34 | 51 | using: composite | |
| ⋯ | |||
| 39 | 56 | ROOT_DIR: ${{ inputs.root-dir }} | |
| 40 | 57 | BUILD_COMMAND: ${{ inputs.build-command }} | |
| 41 | 58 | OUTPUT_DIR: ${{ inputs.output-dir }} | |
| 59 | + | BUILD_AS: ${{ inputs.build-as }} | |
| 60 | + | DOCKERFILE: ${{ inputs.dockerfile }} | |
| 42 | 61 | run: | | |
| 43 | 62 | set -euo pipefail | |
| 44 | 63 | runner="$(bash "$GITHUB_ACTION_PATH/runner.sh")" | |
| 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 | + | } |
| 81 | 81 | 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.", | |
| 82 | 82 | 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.", | |
| 83 | 83 | 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.", | |
| 85 | 85 | } | |
| 86 | 86 | } | |
| 87 | 87 |
| 6 | 6 | ||
| 7 | 7 | mod about; | |
| 8 | 8 | mod run_artifacts; | |
| 9 | + | mod archives; | |
| 9 | 10 | mod addresses; | |
| 10 | 11 | mod alerts; | |
| 11 | 12 | mod audit; | |
| ⋯ | |||
| 548 | 549 | } | |
| 549 | 550 | ||
| 550 | 551 | // 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. | |
| 553 | 555 | 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}"))) | |
| 555 | 560 | { | |
| 556 | − | let target = format!("https://deployments/jobs/{rest}"); | |
| 561 | + | let target = format!("https://deployments/{rest}"); | |
| 557 | 562 | let body = request.bytes().await?; | |
| 558 | 563 | let headers = worker::Headers::new(); | |
| 559 | 564 | headers.set("content-type", "application/json")?; | |
| ⋯ | |||
| 864 | 869 | return fail(FailureCode::NotFound, "No such endpoint."); | |
| 865 | 870 | }; | |
| 866 | 871 | 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() => { | |
| 869 | 875 | match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) { | |
| 870 | 876 | Some(url) => Response::redirect_with_status(url, 302), | |
| 871 | 877 | None => reply(&value), | |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::run_artifacts::ArtifactsOp; | |
| 12 | + | use crate::archives::ArchiveOp; | |
| 12 | 13 | use crate::customize::CustomizeOp; | |
| 13 | 14 | use crate::deploy_keys::DeployKeysOp; | |
| 14 | 15 | use crate::mirrors::MirrorsOp; | |
| ⋯ | |||
| 114 | 115 | ), | |
| 115 | 116 | ( | |
| 116 | 117 | "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.", | |
| 118 | 119 | &[ | |
| 119 | 120 | Op::ListRepos, | |
| 120 | 121 | Op::CreateRepo, | |
| 121 | 122 | Op::GetRepo, | |
| 123 | + | Op::Archives(ArchiveOp::DownloadTarball), | |
| 124 | + | Op::Archives(ArchiveOp::DownloadZipball), | |
| 122 | 125 | Op::UpdateRepo, | |
| 123 | 126 | Op::RenameRepo, | |
| 124 | 127 | Op::RenameBranch, | |
| ⋯ | |||
| 498 | 501 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 499 | 502 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 500 | 503 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 504 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 505 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 506 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 507 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 501 | 508 | ], | |
| 502 | 509 | ), | |
| 503 | 510 | ( | |
| ⋯ | |||
| 836 | 843 | Op::Protection(op) => op.title(), | |
| 837 | 844 | Op::Tokens(op) => op.title(), | |
| 838 | 845 | Op::Artifacts(op) => op.title(), | |
| 846 | + | Op::Archives(op) => op.title(), | |
| 839 | 847 | Op::DeployKeys(op) => op.title(), | |
| 840 | 848 | Op::Mirrors(op) => op.title(), | |
| 841 | 849 | Op::Packages(op) => op.title(), | |
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::run_artifacts::ArtifactsOp; | |
| 32 | + | use crate::archives::ArchiveOp; | |
| 32 | 33 | use crate::customize::CustomizeOp; | |
| 33 | 34 | use crate::deploy_keys::DeployKeysOp; | |
| 34 | 35 | use crate::mirrors::MirrorsOp; | |
| ⋯ | |||
| 330 | 331 | Tokens(TokenOp), | |
| 331 | 332 | /// Workflow run artifacts, and how long they are kept: run_artifacts.rs. | |
| 332 | 333 | Artifacts(ArtifactsOp), | |
| 334 | + | /// A branch, tag or commit as a tarball or zipball: archives.rs. | |
| 335 | + | Archives(ArchiveOp), | |
| 333 | 336 | /// A repository's deploy keys: deploy_keys.rs. | |
| 334 | 337 | DeployKeys(DeployKeysOp), | |
| 335 | 338 | /// A repository's mirroring: its remotes, takeovers and hand-backs: | |
| ⋯ | |||
| 713 | 716 | } | |
| 714 | 717 | ||
| 715 | 718 | impl Op { | |
| 716 | − | pub const ALL: [Op; 382] = [ | |
| 719 | + | pub const ALL: [Op; 388] = [ | |
| 717 | 720 | Op::Whoami, | |
| 718 | 721 | Op::GetWorkspace, | |
| 719 | 722 | Op::CreateWorkspace, | |
| ⋯ | |||
| 1007 | 1010 | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 1008 | 1011 | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 1009 | 1012 | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 1013 | + | Op::Archives(ArchiveOp::DownloadTarball), | |
| 1014 | + | Op::Archives(ArchiveOp::DownloadZipball), | |
| 1010 | 1015 | Op::DeployKeys(DeployKeysOp::ListDeployKeys), | |
| 1011 | 1016 | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 1012 | 1017 | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| ⋯ | |||
| 1056 | 1061 | Op::Protection(ProtectionOp::CreateRepositoryDispatch), | |
| 1057 | 1062 | Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), | |
| 1058 | 1063 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 1064 | + | Op::Protection(ProtectionOp::ListWorkspaceManagedWorkflows), | |
| 1065 | + | Op::Protection(ProtectionOp::CreateWorkspaceManagedWorkflow), | |
| 1066 | + | Op::Protection(ProtectionOp::UpdateWorkspaceManagedWorkflow), | |
| 1067 | + | Op::Protection(ProtectionOp::DeleteWorkspaceManagedWorkflow), | |
| 1059 | 1068 | Op::Tokens(TokenOp::GetTokenPolicy), | |
| 1060 | 1069 | Op::Tokens(TokenOp::SetTokenPolicy), | |
| 1061 | 1070 | Op::Tokens(TokenOp::ListMemberTokens), | |
| ⋯ | |||
| 1312 | 1321 | Op::Protection(op) => op.name(), | |
| 1313 | 1322 | Op::Tokens(op) => op.name(), | |
| 1314 | 1323 | Op::Artifacts(op) => op.name(), | |
| 1324 | + | Op::Archives(op) => op.name(), | |
| 1315 | 1325 | Op::DeployKeys(op) => op.name(), | |
| 1316 | 1326 | Op::Mirrors(op) => op.name(), | |
| 1317 | 1327 | Op::Packages(op) => op.name(), | |
| ⋯ | |||
| 1891 | 1901 | Op::Protection(op) => op.description(), | |
| 1892 | 1902 | Op::Tokens(op) => op.description(), | |
| 1893 | 1903 | Op::Artifacts(op) => op.description(), | |
| 1904 | + | Op::Archives(op) => op.description(), | |
| 1894 | 1905 | Op::DeployKeys(op) => op.description(), | |
| 1895 | 1906 | Op::Mirrors(op) => op.description(), | |
| 1896 | 1907 | Op::Packages(op) => op.description(), | |
| ⋯ | |||
| 3413 | 3424 | Op::Protection(op) => op.input(), | |
| 3414 | 3425 | Op::Tokens(op) => op.input(), | |
| 3415 | 3426 | Op::Artifacts(op) => op.input(), | |
| 3427 | + | Op::Archives(op) => op.input(), | |
| 3416 | 3428 | Op::DeployKeys(op) => op.input(), | |
| 3417 | 3429 | Op::Mirrors(op) => op.input(), | |
| 3418 | 3430 | Op::Packages(op) => op.input(), | |
| ⋯ | |||
| 3421 | 3433 | } | |
| 3422 | 3434 | } | |
| 3423 | 3435 | ||
| 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 | + | ||
| 3424 | 3442 | /// Whether the operation refuses an anonymous caller outright. | |
| 3425 | 3443 | pub(crate) fn needs_user(self) -> bool { | |
| 3426 | 3444 | // A public repository's checks are anyone's to read. | |
| ⋯ | |||
| 3434 | 3452 | if let Op::Artifacts(op) = self { | |
| 3435 | 3453 | return op.writes(); | |
| 3436 | 3454 | } | |
| 3455 | + | // And its archives. | |
| 3456 | + | if let Op::Archives(_) = self { | |
| 3457 | + | return false; | |
| 3458 | + | } | |
| 3437 | 3459 | // So are public packages. | |
| 3438 | 3460 | if let Op::Packages(op) = self { | |
| 3439 | 3461 | return !op.anonymous(); | |
| ⋯ | |||
| 5801 | 5823 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5802 | 5824 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5803 | 5825 | Op::Artifacts(op) => crate::run_artifacts::run(op, services, viewer, input).await, | |
| 5826 | + | Op::Archives(op) => crate::archives::run(op, services, viewer, input).await, | |
| 5804 | 5827 | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5805 | 5828 | Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await, | |
| 5806 | 5829 | Op::Packages(op) => crate::packages::run(op, services, viewer, input).await, | |
| 29 | 29 | CreateRepositoryDispatch, | |
| 30 | 30 | GetWorkspaceWorkflowPermissions, | |
| 31 | 31 | SetWorkspaceWorkflowPermissions, | |
| 32 | + | ListWorkspaceManagedWorkflows, | |
| 33 | + | CreateWorkspaceManagedWorkflow, | |
| 34 | + | UpdateWorkspaceManagedWorkflow, | |
| 35 | + | DeleteWorkspaceManagedWorkflow, | |
| 32 | 36 | } | |
| 33 | 37 | ||
| 34 | 38 | impl ProtectionOp { | |
| 35 | 39 | /// Every one: `Op::ALL` lists each as `Op::Protection(…)`, which a test | |
| 36 | 40 | /// checks against this. | |
| 37 | 41 | #[cfg(test)] | |
| 38 | − | pub const ALL: [ProtectionOp; 14] = [ | |
| 42 | + | pub const ALL: [ProtectionOp; 18] = [ | |
| 39 | 43 | ProtectionOp::UpdateEnvironment, | |
| 40 | 44 | ProtectionOp::DeleteEnvironment, | |
| 41 | 45 | ProtectionOp::GetPendingDeployments, | |
| ⋯ | |||
| 50 | 54 | ProtectionOp::CreateRepositoryDispatch, | |
| 51 | 55 | ProtectionOp::GetWorkspaceWorkflowPermissions, | |
| 52 | 56 | ProtectionOp::SetWorkspaceWorkflowPermissions, | |
| 57 | + | ProtectionOp::ListWorkspaceManagedWorkflows, | |
| 58 | + | ProtectionOp::CreateWorkspaceManagedWorkflow, | |
| 59 | + | ProtectionOp::UpdateWorkspaceManagedWorkflow, | |
| 60 | + | ProtectionOp::DeleteWorkspaceManagedWorkflow, | |
| 53 | 61 | ]; | |
| 54 | 62 | ||
| 55 | 63 | pub fn name(self) -> &'static str { | |
| ⋯ | |||
| 68 | 76 | ProtectionOp::CreateRepositoryDispatch => "create_repository_dispatch", | |
| 69 | 77 | ProtectionOp::GetWorkspaceWorkflowPermissions => "get_workspace_workflow_permissions", | |
| 70 | 78 | 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", | |
| 71 | 83 | } | |
| 72 | 84 | } | |
| 73 | 85 | ||
| 74 | 86 | /// Whether it is about one repository, named by `repo`; the rest are a | |
| 75 | 87 | /// workspace's. | |
| 76 | 88 | 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 | + | ) | |
| 78 | 101 | } | |
| 79 | 102 | ||
| 80 | 103 | pub fn title(self) -> &'static str { | |
| ⋯ | |||
| 93 | 116 | ProtectionOp::CreateRepositoryDispatch => "Create a repository dispatch event", | |
| 94 | 117 | ProtectionOp::GetWorkspaceWorkflowPermissions => "Get a workspace's default workflow permissions", | |
| 95 | 118 | 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", | |
| 96 | 123 | } | |
| 97 | 124 | } | |
| 98 | 125 | ||
| ⋯ | |||
| 111 | 138 | 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.", | |
| 112 | 139 | 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.", | |
| 113 | 140 | 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.", | |
| 114 | 145 | 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.", | |
| 115 | 146 | } | |
| 116 | 147 | } | |
| ⋯ | |||
| 124 | 155 | | ProtectionOp::GetForkPrApproval | |
| 125 | 156 | | ProtectionOp::GetActionsAccess | |
| 126 | 157 | | ProtectionOp::GetWorkspaceWorkflowPermissions | |
| 158 | + | | ProtectionOp::ListWorkspaceManagedWorkflows | |
| 127 | 159 | ) | |
| 128 | 160 | } | |
| 129 | 161 | ||
| ⋯ | |||
| 201 | 233 | }), | |
| 202 | 234 | &["repo"], | |
| 203 | 235 | ), | |
| 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"]), | |
| 205 | 261 | ProtectionOp::SetWorkspaceWorkflowPermissions => ( | |
| 206 | 262 | json!({ | |
| 207 | 263 | "workspace": workspace, | |
| ⋯ | |||
| 231 | 287 | } | |
| 232 | 288 | } | |
| 233 | 289 | ||
| 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 | + | ||
| 234 | 373 | fn text(input: &Value, key: &str) -> Option<String> { | |
| 235 | 374 | match &input[key] { | |
| 236 | 375 | Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()), | |
| ⋯ | |||
| 455 | 594 | let Some(workspace) = text(input, "workspace") else { | |
| 456 | 595 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 457 | 596 | }; | |
| 597 | + | if op.managed() { | |
| 598 | + | return run_managed(op, actions, viewer, actor(), workspace, input).await; | |
| 599 | + | } | |
| 458 | 600 | let settings: Outcome<Value> = if op == ProtectionOp::GetWorkspaceWorkflowPermissions { | |
| 459 | 601 | g1t_kit::call(actions, "workspace_actions_settings", &json!({ "viewer": viewer, "workspace": workspace })).await? | |
| 460 | 602 | } else { | |
| ⋯ | |||
| 587 | 729 | map(started, |runs| json!({ "runs": runs })) | |
| 588 | 730 | } | |
| 589 | 731 | // 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."), | |
| 593 | 738 | }) | |
| 594 | 739 | } | |
| 595 | 740 | ||
| 11889 | 11889 | "root_dir": "", | |
| 11890 | 11890 | "build_command": null, | |
| 11891 | 11891 | "output_dir": null, | |
| 11892 | + | "build_as": "auto", | |
| 11893 | + | "dockerfile": null, | |
| 11892 | 11894 | "build_env": { | |
| 11893 | 11895 | "PUBLIC_API_URL": "https://api.example.com" | |
| 11894 | 11896 | }, | |
| ⋯ | |||
| 12251 | 12253 | }, | |
| 12252 | 12254 | "notes": "A maximum of read holds every repository's default to read-only, and makes the workspace's default read too." | |
| 12253 | 12255 | }, | |
| 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 | + | }, | |
| 12254 | 12354 | "get_token_policy": { | |
| 12255 | 12355 | "params": { | |
| 12256 | 12356 | "workspace": "flagon-io" | |
| ⋯ | |||
| 13234 | 13334 | }, | |
| 13235 | 13335 | "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." | |
| 13236 | 13336 | }, | |
| 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 | + | }, | |
| 13237 | 13363 | "delete_artifact": { | |
| 13238 | 13364 | "params": { | |
| 13239 | 13365 | "owner": "flagon-io", | |
| 119 | 119 | Op::Deployments(_) => return as_is, | |
| 120 | 120 | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 121 | 121 | Op::Artifacts(_) => return as_is, | |
| 122 | + | // So are archive links. | |
| 123 | + | Op::Archives(_) => return as_is, | |
| 122 | 124 | // Deploy keys travel in `snake_case` from identity. | |
| 123 | 125 | Op::DeployKeys(DeployKeysOp::ListDeployKeys) => return through::<Vec<g1t_contracts::deploy_keys::DeployKey>>(op, as_is), | |
| 124 | 126 | Op::DeployKeys(DeployKeysOp::GetDeployKey | DeployKeysOp::CreateDeployKey) => { |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::run_artifacts::ArtifactsOp; | |
| 7 | + | use crate::archives::ArchiveOp; | |
| 7 | 8 | use crate::customize::CustomizeOp; | |
| 8 | 9 | use crate::deploy_keys::DeployKeysOp; | |
| 9 | 10 | use crate::mirrors::MirrorsOp; | |
| ⋯ | |||
| 274 | 275 | ), | |
| 275 | 276 | route("POST", "/repos", Op::CreateRepo, &[]), | |
| 276 | 277 | 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), &[]), | |
| 277 | 282 | route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]), | |
| 278 | 283 | route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]), | |
| 279 | 284 | route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]), | |
| ⋯ | |||
| 423 | 428 | Op::Protection(ProtectionOp::SetWorkspaceWorkflowPermissions), | |
| 424 | 429 | &[], | |
| 425 | 430 | ), | |
| 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 | + | ), | |
| 426 | 455 | route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]), | |
| 427 | 456 | route( | |
| 428 | 457 | "POST", | |
| ⋯ | |||
| 1439 | 1468 | } | |
| 1440 | 1469 | ||
| 1441 | 1470 | #[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] | |
| 1442 | 1490 | fn query_parameters_are_renamed() { | |
| 1443 | 1491 | let query = [ | |
| 1444 | 1492 | ("q".to_owned(), "parser".to_owned()), | |
| 70 | 70 | args["version"] = json!(body["version"].as_str().unwrap_or_default()); | |
| 71 | 71 | args["running"] = json!(body["running"].as_array().cloned().unwrap_or_default()); | |
| 72 | 72 | args["wait_ms"] = json!(body["wait_ms"].as_u64().unwrap_or(0)); | |
| 73 | + | args["isolation"] = json!(body["isolation"].as_str()); | |
| 73 | 74 | "runner_poll" | |
| 74 | 75 | } | |
| 75 | 76 | "finished" => { |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::run_artifacts::ArtifactsOp; | |
| 23 | + | use crate::archives::ArchiveOp; | |
| 23 | 24 | use crate::customize::CustomizeOp; | |
| 24 | 25 | use crate::deploy_keys::DeployKeysOp; | |
| 25 | 26 | use crate::mirrors::MirrorsOp; | |
| ⋯ | |||
| 75 | 76 | actions: &[ | |
| 76 | 77 | a("list", Op::ListRepos, "Repositories you can see"), | |
| 77 | 78 | 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"), | |
| 78 | 81 | a("create", Op::CreateRepo, "Create one, empty or copied from a public git URL"), | |
| 79 | 82 | a("update", Op::UpdateRepo, "Change description, website, topics, default branch, protection"), | |
| 80 | 83 | a("get_settings", Op::GetRepoSettings, "How pull requests merge, and the default branch's protection as its rules stack"), | |
| ⋯ | |||
| 229 | 232 | Tool { | |
| 230 | 233 | name: "workflow", | |
| 231 | 234 | 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.", | |
| 233 | 236 | default_action: None, | |
| 234 | 237 | actions: &[ | |
| 235 | 238 | a("list", Op::ListWorkflows, "Workflows on the default branch"), | |
| ⋯ | |||
| 281 | 284 | a("repository_dispatch", Op::Protection(ProtectionOp::CreateRepositoryDispatch), "Start repository_dispatch workflows with an event"), | |
| 282 | 285 | a("get_workspace_permissions", Op::Protection(ProtectionOp::GetWorkspaceWorkflowPermissions), "A workspace's default and maximum token permissions"), | |
| 283 | 286 | 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"), | |
| 284 | 291 | a("list_runners", Op::ListRunners, "Self-hosted runners, with status, labels and what each is doing"), | |
| 285 | 292 | a("create_runner_token", Op::CreateRunnerRegistrationToken, "A one-hour token for g1t-runner register"), | |
| 286 | 293 | a("remove_runner", Op::RemoveRunner, "Remove a self-hosted runner"), | |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.