Skip to content

Compare changes

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

Open a pull request

18 commits

195 files+1753−2270/195 viewed
+3−4
8585 name: linux-binaries
8686 path: release
8787 - name: Build and push for amd64 and arm64
88+ # To g1t's own registry, where flagon-io keeps it public.
8889 env:
89− REGISTRY_USER: ${{ vars.RUNNER_IMAGE_REGISTRY_USER }}
90− REGISTRY_TOKEN: ${{ secrets.RUNNER_IMAGE_REGISTRY_TOKEN }}
91− IMAGE: ${{ vars.RUNNER_IMAGE }}
90+ IMAGE: g1t.sh/flagon-io/g1t-runner
9291 run: |
9392 version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)"
94− echo "$REGISTRY_TOKEN" | docker login --username "$REGISTRY_USER" --password-stdin
93+ echo "${{ secrets.G1T_TOKEN }}" | docker login g1t.sh -u g1t --password-stdin
9594 for arch in amd64 arm64; do
9695 mkdir -p "context-$arch"
9796 cp deploy/runner/Dockerfile "context-$arch/"
+63−4
741741 checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
742742
743743 [[package]]
744+name = "filetime"
745+version = "0.2.29"
746+source = "registry+https://github.com/rust-lang/crates.io-index"
747+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
748+dependencies = [
749+ "cfg-if",
750+ "libc",
751+]
752+
753+[[package]]
744754 name = "find-msvc-tools"
745755 version = "0.1.14"
746756 source = "registry+https://github.com/rust-lang/crates.io-index"
869879 [[package]]
870880 name = "g1t"
871881 version = "0.1.0"
882+dependencies = [
883+ "base64 0.22.1",
884+ "flate2",
885+ "hex",
886+ "serde_json",
887+ "sha2 0.10.9",
888+ "tar",
889+ "ureq",
890+]
872891
873892 [[package]]
874893 name = "g1t-actions"
9851004 ]
9861005
9871006 [[package]]
1007+name = "g1t-packages"
1008+version = "0.1.0"
1009+dependencies = [
1010+ "base64 0.22.1",
1011+ "futures-util",
1012+ "g1t-contracts",
1013+ "g1t-kit",
1014+ "hex",
1015+ "hmac 0.12.1",
1016+ "miniz_oxide",
1017+ "serde",
1018+ "serde_json",
1019+ "sha1 0.10.7",
1020+ "sha2 0.10.9",
1021+ "worker",
1022+]
1023+
1024+[[package]]
9881025 name = "g1t-repos"
9891026 version = "0.1.0"
9901027 dependencies = [
1028+ "base64 0.22.1",
9911029 "futures-util",
9921030 "g1t-contracts",
9931031 "g1t-kit",
10251063 "miniz_oxide",
10261064 "serde",
10271065 "serde_json",
1028− "sha1",
1066+ "sha1 0.11.0",
10291067 "sha2 0.10.9",
10301068 "similar",
10311069 ]
23542392 "salsa20",
23552393 "scrypt",
23562394 "sec1",
2357− "sha1",
2395+ "sha1 0.11.0",
23582396 "sha2 0.11.0",
23592397 "sha3 0.12.0",
23602398 "signature",
27222760
27232761 [[package]]
27242762 name = "sha1"
2763+version = "0.10.7"
2764+source = "registry+https://github.com/rust-lang/crates.io-index"
2765+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
2766+dependencies = [
2767+ "cfg-if",
2768+ "cpufeatures 0.2.17",
2769+ "digest 0.10.7",
2770+]
2771+
2772+[[package]]
2773+name = "sha1"
27252774 version = "0.11.0"
27262775 source = "registry+https://github.com/rust-lang/crates.io-index"
27272776 checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
29172966 "rand_core 0.10.1",
29182967 "rsa",
29192968 "sec1",
2920− "sha1",
2969+ "sha1 0.11.0",
29212970 "sha2 0.11.0",
29222971 "signature",
29232972 "ssh-cipher",
30223071 ]
30233072
30243073 [[package]]
3074+name = "tar"
3075+version = "0.4.46"
3076+source = "registry+https://github.com/rust-lang/crates.io-index"
3077+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
3078+dependencies = [
3079+ "filetime",
3080+ "libc",
3081+]
3082+
3083+[[package]]
30253084 name = "thiserror"
30263085 version = "2.0.21"
30273086 source = "registry+https://github.com/rust-lang/crates.io-index"
32443303 "httparse",
32453304 "log",
32463305 "rand",
3247− "sha1",
3306+ "sha1 0.11.0",
32483307 "thiserror",
32493308 ]
32503309
+1−1
11 [workspace]
22 resolver = "3"
3−members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/webhooks", "services/repos", "services/search", "services/security", "services/work"]
3+members = ["apps/api", "crates/*", "services/actions", "services/billing", "services/events", "services/identity", "services/integrations", "services/packages", "services/webhooks", "services/repos", "services/search", "services/security", "services/work"]
44
55 [workspace.package]
66 edition = "2024"
+3−3
702702 }
703703 Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.",
704704 Op::DeleteWorkspace => {
705− "Delete a workspace. Owners only, signed in as a person, and confirm must be the workspace's slug. It must hold no repositories (move them with transfer_repo first) and no projects, and billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once. Its members, access tokens, webhooks, integrations and workspace secrets are removed; its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again."
705+ "Delete a workspace and everything in it. Owners only, signed in as a person, and confirm must be the workspace's slug. Billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once and its plan ends. Its repositories, projects and apps go with it at once, nobody can reach it, and its access tokens stop working. It is kept for 30 days, when g1t's support can restore it as it was; then it is purged, with its webhooks, integrations and workspace secrets. Its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again once it is purged. Some workspaces, such as Flagon's, can never be deleted."
706706 }
707707 Op::UpdateWorkspace => {
708708 "Change a workspace's display name and description, and what every member gets on each of its repositories (base_permission: none, read, write or admin). Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now."
914914 Op::SetActionsVariable => "Add or change a variable's row, as for secrets.",
915915 Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.",
916916 Op::ListRunners => {
917− "A workspace's self-hosted runners, or a repository's: its own and the workspace's that its runner group lets it use. Each has its `labels` (always `self-hosted`, its OS and its architecture), `status` (`online`, `busy` or `offline`), the `work` it is doing, its `version` and when it was last seen. A workspace's are seen by its members; a repository's need the Admin role on it."
917+ "A workspace's self-hosted runners, or a repository's: its own and the workspace's that its runner group lets it use. Each has its `labels` (always `self-hosted`, its OS and its architecture), `status` (`online`, `busy` or `offline`), the `work` it is doing, its `version` and when it was last seen. A workspace's are seen by its owners; a repository's need the Admin role on it."
918918 }
919919 Op::ListRunnerGroups => {
920− "A workspace's runner groups: which of its repositories may use the runners in each. The default group (every repository) is where runners go when no group is named. Members only."
920+ "A workspace's runner groups: which of its repositories may use the runners in each. The default group (every repository) is where runners go when no group is named. Owners only."
921921 }
922922 Op::GetRunnerSettings => {
923923 "Where a workspace's (or a repository's) g1t agent work runs, and whether pull requests from forks may use its self-hosted runners. `agents_on_self_hosted` sends agent runs, checks, reviews and the merge queue to runners with `agent_labels` instead of g1t's sandboxes. A repository's are its workspace's unless it has its own (`inherited`)."
+1−1
8888 "confirm": "acme-labs"
8989 },
9090 "response": true,
91− "notes": "Refused with `409` while the workspace holds repositories or projects, and with `402` while billing cannot settle it, each with a message that says what to do. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)."
91+ "notes": "Refused with `402` while billing cannot settle it, with a message that says what to do, and with `403` for anyone but an owner signed in as a person, or for a protected workspace. Its repositories, projects and apps go with it; g1t's support can restore it for 30 days. See [Delete a workspace](/guides/workspaces/#delete-a-workspace)."
9292 },
9393 "list_emails": {
9494 "response": {
+1−1
231231 default_action: None,
232232 actions: &[
233233 a("create", Op::CreateWorkspace, "Create a workspace"),
234− a("delete", Op::DeleteWorkspace, "Delete an empty workspace"),
234+ a("delete", Op::DeleteWorkspace, "Delete a workspace and everything in it (support can restore it for 30 days)"),
235235 a("update", Op::UpdateWorkspace, "Change its name, description or base permission"),
236236 a("list_invites", Op::ListWorkspaceInvites, "Its invites"),
237237 a("invite_member", Op::InviteMember, "Invite an email address"),
+5−0
7373 items: [
7474 { label: 'Projects', slug: 'guides/projects' },
7575 { label: 'Deployments', slug: 'guides/deployments' },
76+ { label: 'Packages', slug: 'guides/packages' },
77+ { label: 'Container images', slug: 'guides/containers' },
78+ { label: 'npm', slug: 'guides/npm' },
79+ { label: 'Composer', slug: 'guides/composer' },
80+ { label: 'Go modules', slug: 'guides/go' },
7681 { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' },
7782 { label: 'Security', slug: 'guides/security' },
7883 ],
+2−2
2626 flex-direction: column;
2727 height: 100%;
2828 }
29− /* The same bar as the app's: 4rem, and a line under it. */
29+ /* The same bar as the app's: 3.5rem, and a line under it. */
3030 .g1t-header-top {
3131 box-sizing: border-box;
32− height: 4rem;
32+ height: 3.5rem;
3333 flex-shrink: 0;
3434 padding: 0 var(--sl-nav-pad-x);
3535 border-bottom: 1px solid var(--g1t-line);
+1−1
3939 .g1t-title-lockup {
4040 display: inline-flex;
4141 align-items: baseline;
42− font-size: 1.5rem;
42+ font-size: 1.25rem;
4343 line-height: 1;
4444 }
4545 svg {
+2−2
105105
106106 Owners always have Admin, whatever it says. Only owners can change it:
107107
108−1. Open the workspace's **Settings → Members**, `g1t.sh/<workspace>/-/people`.
108+1. Open the workspace's **Members** in the sidebar, `g1t.sh/<workspace>/-/people`. Every member can see it; owners manage it.
109109 2. Under **Base permission**, choose one.
110110
111111 It takes effect on everyone's next request. To give one member more on
178178
179179 Owners see every outside collaborator, and the repositories and roles each
180180 has, on the **Outside collaborators** tab of the workspace's
181−**Settings → Members**. **Convert to member** adds one to the workspace
181+**Members**. **Convert to member** adds one to the workspace
182182 (see [members and roles](/guides/workspaces/#members-and-roles)); the
183183 roles they have stay, and the base permission adds to them.
184184
+4−1
1313
1414 - **A repository's lifecycle**, wherever the change was made, g1t.sh
1515 included. A transfer is recorded in both workspaces' logs, and a
16− workspace's deletion as `workspace.deleted`, its log's last entry.
16+ workspace's deletion as `workspace.deleted`. A restore by g1t's support
17+ is recorded as `workspace.restored`, and the purge 30 days after a
18+ deletion as `workspace.purged`, its log's last entry.
1719
1820 | Action | Recorded when |
1921 | --- | --- |
2729 | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had it changed, or lost it. See [access and roles](/guides/access-and-roles/). |
2830 | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. |
2931 | `workspace.base_permission_changed` | An owner changed what members get on every repository. |
32+| `workspace.deleted`, `workspace.restored`, `workspace.purged` | An owner deleted the workspace, g1t's support restored it, or it was removed for good. See [deleting a workspace](/guides/workspaces/#delete-a-workspace). |
3033
3134 Through the API and the MCP server, the call itself is recorded under its
3235 operation's name too, such as `delete_repo`. See
+6−2
317317 | Group | Scopes |
318318 | --- | --- |
319319 | Repositories & code | `repo:read`, `repo:write`, `code:read`, `code:write` |
320+| Packages | `packages:read`, `packages:write` |
320321 | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` |
321322 | Agents | `agents:run` |
322323 | Workflows | `workflows:read`, `workflows:write` |
324325 | Account | `account:read`, `account:write` |
325326 | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` |
326327 | Runners | `runners:read` |
327−| Dangerous | `repo:admin`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` |
328+| Dangerous | `repo:admin`, `packages:delete`, `workspace:admin`, `access:admin`, `webhooks:admin`, `secrets:admin`, `runners:admin` |
328329
329330 Ticking a higher level ticks the lower ones of its resource and greys
330331 them out: tick `issues:write` and `issues:read` is ticked too. Untick
337338 | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts |
338339 | `code:read` | Clone and fetch private repositories with git |
339340 | `code:write` | Push commits with git |
341+| `packages:read` | Pull container images and install private [packages](/guides/packages/). Public ones need no scope. |
342+| `packages:write` | Push container images and publish packages |
343+| `packages:delete` | Delete packages and their versions |
340344 | `issues:read` | Read issues, comments and plans |
341345 | `issues:write` | Open, edit, close and comment on issues |
342346 | `pull_requests:read` | Read pull requests, their changes, sessions and merge queues |
395399 | --- | --- |
396400 | Read only | Every `read` scope. Changes nothing. |
397401 | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and puts g1t to work. No admin scope. |
398−| CI | `repo:read`, `code:read`, `code:write`, `workflows:read` and `workflows:write`. Clones and pushes code, and runs workflows. |
402+| CI | `repo:read`, `code:read`, `code:write`, `packages:read`, `packages:write`, `workflows:read` and `workflows:write`. Clones and pushes code, pushes and pulls packages, and runs workflows. |
399403 | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. |
400404
401405 Admin scopes change things that are hard to undo, or decide who can reach
+110−0
1+---
2+title: Composer
3+description: Install a workspace's PHP packages with Composer, straight from its repositories on g1t.sh. Push a tag and it is released.
4+---
5+
6+Every workspace has a Composer repository of its own, made from its
7+repositories: one with a `composer.json` at its root is a package. There
8+is nothing to upload and nothing to keep in step. Push a tag and Composer
9+can install it; push to a branch and its `dev-` version follows.
10+
11+```text
12+https://g1t.sh/-/composer/<workspace>/
13+```
14+
15+## Make a repository a package
16+
17+Give the repository a `composer.json` at its root with a `name`:
18+
19+```json
20+{
21+ "name": "acme/http-client",
22+ "description": "Our HTTP client",
23+ "type": "library",
24+ "require": { "php": ">=8.1" },
25+ "autoload": { "psr-4": { "Acme\Http\\": "src/" } }
26+}
27+```
28+
29+Push it to the default branch, and the package appears in the workspace's
30+**Packages**, linked to the repository, with its README. Any vendor name
31+works; each name is one package in a workspace.
32+
33+## Versions
34+
35+| In git | Version |
36+| --- | --- |
37+| A tag that reads as a version: `v1.2.0`, `1.2.0`, `2.0.0-RC1`, `1.0.0-beta.2` | That version (`v1.2.0`) |
38+| A branch | `dev-<branch>`; a branch named like a version, `1.x`, is `1.x-dev` |
39+| The default branch | Its `dev-` version, marked as the default branch |
40+
41+Each version's requirements, autoloading and the rest are read from the
42+`composer.json` at that tag or branch, so a version installs exactly what
43+it was tagged with. Tags that do not read as versions, such as `nightly`,
44+are left out. `extra.branch-alias` in the default branch's `composer.json`
45+aliases it as usual (`"dev-main": "1.x-dev"`).
46+
47+To release, tag and push:
48+
49+```sh
50+git tag v1.3.0
51+git push origin v1.3.0
52+```
53+
54+Deleting a tag or branch takes its version away.
55+
56+## Install
57+
58+Add the workspace's repository to your project, then require the package:
59+
60+```sh
61+composer config repositories.acme composer https://g1t.sh/-/composer/acme/
62+composer require acme/http-client
63+```
64+
65+Packages install from a zip of the tag's commit, made the first time
66+anyone asks for it and kept from then on. Files the repository's
67+`.gitattributes` marks `export-ignore`, such as tests, are left out of it,
68+as `git archive` leaves them out. `--prefer-source` clones from g1t.sh
69+instead.
70+
71+## Private packages
72+
73+A package has its repository's visibility and
74+[roles](/guides/access-and-roles/): Read installs it. For a private one,
75+give Composer your username and an
76+[access token](https://g1t.sh/settings/tokens) (one with full access, or
77+with `packages:read` and `code:read`, so that `--prefer-source` can clone
78+too):
79+
80+```sh
81+composer config --global --auth http-basic.g1t.sh <you> <token>
82+```
83+
84+That writes `~/.composer/auth.json`, which stays out of the project. A
85+`Bearer` token works as well (`bearer.g1t.sh`). Without credentials, a
86+workspace's repository lists only its public packages.
87+
88+## In workflows
89+
90+A workflow's `G1T_TOKEN` can install the workspace's private packages:
91+
92+```yaml
93+jobs:
94+ test:
95+ runs-on: ubuntu-latest
96+ steps:
97+ - uses: actions/checkout@v4
98+ - run: composer config --global --auth http-basic.g1t.sh g1t "$G1T_TOKEN"
99+ env:
100+ G1T_TOKEN: ${{ secrets.G1T_TOKEN }}
101+ - run: composer install --no-interaction
102+```
103+
104+## Limits
105+
106+A zip is made of at most 10,000 files and 64 MB; a larger commit installs
107+from source (`composer install --prefer-source`). A package lists its
108+newest 300 version tags and 50 branches. Zips count toward the workspace's
109+package storage, as other packages do (see
110+[storage and pull limits](/guides/containers/#storage-and-pull-limits)).
+221−0
1+---
2+title: Container images
3+description: Push and pull container images on g1t.sh with docker, in workflows with G1T_TOKEN, and push layers over 100 MB with g1t push.
4+---
5+
6+g1t.sh is a container registry. Images are named after their workspace,
7+pushed and pulled with `docker` or any client of the OCI Distribution
8+protocol, and have the access of the repository they are linked to (see
9+[who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package)).
10+
11+```text
12+g1t.sh/<workspace>/<name>[:<tag>]
13+```
14+
15+## Sign in
16+
17+Use your username, and an [access token](https://g1t.sh/settings/tokens) as
18+the password: one with full access, or with `packages:write` (to push) or
19+`packages:read` (to pull private images).
20+
21+```sh
22+echo "$G1T_TOKEN" | docker login g1t.sh -u <you> --password-stdin
23+```
24+
25+Public images pull without signing in.
26+
27+## Push
28+
29+Tag the image with its address and push it:
30+
31+```sh
32+docker build -t g1t.sh/acme/web:1.4.0 .
33+docker push g1t.sh/acme/web:1.4.0
34+```
35+
36+The first push makes the package. When its name starts with a repository
37+of the workspace, here `acme/web`, it is linked to that repository and
38+follows its visibility and roles; pushing needs Write on it. Otherwise it is
39+the workspace's, private, and needs the workspace's Write base permission.
40+
41+Pushing a tag again moves it to the new image. Layers already on g1t are
42+not uploaded again, and images in the same workspace share them.
43+
44+Multi-platform images (`docker buildx build --platform linux/amd64,linux/arm64 --push`),
45+OCI image indexes, and artifacts attached to an image with a `subject`
46+(signatures, SBOMs, attestations) are all kept; the registry lists an image's
47+attached artifacts at `/v2/<name>/referrers/<digest>`.
48+
49+## Pull
50+
51+```sh
52+docker pull g1t.sh/acme/web:1.4.0
53+docker pull g1t.sh/acme/web@sha256:…
54+```
55+
56+## In workflows
57+
58+A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can
59+push and pull the workspace's images:
60+
61+```yaml
62+jobs:
63+ image:
64+ runs-on: ubuntu-latest
65+ steps:
66+ - uses: actions/checkout@v4
67+ - name: Sign in to g1t.sh
68+ run: echo "${{ secrets.G1T_TOKEN }}" | docker login g1t.sh -u g1t --password-stdin
69+ - name: Build and push
70+ run: |
71+ docker build -t g1t.sh/${{ github.repository }}:${{ github.sha }} .
72+ docker push g1t.sh/${{ github.repository }}:${{ github.sha }}
73+```
74+
75+Runs that get no secrets (a pull request from someone without Write) get an
76+empty token, and cannot push. See
77+[secrets and variables](/guides/actions/#secrets-and-variables).
78+
79+## The 100 MB limit
80+
81+A single request to g1t.sh may carry at most 100 MB. `docker push` sends
82+each layer whole, in one request, so a layer over 100 MB, as compressed for
83+the push, is refused. [`g1t push`](#push-large-layers-with-g1t-push) sends
84+it in chunks instead, and has no limit.
85+
86+What you see depends on where it is refused. Usually it is before the
87+request reaches g1t, and `docker push` stops with a bare status:
88+
89+```text
90+unknown: failed commit on ref "layer-sha256:…": unexpected status from PUT request to https://g1t.sh/v2/acme/web/blobs/uploads/…?digest=sha256%3A…: 413 Request Entity Too Large
91+```
92+
93+(the request may be a `PATCH` instead of a `PUT`, and some versions of
94+Docker show the HTML page that came with the 413 instead). When g1t sees
95+the request itself, the error is `SIZE_INVALID`, with a message naming the
96+limit and this page.
97+
98+An installation you [run yourself](/guides/self-hosting/) has no such
99+limit.
100+
101+### Push large layers with g1t push
102+
103+`g1t push` takes an image from your local docker and pushes it to g1t.sh,
104+each layer in chunks of 90 MiB, each chunk its own request. A layer can be
105+any size.
106+
107+```sh
108+docker build -t g1t.sh/acme/model:1 .
109+g1t push g1t.sh/acme/model:1
110+```
111+
112+An image with a local name is pushed to the address after `--as`:
113+
114+```sh
115+g1t push model:dev --as g1t.sh/acme/model:1
116+```
117+
118+```text
119+Reading model:dev from docker
120+Pushing to g1t.sh/acme/model:1
121+ config sha256:040e744c070b 851 B already on the registry, skipped
122+ layer sha256:25f1d6b1951a 3.5 MiB already on the registry, skipped
123+ chunk 1/2 90.0 MiB 63%
124+ chunk 2/2 53.1 MiB 100%
125+ layer sha256:c74595c4a2cd 143.1 MiB uploaded in 2 chunks
126+Pushed g1t.sh/acme/model:1
127+digest: sha256:39b972d91774d58b5fbd27cfdce73fe58034d9e5772a261d183c11bcf78c1dba
128+```
129+
130+It pushes the image docker has: the same config, layers and manifest, so
131+`docker pull` gets back exactly what you built. When docker keeps a layer
132+uncompressed, `g1t push` gzips it for the push, as `docker push` does.
133+Layers already on g1t are not sent again. A request refused with `429` or
134+an error on g1t's side is tried again after a wait, and an interrupted
135+layer goes on from where it stopped.
136+
137+It signs in with the first of:
138+
139+1. a token on stdin, with `--token-stdin`
140+ (`echo "$G1T_TOKEN" | g1t push … --token-stdin`);
141+2. the `G1T_TOKEN` environment variable, as in [workflows](#in-workflows);
142+3. what `docker login g1t.sh` stored, in `~/.docker/config.json` or the
143+ credential store it names.
144+
145+| Option | |
146+| --- | --- |
147+| `--as <address>` | Where to push, `g1t.sh/<workspace>/<name>:<tag>`. Without it, the image's own name must be such an address. The tag defaults to `latest`. |
148+| `--chunk-size <size>` | How much each request carries: `5MB` to `95MB`, `90MB` unless set. `MB` and `MiB` both mean 1,048,576 bytes. |
149+| `--token-stdin` | Read the token from stdin. |
150+| `--archive <file>` | Push a tarball written by `docker save`, instead of asking docker. Needs `--as`. |
151+
152+`g1t --version` prints its version, and `g1t help` its options.
153+
154+### Getting g1t
155+
156+One file for each platform, from `https://g1t.sh/downloads/cli/latest/`:
157+
158+```sh
159+# Linux (x64; use g1t-linux-arm64 on ARM)
160+curl -fsSLo g1t https://g1t.sh/downloads/cli/latest/g1t-linux-x64 && chmod +x g1t
161+# macOS (Apple silicon; use g1t-macos-x64 on Intel)
162+curl -fsSLo g1t https://g1t.sh/downloads/cli/latest/g1t-macos-arm64 && chmod +x g1t
163+```
164+
165+```powershell
166+# Windows
167+Invoke-WebRequest https://g1t.sh/downloads/cli/latest/g1t-windows-x64.exe -OutFile g1t.exe
168+```
169+
170+Check a download against `https://g1t.sh/downloads/cli/latest/SHA256SUMS`.
171+To build it from source instead, with
172+[Rust](https://www.rust-lang.org/tools/install):
173+
174+```sh
175+git clone https://g1t.sh/flagon-io/g1t
176+cd g1t
177+cargo install --path crates/g1t
178+```
179+
180+## Storage and pull limits
181+
182+Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), a
183+workspace's public packages may hold 10 GB and its private ones 500 MB,
184+each file counted once. A push that would go past either is refused with
185+`DENIED` and a message saying how much is used; layers the workspace
186+already holds add nothing. On the plan nothing is refused: storage past
187+the free amounts is charged.
188+
189+Anonymous pulls are limited to 300 requests a minute from each address, and
190+signed-in ones to 5,000 a minute for each person, workspace or agent.
191+Past the limit, requests are answered `429` with `TOOMANYREQUESTS` and a
192+`Retry-After`; docker waits and tries again. Signing in raises the limit.
193+
194+## Delete
195+
196+Deleting needs Admin on the linked repository, or for an unlinked image, an
197+owner of the workspace; a token needs `packages:delete`.
198+
199+The registry protocol's `DELETE` removes a tag, or a whole version by its
200+digest (with every tag that points to it):
201+
202+```sh
203+TOKEN=$(curl -s -u <you>:<token> "https://g1t.sh/v2/token?scope=repository:acme/web:delete" | jq -r .token)
204+curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/1.4.0
205+curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/sha256:…
206+```
207+
208+Layers no version uses any more are deleted from storage a day later.
209+
210+## Errors
211+
212+| Error | Means |
213+| --- | --- |
214+| `UNAUTHORIZED` | Not signed in, or the token is wrong or expired. `docker login g1t.sh` again. |
215+| `DENIED` | Signed in, but your role or your token's scopes do not allow it. The message says which. |
216+| `NAME_UNKNOWN` | No such image, or one you cannot see. |
217+| `MANIFEST_UNKNOWN`, `BLOB_UNKNOWN` | No such tag, digest or layer in that image. |
218+| `NAME_INVALID` | Names are lowercase letters and digits, separated by `.`, `_`, `__`, `-` or `/`, and start with a workspace. |
219+| `SIZE_INVALID`, or a bare `413` | A request over [the 100 MB limit](#the-100-mb-limit). Push the image with [`g1t push`](#push-large-layers-with-g1t-push). |
220+| `TOOMANYREQUESTS` | Too many requests in a minute; see [the limits](#storage-and-pull-limits). |
221+| `DIGEST_INVALID` | What was uploaded does not have the digest the client said. Push again. |
+14−0
2121 `pr-<number>` in place of the branch. A name too long for an address, or
2222 one another app already has, is shortened or given a short suffix.
2323
24+A workspace whose name ends like a domain is written without that last
25+hyphen: `flagon-io` gives `https://web-flagonio.g1t.page`, not
26+`web-flagon-io.g1t.page`. Browsers read `flagon-io` in an address as a
27+copy of `flagon.io`, and warn people who visit flagon.io that the page
28+looks fake. Apps made before this moved to the new address on their own;
29+the old one redirects to it.
30+
2431 When a workspace is [renamed](/guides/workspaces/#rename-a-workspace), or a
2532 repository is [transferred](/guides/transferring-repositories/#deployments)
2633 to another workspace or [renamed](/guides/managing-repositories/), its apps
5663 Production starts building at once from the default branch. Every pull
5764 request opened or pushed to from then on gets a preview.
5865
66+A project g1t takes for a library or a tool, such as a Composer package,
67+does not offer to deploy on its overview; it shows its packages instead.
68+Its **Deployments** page still turns them on, and doing so makes it an
69+app. A project set to **Doesn't deploy** cannot have deployments turned
70+on until the setting changes. See
71+[apps and libraries](/guides/projects/#apps-and-libraries).
72+
5973 While payments on g1t are in test mode, no real card is charged: use the
6074 test card `4242 4242 4242 4242` with any future date and any code.
6175
+79−0
1+---
2+title: Go modules
3+description: go get a workspace's Go modules straight from its repositories on g1t.sh, public and private.
4+---
5+
6+A repository on g1t.sh is a Go module at its own address. `go get` finds
7+its code from the address, and fetches it with git:
8+
9+```sh
10+go get g1t.sh/acme/tools
11+go get g1t.sh/acme/tools/cmd/lint@v1.4.0
12+```
13+
14+The repository's `go.mod` names the module:
15+
16+```text
17+module g1t.sh/acme/tools
18+```
19+
20+Versions are its tags (`v1.4.0`); a pseudo-version names any other commit.
21+Packages in subdirectories are imported by their path, as above.
22+
23+## Public modules
24+
25+Public repositories need nothing: `go get` works as is, and the public Go
26+module proxy and checksum database serve them like any other public module.
27+
28+## Private modules
29+
30+Tell Go which modules are private, so it fetches them from g1t.sh directly
31+and does not ask the public proxy or checksum database about them:
32+
33+```sh
34+go env -w GOPRIVATE=g1t.sh/acme
35+```
36+
37+Then give git credentials for g1t.sh: your username and an
38+[access token](https://g1t.sh/settings/tokens) (full access, or with
39+`code:read`) in `~/.netrc` (`_netrc` on Windows):
40+
41+```text
42+machine g1t.sh
43+login <you>
44+password <token>
45+```
46+
47+or with a git credential helper, as for any clone (see
48+[Git](/guides/git/#authentication)). Reading a private module needs the
49+Read role on its repository.
50+
51+Keep `GOINSECURE` and `GOFLAGS=-insecure` unset: g1t.sh is served over
52+HTTPS, and neither is ever needed.
53+
54+## In workflows
55+
56+```yaml
57+jobs:
58+ build:
59+ runs-on: ubuntu-latest
60+ env:
61+ GOPRIVATE: g1t.sh/acme
62+ steps:
63+ - uses: actions/checkout@v4
64+ - uses: actions/setup-go@v5
65+ with:
66+ go-version: stable
67+ - run: git config --global url."https://g1t:${G1T_TOKEN}@g1t.sh/".insteadOf "https://g1t.sh/"
68+ env:
69+ G1T_TOKEN: ${{ secrets.G1T_TOKEN }}
70+ - run: go build ./...
71+```
72+
73+## How it works
74+
75+Go asks `https://g1t.sh/<workspace>/<repo>?go-get=1` and reads a
76+`go-import` tag pointing at `https://g1t.sh/<workspace>/<repo>.git`. Every
77+repository address answers, private ones included, and the answer names
78+nothing but that address; whether anything can be fetched is up to git and
79+your credentials.
+136−0
1+---
2+title: npm
3+description: Publish and install a workspace's npm packages on g1t.sh, from your machine and from workflows with G1T_TOKEN.
4+---
5+
6+g1t.sh is an npm registry for packages scoped by workspace: `@acme/ui` is
7+the `ui` package of the `acme` workspace. `npm` (and any client that reads
8+`.npmrc`) publishes and installs them with two lines of configuration.
9+
10+```text
11+https://g1t.sh/-/npm/
12+```
13+
14+Packages of other scopes and unscoped ones still come from the registry
15+npm uses by default; only your workspace's scope is pointed at g1t.
16+
17+## Set up `.npmrc`
18+
19+In the project (or in `~/.npmrc` for every project), name g1t as the
20+registry for the workspace's scope, and give it an
21+[access token](https://g1t.sh/settings/tokens):
22+
23+```ini
24+@acme:registry=https://g1t.sh/-/npm/
25+//g1t.sh/-/npm/:_authToken=${G1T_TOKEN}
26+```
27+
28+npm reads `${G1T_TOKEN}` from the environment, so the token itself stays
29+out of the file you commit. A token with full access works; one with scopes
30+needs `packages:read` to install private packages and `packages:write` to
31+publish. Public packages install without a token.
32+
33+Check it:
34+
35+```sh
36+npm whoami --registry https://g1t.sh/-/npm/
37+```
38+
39+`npm login --scope=@acme --auth-type=legacy` also works, with your username
40+and a g1t token (not your password) as the password.
41+
42+## Publish
43+
44+The package's `name` is scoped by its workspace:
45+
46+```json
47+{
48+ "name": "@acme/ui",
49+ "version": "1.0.0",
50+ "repository": "https://g1t.sh/acme/ui"
51+}
52+```
53+
54+```sh
55+npm publish
56+```
57+
58+The first publish makes the package. When its `repository` is a g1t.sh
59+repository of the same workspace, or a repository is named like the
60+package, it is linked to that repository and has its visibility and roles:
61+publishing needs Write on it. Otherwise it is the workspace's, private,
62+and needs the workspace's Write base permission. See
63+[who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package).
64+
65+A version is published once: publishing a version that is already there is
66+refused, so bump `version` first. `npm publish --tag next` publishes
67+without moving `latest`. The README of the version `latest` points to is
68+shown on the package's page.
69+
70+## Install
71+
72+```sh
73+npm install @acme/ui
74+```
75+
76+Lockfiles record `https://g1t.sh/-/npm/…` tarball addresses and each
77+tarball's `sha512` integrity, which g1t computes when the version is
78+published.
79+
80+## In workflows
81+
82+A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can
83+install and publish the workspace's packages:
84+
85+```yaml
86+jobs:
87+ publish:
88+ runs-on: ubuntu-latest
89+ steps:
90+ - uses: actions/checkout@v4
91+ - uses: actions/setup-node@v4
92+ with:
93+ node-version: 22
94+ - run: |
95+ echo "@acme:registry=https://g1t.sh/-/npm/" >> .npmrc
96+ echo "//g1t.sh/-/npm/:_authToken=\${G1T_TOKEN}" >> .npmrc
97+ - run: npm ci
98+ - run: npm publish
99+ env:
100+ G1T_TOKEN: ${{ secrets.G1T_TOKEN }}
101+```
102+
103+`npm ci` needs the token too when the project depends on private packages;
104+set `G1T_TOKEN` for the whole job instead.
105+
106+## Tags, deprecating and unpublishing
107+
108+```sh
109+npm dist-tag add @acme/ui@1.2.0 stable
110+npm dist-tag ls @acme/ui
111+npm deprecate @acme/ui@1.0.0 "Use 1.2 or later"
112+npm unpublish @acme/ui@1.2.1
113+npm unpublish @acme/ui --force
114+```
115+
116+Moving tags and deprecating need what publishing does. Unpublishing a
117+version needs it too within 72 hours of publishing; after that it needs
118+Admin on the linked repository (an owner, for the workspace's own
119+packages). Deprecate a version instead when people may depend on it.
120+Versions can also be deleted on the package's page.
121+
122+## Size
123+
124+A publish is one request with the tarball inside it, and may hold at most
125+100 MB. Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), a
126+workspace's private packages may hold 500 MB and its public ones 10 GB, as
127+for [container images](/guides/containers/#storage-and-pull-limits).
128+
129+## Errors
130+
131+| Error | Means |
132+| --- | --- |
133+| `E401` | No token, or a wrong or expired one. Check `.npmrc` and `npm whoami`. |
134+| `E403` | Signed in, but your role or your token's scopes do not allow it, or the version is already published. The message says which. |
135+| `E404` | No such package, or one you cannot see. A private package needs a token in `.npmrc`. |
136+| `E413` | The publish is over 100 MB. Leave build output and fixtures out with `files` in `package.json` or `.npmignore`. |
+99−0
1+---
2+title: Packages
3+description: Publish and install packages beside your code, with the same people, roles and tokens.
4+---
5+
6+A workspace can publish packages to g1t and install them from it, beside
7+the code they are built from: container images, npm packages, Composer
8+packages and Go modules, with Cargo to follow. Each registry speaks its
9+tool's own protocol, so `docker`, `npm`, `composer` and `go` work with
10+nothing but a login and an address. Composer packages and Go modules are
11+read from the workspace's repositories: there is nothing to upload.
12+
13+| Registry | Address | Guide |
14+| --- | --- | --- |
15+| Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) |
16+| npm | `https://g1t.sh/-/npm/`, for the scope `@<workspace>` | [npm](/guides/npm/) |
17+| Composer | `https://g1t.sh/-/composer/<workspace>/`, from the workspace's repositories | [Composer](/guides/composer/) |
18+| Go | `g1t.sh/<workspace>/<repo>`, straight from git | [Go modules](/guides/go/) |
19+
20+## Names
21+
22+Every package's name starts with its workspace: `g1t.sh/acme/web` is the
23+`web` image of the `acme` workspace. A name may have more parts after it,
24+such as `g1t.sh/acme/web/worker`.
25+
26+## Who can see and publish a package
27+
28+A package is linked to a repository, or belongs to its workspace.
29+
30+- **Linked.** The first push of an image whose name starts with a
31+ repository's name (`acme/web`, `acme/web/worker` for the repository
32+ `acme/web`) links it to that repository; so does the first publish of
33+ an npm package whose `package.json` `repository` is a g1t.sh repository
34+ of the workspace, or which is named like one (`@acme/web`). It then has
35+ the repository's visibility and [roles](/guides/access-and-roles/):
36+
37+ | | Needs |
38+ | --- | --- |
39+ | Pull | Read: on a public repository, anyone, signed in or not |
40+ | Push a new version or tag | Write |
41+ | Delete versions and the package, change its settings | Admin |
42+
43+- **Unlinked.** A package whose name matches no repository is the
44+ workspace's. It is private: members pull and push it by the workspace's
45+ [base permission](/guides/access-and-roles/#the-base-permission) (Read pulls,
46+ Write pushes), and only owners delete it or change its settings. An owner
47+ can make it public, and then anyone can pull it.
48+
49+A linked package can be unlinked, and an unlinked one linked to a
50+repository of its workspace by someone with Admin on that repository.
51+
52+Private packages look exactly like ones that do not exist to anyone who may
53+not pull them.
54+
55+## Tokens
56+
57+Sign in to a registry with your username and an
58+[access token](/guides/authentication/#access-tokens) as the password.
59+A token with scopes needs the package ones:
60+
61+| Scope | Lets a token |
62+| --- | --- |
63+| `packages:read` | Pull private packages. Public ones need no scope. |
64+| `packages:write` | Push and publish. Includes `packages:read`. |
65+| `packages:delete` | Delete versions and packages |
66+
67+Tokens with full access, and tokens made before scopes, have all three. A
68+token never does more than its owner could: `packages:delete` alone does not
69+let a member delete an owner's package.
70+
71+In [workflows](/guides/actions/#secrets-and-variables), `G1T_TOKEN` is the
72+workspace's own token for the run: it pushes and pulls the workspace's
73+packages with no setup. A g1t agent at work on a repository may push the
74+packages of that repository, as it may push its code, and never deletes
75+them.
76+
77+## Storage
78+
79+Every file is kept once, by its content: two images that share a layer
80+store it once, and a layer pushed again is not stored again. A workspace's
81+storage counts each file once, as public when any public package uses it.
82+
83+Files no version uses any more are deleted a day after the last version
84+that used them goes.
85+
86+Without the [g1t plan](/guides/usage-and-billing/#the-g1t-plan), public
87+packages may hold 10 GB and private ones 500 MB per workspace; a push past
88+either is refused, with a message saying how much is used. On the plan,
89+storage past those amounts is charged instead. See
90+[storage and pull limits](/guides/containers/#storage-and-pull-limits).
91+
92+## Events and the audit log
93+
94+Publishing a version, deleting a version and deleting a package are
95+[audit log](/guides/audit-log/) entries, and the events
96+`package.published`, `package.version_deleted`, `package.deleted` and
97+`package.visibility_changed`, which [webhooks](/guides/webhooks/) can be
98+sent: a linked package's go to its repository's webhooks and its
99+workspace's, an unlinked package's to its workspace's webhooks.
+64−5
3232
3333 | Page | Address | |
3434 | --- | --- | --- |
35−| **Overview** | `g1t.sh/<workspace>/<project>` | Production with a screenshot, the steps left to get to production, what is in progress, active branches, live previews, recent builds, and the source. See [the overview](#the-overview). |
35+| **Overview** | `g1t.sh/<workspace>/<project>` | Production with a screenshot, or for a library its packages; the steps left to get to production or to a first release; what is in progress, active branches, live previews, recent builds, and the source. See [the overview](#the-overview). |
3636 | **Code** | `…/code` | The repository's files, commits and branches. |
3737 | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. |
3838 | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). |
5050
5151 | Part | What it shows |
5252 | --- | --- |
53−| **Production** | A screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. See [the production screenshot](/guides/deployments/#the-production-screenshot). |
54−| **Get to production** | The checklist below, until every step is done or you dismiss it. |
53+| **Production** | For an app: a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. See [the production screenshot](/guides/deployments/#the-production-screenshot). |
54+| **Packages** | For a [library or a tool](#apps-and-libraries), in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. |
55+| **Get to production**, or **Ship a release** for a library | The checklists below, until every step is done or you dismiss it. |
5556 | **Right now** | Agents at work, and open pull requests moving from working to landed. |
5657 | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. |
5758 | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). |
7677 The card goes away when every step is done. To hide it sooner, choose
7778 **×** on it. That hides it for this project in this browser only.
7879
80+### Ship a release
81+
82+A [library or a tool](#apps-and-libraries) does not deploy, so its card
83+counts the steps to a first release instead:
84+
85+| Step | Done when | Links to |
86+| --- | --- | --- |
87+| Connect a source or push code | As above. | **Code**. |
88+| Add checks on pull requests | The repository has a workflow in `.g1t/workflows`. Its runs are every pull request's checks. | **Actions**, which offers to add a starter workflow. |
89+| Tag a release or publish a package | A package its repository publishes has a version. For [Composer](/guides/composer/) and [Go](/guides/go/), pushing a tag such as `v1.0.0` is the release. | The package's page, or the guide for its registry. |
90+| Set up repository instructions | As above. | The **Agents** page. |
91+| Assign a first issue to g1t | As above. | A new issue. |
92+
93+## Apps and libraries
94+
95+Every project is either an **app**, which deploys, or a **library or a
96+tool**, which is published and installed. An app's overview shows
97+production and the steps to get there; a library's shows its packages
98+and the steps to a first release, and never offers to turn on
99+deployments. Its **Deployments** page stays in the sidebar either way.
100+
101+g1t works it out for itself, in this order:
102+
103+1. If [Deployments](/guides/deployments/) are on for the project, it is an app.
104+2. If its repository publishes a package other than a container image,
105+ such as a [Composer](/guides/composer/) or [npm](/guides/npm/)
106+ package, it is a library.
107+3. If the files at the root of its default branch (or of its root
108+ directory) say it is a library, it is one:
109+
110+ | File | A library when |
111+ | --- | --- |
112+ | `composer.json` | Its `type` is anything but `project`, such as `library`; or it has no `type`, has `autoload`, and has no `public/index.php`. |
113+ | `Cargo.toml` | It builds a library (`[lib]` or `src/lib.rs`) and no binary (`[[bin]]` or `src/main.rs`). |
114+ | `go.mod` | No `.go` file at the root is `package main`. |
115+ | `pyproject.toml` | It has a build backend and depends on no app framework, such as Django, Flask or FastAPI. |
116+ | `package.json` | It has `main`, `exports`, `module`, `files` or `bin`, no `start` or `dev` script, and no app framework such as Next.js, Astro, Nuxt, Remix or SvelteKit. |
117+
118+ The language's own manifest is read before `package.json`, which many
119+ projects carry only for tooling. A Workers config (`wrangler.jsonc`,
120+ `wrangler.json` or `wrangler.toml`) or an `index.html` at the root
121+ makes it an app.
122+4. Anything else is an app.
123+
124+The files are read again on every push to the default branch.
125+
126+To decide for yourself, open **Settings**, then **General**, and under
127+**Deployments for this project** choose:
128+
129+- **Detect automatically**: the rules above. It shows what was detected
130+ and why.
131+- **Deploys**: an app, whatever its files say.
132+- **Doesn't deploy**: a library or a tool. Its overview offers no
133+ deploying. If Deployments are on for it, turn them off first under
134+ **Settings**, then **Deployments**; g1t does not turn them off for you.
135+ While it is set this way, Deployments cannot be turned on.
136+
79137 ### Active branches
80138
81139 Up to five branches other than the default, the most recently changed
95153
96154 | Tab | What it holds |
97155 | --- | --- |
98−| **General** | The project's name and description, its source, and its **root directory**. A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
156+| **General** | The project's name and description, its source, its **root directory**, and whether it deploys (see [apps and libraries](#apps-and-libraries)). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. |
99157 | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). |
100158 | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). |
101159 | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). |
132190 2. Choose where its code comes from:
133191 - **Start empty**: a new repository on g1t.
134192 - **Import code**: copy a public repository from GitHub or any git host
135− into a new one on g1t.
193+ into a new one on g1t, with every branch and tag (up to 40 MB of
194+ history).
136195 - **Import from GitHub**: import, mirror or move repositories you can
137196 reach on GitHub, private ones too, with every branch and tag and,
138197 if you like, their issues. See [GitHub](/guides/github/).
+3−8
1818
1919 | Where | Page | Who manages it |
2020 | --- | --- | --- |
21−| A workspace | **Settings → Runners**, `g1t.sh/<workspace>/-/runners` | Owners. Members can see the list. |
21+| A workspace | **Settings → Runners**, `g1t.sh/<workspace>/-/runners` | Owners only. |
2222 | A project | **Settings → Runners**, `g1t.sh/<workspace>/<project>/settings/runners` | People with the Admin [role](/guides/access-and-roles/) on its repository |
2323
2424 A workspace's runners serve the repositories their [group](#groups) allows.
226226
227227 ## Docker and Kubernetes
228228
229−**The `flagonio/g1t-runner` image is not published yet.** Until it is, run
230−the binary on the machine, or build the image from
231−[`deploy/runner/Dockerfile`](https://g1t.sh/flagon-io/g1t/blob/main/deploy/runner/Dockerfile)
232−with the Linux binary beside it.
233−
234229 The runner's image runs `register-and-run`, which registers once and then
235230 runs. Each option can also come from `G1T_RUNNER_<OPTION>` in the
236231 environment, such as `G1T_RUNNER_TOKEN` and `G1T_RUNNER_LABELS`, so a
240235 docker run -d --name g1t-runner --restart unless-stopped \
241236 -v /var/run/docker.sock:/var/run/docker.sock \
242237 -v g1t-runner:/data -e G1T_RUNNER_DIR=/data \
243− flagonio/g1t-runner register-and-run --url https://g1t.sh --token g1trt_…
238+ g1t.sh/flagon-io/g1t-runner register-and-run --url https://g1t.sh --token g1trt_…
244239 ```
245240
246241 With the host's Docker socket, each job runs in a sibling container.
266261 spec:
267262 containers:
268263 - name: runner
269− image: flagonio/g1t-runner
264+ image: g1t.sh/flagon-io/g1t-runner
270265 command: ["sh", "-c"]
271266 args:
272267 - |
+6−2
1919 | Workspaces, members, access tokens | Works |
2020 | Repositories: create, push and clone over HTTP, browse code, commits | Works |
2121 | Issues, comments, labels | Works |
22+| [Container images](/guides/containers/): `docker login`, push and pull at your `PUBLIC_URL` | Works, kept in the bundled MinIO, with no limit on a layer's size or on pulls |
2223 | Site search | Works |
2324 | A status page of your own | Works, at `http://localhost:8788` ([below](#the-status-page)) |
24−| Webhooks, integrations | Run, but scheduled retries do not (see below) |
25+| Webhooks, integrations | Work, retries included |
2526 | Sign in with GitHub, import from GitHub | Off until you register a GitHub App of your own ([below](#sign-in-with-github-and-import-from-github)). Mirrors sync with **Sync now**: GitHub's webhook needs the REST API. |
2627 | g1t's agent: changes, plans and reviews | Off |
2728 | Context hub search | Off |
2829 | Deployments on `g1t.page` | Off |
2930 | Billing | Off. Nothing is charged, and no usage limit stops work. |
3031 | Git over SSH, the REST API, MCP and the `g1t` CLI | Not available yet |
31−| Scheduled jobs (webhook retries, Actions schedules) | Not run yet |
32+| Scheduled jobs | Run once a minute inside the g1t container: webhook retries, purging deleted repositories, the packages sweep, security sweeps, audit log retention and access request summaries. Actions schedules (`on: schedule`) are not run. |
3233
3334 What hosted g1t cannot do yet either is on
3435 [What g1t can't do yet](/about/limitations/).
105106 | `REGISTRATION_MODE` | `open` | `open`: anyone can make an account. `invite`: every new account needs an [invite](/guides/authentication/#invites), as on g1t.sh. |
106107 | `INVITES_PER_USER` | `5` | How many invites each person can have out, while `REGISTRATION_MODE` is `invite` |
107108 | `WAITLIST_NOTIFY_EMAIL` | (none) | Where a summary of new access requests goes, at most every 15 minutes. Empty sends none; requests still wait for you in the database. |
109+| `S3_ENDPOINT`, `S3_BUCKET`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` | the bundled MinIO, bucket `g1t-packages` | Where packages' files are kept: any S3-compatible store. Change the two keys before first start; MinIO is made with them. |
110+| `S3_PUBLIC_ENDPOINT` | (none) | The store's address as clients reach it. When set, large layers are downloaded from it directly with a signed URL. |
108111 | `STATUS_PORT` | `8788` | The port the status page is published on |
109112 | `STATUS_PROBE_REPO` | (none) | A public repository, `workspace/repo`, whose branches the status page lists every minute as a clone would. Empty: git is not checked. |
110113 | `INVITE_STAFF_WORKSPACES` | (none) | Workspace slugs, comma separated, whose owners can make invites without a limit. Set it to your own workspace before you switch to `invite`, so someone can invite the first people. |
183186 | --- | --- |
184187 | `g1t_g1t-data` | Accounts, workspaces, issues and every other record, as SQLite files; the keys that seal stored secrets (`keys.env`) |
185188 | `g1t_g1t-git` | Your repositories, one bare git repository each |
189+| `g1t_g1t-packages` | Container images' layers and other package files (MinIO) |
186190 | `g1t_g1t-secrets` | The key the site and the git store share |
187191
188192 To back up, stop g1t and copy the volumes:
+1−1
1313 | | Sent the events of | Managed by | Where |
1414 | --- | --- | --- | --- |
1515 | A repository's | That repository | People with the Admin [role](/guides/access-and-roles/) on it | The project's **Settings → Webhooks** |
16−| A workspace's | Every repository in the workspace | Owners | The workspace's **Settings → Webhooks** |
16+| A workspace's | Every repository in the workspace, and the workspace's own [packages](/guides/packages/) | Owners | The workspace's **Settings → Webhooks** |
1717
1818 Seeing a repository's webhooks, and their deliveries, needs Admin too: the
1919 page is not shown to anyone else, since a webhook's address and secret are
+62−36
143143
144144 ## Delete a workspace
145145
146−Deleting a workspace removes it for good. Only an owner can, signed in as a
147−person, and only once nothing is left in it.
148−
149−Before you start:
150−
151−1. **Move or delete its repositories.** [Transfer](/guides/transferring-repositories/)
152− each one you want to keep to another workspace you own; their old
153− addresses keep redirecting after the workspace is gone.
154− [Delete](/guides/managing-repositories/#delete-a-repository) the ones
155− you do not. Recently deleted repositories do not stand in the way: they
156− are purged with the workspace, and cannot be restored afterwards.
157−2. **Move its projects out.** A project that builds from a repository in
158− another workspace does not move with a transfer; it stops the deletion
159− until it is gone.
160−3. **Settle billing.** See [what billing needs](#what-billing-needs).
146+Deleting a workspace takes everything in it with it, in one step: its
147+repositories, projects, apps, members' access and tokens. Only an owner can,
148+signed in as a person, typing the workspace's slug to confirm.
161149
162−Then:
150+It is not gone at once. For **30 days** g1t keeps all of it, so that a
151+deletion you did not mean, or did not make, can be undone: an owner writes
152+to support@g1t.sh, and support restores the workspace as it was. After 30
153+days it is purged for good.
163154
164155 1. Open the workspace's **Settings → General** and go to **Danger zone**.
165− It says what is still in the way, if anything.
156+ It lists what will go with the workspace: its repositories, projects,
157+ live apps and members.
166158 2. Choose **Delete workspace**, read what happens, type the workspace's
167− slug to confirm, and choose **Delete workspace** again.
159+ slug to confirm, and choose **Delete workspace** again. You are taken
160+ back to your own home.
161+
162+The one thing that can stand in the way is billing: see
163+[what billing needs](#what-billing-needs). Repositories you want to keep in
164+another workspace, [transfer](/guides/transferring-repositories/) first;
165+their old addresses keep redirecting after the workspace is gone.
168166
169167 From the API, call
170168 [`DELETE /workspaces/{workspace}`](/reference/api/workspaces/delete-workspace/)
171169 with the slug in `confirm`; over MCP, the `workspace` tool's `delete`
172170 action.
173171
172+Some workspaces can never be deleted, by anyone, such as Flagon's, which
173+runs g1t. Their Danger zone says so instead of offering the button.
174+
174175 ### What billing needs
175176
176177 | | |
186187
187188 ### What happens
188189
190+At once, when an owner deletes it:
191+
192+| | |
193+| --- | --- |
194+| Members | Lose access, and the workspace leaves their list. Their own accounts are not touched: a person with no workspace left can still sign in, and create or join one. |
195+| Access tokens | The workspace's own tokens stop working. Personal tokens are not affected. |
196+| Repositories | Deleted with it: git refuses them, and their pages answer 404. Agents and workflow runs stop. Ones deleted on their own earlier stay deleted. |
197+| Projects and apps | Hidden. Its apps are taken offline and nothing builds. Custom domains are kept for a restore. |
198+| Its pages | Answer 404, and it drops out of search. |
199+| Billing | What it owes is charged, and its plan ends, as [billing needs](#what-billing-needs). Nothing more is charged. |
200+| The audit log | Records the deletion. |
201+
202+Within 30 days, support can restore it: its members, tokens, repositories,
203+projects and apps come back as they were, and its apps go back up as its
204+limit allows. Its plan does not come back by itself: an owner starts it
205+again from **Billing**. A repository deleted on its own before the
206+workspace was stays in **Recently deleted**.
207+
208+After 30 days it is purged:
209+
189210 | | |
190211 | --- | --- |
191−| Members | Lose access. Their own accounts are not touched: a person with no workspace left can still sign in, and create or join one. |
192−| Access tokens | The workspace's own tokens stop working at once. Personal tokens are not affected. |
212+| Repositories | Purged, their git data with them, including any that were in Recently deleted. |
213+| Projects, apps and custom domains | Removed. |
193214 | Webhooks, integrations, secrets and variables | The workspace's own are removed. |
194215 | Memory and guardrails | The workspace's own are removed. |
195216 | Statements, invoices and the ledger | Kept, for accounting. |
196−| The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the deletion as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. |
197−| Recently deleted repositories | Purged with it, their git data with them. |
217+| The audit log | Kept as [long as its account keeps it](/guides/audit-log/#how-long-it-is-kept), with the purge as its last entry: once the plan ends with the workspace, that is 7 days, unless an enterprise pays for it or longer was arranged. With no owners left, ask support@g1t.sh for an export. |
198218 | Old addresses | Redirects for repositories transferred out keep working. The workspace's own pages answer 404. |
199219
200220 ### The name afterwards
201221
202222 A deleted workspace's slug is never given to another workspace or used as
203−someone else's username. Links and git remotes that still use it keep
223+someone else's username. While it can still be restored, the slug is held
224+for it. Links and git remotes that still use it keep
204225 meaning what they meant: a transferred repository's old address keeps
205226 redirecting to it, and nobody can take the name in the meantime.
206227
207228 The one exception: when the slug is your own username, you may create a
208−workspace with that name again. It starts empty, on standard billing terms,
229+workspace with that name again once the old one is purged. It starts empty, on standard billing terms,
209230 and a repository made in it at an old address ends that address's redirect.
210231
211232 ## Members and roles
216237 | Owner | Everything a member can, and manage members, the base permission, the workspace's access tokens, its details, and billing: the plan, card checks, prepayment and limits. Admin on every repository, and the only ones who can transfer and delete them; see [access and roles](/guides/access-and-roles/). |
217238
218239 Whoever creates a workspace is its owner. An owner adds people on the
219−workspace's **Settings → Members**, `g1t.sh/<workspace>/-/people`:
240+workspace's **Members** (in the sidebar), `g1t.sh/<workspace>/-/people`:
220241
221242 - **By username**: someone already on g1t joins at once, as a member.
222243 - **By email address**: g1t emails an invite that only that address can
251272 **Usage** card there with this month's spend.
252273
253274 The sidebar is one list, in groups: **Mission control**, the workspace's
254−**Overview** and **Explore**; its projects; what it builds and runs with
255−across them (**Agent fleet**, **Context**, **Memory**, **Security**,
256−**Guardrails**, [**Secrets and variables**](/guides/secrets-and-variables/),
257−[**Integrations**](/guides/integrations/) and
258−[**Webhooks**](/guides/webhooks/)); then **Usage**, what g1t's runs have
259−cost (see [usage and billing](/guides/usage-and-billing/)), **Support** and
275+**Overview** and **Explore**; its projects; the places work happens across
276+them (**Agent fleet**, **Context**, **Memory**, **Security** and
277+[**Packages**](/guides/packages/)); then who belongs (**Members**, and
278+**Teams** soon), **Usage**, what g1t's runs have cost (see
279+[usage and billing](/guides/usage-and-billing/)), **Support** and
260280 **Settings**. An item with an arrow opens a list of its own in the sidebar:
261−**Settings** slides over to the workspace's settings, and the row at the
262−top, **‹ Settings**, slides back:
281+**Settings** slides over to how the workspace is set up and connected, and
282+the row at the top, **‹ Settings**, slides back:
263283
264284 | Settings | Who | |
265285 | --- | --- | --- |
266286 | **General** | Owners | The icon, the display name, a one-line description and the address (the slug). |
267−| **Members** | Members | Who belongs, and their roles. Owners add and remove people, set the [base permission](/guides/access-and-roles/#the-base-permission), and see the **Outside collaborators** tab. |
268287 | **Repositories** | Members | The workspace's repositories. Owners also see **Recently deleted**, where a [deleted repository](/guides/managing-repositories/#restore-a-repository) can be restored, or purged, for 30 days. |
269288 | **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
289+| **Guardrails** | Members | What agents may do and spend across the workspace. Owners change them. |
290+| [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. |
291+| **Runners** | Owners | The workspace's self-hosted machines, their groups and registration tokens. |
292+| [**Integrations**](/guides/integrations/) | Members | Model providers and connected services. Owners connect and remove them. |
293+| [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. |
270294 | **Billing and plans** | Members | [The g1t plan](/guides/usage-and-billing/#the-g1t-plan), [limits](/guides/usage-and-billing/#limits) and the statement. Owners start the plan, check a card, prepay and set limits. |
271295 | **Audit log** | Members | [Every action agents, people and tokens took](/guides/audit-log/). |
272296
273−Integrations, secrets and variables, webhooks and guardrails are in the
274−main list. Members see each; owners change them.
297+**Members** is in the main list, for every member to see; owners add and
298+remove people there, set the
299+[base permission](/guides/access-and-roles/#the-base-permission), and see
300+the **Outside collaborators** tab.
275301
276302 Opening a [project](/guides/projects/) slides the sidebar over to the
277303 project's own list, with **‹ All projects** at the top to go back. Its
+0−0

Binary or large file; its contents are not shown.

+3−3
3737 --sl-text-body: 0.9375rem;
3838 --sl-line-height: 1.75;
3939 --sl-content-width: 46rem;
40− /* The header's row, as tall as the app's top bar (4rem), and the tabs
40+ /* The header's row, as tall as the app's top bar (3.5rem), and the tabs
4141 beneath it. */
42− --sl-nav-height: 6.75rem;
42+ --sl-nav-height: 6.25rem;
4343 --sl-sidebar-width: 17rem;
4444
4545 color-scheme: dark;
6363 }
6464 /* On phones the menu button sits in the header's top row, not its middle. */
6565 .sl-menu-button {
66− top: calc((4rem - var(--sl-menu-button-size)) / 2) !important;
66+ top: calc((3.5rem - var(--sl-menu-button-size)) / 2) !important;
6767 }
6868 site-search button[data-open-modal] {
6969 border-radius: 0.5rem;
+19−1
5151 with Stripe's page and PDF), its terms, who it is billed to (move it onto or off an
5252 enterprise), a credit form, a Stripe billing link, its ledger and its
5353 audit log. If billing does not answer for sales or invoices, the page
54− still opens and says so in those sections.
54+ still opens and says so in those sections. A protected workspace (one
55+ nobody can ever delete: identity's `PROTECTED_WORKSPACES`, and
56+ flagon-io always) says so beside its name.
57+- **Deleted workspaces** (`/workspaces/deleted`, linked from Workspaces):
58+ workspaces their owners deleted, newest first (`admin_deleted_workspaces`),
59+ each with who deleted it and when, when it is purged, what went with it
60+ (repositories, projects, members, counted at the deletion) and the days
61+ left. An owner deletes a workspace with everything in it in one step, and
62+ identity keeps it 30 days (`WORKSPACE_RESTORE_DAYS`) so support can undo a
63+ deletion that was a mistake or not theirs to make. **Restore**
64+ (`admin_restore_workspace`) brings it back with its members and tokens,
65+ and its repositories, projects and apps with `workspace.restored`; its
66+ plan stays ended, so its owners start it again from Billing. Check that
67+ whoever asks is an owner of it before restoring. **Purge now**
68+ (`admin_purge_workspace`, the slug typed to confirm) removes it at once,
69+ as the sweep does every 15 minutes once its 30 days are up; never for a
70+ protected workspace. Both go in the workspace's audit log, as g1t, and in
71+ sudo's (`workspace_restored`, `workspace_purged`), naming the staff
72+ member.
5573 - **Enterprises**: customers that pay for several workspaces with one
5674 bill, one limit and one set of terms. Each has its workspaces (add or
5775 remove them), combined usage, terms, credits, ledger and audit log, and
+30−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { confirmsPurge, daysLeft, wentSummary } from "./deleted-workspaces.ts";
5+
6+test("what went reads as one line", () => {
7+ assert.equal(
8+ wentSummary({ repositories: 3, projects: 1, members: 2, billing: null, protected: false }),
9+ "3 repositories, 1 project, 2 members",
10+ );
11+ assert.equal(
12+ wentSummary({ repositories: 1, projects: 0, members: 1, billing: null, protected: false }),
13+ "1 repository, 0 projects, 1 member",
14+ );
15+});
16+
17+test("days left round up, and none once it is due", () => {
18+ const now = Date.parse("2026-10-06T12:00:00Z");
19+ assert.equal(daysLeft("2026-11-05T12:00:00Z", now), 30);
20+ assert.equal(daysLeft("2026-10-06T13:00:00Z", now), 1);
21+ assert.equal(daysLeft("2026-10-06T12:00:00Z", now), 0);
22+ assert.equal(daysLeft("2026-10-01T00:00:00Z", now), 0);
23+ assert.equal(daysLeft("not a date", now), 0);
24+});
25+
26+test("a purge is confirmed by the slug alone", () => {
27+ assert.ok(confirmsPurge("acme", " Acme "));
28+ assert.ok(!confirmsPurge("acme", ""));
29+ assert.ok(!confirmsPurge("acme", "acme-inc"));
30+});
+30−0
1+/**
2+ * Deleted workspaces, as the Deleted workspaces page shows them: what went
3+ * with each, how long is left to restore it, and what staff must type to
4+ * purge one now. No Workers imports, so it can be tested under Node.
5+ */
6+import type { WorkspaceDeletion } from "@g1t/contracts";
7+
8+const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
9+
10+/** What went with a workspace, in a line: "3 repositories, 1 project, 2 members". */
11+export function wentSummary(went: WorkspaceDeletion): string {
12+ const parts = [
13+ plural(went.repositories, "repository", "repositories"),
14+ plural(went.projects, "project", "projects"),
15+ plural(went.members, "member", "members"),
16+ ];
17+ return parts.join(", ");
18+}
19+
20+/** Whole days left before `purgeAfter` (RFC 3339), from `now`; 0 once it is due. */
21+export function daysLeft(purgeAfter: string, now: number): number {
22+ const left = Date.parse(purgeAfter) - now;
23+ if (!Number.isFinite(left) || left <= 0) return 0;
24+ return Math.ceil(left / 86_400_000);
25+}
26+
27+/** Whether what staff typed to purge a workspace now is its slug. Identity checks it again. */
28+export function confirmsPurge(slug: string, typed: string): boolean {
29+ return typed.trim().toLowerCase() === slug.toLowerCase();
30+}
+4−0
105105 note: "Sales note added",
106106 stripe: "From Stripe",
107107 webhook: "Stripe webhook registered",
108+ close: "Billing closed",
109+ // From identity: deleted workspaces staff restored or purged.
110+ workspace_restored: "Workspace restored",
111+ workspace_purged: "Workspace purged",
108112 // From the status page (apps/status), merged in by the Audit log page.
109113 incident_declared: "Incident declared",
110114 incident_detected: "Incident detected",
+1−0
55 export default [
66 index("routes/overview.tsx"),
77 route("workspaces", "routes/workspaces.tsx"),
8+ route("workspaces/deleted", "routes/deleted-workspaces.tsx"),
89 route("workspaces/:slug", "routes/workspace.tsx"),
910 route("users/:username", "routes/user.tsx"),
1011 route("enterprises", "routes/enterprises.tsx"),
+156−0
1+import { ArrowLeft } from "lucide-react";
2+import { Link, data, redirect } from "react-router";
3+
4+import { WORKSPACE_RESTORE_DAYS, type DeletedWorkspace } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/deleted-workspaces";
7+import { Badge, Button, EmptyState, Input, Notice, PageHeader, When } from "~/components/ui";
8+import { confirmsPurge, daysLeft, wentSummary } from "~/lib/deleted-workspaces";
9+import { text } from "~/lib/forms";
10+import { identity } from "~/lib/services.server";
11+import { settle } from "~/lib/settle";
12+import { requireStaff } from "~/lib/staff";
13+
14+export const meta: Route.MetaFunction = () => [
15+ { title: "Deleted workspaces · sudo" },
16+ { name: "robots", content: "noindex, nofollow" },
17+];
18+
19+export async function loader({ request, context }: Route.LoaderArgs) {
20+ requireStaff(context);
21+ const url = new URL(request.url);
22+ const deleted = await settle(identity.deletedWorkspaces());
23+ const done = url.searchParams.get("done");
24+ const slug = url.searchParams.get("slug") ?? "";
25+ return {
26+ workspaces: deleted.ok ? deleted.value : [],
27+ error: deleted.ok ? null : deleted.error,
28+ done: done === "restored" ? `Restored ${slug}.` : done === "purged" ? `Purged ${slug}.` : null,
29+ now: Date.now(),
30+ };
31+}
32+
33+/**
34+ * Restoring and purging. Identity checks the window, the typed slug and
35+ * protection again, and records each in the workspace's audit log and in
36+ * sudo's, naming the staff member.
37+ */
38+export async function action({ request, context }: Route.ActionArgs) {
39+ const staff = requireStaff(context);
40+ const form = await request.formData();
41+ const id = text(form, "id");
42+ const slug = text(form, "slug");
43+ const back = (done: string) => redirect(`/workspaces/deleted?done=${done}&slug=${encodeURIComponent(slug)}`);
44+ switch (text(form, "intent")) {
45+ case "restore": {
46+ const result = await identity.restoreWorkspace(id, staff.email);
47+ if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
48+ throw back("restored");
49+ }
50+ case "purge": {
51+ const confirm = text(form, "confirm");
52+ if (!confirmsPurge(slug, confirm)) return data({ error: `Type ${slug} to confirm.`, id }, { status: 422 });
53+ const result = await identity.purgeWorkspace(id, staff.email, confirm);
54+ if (!result.ok) return data({ error: result.error.message, id }, { status: 422 });
55+ throw back("purged");
56+ }
57+ }
58+ return data({ error: "Unknown action.", id }, { status: 400 });
59+}
60+
61+export default function DeletedWorkspaces({ loaderData, actionData }: Route.ComponentProps) {
62+ const { workspaces, error, done, now } = loaderData;
63+ const errorFor = (id: string) => (actionData && "id" in actionData && actionData.id === id ? actionData.error : null);
64+ return (
65+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
66+ <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
67+ <ArrowLeft size={14} />
68+ Workspaces
69+ </Link>
70+ <div className="mt-4">
71+ <PageHeader
72+ title="Deleted workspaces"
73+ description={`Workspaces their owners deleted, with everything in them. Each is kept for ${WORKSPACE_RESTORE_DAYS} days: restore one an owner asks back, as after a deletion they did not mean or did not make. Then it is purged for good.`}
74+ />
75+ </div>
76+ <div className="mt-6 space-y-3">
77+ {done && <Notice tone="ok">{done}</Notice>}
78+ {error && <Notice tone="error">Identity did not answer: {error}</Notice>}
79+ </div>
80+ {workspaces.length === 0 ? (
81+ <div className="mt-6">
82+ <EmptyState title="No deleted workspaces">Workspaces appear here when an owner deletes one, until they are purged.</EmptyState>
83+ </div>
84+ ) : (
85+ <ul className="mt-6 space-y-3">
86+ {workspaces.map((workspace) => (
87+ <DeletedRow key={workspace.workspaceId} workspace={workspace} now={now} error={errorFor(workspace.workspaceId)} />
88+ ))}
89+ </ul>
90+ )}
91+ </main>
92+ );
93+}
94+
95+function DeletedRow({ workspace, now, error }: { workspace: DeletedWorkspace; now: number; error: string | null }) {
96+ const left = daysLeft(workspace.purgeAfter, now);
97+ return (
98+ <li className="rounded-lg border border-line bg-surface p-4 sm:p-5">
99+ <div className="flex flex-wrap items-start justify-between gap-3">
100+ <div className="min-w-0">
101+ <p className="font-medium">
102+ {workspace.name} <span className="font-mono text-sm text-muted">{workspace.slug}</span>
103+ </p>
104+ <p className="mt-1 text-sm text-muted">
105+ Deleted by <span className="text-fg-soft">{workspace.deletedBy || "an owner"}</span> <When at={workspace.deletedAt} time />
106+ {" · "}purged <When at={workspace.purgeAfter} time />
107+ </p>
108+ <p className="mt-1 text-sm text-muted">Went with it: {wentSummary(workspace.went)}</p>
109+ </div>
110+ <div className="flex flex-wrap gap-1.5">
111+ {workspace.went.protected && <Badge tone="info">Protected</Badge>}
112+ {workspace.restorable ? (
113+ <Badge tone="warn">
114+ {left} day{left === 1 ? "" : "s"} left
115+ </Badge>
116+ ) : (
117+ <Badge tone="danger">Being purged</Badge>
118+ )}
119+ </div>
120+ </div>
121+ {error && (
122+ <div className="mt-3">
123+ <Notice tone="error">{error}</Notice>
124+ </div>
125+ )}
126+ <div className="mt-4 flex flex-col gap-3 border-t border-line pt-4 sm:flex-row sm:items-end sm:justify-between">
127+ <form method="post">
128+ <input type="hidden" name="intent" value="restore" />
129+ <input type="hidden" name="id" value={workspace.workspaceId} />
130+ <input type="hidden" name="slug" value={workspace.slug} />
131+ <Button type="submit" variant="lavender" disabled={!workspace.restorable}>
132+ Restore
133+ </Button>
134+ </form>
135+ {workspace.went.protected ? (
136+ <p className="text-sm text-muted">Protected: it can never be purged.</p>
137+ ) : (
138+ <form method="post" className="flex flex-col gap-2 sm:flex-row sm:items-end">
139+ <input type="hidden" name="intent" value="purge" />
140+ <input type="hidden" name="id" value={workspace.workspaceId} />
141+ <input type="hidden" name="slug" value={workspace.slug} />
142+ <label className="grid gap-1 text-xs text-muted">
143+ <span>
144+ Type <span className="font-mono text-fg">{workspace.slug}</span> to purge it now
145+ </span>
146+ <Input name="confirm" autoComplete="off" spellCheck={false} className="font-mono" aria-label={`Type ${workspace.slug} to purge it now`} />
147+ </label>
148+ <Button type="submit" variant="danger">
149+ Purge now
150+ </Button>
151+ </form>
152+ )}
153+ </div>
154+ </li>
155+ );
156+}
+1−0
208208 </p>
209209 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
210210 <div className="mt-2 flex flex-wrap gap-1.5">
211+ {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
211212 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
212213 <TermsBadge terms={terms} />
213214 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
+11−3
9494
9595 return (
9696 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
97− <div>
98− <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
99− <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p>
97+ <div className="flex flex-wrap items-start justify-between gap-4">
98+ <div>
99+ <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
100+ <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p>
101+ </div>
102+ <Link
103+ to="/workspaces/deleted"
104+ className="rounded-md border border-line px-3 py-1.5 text-sm text-muted hover:border-line-strong hover:text-fg"
105+ >
106+ Deleted workspaces
107+ </Link>
100108 </div>
101109
102110 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
+14−5
1313 };
1414
1515 /**
16− * The steps to production for one project, as `3/6`, until they are all
17− * done or someone dismisses it. Dismissing is per project and remembered by
18− * this browser.
16+ * The steps to production, or to a library's first release, for one
17+ * project, as `3/6`, until they are all done or someone dismisses it.
18+ * Dismissing is per project and remembered by this browser.
1919 */
20−export function ProductionChecklist({ base, items }: { base: string; items: ChecklistItem[] }) {
20+export function ProductionChecklist({
21+ base,
22+ items,
23+ title = "Get to production",
24+}: {
25+ base: string;
26+ items: ChecklistItem[];
27+ /** "Ship a release" for a library. */
28+ title?: string;
29+}) {
2130 const hidden = useSyncExternalStore(
2231 subscribe,
2332 useCallback(() => isDismissed(storage, base), [base]),
3140 <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface">
3241 <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6">
3342 <h2 id="checklist-title" className="text-sm font-semibold">
34− Get to production
43+ {title}
3544 </h2>
3645 <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted">
3746 {done}/{total}
+2−2
395395 className="scroll-mt-28 rounded-xl border border-line"
396396 >
397397 <header
398− className={`sticky top-16 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
398+ className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
399399 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
400400 }`}
401401 >
707707 const allCollapsed = files.every((file) => collapsed.has(file.path));
708708 return (
709709 <div>
710− <div className="sticky top-16 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
710+ <div className="sticky top-14 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
711711 <span className="text-sm text-muted">
712712 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
713713 </span>
+31−18
425425
426426 /** Seven days of landed changes, each split into what agents landed alone and what a person merged. */
427427 function WeekChart({ week }: { week: Week }) {
428− const max = Math.max(1, ...week.days.map((d) => d.agents + d.people));
428+ const max = Math.max(1, ...week.days.map((d) => d.agents + d.assisted + d.people));
429429 const height = 112;
430430 const delta = change(week.total, week.previous);
431431 return (
450450 </div>
451451 {week.total === 0 ? (
452452 <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm leading-6 text-muted">
453− Nothing landed in the last 7 days. Each change that does shows here, by day, split by whether a person had to merge it.
453+ Nothing landed in the last 7 days. Each change that does shows here, by day: agents' changes that landed on their own, agents' that a person merged, and people's own.
454454 </p>
455455 ) : (
456456 <div className="relative mt-5" style={{ height: height + 20 }}>
457457 <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} />
458458 <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}>
459459 {week.days.map((day, index) => {
460− const total = day.agents + day.people;
461− const agentsH = Math.round((day.agents / max) * (height - 4));
462− const peopleH = Math.round((day.people / max) * (height - 4));
460+ const total = day.agents + day.assisted + day.people;
461+ const bar = (n: number) => Math.max(3, Math.round((n / max) * (height - 4)));
462+ // Top to bottom: agents alone, agents with a person, people.
463+ const segments = [
464+ { n: day.agents, tone: "bg-merged" },
465+ { n: day.assisted, tone: "bg-warn" },
466+ { n: day.people, tone: "bg-info" },
467+ ].filter((segment) => segment.n > 0);
463468 const today = index === week.days.length - 1;
464469 return (
465470 <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end">
474479 </span>
475480 )}
476481 <div className="flex w-full max-w-6 flex-col items-stretch gap-[2px]">
477− {day.people > 0 && <span className="block rounded-t bg-warn" style={{ height: Math.max(3, peopleH) }} />}
478− {day.agents > 0 && (
479− <span className={cn("block bg-merged", day.people > 0 ? "" : "rounded-t")} style={{ height: Math.max(3, agentsH) }} />
480− )}
482+ {segments.map((segment, at) => (
483+ <span key={segment.tone} className={cn("block", segment.tone, at === 0 && "rounded-t")} style={{ height: bar(segment.n) }} />
484+ ))}
481485 {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />}
482486 </div>
483487 <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 group-hover:block">
484488 <p className="font-medium text-fg">{day.label}</p>
485489 <p className="mt-0.5 flex items-center gap-1.5 text-muted">
486− <span className="size-1.5 rounded-full bg-merged" /> {day.agents} by agents
490+ <span className="size-1.5 rounded-full bg-merged" /> {day.agents} by agents on their own
491+ </p>
492+ <p className="flex items-center gap-1.5 text-muted">
493+ <span className="size-1.5 rounded-full bg-warn" /> {day.assisted} by agents, merged by a person
487494 </p>
488495 <p className="flex items-center gap-1.5 text-muted">
489− <span className="size-1.5 rounded-full bg-warn" /> {day.people} needed a person
496+ <span className="size-1.5 rounded-full bg-info" /> {day.people} by people
490497 </p>
491498 </div>
492499 </div>
510517 )}
511518 <div className={cn("mt-3 flex-wrap gap-x-4 gap-y-1 text-xs text-muted", week.total === 0 ? "hidden" : "flex")}>
512519 <span className="inline-flex items-center gap-1.5">
513− <span className="size-2 rounded-sm bg-merged" /> Landed by agents
520+ <span className="size-2 rounded-sm bg-merged" /> Agents, on their own
521+ </span>
522+ <span className="inline-flex items-center gap-1.5">
523+ <span className="size-2 rounded-sm bg-warn" /> Agents, merged by a person
514524 </span>
515525 <span className="inline-flex items-center gap-1.5">
516− <span className="size-2 rounded-sm bg-warn" /> Needed a person
526+ <span className="size-2 rounded-sm bg-info" /> People
517527 </span>
518528 </div>
519529 <table className="sr-only">
521531 <thead>
522532 <tr>
523533 <th>Day</th>
524− <th>Landed by agents</th>
525− <th>Needed a person</th>
534+ <th>Agents, on their own</th>
535+ <th>Agents, merged by a person</th>
536+ <th>People</th>
526537 </tr>
527538 </thead>
528539 <tbody>
530541 <tr key={day.key}>
531542 <td>{day.key}</td>
532543 <td>{day.agents}</td>
544+ <td>{day.assisted}</td>
533545 <td>{day.people}</td>
534546 </tr>
535547 ))}
781793
782794 const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort);
783795 const shown = all ? rows : rows.slice(0, ROWS);
784− const share = week.total > 0 ? week.byAgents / week.total : null;
796+ // Of the agents' own changes only: people's work is not theirs to land.
797+ const share = week.agentChanges > 0 ? week.byAgents / week.agentChanges : null;
785798 const delta = change(week.total, week.previous);
786799 const feed = everyActivity ? groups : groups.slice(0, 8);
787800
903916 label="Landed without you"
904917 dot="bg-merged"
905918 value={share == null ? "—" : `${Math.round(share * 100)}%`}
906− hint={share == null ? "nothing landed yet" : `${week.byAgents} of ${week.total}`}
907− title="Merged by g1t, by auto-merge or the merge queue, with no person pressing merge."
919+ hint={share == null ? "no agent changes yet" : `${week.byAgents} of ${week.agentChanges} agent changes`}
920+ title="Of the changes agents wrote, those g1t merged by auto-merge or the merge queue, with no person pressing merge. People's own changes are not counted."
908921 />
909922 <div className="col-span-2 lg:col-span-1">
910923 <Stat
+33−0
1+import { Container, Layers, Package } from "lucide-react";
2+
3+import type { Ecosystem } from "@g1t/contracts";
4+
5+import { cn } from "../lib/cn";
6+
7+/** Each registry's colour, as its own tools use it, so a list reads at a glance. */
8+const TINT: Record<Ecosystem, string> = {
9+ container: "#2496ed",
10+ npm: "#cb3837",
11+ composer: "#c08a5b",
12+ go: "#00add8",
13+ cargo: "#dea584",
14+};
15+
16+/** A small tile marking which registry a package is in. */
17+export function PackageIcon({ ecosystem, size = 28, className }: { ecosystem: Ecosystem; size?: number; className?: string }) {
18+ const tint = TINT[ecosystem];
19+ const glyph = Math.round(size * 0.55);
20+ return (
21+ <span
22+ aria-hidden
23+ className={cn("inline-grid shrink-0 place-items-center rounded-md font-bold", className)}
24+ style={{ width: size, height: size, color: tint, background: `color-mix(in oklab, ${tint} 16%, transparent)` }}
25+ >
26+ {ecosystem === "container" && <Container size={glyph} />}
27+ {ecosystem === "composer" && <Layers size={glyph} />}
28+ {ecosystem === "cargo" && <Package size={glyph} />}
29+ {ecosystem === "npm" && <span style={{ fontSize: glyph }}>n</span>}
30+ {ecosystem === "go" && <span style={{ fontSize: Math.round(size * 0.38) }}>GO</span>}
31+ </span>
32+ );
33+}
+49−78
1−import {
2− Activity,
3− ServerCog,
4− GanttChart,
5− KanbanSquare,
6− Package,
7− Sparkles,
8− BarChart3,
9− BookMarked,
10− BookOpen,
11− Check,
12− ChevronLeft,
13− ChevronRight,
14− ChevronsUpDown,
15− CircleUserRound,
16− Mail,
17− Ticket,
18− CircleDot,
19− Code2,
20− Compass,
21− CreditCard,
22− GitPullRequest,
23− History,
24− House,
25− Fingerprint,
26− Gauge,
27− KeyRound,
28− LifeBuoy,
29− Box,
30− LayoutGrid,
31− ListTree,
32− Lock,
33− LogIn,
34− LogOut,
35− Menu,
36− Plus,
37− Search,
38− Plug,
39− Settings,
40− Users,
41− Webhook,
42− Globe,
43− GitBranch,
44− PlayCircle,
45− Bot,
46− Brain,
47− Network,
48− ShieldCheck,
49− Rocket,
50− X,
51−} from "lucide-react";
1+import { Activity, BarChart3, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, KanbanSquare, KeyRound, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Sparkles, Ticket, Users, UsersRound, Webhook, X } from "lucide-react";
522 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
533 import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
544
232182 const WORKSPACE_ICONS: Record<string, ReactNode> = {
233183 board: <KanbanSquare size={15} />,
234184 roadmap: <GanttChart size={15} />,
185+ teams: <UsersRound size={15} />,
235186 packages: <Package size={15} />,
236187 fleet: <Bot size={15} />,
237188 };
488439 }
489440
490441 /**
491− * A workspace's settings pages, which the sidebar drills into: who belongs,
492− * what it pays, and its record. What it builds and runs with (secrets,
493− * integrations, webhooks, guardrails) sits in the main list.
442+ * A workspace's settings pages, which the sidebar drills into: how it is
443+ * set up and connected (guardrails, secrets, runners, integrations,
444+ * webhooks), what it pays, its repositories, tokens and record. The main
445+ * list keeps the places work happens and who belongs; every member can
446+ * still open these.
494447 */
495−const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|repositories|tokens|billing|audit)(\/|$)/;
448+const SETTINGS_PAGE =
449+ /^\/([^/]+)\/-\/(settings|repositories|tokens|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/;
496450 /** A project's settings pages, which the project's menu drills into. */
497451 const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
498452
695649 <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}>
696650 Security
697651 </SidebarLink>
698− <SidebarLink to={`/${ws.slug}/-/guardrails`} icon={<Gauge size={15} />}>
699− Guardrails
652+ <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}>
653+ Packages
700654 </SidebarLink>
701− <SidebarLink to={`/${ws.slug}/-/secrets`} icon={<Lock size={15} />}>
702− Secrets and variables
703− </SidebarLink>
704− <SidebarLink to={`/${ws.slug}/-/runners`} icon={<ServerCog size={15} />}>
705− Runners
706− </SidebarLink>
707− <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}>
708− Integrations
709− </SidebarLink>
710− <SidebarLink to={`/${ws.slug}/-/webhooks`} icon={<Webhook size={15} />}>
711− Webhooks
712− </SidebarLink>
713− {roadmapIn("Workspace").map((item) => (
655+ {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => (
714656 <SidebarSoonLink
715657 key={item.key}
716658 to={`/${ws.slug}/-/soon/${item.key}`}
724666
725667 <Rule />
726668 <div className="space-y-px">
669+ {/* Who belongs, for every member to see; owners invite and manage there. */}
670+ <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
671+ Members
672+ </SidebarLink>
673+ {roadmapIn("Workspace")
674+ .filter((item) => item.key === "teams")
675+ .map((item) => (
676+ <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}>
677+ {item.title}
678+ </SidebarSoonLink>
679+ ))}
727680 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
728681 Usage
729682 </SidebarLink>
730683 <SidebarLink to="/support" icon={<LifeBuoy size={15} />}>
731684 Support
732685 </SidebarLink>
733− {/* Who belongs, what it pays and its record: a list of their own. */}
686+ {/* How it is set up and connected, what it pays and its record: a list of their own. */}
734687 <SidebarLink
735− to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
688+ to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`}
736689 icon={<Settings size={15} />}
737690 drill
738691 >
756709 General
757710 </SidebarLink>
758711 )}
759− <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
760− Members
761− </SidebarLink>
762712 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
763713 Repositories
764714 </SidebarLink>
766716 Access tokens
767717 </SidebarLink>
768718 </div>
719+ <SidebarGroup title="Agents and runs" className="mt-3">
720+ <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />}>
721+ Guardrails
722+ </SidebarLink>
723+ <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
724+ Secrets and variables
725+ </SidebarLink>
726+ {owner && (
727+ <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}>
728+ Runners
729+ </SidebarLink>
730+ )}
731+ </SidebarGroup>
732+ <SidebarGroup title="Connections" className="mt-3">
733+ <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
734+ Integrations
735+ </SidebarLink>
736+ <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
737+ Webhooks
738+ </SidebarLink>
739+ </SidebarGroup>
769740 <Rule />
770741 <div className="space-y-px">
771742 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
11751146 return (
11761147 <div className="flex h-full flex-col">
11771148 {/* The same height and rule as the top bar, so the two read as one line. */}
1178− <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
1149+ <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
11791150 {user ? (
11801151 <>
11811152 {/* The 1 alone beside the workspace: a square hover the height of the
11821153 switcher, the mark as tall as the workspace avatar. */}
11831154 <Link to="/" aria-label="g1t home" className="flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised">
1184− <Mark tight className="h-5 w-auto" />
1155+ <Mark tight className="h-4 w-auto" />
11851156 </Link>
11861157 <span className="shrink-0 text-line-strong" aria-hidden="true">
11871158 /
15541525 )}
15551526
15561527 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
1557− <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
1528+ <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
15581529 <button
15591530 type="button"
15601531 aria-label="Open menu"
+4−2
2222 try {
2323 const repo = await repos.get(path, viewer);
2424 if (!repo.ok) return;
25− const now = await repos.resolveBranch(repo.value.id, ref);
25+ // `HEAD` is the default branch, as git means it (Go's go-source links use it).
26+ const now = ref === "HEAD" ? repo.value.defaultBranch : await repos.resolveBranch(repo.value.id, ref);
2627 if (!now || now === ref) return;
2728 const url = new URL(request.url);
2829 to = renamedBranchPath(url.pathname, url.search, now);
3031 // A lookup that fails leaves the page a 404, never a 500.
3132 return;
3233 }
33− if (to) throw redirect(to, 301);
34+ // A rename is for good; what HEAD names can change.
35+ if (to) throw redirect(to, ref === "HEAD" ? 302 : 301);
3436 }
+24−0
1010 isDismissed,
1111 productionChecklist,
1212 progress,
13+ releaseChecklist,
1314 } from "./checklist.ts";
1415
1516 const fresh: ChecklistFacts = {
9697 assert.equal(isDismissed(() => blocked, "/acme/web"), false);
9798 assert.equal(dismiss(() => blocked, "/acme/web"), false);
9899 });
100+
101+test("a library's checklist ships a release instead of deploying", () => {
102+ const facts = {
103+ base: "/flagon-io/php-log",
104+ hasCode: true,
105+ instructions: false,
106+ agentAssigned: false,
107+ hasWorkflow: false,
108+ released: true,
109+ releaseTo: "/flagon-io/-/packages/composer/psr/log",
110+ };
111+ const items = releaseChecklist(facts);
112+ assert.deepEqual(
113+ items.map((item) => item.key),
114+ ["code", "checks", "release", "instructions", "agent"],
115+ );
116+ assert.deepEqual(progress(items), { done: 2, total: 5, complete: false });
117+ assert.equal(items.find((item) => item.key === "checks")?.to, "/flagon-io/php-log/actions");
118+ assert.equal(items.find((item) => item.key === "release")?.to, "/flagon-io/-/packages/composer/psr/log");
119+ assert.ok(items.every((item) => !/deploy|production|domain|preview/i.test(item.title)));
120+ // An unknown workflow list counts as not done.
121+ assert.equal(releaseChecklist({ ...facts, hasWorkflow: null }).find((item) => item.key === "checks")?.done, false);
122+});
+45−5
11 /**
2− * The steps that take a project to production, each worked out from what
3− * the project has done, with where to do the ones it has not. Shown on the
4− * project's overview to its members until they are all done or it is
5− * dismissed.
2+ * The steps that take a project to production, or for a library to its
3+ * first release, each worked out from what the project has done, with
4+ * where to do the ones it has not. Shown on the project's overview to its
5+ * members until they are all done or it is dismissed.
66 */
77
88 export type ChecklistFacts = {
2525 };
2626
2727 export type ChecklistItem = {
28− key: "code" | "deploy" | "domain" | "preview" | "instructions" | "agent";
28+ key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "instructions" | "agent";
2929 title: string;
3030 detail: string;
3131 done: boolean;
8989 ];
9090 }
9191
92+export type ReleaseFacts = Pick<ChecklistFacts, "base" | "hasCode" | "instructions" | "agentAssigned"> & {
93+ /** It has a workflow, whose runs are its pull requests' checks; null when unknown. */
94+ hasWorkflow: boolean | null;
95+ /** A package its repository publishes has a version. */
96+ released: boolean;
97+ /** Where publishing a first version is explained: its package's page or its registry's guide. */
98+ releaseTo: string;
99+};
100+
101+/**
102+ * The steps for a library or a tool, which ships as releases rather than
103+ * deploying: the same first and last steps as production, with checks and
104+ * a first version in between.
105+ */
106+export function releaseChecklist(facts: ReleaseFacts): ChecklistItem[] {
107+ const shared = productionChecklist({ ...facts, deploysEnabled: false, productionDeployed: false, domains: null, previewOpened: false });
108+ const step = (key: ChecklistItem["key"]) => shared.find((item) => item.key === key)!;
109+ return [
110+ step("code"),
111+ {
112+ key: "checks",
113+ title: "Add checks on pull requests",
114+ detail: "A workflow that builds and tests it. Its runs are every pull request's checks.",
115+ done: facts.hasWorkflow === true,
116+ to: `${facts.base}/actions`,
117+ action: "Add CI",
118+ },
119+ {
120+ key: "release",
121+ title: "Tag a release or publish a package",
122+ detail: "Publish a first version to the workspace's registry for others to install.",
123+ done: facts.released,
124+ to: facts.releaseTo,
125+ action: "How",
126+ },
127+ step("instructions"),
128+ step("agent"),
129+ ];
130+}
131+
92132 /** `3/6`, for the card's heading. */
93133 export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } {
94134 const done = items.filter((item) => item.done).length;
+22−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { goImport } from "./go-get.ts";
5+
6+test("go get finds a repository's code from its address and any package in it", () => {
7+ for (const path of ["/acme/lib", "/acme/lib/", "/acme/lib/pkg/sub", "/acme/lib.git"]) {
8+ const page = goImport(new URL(`https://g1t.sh${path}?go-get=1`));
9+ assert.ok(page, path);
10+ assert.match(page, /<meta name="go-import" content="g1t\.sh\/acme\/lib git https:\/\/g1t\.sh\/acme\/lib\.git">/);
11+ assert.match(page, /<meta name="go-source" content="g1t\.sh\/acme\/lib https:\/\/g1t\.sh\/acme\/lib /);
12+ }
13+});
14+
15+test("only go-get requests for a repository's address are answered", () => {
16+ assert.equal(goImport(new URL("https://g1t.sh/acme/lib")), null, "no go-get");
17+ assert.equal(goImport(new URL("https://g1t.sh/acme/lib?go-get=0")), null);
18+ assert.equal(goImport(new URL("https://g1t.sh/acme?go-get=1")), null, "a workspace is not a module");
19+ assert.equal(goImport(new URL("https://g1t.sh/acme/-/packages?go-get=1")), null, "workspace pages");
20+ assert.equal(goImport(new URL("https://g1t.sh/Acme/lib?go-get=1")), null);
21+ assert.equal(goImport(new URL("https://g1t.sh/acme/%22%3E?go-get=1")), null, "nothing is put in the page unchecked");
22+});
+32−0
1+/**
2+ * `go get g1t.sh/<workspace>/<repo>[/<package>]`: Go asks the address with
3+ * `?go-get=1` and reads where the module's code is from a `go-import` meta
4+ * tag. Every repository answers, public or private, without looking it up:
5+ * the answer only says where git would find it, and git then asks for
6+ * credentials (GOPRIVATE and .netrc) as it would for a clone.
7+ */
8+
9+const WORKSPACE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/;
10+const REPO = /^[a-z0-9._-]{1,100}$/;
11+
12+/** The page Go reads for `url`, or null when it is not a go-get request for a repository. */
13+export function goImport(url: URL): string | null {
14+ if (url.searchParams.get("go-get") !== "1") return null;
15+ const [workspace, rawRepo] = url.pathname.split("/").filter(Boolean);
16+ if (!workspace || !rawRepo) return null;
17+ const repo = rawRepo.replace(/\.git$/, "");
18+ if (!WORKSPACE.test(workspace) || !REPO.test(repo) || repo.startsWith(".") || workspace === "-" || repo === "-") return null;
19+ const host = url.host;
20+ const prefix = `${host}/${workspace}/${repo}`;
21+ const home = `${url.protocol}//${prefix}`;
22+ return [
23+ "<!doctype html>",
24+ "<html><head>",
25+ `<meta name="go-import" content="${prefix} git ${home}.git">`,
26+ `<meta name="go-source" content="${prefix} ${home} ${home}/tree/HEAD{/dir} ${home}/blob/HEAD{/dir}/{file}#L{line}">`,
27+ "</head><body>",
28+ `go get ${prefix}`,
29+ "</body></html>",
30+ "",
31+ ].join("\n");
32+}
+42−12
160160 assert.ok(!isTestFile("src/contest.ts"));
161161 });
162162
163−const merged = (daysAgo: number, mergedBy: string | null, number = 1): Merged => ({
163+const merged = (daysAgo: number, mergedBy: string | null, number = 1, authoredByAgent = true): Merged => ({
164164 repo,
165165 number,
166166 title: `Change ${number}`,
167167 agent: "g1t",
168+ authoredByAgent,
168169 mergedBy,
169170 mergedAt: new Date(NOW - daysAgo * DAY).toISOString(),
170171 files: [],
177178 assert.ok(!landedByAgents({ mergedBy: "syntaqx" }));
178179 });
179180
180−test("the week is seven days, each split by who landed it, with the week before", () => {
181+test("the week is seven days, each split by who did the work, with the week before", () => {
181182 const week = weekOf(
182− [merged(0, "g1t"), merged(0, "syntaqx"), merged(1, "g1t"), merged(6, "g1t"), merged(8, "g1t"), merged(10, "alex"), merged(20, "g1t")],
183+ [
184+ merged(0, "g1t"),
185+ merged(0, "syntaqx"),
186+ // A person's own change, merged by them and auto-merged by g1t: both theirs.
187+ merged(0, "syntaqx", 2, false),
188+ merged(1, "g1t", 3, false),
189+ merged(1, "g1t"),
190+ merged(6, "g1t"),
191+ merged(8, "g1t"),
192+ merged(10, "alex"),
193+ merged(20, "g1t"),
194+ ],
183195 NOW,
184196 "UTC",
185197 );
187199 assert.equal(week.days[6].key, "2026-10-05");
188200 assert.equal(week.days[6].label, "Mon");
189201 assert.deepEqual(
190− week.days.map((d) => [d.agents, d.people]),
191− [[1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 1]],
202+ week.days.map((d) => [d.agents, d.assisted, d.people]),
203+ [[1, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [1, 0, 1], [1, 1, 1]],
192204 );
193− assert.equal(week.total, 4);
205+ assert.equal(week.total, 6);
194206 assert.equal(week.byAgents, 3);
207+ assert.equal(week.agentChanges, 4);
208+ assert.equal(week.people, 2);
195209 assert.equal(week.previous, 2);
196210 // Not knowing the week before is not the same as nothing in it.
197211 assert.equal(weekOf([merged(0, "g1t")], NOW, "UTC", false).previous, null);
203217 assert.equal(dayKey(late, "America/Los_Angeles"), "2026-10-04");
204218 assert.equal(dayKey(late, "UTC"), "2026-10-05");
205219 assert.equal(dayKey(late, "Not/AZone"), "2026-10-05");
206− const week = weekOf([{ mergedAt: new Date(late).toISOString(), mergedBy: "g1t" }], NOW, "America/Los_Angeles");
220+ const week = weekOf([{ mergedAt: new Date(late).toISOString(), mergedBy: "g1t", authoredByAgent: true }], NOW, "America/Los_Angeles");
207221 assert.equal(week.days.find((d) => d.key === "2026-10-04")?.agents, 1);
208222 });
209223
315329 });
316330
317331 test("the summary says the week honestly", () => {
318− assert.equal(summaryLine({ total: 47, byAgents: 39, live: 2, needs: 8 }), "Agents landed 39 of 47 changes this week without you.");
319− assert.equal(summaryLine({ total: 3, byAgents: 3, live: 0, needs: 0 }), "Agents landed all 3 changes this week without you.");
320− assert.equal(summaryLine({ total: 2, byAgents: 0, live: 0, needs: 0 }), "2 changes landed this week, each merged by a person.");
321− assert.equal(summaryLine({ total: 0, byAgents: 0, live: 1, needs: 0 }), "1 agent is at work. Nothing has landed this week yet.");
322− assert.match(summaryLine({ total: 0, byAgents: 0, live: 0, needs: 0 }), /Assign an issue/);
332+ const none = { total: 0, byAgents: 0, agentChanges: 0, people: 0, live: 0, needs: 0 };
333+ assert.equal(
334+ summaryLine({ ...none, total: 47, byAgents: 39, agentChanges: 47 }),
335+ "Agents landed 39 of their 47 changes this week without you.",
336+ );
337+ assert.equal(
338+ summaryLine({ ...none, total: 3, byAgents: 3, agentChanges: 3 }),
339+ "Agents landed all 3 changes of theirs this week without you.",
340+ );
341+ assert.equal(
342+ summaryLine({ ...none, total: 2, agentChanges: 2 }),
343+ "Agents made 2 changes this week, each merged by a person.",
344+ );
345+ // People's own work is counted as theirs, never as agents' that needed help.
346+ assert.equal(
347+ summaryLine({ ...none, total: 7, byAgents: 4, agentChanges: 5, people: 2 }),
348+ "Agents landed 4 of their 5 changes this week without you, and people landed 2 changes of their own.",
349+ );
350+ assert.equal(summaryLine({ ...none, total: 3, people: 3 }), "People landed 3 changes this week; none were agents'.");
351+ assert.equal(summaryLine({ ...none, live: 1 }), "1 agent is at work. Nothing has landed this week yet.");
352+ assert.match(summaryLine(none), /Assign an issue/);
323353 });
+53−24
429429 number: number;
430430 title: string;
431431 agent: string;
432+ /** Whether an agent wrote it (its author is g1t or another agent), not a person. */
433+ authoredByAgent: boolean;
432434 mergedBy: string | null;
433435 mergedAt: string;
434436 files: ChangedFile[];
451453 }
452454 }
453455
454−export type WeekDay = { key: string; label: string; agents: number; people: number };
456+/**
457+ * One day's changes by who did the work: `agents`, written by an agent and
458+ * landed without a person; `assisted`, written by an agent and merged by a
459+ * person; `people`, written by a person, however it landed.
460+ */
461+export type WeekDay = { key: string; label: string; agents: number; assisted: number; people: number };
455462
456463 export type Week = {
457464 days: WeekDay[];
458− /** Changes landed in the last seven days, and how many without a person. */
465+ /** Changes landed in the last seven days. */
459466 total: number;
467+ /** Of those, agents' changes that landed without a person. */
460468 byAgents: number;
469+ /** Agents' changes, with a person or without. */
470+ agentChanges: number;
471+ /** People's own changes. */
472+ people: number;
461473 /** The seven days before, or null when the lists read do not reach back that far. */
462474 previous: number | null;
463475 };
464476
465477 /**
466− * The last seven days in the viewer's zone, oldest first, each split into
467− * what agents landed alone and what a person merged, and the week before
468− * as one number. `complete` says whether what was read reaches back two
469− * weeks; when it does not, the week before is not guessed.
478+ * The last seven days in the viewer's zone, oldest first, each split by who
479+ * did the work (see `WeekDay`), and the week before as one number.
480+ * `complete` says whether what was read reaches back two weeks; when it
481+ * does not, the week before is not guessed.
470482 */
471−export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week {
483+export function weekOf(
484+ changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[],
485+ now: number,
486+ timeZone: string | null,
487+ complete = true,
488+): Week {
472489 const days: WeekDay[] = [];
473490 const index = new Map<string, number>();
474491 for (let back = 6; back >= 0; back -= 1) {
482499 } catch {
483500 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
484501 }
485− days.push({ key, label, agents: 0, people: 0 });
502+ days.push({ key, label, agents: 0, assisted: 0, people: 0 });
486503 }
487504 const oldest = days[0]?.key ?? "";
488505 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
493510 const key = dayKey(at, timeZone);
494511 const slot = index.get(key);
495512 if (slot != null) {
496− if (landedByAgents(change)) days[slot].agents += 1;
497− else days[slot].people += 1;
513+ if (!change.authoredByAgent) days[slot].people += 1;
514+ else if (landedByAgents(change)) days[slot].agents += 1;
515+ else days[slot].assisted += 1;
498516 } else if (key < oldest && key >= twoWeeks) {
499517 previous += 1;
500518 }
501519 }
502− const byAgents = days.reduce((sum, day) => sum + day.agents, 0);
503− const total = byAgents + days.reduce((sum, day) => sum + day.people, 0);
504− return { days, total, byAgents, previous: complete ? previous : null };
520+ const sum = (pick: (day: WeekDay) => number) => days.reduce((total, day) => total + pick(day), 0);
521+ const byAgents = sum((day) => day.agents);
522+ const agentChanges = byAgents + sum((day) => day.assisted);
523+ const people = sum((day) => day.people);
524+ return { days, total: agentChanges + people, byAgents, agentChanges, people, previous: complete ? previous : null };
505525 }
506526
507527 /** The change from one number to another, as a share; null from nothing. */
553573 title: change.title,
554574 by: who(change.mergedBy ?? "g1t"),
555575 agent: change.agent,
556− byAgents: landedByAgents(change),
576+ byAgents: change.authoredByAgent && landedByAgents(change),
557577 at: Date.parse(change.mergedAt),
558578 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
559579 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"),
582602
583603 // --- The summary ------------------------------------------------------------
584604
585−/** The sentence under the greeting: the week, honestly, in one line. */
586−export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string {
587− const { total, byAgents, live, needs } = input;
605+/**
606+ * The sentence under the greeting: the week, honestly, in one line. What
607+ * agents landed alone is counted against the agents' own changes, never
608+ * against what people wrote.
609+ */
610+export function summaryLine(input: {
611+ total: number;
612+ byAgents: number;
613+ agentChanges: number;
614+ people: number;
615+ live: number;
616+ needs: number;
617+}): string {
618+ const { total, byAgents, agentChanges, people, live, needs } = input;
588619 if (total > 0) {
589− const landed =
590− byAgents === total
591− ? `Agents landed all ${plural(total, "change")} this week without you.`
592− : byAgents === 0
593− ? `${plural(total, "change")} landed this week, each merged by a person.`
594− : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`;
595− return landed;
620+ const theirs = people > 0 ? `, and people landed ${plural(people, "change")} of their own` : "";
621+ if (agentChanges === 0) return `People landed ${plural(people, "change")} this week; none were agents'.`;
622+ if (byAgents === agentChanges) return `Agents landed all ${plural(agentChanges, "change")} of theirs this week without you${theirs}.`;
623+ if (byAgents === 0) return `Agents made ${plural(agentChanges, "change")} this week, each merged by a person${theirs}.`;
624+ return `Agents landed ${byAgents} of their ${plural(agentChanges, "change")} this week without you${theirs}.`;
596625 }
597626 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
598627 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
+63−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { arrange, formatBytes, installCommands, shortCount, shortDigest } from "./packages.ts";
5+
6+test("sizes read as registries show them", () => {
7+ assert.equal(formatBytes(0), "0 B");
8+ assert.equal(formatBytes(812), "812 B");
9+ assert.equal(formatBytes(12_340_000), "12.3 MB");
10+ assert.equal(formatBytes(1_400_000_000), "1.4 GB");
11+ assert.equal(formatBytes(250_000_000), "250 MB");
12+});
13+
14+test("digests shorten for lists", () => {
15+ assert.equal(shortDigest("sha256:3f2a9c1b7d0e55aa"), "3f2a9c1b7d0e");
16+});
17+
18+test("an npm package is installed after .npmrc names its scope's registry, and a token for private ones", () => {
19+ const pkg = { ecosystem: "npm" as const, address: "g1t.sh/-/npm/@acme/ui", name: "ui", workspace: "acme" };
20+ assert.deepEqual(installCommands(pkg, "1.2.0", "ada"), {
21+ registry: "npm config set @acme:registry=https://g1t.sh/-/npm/",
22+ login: "npm config set //g1t.sh/-/npm/:_authToken=YOUR_TOKEN",
23+ install: "npm install @acme/ui@1.2.0",
24+ });
25+ assert.equal(installCommands(pkg, null, "ada").install, "npm install @acme/ui");
26+});
27+
28+test("a Composer package is required after its workspace's repository is added, with credentials for private ones", () => {
29+ const pkg = { ecosystem: "composer" as const, address: "g1t.sh/-/composer/acme/acme/lib", name: "acme/lib", workspace: "acme" };
30+ assert.deepEqual(installCommands(pkg, "v1.1.0", "ada"), {
31+ registry: "composer config repositories.acme composer https://g1t.sh/-/composer/acme/",
32+ login: "composer config --global --auth http-basic.g1t.sh ada YOUR_TOKEN",
33+ install: "composer require acme/lib:v1.1.0",
34+ });
35+});
36+
37+test("a container image is pulled by its address and tag", () => {
38+ const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" };
39+ assert.deepEqual(installCommands(pkg, "latest", "ada"), {
40+ login: "docker login g1t.sh -u ada",
41+ install: "docker pull g1t.sh/acme/web:latest",
42+ });
43+ assert.equal(installCommands(pkg, null, "ada").install, "docker pull g1t.sh/acme/web");
44+});
45+
46+test("packages are listed by the visibility and order asked for", () => {
47+ const list = [
48+ { name: "web", visibility: "private" as const, updated_at: "2026-10-01T00:00:00Z", downloads: 5 },
49+ { name: "api", visibility: "public" as const, updated_at: "2026-10-03T00:00:00Z", downloads: 9 },
50+ { name: "cli", visibility: "public" as const, updated_at: "2026-10-02T00:00:00Z", downloads: 9 },
51+ ];
52+ assert.deepEqual(arrange(list, "all", "updated").map((p) => p.name), ["api", "cli", "web"]);
53+ assert.deepEqual(arrange(list, "all", "downloads").map((p) => p.name), ["api", "cli", "web"]);
54+ assert.deepEqual(arrange(list, "all", "name").map((p) => p.name), ["api", "cli", "web"]);
55+ assert.deepEqual(arrange(list, "private", "updated").map((p) => p.name), ["web"]);
56+ assert.deepEqual(arrange(list, "public", "name").map((p) => p.name), ["api", "cli"]);
57+});
58+
59+test("download counts read short", () => {
60+ assert.equal(shortCount(940), "940");
61+ assert.equal(shortCount(16_100), "16.1k");
62+ assert.equal(shortCount(2_000_000), "2M");
63+});
+156−0
1+/**
2+ * Words and commands for the Packages pages: sizes, digests, and how each
3+ * registry's own tool logs in and installs.
4+ */
5+import type { Ecosystem, PackageSummary } from "@g1t/contracts";
6+
7+/** What each registry is called on the pages. */
8+export const ECOSYSTEM_LABEL: Record<Ecosystem, string> = {
9+ container: "Container",
10+ npm: "npm",
11+ composer: "Composer",
12+ cargo: "Cargo",
13+ go: "Go",
14+};
15+
16+/**
17+ * Each registry as the Packages page introduces it: what it is for, the
18+ * command that starts one, its guide, and whether it is open yet.
19+ */
20+export const REGISTRIES: { ecosystem: Ecosystem; blurb: string; start: string; guide: string; ready: boolean }[] = [
21+ {
22+ ecosystem: "container",
23+ blurb: "Docker and OCI images, signatures and attestations, pulled by anyone the image's repository lets read it.",
24+ start: "docker push g1t.sh/<workspace>/<name>:<tag>",
25+ guide: "/guides/containers/",
26+ ready: true,
27+ },
28+ {
29+ ecosystem: "npm",
30+ blurb: "JavaScript and TypeScript packages under the workspace's scope, installed with npm, pnpm or yarn.",
31+ start: "npm publish",
32+ guide: "/guides/npm/",
33+ ready: true,
34+ },
35+ {
36+ ecosystem: "composer",
37+ blurb: "PHP packages straight from the workspace's repositories: each tag is a version, nothing to upload.",
38+ start: "git tag v1.0.0 && git push --tags",
39+ guide: "/guides/composer/",
40+ ready: true,
41+ },
42+ {
43+ ecosystem: "go",
44+ blurb: "Go modules fetched from the repositories themselves with go get, private ones with a token.",
45+ start: "go get g1t.sh/<workspace>/<repo>",
46+ guide: "/guides/go/",
47+ ready: true,
48+ },
49+ {
50+ ecosystem: "cargo",
51+ blurb: "Rust crates in a registry of the workspace's own, published with cargo publish.",
52+ start: "cargo publish --registry <workspace>",
53+ guide: "/guides/packages/",
54+ ready: false,
55+ },
56+];
57+
58+export type PackageSort = "updated" | "downloads" | "name";
59+export type VisibilityFilter = "all" | "public" | "private";
60+
61+export const SORT_LABEL: Record<PackageSort, string> = {
62+ updated: "Recently updated",
63+ downloads: "Most downloads",
64+ name: "Name",
65+};
66+
67+export const VISIBILITY_LABEL: Record<VisibilityFilter, string> = { all: "All", public: "Public", private: "Private" };
68+
69+/** The packages to list: those of the visibility asked for, in the order asked for. */
70+export function arrange<T extends Pick<PackageSummary, "visibility" | "updated_at" | "downloads" | "name">>(
71+ list: T[],
72+ visibility: VisibilityFilter,
73+ sort: PackageSort,
74+): T[] {
75+ const shown = visibility === "all" ? [...list] : list.filter((pkg) => pkg.visibility === visibility);
76+ const byName = (a: T, b: T) => a.name.localeCompare(b.name);
77+ if (sort === "name") return shown.sort(byName);
78+ if (sort === "downloads") return shown.sort((a, b) => b.downloads - a.downloads || byName(a, b));
79+ return shown.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || byName(a, b));
80+}
81+
82+/** "940", "16.1k", "2.3M": download counts, short. */
83+export function shortCount(n: number): string {
84+ if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`;
85+ if (n >= 1_000) return `${Number((n / 1_000).toFixed(1))}k`;
86+ return String(Math.max(0, Math.round(n)));
87+}
88+
89+/** "0 B", "812 KB", "12.3 MB", "1.4 GB", in powers of 1,000 as registries show them. */
90+export function formatBytes(bytes: number): string {
91+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
92+ const units = ["B", "KB", "MB", "GB", "TB"];
93+ let value = bytes;
94+ let unit = 0;
95+ while (value >= 1000 && unit < units.length - 1) {
96+ value /= 1000;
97+ unit++;
98+ }
99+ const shown = unit === 0 || value >= 100 ? Math.round(value) : Number(value.toFixed(1));
100+ return `${shown} ${units[unit]}`;
101+}
102+
103+/** `sha256:3f2a…` shortened to `3f2a9c1b7d0e` for lists. */
104+export function shortDigest(digest: string): string {
105+ return digest.replace(/^sha256:/, "").slice(0, 12);
106+}
107+
108+/** The host packages are published to and installed from. */
109+export function registryHost(address: string): string {
110+ return address.split("/")[0] || "g1t.sh";
111+}
112+
113+/**
114+ * How to log in and install `pkg` at `version` (a tag or version) with its
115+ * tool. `you` stands in the username a login takes.
116+ */
117+export function installCommands(
118+ pkg: Pick<PackageSummary, "ecosystem" | "address" | "name" | "workspace">,
119+ version: string | null,
120+ you: string,
121+): { login: string; install: string; registry?: string } {
122+ const host = registryHost(pkg.address);
123+ switch (pkg.ecosystem) {
124+ case "container":
125+ return {
126+ login: `docker login ${host} -u ${you}`,
127+ install: `docker pull ${pkg.address}${version ? `:${version}` : ""}`,
128+ };
129+ case "npm":
130+ // The scope's registry (in .npmrc, needed for any install), then the
131+ // token a private package also needs.
132+ return {
133+ registry: `npm config set @${pkg.workspace}:registry=https://${host}/-/npm/`,
134+ login: `npm config set //${host}/-/npm/:_authToken=YOUR_TOKEN`,
135+ install: `npm install @${pkg.workspace}/${pkg.name}${version ? `@${version}` : ""}`,
136+ };
137+ case "composer":
138+ // The workspace's repository (in composer.json, needed for any
139+ // install), then the credentials a private package also needs.
140+ return {
141+ registry: `composer config repositories.${pkg.workspace} composer https://${host}/-/composer/${pkg.workspace}/`,
142+ login: `composer config --global --auth http-basic.${host} ${you} YOUR_TOKEN`,
143+ install: `composer require ${pkg.name}${version ? `:${version}` : ""}`,
144+ };
145+ case "cargo":
146+ return {
147+ login: `cargo login --registry ${pkg.workspace}`,
148+ install: `cargo add ${pkg.name} --registry ${pkg.workspace}${version ? ` --vers ${version}` : ""}`,
149+ };
150+ case "go":
151+ return {
152+ login: `go env -w GOPRIVATE=${host}/${pkg.workspace}`,
153+ install: `go get ${pkg.address}${version ? `@${version}` : ""}`,
154+ };
155+ }
156+}
+41−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { hasRelease, libraryPackages, packageLine, packagePath, publishGuide } from "./project-kind.ts";
5+
6+const pkg = (over: Record<string, unknown>) => ({
7+ ecosystem: "composer" as const,
8+ name: "psr/log",
9+ workspace: "flagon-io",
10+ latest: "3.0.2",
11+ versions: 3,
12+ updated_at: "2026-10-01T00:00:00Z",
13+ ...over,
14+});
15+
16+test("a package reads as its registry, its name and its latest version", () => {
17+ assert.equal(packageLine(pkg({})), "Composer · psr/log 3.0.2");
18+ assert.equal(packageLine(pkg({ ecosystem: "npm", name: "ui", latest: null })), "npm · @flagon-io/ui");
19+ assert.equal(packagePath(pkg({})), "/flagon-io/-/packages/composer/psr/log");
20+});
21+
22+test("what a library publishes comes before an image, released ones first", () => {
23+ const list = [
24+ pkg({ ecosystem: "container", name: "img", updated_at: "2026-10-05T00:00:00Z" }),
25+ pkg({ name: "empty", versions: 0, updated_at: "2026-10-04T00:00:00Z" }),
26+ pkg({}),
27+ ];
28+ assert.deepEqual(libraryPackages(list).map((p) => p.name), ["psr/log", "empty", "img"]);
29+ assert.equal(hasRelease(list), true);
30+ assert.equal(hasRelease([pkg({ versions: 0 })]), false);
31+ assert.equal(hasRelease([]), false);
32+});
33+
34+test("each ecosystem links to how it publishes", () => {
35+ assert.equal(publishGuide("composer")?.guide, "https://docs.g1t.sh/guides/composer/");
36+ assert.equal(publishGuide("composer")?.start, "git tag v1.0.0 && git push --tags");
37+ assert.equal(publishGuide("npm")?.start, "npm publish");
38+ assert.equal(publishGuide("go")?.guide, "https://docs.g1t.sh/guides/go/");
39+ assert.equal(publishGuide("python")?.guide, "https://docs.g1t.sh/guides/packages/");
40+ assert.equal(publishGuide(null), null);
41+});
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are 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.