Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
18 commits
- Project settings no longer print the creator's raw idChase Pierceff824ee
- A package's latest version is its highest, not the last one publishedChase Pierce1465e06
- A project is an app or a library: libraries show their package and how to ship a release, not productionChase Pierce840c24d
- A workspace's runners are its owners' to see, through the API too; docs for the new sidebar and SettingsChase Pierce0ea1f5e
- Runners are owner-only: the page, its actions, the Settings entry and the links to itChase Pierce1378eef
- Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top barChase Pierceb6c6403
- A public import copies every branch and tag, so an imported library keeps its releasesChase Piercefe52cb0
- Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to startChase Pierce5ab6b8a
- Composer from the workspace's own repositories, and go get from g1t.shChase Pierce0df7c3d
- The g1t CLI 0.1.0 is downloadable: g1t.sh/downloads/cli/latest/, five platforms with SHA256SUMSChase Pierce91d61ca
- npm on g1t.sh: publish and install @<workspace>/<name> with the npm CLI and a g1t tokenChase Piercee3455e6
- g1t push: images with layers of any size, uploaded in chunks under the edge's limitChase Pierce8e769b5
- The runner's image is g1t.sh/flagon-io/g1t-runner, on g1t's own registry, publicChase Pierce93eb962
- Money in billing's messages reads to the cent, and the deploy reads migrations again after a failureChase Pierce98964e7
- Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every memberChase Piercebcf63dc
- Production thumbnails follow each deploy, not each commit, and old ones are cleared dailyChase Piercef47f9da
- Packages: the design every phase builds toChase Pierce384551f
- App addresses do not look like other sites: a workspace named like a domain loses that hyphen (flagon-io -> flagonio)Chase Pierce15a46f5
| 85 | 85 | name: linux-binaries | |
| 86 | 86 | path: release | |
| 87 | 87 | - name: Build and push for amd64 and arm64 | |
| 88 | + | # To g1t's own registry, where flagon-io keeps it public. | |
| 88 | 89 | 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 | |
| 92 | 91 | run: | | |
| 93 | 92 | 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 | |
| 95 | 94 | for arch in amd64 arm64; do | |
| 96 | 95 | mkdir -p "context-$arch" | |
| 97 | 96 | cp deploy/runner/Dockerfile "context-$arch/" |
| 741 | 741 | checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" | |
| 742 | 742 | ||
| 743 | 743 | [[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]] | |
| 744 | 754 | name = "find-msvc-tools" | |
| 745 | 755 | version = "0.1.14" | |
| 746 | 756 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 869 | 879 | [[package]] | |
| 870 | 880 | name = "g1t" | |
| 871 | 881 | 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 | + | ] | |
| 872 | 891 | ||
| 873 | 892 | [[package]] | |
| 874 | 893 | name = "g1t-actions" | |
| 985 | 1004 | ] | |
| 986 | 1005 | ||
| 987 | 1006 | [[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]] | |
| 988 | 1025 | name = "g1t-repos" | |
| 989 | 1026 | version = "0.1.0" | |
| 990 | 1027 | dependencies = [ | |
| 1028 | + | "base64 0.22.1", | |
| 991 | 1029 | "futures-util", | |
| 992 | 1030 | "g1t-contracts", | |
| 993 | 1031 | "g1t-kit", | |
| 1025 | 1063 | "miniz_oxide", | |
| 1026 | 1064 | "serde", | |
| 1027 | 1065 | "serde_json", | |
| 1028 | − | "sha1", | |
| 1066 | + | "sha1 0.11.0", | |
| 1029 | 1067 | "sha2 0.10.9", | |
| 1030 | 1068 | "similar", | |
| 1031 | 1069 | ] | |
| 2354 | 2392 | "salsa20", | |
| 2355 | 2393 | "scrypt", | |
| 2356 | 2394 | "sec1", | |
| 2357 | − | "sha1", | |
| 2395 | + | "sha1 0.11.0", | |
| 2358 | 2396 | "sha2 0.11.0", | |
| 2359 | 2397 | "sha3 0.12.0", | |
| 2360 | 2398 | "signature", | |
| 2722 | 2760 | ||
| 2723 | 2761 | [[package]] | |
| 2724 | 2762 | 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" | |
| 2725 | 2774 | version = "0.11.0" | |
| 2726 | 2775 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2727 | 2776 | checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | |
| 2917 | 2966 | "rand_core 0.10.1", | |
| 2918 | 2967 | "rsa", | |
| 2919 | 2968 | "sec1", | |
| 2920 | − | "sha1", | |
| 2969 | + | "sha1 0.11.0", | |
| 2921 | 2970 | "sha2 0.11.0", | |
| 2922 | 2971 | "signature", | |
| 2923 | 2972 | "ssh-cipher", | |
| 3022 | 3071 | ] | |
| 3023 | 3072 | ||
| 3024 | 3073 | [[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]] | |
| 3025 | 3084 | name = "thiserror" | |
| 3026 | 3085 | version = "2.0.21" | |
| 3027 | 3086 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3244 | 3303 | "httparse", | |
| 3245 | 3304 | "log", | |
| 3246 | 3305 | "rand", | |
| 3247 | − | "sha1", | |
| 3306 | + | "sha1 0.11.0", | |
| 3248 | 3307 | "thiserror", | |
| 3249 | 3308 | ] | |
| 3250 | 3309 |
| 1 | 1 | [workspace] | |
| 2 | 2 | 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"] | |
| 4 | 4 | ||
| 5 | 5 | [workspace.package] | |
| 6 | 6 | edition = "2024" |
| 702 | 702 | } | |
| 703 | 703 | Op::RevokeWorkspaceInvite => "Revoke a workspace's pending invite. Owners only.", | |
| 704 | 704 | 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." | |
| 706 | 706 | } | |
| 707 | 707 | Op::UpdateWorkspace => { | |
| 708 | 708 | "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." | |
| 914 | 914 | Op::SetActionsVariable => "Add or change a variable's row, as for secrets.", | |
| 915 | 915 | Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.", | |
| 916 | 916 | 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." | |
| 918 | 918 | } | |
| 919 | 919 | 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." | |
| 921 | 921 | } | |
| 922 | 922 | Op::GetRunnerSettings => { | |
| 923 | 923 | "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`)." |
| 88 | 88 | "confirm": "acme-labs" | |
| 89 | 89 | }, | |
| 90 | 90 | "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)." | |
| 92 | 92 | }, | |
| 93 | 93 | "list_emails": { | |
| 94 | 94 | "response": { |
| 231 | 231 | default_action: None, | |
| 232 | 232 | actions: &[ | |
| 233 | 233 | 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)"), | |
| 235 | 235 | a("update", Op::UpdateWorkspace, "Change its name, description or base permission"), | |
| 236 | 236 | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 237 | 237 | a("invite_member", Op::InviteMember, "Invite an email address"), |
| 73 | 73 | items: [ | |
| 74 | 74 | { label: 'Projects', slug: 'guides/projects' }, | |
| 75 | 75 | { 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' }, | |
| 76 | 81 | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, | |
| 77 | 82 | { label: 'Security', slug: 'guides/security' }, | |
| 78 | 83 | ], |
| 26 | 26 | flex-direction: column; | |
| 27 | 27 | height: 100%; | |
| 28 | 28 | } | |
| 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. */ | |
| 30 | 30 | .g1t-header-top { | |
| 31 | 31 | box-sizing: border-box; | |
| 32 | − | height: 4rem; | |
| 32 | + | height: 3.5rem; | |
| 33 | 33 | flex-shrink: 0; | |
| 34 | 34 | padding: 0 var(--sl-nav-pad-x); | |
| 35 | 35 | border-bottom: 1px solid var(--g1t-line); |
| 39 | 39 | .g1t-title-lockup { | |
| 40 | 40 | display: inline-flex; | |
| 41 | 41 | align-items: baseline; | |
| 42 | − | font-size: 1.5rem; | |
| 42 | + | font-size: 1.25rem; | |
| 43 | 43 | line-height: 1; | |
| 44 | 44 | } | |
| 45 | 45 | svg { |
| 105 | 105 | ||
| 106 | 106 | Owners always have Admin, whatever it says. Only owners can change it: | |
| 107 | 107 | ||
| 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. | |
| 109 | 109 | 2. Under **Base permission**, choose one. | |
| 110 | 110 | ||
| 111 | 111 | It takes effect on everyone's next request. To give one member more on | |
| 178 | 178 | ||
| 179 | 179 | Owners see every outside collaborator, and the repositories and roles each | |
| 180 | 180 | 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 | |
| 182 | 182 | (see [members and roles](/guides/workspaces/#members-and-roles)); the | |
| 183 | 183 | roles they have stay, and the base permission adds to them. | |
| 184 | 184 |
| 13 | 13 | ||
| 14 | 14 | - **A repository's lifecycle**, wherever the change was made, g1t.sh | |
| 15 | 15 | 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. | |
| 17 | 19 | ||
| 18 | 20 | | Action | Recorded when | | |
| 19 | 21 | | --- | --- | | |
| 27 | 29 | | `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/). | | |
| 28 | 30 | | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. | | |
| 29 | 31 | | `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). | | |
| 30 | 33 | ||
| 31 | 34 | Through the API and the MCP server, the call itself is recorded under its | |
| 32 | 35 | operation's name too, such as `delete_repo`. See |
| 317 | 317 | | Group | Scopes | | |
| 318 | 318 | | --- | --- | | |
| 319 | 319 | | Repositories & code | `repo:read`, `repo:write`, `code:read`, `code:write` | | |
| 320 | + | | Packages | `packages:read`, `packages:write` | | |
| 320 | 321 | | Issues & pull requests | `issues:read`, `issues:write`, `pull_requests:read`, `pull_requests:write` | | |
| 321 | 322 | | Agents | `agents:run` | | |
| 322 | 323 | | Workflows | `workflows:read`, `workflows:write` | | |
| 324 | 325 | | Account | `account:read`, `account:write` | | |
| 325 | 326 | | Workspace | `workspace:read`, `access:read`, `webhooks:read`, `secrets:read` | | |
| 326 | 327 | | 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` | | |
| 328 | 329 | ||
| 329 | 330 | Ticking a higher level ticks the lower ones of its resource and greys | |
| 330 | 331 | them out: tick `issues:write` and `issues:read` is ticked too. Untick | |
| 337 | 338 | | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts | | |
| 338 | 339 | | `code:read` | Clone and fetch private repositories with git | | |
| 339 | 340 | | `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 | | |
| 340 | 344 | | `issues:read` | Read issues, comments and plans | | |
| 341 | 345 | | `issues:write` | Open, edit, close and comment on issues | | |
| 342 | 346 | | `pull_requests:read` | Read pull requests, their changes, sessions and merge queues | | |
| 395 | 399 | | --- | --- | | |
| 396 | 400 | | Read only | Every `read` scope. Changes nothing. | | |
| 397 | 401 | | 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. | | |
| 399 | 403 | | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. | | |
| 400 | 404 | ||
| 401 | 405 | Admin scopes change things that are hard to undo, or decide who can reach |
| 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)). |
| 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. | |
| 21 | 21 | `pr-<number>` in place of the branch. A name too long for an address, or | |
| 22 | 22 | one another app already has, is shortened or given a short suffix. | |
| 23 | 23 | ||
| 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 | + | ||
| 24 | 31 | When a workspace is [renamed](/guides/workspaces/#rename-a-workspace), or a | |
| 25 | 32 | repository is [transferred](/guides/transferring-repositories/#deployments) | |
| 26 | 33 | to another workspace or [renamed](/guides/managing-repositories/), its apps | |
| 56 | 63 | Production starts building at once from the default branch. Every pull | |
| 57 | 64 | request opened or pushed to from then on gets a preview. | |
| 58 | 65 | ||
| 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 | + | ||
| 59 | 73 | While payments on g1t are in test mode, no real card is charged: use the | |
| 60 | 74 | test card `4242 4242 4242 4242` with any future date and any code. | |
| 61 | 75 |
| 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. |
| 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`. | |
| 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. |
| 32 | 32 | ||
| 33 | 33 | | Page | Address | | | |
| 34 | 34 | | --- | --- | --- | | |
| 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). | | |
| 36 | 36 | | **Code** | `…/code` | The repository's files, commits and branches. | | |
| 37 | 37 | | **Issues**, **Pull requests**, **Merge queue**, **Plan** | `…/issues` and so on | As they always were. | | |
| 38 | 38 | | **Actions** | `…/actions` | [GitHub Actions workflows](/guides/actions/). | | |
| 50 | 50 | ||
| 51 | 51 | | Part | What it shows | | |
| 52 | 52 | | --- | --- | | |
| 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. | | |
| 55 | 56 | | **Right now** | Agents at work, and open pull requests moving from working to landed. | | |
| 56 | 57 | | **Needs you** | What is waiting on a person: a failed production build, a pull request to merge or review, a stuck run. | | |
| 57 | 58 | | **Active branches** | Branches other than the default, newest first. See [active branches](#active-branches). | | |
| 76 | 77 | The card goes away when every step is done. To hide it sooner, choose | |
| 77 | 78 | **×** on it. That hides it for this project in this browser only. | |
| 78 | 79 | ||
| 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 | + | ||
| 79 | 137 | ### Active branches | |
| 80 | 138 | ||
| 81 | 139 | Up to five branches other than the default, the most recently changed | |
| 95 | 153 | ||
| 96 | 154 | | Tab | What it holds | | |
| 97 | 155 | | --- | --- | | |
| 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. | | |
| 99 | 157 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 100 | 158 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 101 | 159 | | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). | | |
| 132 | 190 | 2. Choose where its code comes from: | |
| 133 | 191 | - **Start empty**: a new repository on g1t. | |
| 134 | 192 | - **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). | |
| 136 | 195 | - **Import from GitHub**: import, mirror or move repositories you can | |
| 137 | 196 | reach on GitHub, private ones too, with every branch and tag and, | |
| 138 | 197 | if you like, their issues. See [GitHub](/guides/github/). |
| 18 | 18 | ||
| 19 | 19 | | Where | Page | Who manages it | | |
| 20 | 20 | | --- | --- | --- | | |
| 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. | | |
| 22 | 22 | | A project | **Settings → Runners**, `g1t.sh/<workspace>/<project>/settings/runners` | People with the Admin [role](/guides/access-and-roles/) on its repository | | |
| 23 | 23 | ||
| 24 | 24 | A workspace's runners serve the repositories their [group](#groups) allows. | |
| 226 | 226 | ||
| 227 | 227 | ## Docker and Kubernetes | |
| 228 | 228 | ||
| 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 | − | ||
| 234 | 229 | The runner's image runs `register-and-run`, which registers once and then | |
| 235 | 230 | runs. Each option can also come from `G1T_RUNNER_<OPTION>` in the | |
| 236 | 231 | environment, such as `G1T_RUNNER_TOKEN` and `G1T_RUNNER_LABELS`, so a | |
| 240 | 235 | docker run -d --name g1t-runner --restart unless-stopped \ | |
| 241 | 236 | -v /var/run/docker.sock:/var/run/docker.sock \ | |
| 242 | 237 | -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_… | |
| 244 | 239 | ``` | |
| 245 | 240 | ||
| 246 | 241 | With the host's Docker socket, each job runs in a sibling container. | |
| 266 | 261 | spec: | |
| 267 | 262 | containers: | |
| 268 | 263 | - name: runner | |
| 269 | − | image: flagonio/g1t-runner | |
| 264 | + | image: g1t.sh/flagon-io/g1t-runner | |
| 270 | 265 | command: ["sh", "-c"] | |
| 271 | 266 | args: | |
| 272 | 267 | - | |
| 19 | 19 | | Workspaces, members, access tokens | Works | | |
| 20 | 20 | | Repositories: create, push and clone over HTTP, browse code, commits | Works | | |
| 21 | 21 | | 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 | | |
| 22 | 23 | | Site search | Works | | |
| 23 | 24 | | 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 | | |
| 25 | 26 | | 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. | | |
| 26 | 27 | | g1t's agent: changes, plans and reviews | Off | | |
| 27 | 28 | | Context hub search | Off | | |
| 28 | 29 | | Deployments on `g1t.page` | Off | | |
| 29 | 30 | | Billing | Off. Nothing is charged, and no usage limit stops work. | | |
| 30 | 31 | | 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. | | |
| 32 | 33 | ||
| 33 | 34 | What hosted g1t cannot do yet either is on | |
| 34 | 35 | [What g1t can't do yet](/about/limitations/). | |
| 105 | 106 | | `REGISTRATION_MODE` | `open` | `open`: anyone can make an account. `invite`: every new account needs an [invite](/guides/authentication/#invites), as on g1t.sh. | | |
| 106 | 107 | | `INVITES_PER_USER` | `5` | How many invites each person can have out, while `REGISTRATION_MODE` is `invite` | | |
| 107 | 108 | | `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. | | |
| 108 | 111 | | `STATUS_PORT` | `8788` | The port the status page is published on | | |
| 109 | 112 | | `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. | | |
| 110 | 113 | | `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. | | |
| 183 | 186 | | --- | --- | | |
| 184 | 187 | | `g1t_g1t-data` | Accounts, workspaces, issues and every other record, as SQLite files; the keys that seal stored secrets (`keys.env`) | | |
| 185 | 188 | | `g1t_g1t-git` | Your repositories, one bare git repository each | | |
| 189 | + | | `g1t_g1t-packages` | Container images' layers and other package files (MinIO) | | |
| 186 | 190 | | `g1t_g1t-secrets` | The key the site and the git store share | | |
| 187 | 191 | ||
| 188 | 192 | To back up, stop g1t and copy the volumes: |
| 13 | 13 | | | Sent the events of | Managed by | Where | | |
| 14 | 14 | | --- | --- | --- | --- | | |
| 15 | 15 | | 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** | | |
| 17 | 17 | ||
| 18 | 18 | Seeing a repository's webhooks, and their deliveries, needs Admin too: the | |
| 19 | 19 | page is not shown to anyone else, since a webhook's address and secret are |
| 143 | 143 | ||
| 144 | 144 | ## Delete a workspace | |
| 145 | 145 | ||
| 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. | |
| 161 | 149 | ||
| 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. | |
| 163 | 154 | ||
| 164 | 155 | 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. | |
| 166 | 158 | 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. | |
| 168 | 166 | ||
| 169 | 167 | From the API, call | |
| 170 | 168 | [`DELETE /workspaces/{workspace}`](/reference/api/workspaces/delete-workspace/) | |
| 171 | 169 | with the slug in `confirm`; over MCP, the `workspace` tool's `delete` | |
| 172 | 170 | action. | |
| 173 | 171 | ||
| 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 | + | ||
| 174 | 175 | ### What billing needs | |
| 175 | 176 | ||
| 176 | 177 | | | | | |
| 186 | 187 | ||
| 187 | 188 | ### What happens | |
| 188 | 189 | ||
| 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 | + | ||
| 189 | 210 | | | | | |
| 190 | 211 | | --- | --- | | |
| 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. | | |
| 193 | 214 | | Webhooks, integrations, secrets and variables | The workspace's own are removed. | | |
| 194 | 215 | | Memory and guardrails | The workspace's own are removed. | | |
| 195 | 216 | | 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. | | |
| 198 | 218 | | Old addresses | Redirects for repositories transferred out keep working. The workspace's own pages answer 404. | | |
| 199 | 219 | ||
| 200 | 220 | ### The name afterwards | |
| 201 | 221 | ||
| 202 | 222 | 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 | |
| 204 | 225 | meaning what they meant: a transferred repository's old address keeps | |
| 205 | 226 | redirecting to it, and nobody can take the name in the meantime. | |
| 206 | 227 | ||
| 207 | 228 | 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, | |
| 209 | 230 | and a repository made in it at an old address ends that address's redirect. | |
| 210 | 231 | ||
| 211 | 232 | ## Members and roles | |
| 216 | 237 | | 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/). | | |
| 217 | 238 | ||
| 218 | 239 | 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`: | |
| 220 | 241 | ||
| 221 | 242 | - **By username**: someone already on g1t joins at once, as a member. | |
| 222 | 243 | - **By email address**: g1t emails an invite that only that address can | |
| 251 | 272 | **Usage** card there with this month's spend. | |
| 252 | 273 | ||
| 253 | 274 | 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 | |
| 260 | 280 | **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: | |
| 263 | 283 | ||
| 264 | 284 | | Settings | Who | | | |
| 265 | 285 | | --- | --- | --- | | |
| 266 | 286 | | **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. | | |
| 268 | 287 | | **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. | | |
| 269 | 288 | | **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. | | |
| 270 | 294 | | **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. | | |
| 271 | 295 | | **Audit log** | Members | [Every action agents, people and tokens took](/guides/audit-log/). | | |
| 272 | 296 | ||
| 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. | |
| 275 | 301 | ||
| 276 | 302 | Opening a [project](/guides/projects/) slides the sidebar over to the | |
| 277 | 303 | project's own list, with **‹ All projects** at the top to go back. Its |
Binary or large file; its contents are not shown.
| 37 | 37 | --sl-text-body: 0.9375rem; | |
| 38 | 38 | --sl-line-height: 1.75; | |
| 39 | 39 | --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 | |
| 41 | 41 | beneath it. */ | |
| 42 | − | --sl-nav-height: 6.75rem; | |
| 42 | + | --sl-nav-height: 6.25rem; | |
| 43 | 43 | --sl-sidebar-width: 17rem; | |
| 44 | 44 | ||
| 45 | 45 | color-scheme: dark; | |
| 63 | 63 | } | |
| 64 | 64 | /* On phones the menu button sits in the header's top row, not its middle. */ | |
| 65 | 65 | .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; | |
| 67 | 67 | } | |
| 68 | 68 | site-search button[data-open-modal] { | |
| 69 | 69 | border-radius: 0.5rem; |
| 51 | 51 | with Stripe's page and PDF), its terms, who it is billed to (move it onto or off an | |
| 52 | 52 | enterprise), a credit form, a Stripe billing link, its ledger and its | |
| 53 | 53 | 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. | |
| 55 | 73 | - **Enterprises**: customers that pay for several workspaces with one | |
| 56 | 74 | bill, one limit and one set of terms. Each has its workspaces (add or | |
| 57 | 75 | remove them), combined usage, terms, credits, ledger and audit log, and |
| 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 | + | }); |
| 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 | + | } |
| 105 | 105 | note: "Sales note added", | |
| 106 | 106 | stripe: "From Stripe", | |
| 107 | 107 | 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", | |
| 108 | 112 | // From the status page (apps/status), merged in by the Audit log page. | |
| 109 | 113 | incident_declared: "Incident declared", | |
| 110 | 114 | incident_detected: "Incident detected", |
| 5 | 5 | export default [ | |
| 6 | 6 | index("routes/overview.tsx"), | |
| 7 | 7 | route("workspaces", "routes/workspaces.tsx"), | |
| 8 | + | route("workspaces/deleted", "routes/deleted-workspaces.tsx"), | |
| 8 | 9 | route("workspaces/:slug", "routes/workspace.tsx"), | |
| 9 | 10 | route("users/:username", "routes/user.tsx"), | |
| 10 | 11 | route("enterprises", "routes/enterprises.tsx"), |
| 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 | + | } |
| 208 | 208 | </p> | |
| 209 | 209 | {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>} | |
| 210 | 210 | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 211 | + | {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>} | |
| 211 | 212 | {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>} | |
| 212 | 213 | <TermsBadge terms={terms} /> | |
| 213 | 214 | {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} |
| 94 | 94 | ||
| 95 | 95 | return ( | |
| 96 | 96 | <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> | |
| 100 | 108 | </div> | |
| 101 | 109 | ||
| 102 | 110 | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| 13 | 13 | }; | |
| 14 | 14 | ||
| 15 | 15 | /** | |
| 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. | |
| 19 | 19 | */ | |
| 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 | + | }) { | |
| 21 | 30 | const hidden = useSyncExternalStore( | |
| 22 | 31 | subscribe, | |
| 23 | 32 | useCallback(() => isDismissed(storage, base), [base]), | |
| 31 | 40 | <section aria-labelledby="checklist-title" className="rounded-2xl border border-line bg-surface"> | |
| 32 | 41 | <div className="flex items-center gap-3 px-5 pt-4 pb-3 sm:px-6"> | |
| 33 | 42 | <h2 id="checklist-title" className="text-sm font-semibold"> | |
| 34 | − | Get to production | |
| 43 | + | {title} | |
| 35 | 44 | </h2> | |
| 36 | 45 | <span className="rounded-full bg-raised px-2 py-px text-xs font-medium tabular-nums text-muted"> | |
| 37 | 46 | {done}/{total} |
| 395 | 395 | className="scroll-mt-28 rounded-xl border border-line" | |
| 396 | 396 | > | |
| 397 | 397 | <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 ${ | |
| 399 | 399 | collapsed ? "rounded-xl" : "rounded-t-xl border-b" | |
| 400 | 400 | }`} | |
| 401 | 401 | > | |
| 707 | 707 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 708 | 708 | return ( | |
| 709 | 709 | <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"> | |
| 711 | 711 | <span className="text-sm text-muted"> | |
| 712 | 712 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 713 | 713 | </span> |
| 425 | 425 | ||
| 426 | 426 | /** Seven days of landed changes, each split into what agents landed alone and what a person merged. */ | |
| 427 | 427 | 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)); | |
| 429 | 429 | const height = 112; | |
| 430 | 430 | const delta = change(week.total, week.previous); | |
| 431 | 431 | return ( | |
| 450 | 450 | </div> | |
| 451 | 451 | {week.total === 0 ? ( | |
| 452 | 452 | <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. | |
| 454 | 454 | </p> | |
| 455 | 455 | ) : ( | |
| 456 | 456 | <div className="relative mt-5" style={{ height: height + 20 }}> | |
| 457 | 457 | <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} /> | |
| 458 | 458 | <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}> | |
| 459 | 459 | {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); | |
| 463 | 468 | const today = index === week.days.length - 1; | |
| 464 | 469 | return ( | |
| 465 | 470 | <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end"> | |
| 474 | 479 | </span> | |
| 475 | 480 | )} | |
| 476 | 481 | <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 | + | ))} | |
| 481 | 485 | {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />} | |
| 482 | 486 | </div> | |
| 483 | 487 | <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"> | |
| 484 | 488 | <p className="font-medium text-fg">{day.label}</p> | |
| 485 | 489 | <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 | |
| 487 | 494 | </p> | |
| 488 | 495 | <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 | |
| 490 | 497 | </p> | |
| 491 | 498 | </div> | |
| 492 | 499 | </div> | |
| 510 | 517 | )} | |
| 511 | 518 | <div className={cn("mt-3 flex-wrap gap-x-4 gap-y-1 text-xs text-muted", week.total === 0 ? "hidden" : "flex")}> | |
| 512 | 519 | <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 | |
| 514 | 524 | </span> | |
| 515 | 525 | <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 | |
| 517 | 527 | </span> | |
| 518 | 528 | </div> | |
| 519 | 529 | <table className="sr-only"> | |
| 521 | 531 | <thead> | |
| 522 | 532 | <tr> | |
| 523 | 533 | <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> | |
| 526 | 537 | </tr> | |
| 527 | 538 | </thead> | |
| 528 | 539 | <tbody> | |
| 530 | 541 | <tr key={day.key}> | |
| 531 | 542 | <td>{day.key}</td> | |
| 532 | 543 | <td>{day.agents}</td> | |
| 544 | + | <td>{day.assisted}</td> | |
| 533 | 545 | <td>{day.people}</td> | |
| 534 | 546 | </tr> | |
| 535 | 547 | ))} | |
| 781 | 793 | ||
| 782 | 794 | const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort); | |
| 783 | 795 | 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; | |
| 785 | 798 | const delta = change(week.total, week.previous); | |
| 786 | 799 | const feed = everyActivity ? groups : groups.slice(0, 8); | |
| 787 | 800 | ||
| 903 | 916 | label="Landed without you" | |
| 904 | 917 | dot="bg-merged" | |
| 905 | 918 | 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." | |
| 908 | 921 | /> | |
| 909 | 922 | <div className="col-span-2 lg:col-span-1"> | |
| 910 | 923 | <Stat |
| 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 | + | } |
| 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"; | |
| 52 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 53 | 3 | import { Form, Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 54 | 4 | ||
| 232 | 182 | const WORKSPACE_ICONS: Record<string, ReactNode> = { | |
| 233 | 183 | board: <KanbanSquare size={15} />, | |
| 234 | 184 | roadmap: <GanttChart size={15} />, | |
| 185 | + | teams: <UsersRound size={15} />, | |
| 235 | 186 | packages: <Package size={15} />, | |
| 236 | 187 | fleet: <Bot size={15} />, | |
| 237 | 188 | }; | |
| 488 | 439 | } | |
| 489 | 440 | ||
| 490 | 441 | /** | |
| 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. | |
| 494 | 447 | */ | |
| 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)(\/|$)/; | |
| 496 | 450 | /** A project's settings pages, which the project's menu drills into. */ | |
| 497 | 451 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 498 | 452 | ||
| 695 | 649 | <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}> | |
| 696 | 650 | Security | |
| 697 | 651 | </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 | |
| 700 | 654 | </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) => ( | |
| 714 | 656 | <SidebarSoonLink | |
| 715 | 657 | key={item.key} | |
| 716 | 658 | to={`/${ws.slug}/-/soon/${item.key}`} | |
| 724 | 666 | ||
| 725 | 667 | <Rule /> | |
| 726 | 668 | <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 | + | ))} | |
| 727 | 680 | <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}> | |
| 728 | 681 | Usage | |
| 729 | 682 | </SidebarLink> | |
| 730 | 683 | <SidebarLink to="/support" icon={<LifeBuoy size={15} />}> | |
| 731 | 684 | Support | |
| 732 | 685 | </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. */} | |
| 734 | 687 | <SidebarLink | |
| 735 | − | to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`} | |
| 688 | + | to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`} | |
| 736 | 689 | icon={<Settings size={15} />} | |
| 737 | 690 | drill | |
| 738 | 691 | > | |
| 756 | 709 | General | |
| 757 | 710 | </SidebarLink> | |
| 758 | 711 | )} | |
| 759 | − | <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}> | |
| 760 | − | Members | |
| 761 | − | </SidebarLink> | |
| 762 | 712 | <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}> | |
| 763 | 713 | Repositories | |
| 764 | 714 | </SidebarLink> | |
| 766 | 716 | Access tokens | |
| 767 | 717 | </SidebarLink> | |
| 768 | 718 | </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> | |
| 769 | 740 | <Rule /> | |
| 770 | 741 | <div className="space-y-px"> | |
| 771 | 742 | <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}> | |
| 1175 | 1146 | return ( | |
| 1176 | 1147 | <div className="flex h-full flex-col"> | |
| 1177 | 1148 | {/* 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"> | |
| 1179 | 1150 | {user ? ( | |
| 1180 | 1151 | <> | |
| 1181 | 1152 | {/* The 1 alone beside the workspace: a square hover the height of the | |
| 1182 | 1153 | switcher, the mark as tall as the workspace avatar. */} | |
| 1183 | 1154 | <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" /> | |
| 1185 | 1156 | </Link> | |
| 1186 | 1157 | <span className="shrink-0 text-line-strong" aria-hidden="true"> | |
| 1187 | 1158 | / | |
| 1554 | 1525 | )} | |
| 1555 | 1526 | ||
| 1556 | 1527 | <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"> | |
| 1558 | 1529 | <button | |
| 1559 | 1530 | type="button" | |
| 1560 | 1531 | aria-label="Open menu" |
| 22 | 22 | try { | |
| 23 | 23 | const repo = await repos.get(path, viewer); | |
| 24 | 24 | 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); | |
| 26 | 27 | if (!now || now === ref) return; | |
| 27 | 28 | const url = new URL(request.url); | |
| 28 | 29 | to = renamedBranchPath(url.pathname, url.search, now); | |
| 30 | 31 | // A lookup that fails leaves the page a 404, never a 500. | |
| 31 | 32 | return; | |
| 32 | 33 | } | |
| 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); | |
| 34 | 36 | } |
| 10 | 10 | isDismissed, | |
| 11 | 11 | productionChecklist, | |
| 12 | 12 | progress, | |
| 13 | + | releaseChecklist, | |
| 13 | 14 | } from "./checklist.ts"; | |
| 14 | 15 | ||
| 15 | 16 | const fresh: ChecklistFacts = { | |
| 96 | 97 | assert.equal(isDismissed(() => blocked, "/acme/web"), false); | |
| 97 | 98 | assert.equal(dismiss(() => blocked, "/acme/web"), false); | |
| 98 | 99 | }); | |
| 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 | + | }); |
| 1 | 1 | /** | |
| 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. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | 8 | export type ChecklistFacts = { | |
| 25 | 25 | }; | |
| 26 | 26 | ||
| 27 | 27 | export type ChecklistItem = { | |
| 28 | − | key: "code" | "deploy" | "domain" | "preview" | "instructions" | "agent"; | |
| 28 | + | key: "code" | "deploy" | "domain" | "preview" | "checks" | "release" | "instructions" | "agent"; | |
| 29 | 29 | title: string; | |
| 30 | 30 | detail: string; | |
| 31 | 31 | done: boolean; | |
| 89 | 89 | ]; | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 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 | + | ||
| 92 | 132 | /** `3/6`, for the card's heading. */ | |
| 93 | 133 | export function progress(items: ChecklistItem[]): { done: number; total: number; complete: boolean } { | |
| 94 | 134 | const done = items.filter((item) => item.done).length; |
| 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 | + | }); |
| 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 | + | } |
| 160 | 160 | assert.ok(!isTestFile("src/contest.ts")); | |
| 161 | 161 | }); | |
| 162 | 162 | ||
| 163 | − | const merged = (daysAgo: number, mergedBy: string | null, number = 1): Merged => ({ | |
| 163 | + | const merged = (daysAgo: number, mergedBy: string | null, number = 1, authoredByAgent = true): Merged => ({ | |
| 164 | 164 | repo, | |
| 165 | 165 | number, | |
| 166 | 166 | title: `Change ${number}`, | |
| 167 | 167 | agent: "g1t", | |
| 168 | + | authoredByAgent, | |
| 168 | 169 | mergedBy, | |
| 169 | 170 | mergedAt: new Date(NOW - daysAgo * DAY).toISOString(), | |
| 170 | 171 | files: [], | |
| 177 | 178 | assert.ok(!landedByAgents({ mergedBy: "syntaqx" })); | |
| 178 | 179 | }); | |
| 179 | 180 | ||
| 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", () => { | |
| 181 | 182 | 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 | + | ], | |
| 183 | 195 | NOW, | |
| 184 | 196 | "UTC", | |
| 185 | 197 | ); | |
| 187 | 199 | assert.equal(week.days[6].key, "2026-10-05"); | |
| 188 | 200 | assert.equal(week.days[6].label, "Mon"); | |
| 189 | 201 | 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]], | |
| 192 | 204 | ); | |
| 193 | − | assert.equal(week.total, 4); | |
| 205 | + | assert.equal(week.total, 6); | |
| 194 | 206 | assert.equal(week.byAgents, 3); | |
| 207 | + | assert.equal(week.agentChanges, 4); | |
| 208 | + | assert.equal(week.people, 2); | |
| 195 | 209 | assert.equal(week.previous, 2); | |
| 196 | 210 | // Not knowing the week before is not the same as nothing in it. | |
| 197 | 211 | assert.equal(weekOf([merged(0, "g1t")], NOW, "UTC", false).previous, null); | |
| 203 | 217 | assert.equal(dayKey(late, "America/Los_Angeles"), "2026-10-04"); | |
| 204 | 218 | assert.equal(dayKey(late, "UTC"), "2026-10-05"); | |
| 205 | 219 | 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"); | |
| 207 | 221 | assert.equal(week.days.find((d) => d.key === "2026-10-04")?.agents, 1); | |
| 208 | 222 | }); | |
| 209 | 223 | ||
| 315 | 329 | }); | |
| 316 | 330 | ||
| 317 | 331 | 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/); | |
| 323 | 353 | }); |
| 429 | 429 | number: number; | |
| 430 | 430 | title: string; | |
| 431 | 431 | agent: string; | |
| 432 | + | /** Whether an agent wrote it (its author is g1t or another agent), not a person. */ | |
| 433 | + | authoredByAgent: boolean; | |
| 432 | 434 | mergedBy: string | null; | |
| 433 | 435 | mergedAt: string; | |
| 434 | 436 | files: ChangedFile[]; | |
| 451 | 453 | } | |
| 452 | 454 | } | |
| 453 | 455 | ||
| 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 }; | |
| 455 | 462 | ||
| 456 | 463 | export type Week = { | |
| 457 | 464 | days: WeekDay[]; | |
| 458 | − | /** Changes landed in the last seven days, and how many without a person. */ | |
| 465 | + | /** Changes landed in the last seven days. */ | |
| 459 | 466 | total: number; | |
| 467 | + | /** Of those, agents' changes that landed without a person. */ | |
| 460 | 468 | byAgents: number; | |
| 469 | + | /** Agents' changes, with a person or without. */ | |
| 470 | + | agentChanges: number; | |
| 471 | + | /** People's own changes. */ | |
| 472 | + | people: number; | |
| 461 | 473 | /** The seven days before, or null when the lists read do not reach back that far. */ | |
| 462 | 474 | previous: number | null; | |
| 463 | 475 | }; | |
| 464 | 476 | ||
| 465 | 477 | /** | |
| 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. | |
| 470 | 482 | */ | |
| 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 { | |
| 472 | 489 | const days: WeekDay[] = []; | |
| 473 | 490 | const index = new Map<string, number>(); | |
| 474 | 491 | for (let back = 6; back >= 0; back -= 1) { | |
| 482 | 499 | } catch { | |
| 483 | 500 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at); | |
| 484 | 501 | } | |
| 485 | − | days.push({ key, label, agents: 0, people: 0 }); | |
| 502 | + | days.push({ key, label, agents: 0, assisted: 0, people: 0 }); | |
| 486 | 503 | } | |
| 487 | 504 | const oldest = days[0]?.key ?? ""; | |
| 488 | 505 | const twoWeeks = dayKey(now - 13 * DAY, timeZone); | |
| 493 | 510 | const key = dayKey(at, timeZone); | |
| 494 | 511 | const slot = index.get(key); | |
| 495 | 512 | 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; | |
| 498 | 516 | } else if (key < oldest && key >= twoWeeks) { | |
| 499 | 517 | previous += 1; | |
| 500 | 518 | } | |
| 501 | 519 | } | |
| 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 }; | |
| 505 | 525 | } | |
| 506 | 526 | ||
| 507 | 527 | /** The change from one number to another, as a share; null from nothing. */ | |
| 553 | 573 | title: change.title, | |
| 554 | 574 | by: who(change.mergedBy ?? "g1t"), | |
| 555 | 575 | agent: change.agent, | |
| 556 | − | byAgents: landedByAgents(change), | |
| 576 | + | byAgents: change.authoredByAgent && landedByAgents(change), | |
| 557 | 577 | at: Date.parse(change.mergedAt), | |
| 558 | 578 | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, | |
| 559 | 579 | facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"), | |
| 582 | 602 | ||
| 583 | 603 | // --- The summary ------------------------------------------------------------ | |
| 584 | 604 | ||
| 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; | |
| 588 | 619 | 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}.`; | |
| 596 | 625 | } | |
| 597 | 626 | if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`; | |
| 598 | 627 | if (needs > 0) return "Nothing has landed this week. What is waiting on you is below."; |
| 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 | + | }); |
| 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 | + | } |
| 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 | + | }); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.