Skip to content

Commit

Merge packages: roles, Actions access, source label, soft delete, API

syntaqxcommitted Parents0a20719c0ca688Browse files
39 files+1376−430/39 viewed
+1−0
1818 mod notifications;
1919 mod oauth;
2020 mod oidc;
21+mod packages;
2122 mod openapi;
2223 mod pins;
2324 mod projects;
+25−0
1111 use crate::artifacts::ArtifactsOp;
1212 use crate::deploy_keys::DeployKeysOp;
1313 use crate::deployments::DeploymentsOp;
14+use crate::packages::PackagesOp;
1415 use crate::protection::ProtectionOp;
1516 use crate::token_policy::TokenOp;
1617 use crate::operations::Op;
401402 ],
402403 ),
403404 (
405+ "Packages",
406+ "A workspace's packages in every registry: their versions and download counts, deleting and restoring them within 30 days, their visibility and repository, the people and teams with a role on them, and which repositories' workflows may use them (Manage Actions access). A package is named by its type and its name, URL-encoded where it holds a slash.",
407+ &[
408+ Op::Packages(PackagesOp::ListPackages),
409+ Op::Packages(PackagesOp::GetPackage),
410+ Op::Packages(PackagesOp::UpdatePackage),
411+ Op::Packages(PackagesOp::DeletePackage),
412+ Op::Packages(PackagesOp::RestorePackage),
413+ Op::Packages(PackagesOp::ListVersions),
414+ Op::Packages(PackagesOp::GetVersion),
415+ Op::Packages(PackagesOp::DeleteVersion),
416+ Op::Packages(PackagesOp::RestoreVersion),
417+ Op::Packages(PackagesOp::LinkPackage),
418+ Op::Packages(PackagesOp::UnlinkPackage),
419+ Op::Packages(PackagesOp::ListAccess),
420+ Op::Packages(PackagesOp::SetAccess),
421+ Op::Packages(PackagesOp::RemoveAccess),
422+ Op::Packages(PackagesOp::ListActionsAccess),
423+ Op::Packages(PackagesOp::SetActionsAccess),
424+ Op::Packages(PackagesOp::RemoveActionsAccess),
425+ ],
426+ ),
427+ (
404428 "Deployments",
405429 "A repository's deployments wherever they run: reported from any CI with these routes, made by g1t Actions jobs with an `environment:`, or built on g1t.page. Each has statuses, shows on its commit as the check `deploy / <environment>`, and belongs to an environment.",
406430 &[
692716 Op::Tokens(op) => op.title(),
693717 Op::Artifacts(op) => op.title(),
694718 Op::DeployKeys(op) => op.title(),
719+ Op::Packages(op) => op.title(),
695720 }
696721 }
697722
+38−1
3131 use crate::artifacts::ArtifactsOp;
3232 use crate::deploy_keys::DeployKeysOp;
3333 use crate::deployments::DeploymentsOp;
34+use crate::packages::PackagesOp;
3435 use crate::protection::ProtectionOp;
3536 use crate::token_policy::TokenOp;
3637 use crate::rules::RulesOp;
6465 pub projects: Fetcher,
6566 /// Deployments wherever they run, and environments.
6667 pub deployments: Fetcher,
68+ /// Packages: their settings, versions, deleting and restoring them.
69+ pub packages: Fetcher,
6770 /// Where the request came in, for its audit entries.
6871 pub audit: crate::audit::AuditContext,
6972 /// Set for a request made with an agent's token: all it may do.
8992 security: env.service("SECURITY")?,
9093 projects: env.service("PROJECTS")?,
9194 deployments: env.service("DEPLOYMENTS")?,
95+ packages: env.service("PACKAGES")?,
9296 scope: None,
9397 audit: crate::audit::AuditContext::default(),
9498 addresses: crate::addresses::Addresses::from_env(env),
302306 Artifacts(ArtifactsOp),
303307 /// A repository's deploy keys: deploy_keys.rs.
304308 DeployKeys(DeployKeysOp),
309+ /// A workspace's packages, their versions, deleting and restoring
310+ /// them, and who may use them: packages.rs.
311+ Packages(PackagesOp),
305312 }
306313
307314 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
664671 }
665672
666673 impl Op {
667− pub const ALL: [Op; 291] = [
674+ pub const ALL: [Op; 308] = [
668675 Op::Whoami,
669676 Op::GetWorkspace,
670677 Op::CreateWorkspace,
956963 Op::Tokens(TokenOp::ListTokenRequests),
957964 Op::Tokens(TokenOp::ReviewTokenRequest),
958965 Op::Tokens(TokenOp::RevokeMemberToken),
966+ Op::Packages(PackagesOp::ListPackages),
967+ Op::Packages(PackagesOp::GetPackage),
968+ Op::Packages(PackagesOp::ListVersions),
969+ Op::Packages(PackagesOp::GetVersion),
970+ Op::Packages(PackagesOp::ListAccess),
971+ Op::Packages(PackagesOp::ListActionsAccess),
972+ Op::Packages(PackagesOp::UpdatePackage),
973+ Op::Packages(PackagesOp::LinkPackage),
974+ Op::Packages(PackagesOp::UnlinkPackage),
975+ Op::Packages(PackagesOp::SetAccess),
976+ Op::Packages(PackagesOp::RemoveAccess),
977+ Op::Packages(PackagesOp::SetActionsAccess),
978+ Op::Packages(PackagesOp::RemoveActionsAccess),
979+ Op::Packages(PackagesOp::DeletePackage),
980+ Op::Packages(PackagesOp::RestorePackage),
981+ Op::Packages(PackagesOp::DeleteVersion),
982+ Op::Packages(PackagesOp::RestoreVersion),
959983 ];
960984
961985 pub fn by_name(name: &str) -> Option<Op> {
11581182 Op::Tokens(op) => op.name(),
11591183 Op::Artifacts(op) => op.name(),
11601184 Op::DeployKeys(op) => op.name(),
1185+ Op::Packages(op) => op.name(),
11611186 }
11621187 }
11631188
16901715 Op::Tokens(op) => op.description(),
16911716 Op::Artifacts(op) => op.description(),
16921717 Op::DeployKeys(op) => op.description(),
1718+ Op::Packages(op) => op.description(),
16931719 }
16941720 }
16951721
31143140 Op::Tokens(op) => op.input(),
31153141 Op::Artifacts(op) => op.input(),
31163142 Op::DeployKeys(op) => op.input(),
3143+ Op::Packages(op) => op.input(),
31173144 }
31183145 }
31193146
31303157 if let Op::Artifacts(op) = self {
31313158 return op.writes();
31323159 }
3160+ // So are public packages.
3161+ if let Op::Packages(op) = self {
3162+ return !op.anonymous();
3163+ }
31333164 !matches!(
31343165 self,
31353166 Op::ListRepos
31763207 if let Op::Rules(op) = self {
31773208 return op.needs_repo();
31783209 }
3210+ // A package belongs to its workspace; its repository is in `repo`
3211+ // only for Manage Actions access, checked by the packages service.
3212+ if let Op::Packages(_) = self {
3213+ return false;
3214+ }
31793215 if let Op::About(op) = self {
31803216 return op.needs_repo();
31813217 }
53225358 Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await,
53235359 Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
53245360 Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
5361+ Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
53255362 Op::ReopenSecurityAlert => {
53265363 let changed: Outcome<AlertChange> = call(
53275364 &services.security,
+551−0
1+//! Packages over REST and MCP, at GitHub's addresses with the workspace in
2+//! place of the organization: a workspace's packages and their versions,
3+//! deleting and restoring them, their visibility and repository, who has a
4+//! role on them, and which repositories' workflows may use them (Manage
5+//! Actions access).
6+//!
7+//! The packages service decides who may do what (`g1t_contracts::packages`)
8+//! and records the audit entries; this is its public shape, in snake_case.
9+//! A package is named by its type (`container`, `npm`, `cargo`, `maven`,
10+//! `nuget`, `rubygems`, `composer`) and its name, URL-encoded when it holds
11+//! a slash (`web%2Fworker`).
12+
13+use g1t_contracts::packages::*;
14+use g1t_contracts::{FailureCode, Outcome, User, Viewer};
15+use serde::de::DeserializeOwned;
16+use serde::Serialize;
17+use serde_json::{Value, json};
18+use worker::Result;
19+
20+use crate::operations::Services;
21+
22+/// One operation on packages.
23+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24+pub enum PackagesOp {
25+ ListPackages,
26+ GetPackage,
27+ ListVersions,
28+ GetVersion,
29+ ListAccess,
30+ ListActionsAccess,
31+ UpdatePackage,
32+ LinkPackage,
33+ UnlinkPackage,
34+ SetAccess,
35+ RemoveAccess,
36+ SetActionsAccess,
37+ RemoveActionsAccess,
38+ DeletePackage,
39+ RestorePackage,
40+ DeleteVersion,
41+ RestoreVersion,
42+}
43+
44+impl PackagesOp {
45+ /// Every one: `Op::ALL` lists each as `Op::Packages(…)`, which a test
46+ /// checks against this.
47+ #[cfg(test)]
48+ pub const ALL: [PackagesOp; 17] = [
49+ PackagesOp::ListPackages,
50+ PackagesOp::GetPackage,
51+ PackagesOp::ListVersions,
52+ PackagesOp::GetVersion,
53+ PackagesOp::ListAccess,
54+ PackagesOp::ListActionsAccess,
55+ PackagesOp::UpdatePackage,
56+ PackagesOp::LinkPackage,
57+ PackagesOp::UnlinkPackage,
58+ PackagesOp::SetAccess,
59+ PackagesOp::RemoveAccess,
60+ PackagesOp::SetActionsAccess,
61+ PackagesOp::RemoveActionsAccess,
62+ PackagesOp::DeletePackage,
63+ PackagesOp::RestorePackage,
64+ PackagesOp::DeleteVersion,
65+ PackagesOp::RestoreVersion,
66+ ];
67+
68+ pub fn name(self) -> &'static str {
69+ match self {
70+ PackagesOp::ListPackages => "list_packages",
71+ PackagesOp::GetPackage => "get_package",
72+ PackagesOp::ListVersions => "list_package_versions",
73+ PackagesOp::GetVersion => "get_package_version",
74+ PackagesOp::ListAccess => "list_package_access",
75+ PackagesOp::ListActionsAccess => "list_package_actions_access",
76+ PackagesOp::UpdatePackage => "update_package",
77+ PackagesOp::LinkPackage => "link_package",
78+ PackagesOp::UnlinkPackage => "unlink_package",
79+ PackagesOp::SetAccess => "set_package_access",
80+ PackagesOp::RemoveAccess => "remove_package_access",
81+ PackagesOp::SetActionsAccess => "set_package_actions_access",
82+ PackagesOp::RemoveActionsAccess => "remove_package_actions_access",
83+ PackagesOp::DeletePackage => "delete_package",
84+ PackagesOp::RestorePackage => "restore_package",
85+ PackagesOp::DeleteVersion => "delete_package_version",
86+ PackagesOp::RestoreVersion => "restore_package_version",
87+ }
88+ }
89+
90+ /// For the API reference.
91+ pub fn title(self) -> &'static str {
92+ match self {
93+ PackagesOp::ListPackages => "List a workspace's packages",
94+ PackagesOp::GetPackage => "Get a package",
95+ PackagesOp::ListVersions => "List a package's versions",
96+ PackagesOp::GetVersion => "Get a package version",
97+ PackagesOp::ListAccess => "List who has access to a package",
98+ PackagesOp::ListActionsAccess => "List a package's Actions access",
99+ PackagesOp::UpdatePackage => "Update a package",
100+ PackagesOp::LinkPackage => "Link a package to a repository",
101+ PackagesOp::UnlinkPackage => "Unlink a package from its repository",
102+ PackagesOp::SetAccess => "Give a person or team a role on a package",
103+ PackagesOp::RemoveAccess => "Remove a person's or team's role on a package",
104+ PackagesOp::SetActionsAccess => "Give a repository's workflows access to a package",
105+ PackagesOp::RemoveActionsAccess => "Remove a repository's Actions access to a package",
106+ PackagesOp::DeletePackage => "Delete a package",
107+ PackagesOp::RestorePackage => "Restore a package",
108+ PackagesOp::DeleteVersion => "Delete a package version",
109+ PackagesOp::RestoreVersion => "Restore a package version",
110+ }
111+ }
112+
113+ pub fn description(self) -> &'static str {
114+ match self {
115+ PackagesOp::ListPackages => "List a workspace's packages you may pull, most recently updated first: each with its id, name, package_type, address (what a client is given, such as g1t.sh/acme/web), visibility, the repository it is linked to, version_count, latest, size_in_bytes, download_count, inherit_access and html_url. Narrow with package_type and q (part of the name). With state deleted, its deleted packages that can still be restored instead, those you administer, each with deleted_at, deleted_by and purge_at. Public packages are open to anyone.",
116+ PackagesOp::GetPackage => "Get one package by its package_type and package_name (URL-encode a slash in a REST path: web%2Fworker). Not found when you may not pull it, as for one that does not exist.",
117+ PackagesOp::ListVersions => "List a package's versions, newest first: each with its id (ver_…), name (the version, or for a container image its digest), digest, size_in_bytes, download_count, tags, media_type, platforms, published_by and created_at. With state deleted, its deleted versions that can still be restored, with deleted_at, deleted_by and purge_at: for the package's admins only.",
118+ PackagesOp::GetVersion => "Get one version of a package by its id (ver_…), its version, its digest, or a tag that points to it.",
119+ PackagesOp::ListAccess => "Who has a role on a package itself (read pulls, write publishes, admin deletes, restores and changes its settings), people and teams, with inherit_access: whether a linked package also takes its repository's roles. Owners of the workspace administer every package. For the package's admins.",
120+ PackagesOp::ListActionsAccess => "Which repositories' workflows may use a package with their job token (G1T_TOKEN), with the read or write role: its linked repository (linked, always write) and those added under Manage Actions access. A job's token from any other repository is refused. For the package's admins.",
121+ PackagesOp::UpdatePackage => "Change a package's visibility (public or private: an unlinked package only, as a linked one has its repository's) or, for a linked package, inherit_access: whether it takes its repository's roles. Off, only the roles given on the package itself and the workspace's owners count. Takes the Admin role on the package. Returns the package.",
122+ PackagesOp::LinkPackage => "Link a package to a repository of its workspace (repository: its name or owner/name): it then has that repository's visibility and, unless inherit_access is off, its roles, and the repository's workflows may publish it. Takes the Admin role on the package and on the repository. Returns the package.",
123+ PackagesOp::UnlinkPackage => "Unlink a package from its repository: it is then the workspace's, private until someone makes it public, and the repository's workflows lose their access unless it is added under Manage Actions access. Takes the Admin role on the package. Returns the package.",
124+ PackagesOp::SetAccess => "Give a person (username) or a team of the workspace (team: its slug) the read, write or admin role on a package, or change theirs. It adds to what its repository or workspace gives them. Takes the Admin role on the package. Returns everyone with a role on it.",
125+ PackagesOp::RemoveAccess => "Take a person's (username) or team's (team) role on a package away. What its repository or workspace gives them stays. Takes the Admin role on the package. Returns everyone left with a role on it.",
126+ PackagesOp::SetActionsAccess => "Let a repository of the package's workspace (repository: its name or owner/name) use the package from its workflows, with the read or write role, or change its role. Takes the Admin role on the package. Returns the package's Actions access.",
127+ PackagesOp::RemoveActionsAccess => "Stop a repository's workflows using a package. The linked repository's access cannot be removed: unlink the package instead. Takes the Admin role on the package. Returns the package's Actions access.",
128+ PackagesOp::DeletePackage => "Delete a package and every version: it is gone from the registries at once, and can be restored for 30 days, during which its name cannot be taken. Takes the Admin role on the package.",
129+ PackagesOp::RestorePackage => "Restore a deleted package, with the versions it had, while it can be (30 days after it was deleted). Takes the Admin role on it. Returns the package.",
130+ PackagesOp::DeleteVersion => "Delete one version by its id, version, digest or a tag that points to it: it is gone from the registries at once, with its tags, and can be restored for 30 days. Its version (or digest) cannot be published again until then. Composer versions follow their repository's tags: delete the tag instead. Takes the Admin role on the package.",
131+ PackagesOp::RestoreVersion => "Restore a deleted version by its id or version, while it can be (30 days after it was deleted), with the tags that still pointed to it. Takes the Admin role on the package. Returns the version.",
132+ }
133+ }
134+
135+ /// Whether it changes anything.
136+ #[cfg(test)]
137+ pub fn writes(self) -> bool {
138+ !matches!(
139+ self,
140+ PackagesOp::ListPackages
141+ | PackagesOp::GetPackage
142+ | PackagesOp::ListVersions
143+ | PackagesOp::GetVersion
144+ | PackagesOp::ListAccess
145+ | PackagesOp::ListActionsAccess
146+ )
147+ }
148+
149+ /// Whether anyone may call it, signed in or not: reading public packages.
150+ pub fn anonymous(self) -> bool {
151+ matches!(self, PackagesOp::ListPackages | PackagesOp::GetPackage | PackagesOp::ListVersions | PackagesOp::GetVersion)
152+ }
153+
154+ pub fn input(self) -> Value {
155+ let workspace = json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." });
156+ let package_type = json!({
157+ "type": "string",
158+ "enum": ["container", "npm", "cargo", "maven", "nuget", "rubygems", "composer"],
159+ "description": "The registry: container (also docker), npm, cargo, maven, nuget, rubygems or composer.",
160+ });
161+ let package_name = json!({ "type": "string", "description": "The package's name without the workspace: web for g1t.sh/acme/web, web/worker for an image with more parts, group:artifact for Maven." });
162+ let version_id = json!({ "type": "string", "description": "The version's id (ver_…), its version, its digest, or a tag that points to it." });
163+ let role = |roles: &[&str]| json!({ "type": "string", "enum": roles, "description": "read pulls, write publishes, admin deletes, restores and changes its settings." });
164+ let package = |mut properties: Value| {
165+ properties["workspace"] = workspace.clone();
166+ properties["package_type"] = package_type.clone();
167+ properties["package_name"] = package_name.clone();
168+ properties
169+ };
170+ let base = ["workspace", "package_type", "package_name"];
171+ let (properties, required): (Value, Vec<&str>) = match self {
172+ PackagesOp::ListPackages => (
173+ json!({
174+ "workspace": workspace,
175+ "package_type": package_type,
176+ "q": { "type": "string", "description": "Only packages whose name holds this." },
177+ "state": { "type": "string", "enum": ["active", "deleted"], "description": "active (the default), or deleted: deleted packages that can still be restored." },
178+ }),
179+ vec!["workspace"],
180+ ),
181+ PackagesOp::GetPackage
182+ | PackagesOp::ListAccess
183+ | PackagesOp::ListActionsAccess
184+ | PackagesOp::UnlinkPackage
185+ | PackagesOp::DeletePackage
186+ | PackagesOp::RestorePackage => (package(json!({})), base.to_vec()),
187+ PackagesOp::ListVersions => (
188+ package(json!({
189+ "state": { "type": "string", "enum": ["active", "deleted"], "description": "active (the default), or deleted: deleted versions that can still be restored." },
190+ })),
191+ base.to_vec(),
192+ ),
193+ PackagesOp::GetVersion | PackagesOp::DeleteVersion | PackagesOp::RestoreVersion => {
194+ (package(json!({ "version_id": version_id })), [base.as_slice(), &["version_id"]].concat())
195+ }
196+ PackagesOp::UpdatePackage => (
197+ package(json!({
198+ "visibility": { "type": "string", "enum": ["public", "private"], "description": "Who may pull an unlinked package: anyone, or the workspace's members by its base permission." },
199+ "inherit_access": { "type": "boolean", "description": "For a linked package: whether it takes its repository's roles." },
200+ })),
201+ base.to_vec(),
202+ ),
203+ PackagesOp::LinkPackage => (
204+ package(json!({ "repository": { "type": "string", "description": "A repository of the package's workspace: its name, or owner/name." } })),
205+ [base.as_slice(), &["repository"]].concat(),
206+ ),
207+ PackagesOp::SetAccess => (
208+ package(json!({
209+ "username": { "type": "string", "description": "The person's username. Give this or team." },
210+ "team": { "type": "string", "description": "A team of the workspace: its slug, or workspace/slug. Give this or username." },
211+ "role": role(&["read", "write", "admin"]),
212+ })),
213+ [base.as_slice(), &["role"]].concat(),
214+ ),
215+ PackagesOp::RemoveAccess => (
216+ package(json!({
217+ "username": { "type": "string", "description": "The person's username. Give this or team." },
218+ "team": { "type": "string", "description": "The team's slug, or workspace/slug. Give this or username." },
219+ })),
220+ base.to_vec(),
221+ ),
222+ PackagesOp::SetActionsAccess => (
223+ package(json!({
224+ "repository": { "type": "string", "description": "A repository of the package's workspace: its name, or owner/name." },
225+ "role": json!({ "type": "string", "enum": ["read", "write"], "description": "read pulls the package from the repository's workflows; write publishes it too." }),
226+ })),
227+ [base.as_slice(), &["repository", "role"]].concat(),
228+ ),
229+ PackagesOp::RemoveActionsAccess => (
230+ package(json!({ "repository": { "type": "string", "description": "The repository: its name, or owner/name." } })),
231+ [base.as_slice(), &["repository"]].concat(),
232+ ),
233+ };
234+ json!({ "type": "object", "properties": properties, "required": required })
235+ }
236+}
237+
238+fn text(input: &Value, key: &str) -> Option<String> {
239+ input[key].as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned)
240+}
241+
242+/// The registry `package_type` names: GitHub's `docker` is a container
243+/// image too.
244+pub(crate) fn ecosystem(text: &str) -> Option<Ecosystem> {
245+ match text.trim().to_ascii_lowercase().as_str() {
246+ "docker" | "container" | "oci" => Some(Ecosystem::Container),
247+ other => Ecosystem::parse(other).filter(|ecosystem| *ecosystem != Ecosystem::Go),
248+ }
249+}
250+
251+/// The page of a package on the site.
252+fn html_url(site: &str, package: &PackageSummary) -> String {
253+ format!(
254+ "{}/{}/-/packages/{}/{}",
255+ site.trim_end_matches('/'),
256+ package.workspace,
257+ package.ecosystem.as_str(),
258+ package.name
259+ )
260+}
261+
262+/// A package as the API shows one.
263+pub fn package_json(package: &PackageSummary, site: &str) -> Value {
264+ json!({
265+ "id": package.id,
266+ "name": package.name,
267+ "package_type": package.ecosystem.as_str(),
268+ "workspace": package.workspace,
269+ "address": package.address,
270+ "visibility": package.visibility.as_str(),
271+ "repository": package.repo.as_ref().map(|repo| json!({
272+ "id": repo.id,
273+ "name": repo.name,
274+ "full_name": format!("{}/{}", repo.namespace, repo.name),
275+ })),
276+ "description": package.description,
277+ "version_count": package.versions,
278+ "latest": package.latest,
279+ "size_in_bytes": package.size,
280+ "download_count": package.downloads,
281+ "inherit_access": package.inherit_access,
282+ "created_at": package.created_at,
283+ "updated_at": package.updated_at,
284+ "deleted_at": package.deleted_at,
285+ "deleted_by": package.deleted_by,
286+ "purge_at": package.purge_at,
287+ "html_url": html_url(site, package),
288+ })
289+}
290+
291+/// A version as the API shows one.
292+pub fn version_json(version: &PackageVersion) -> Value {
293+ json!({
294+ "id": version.id,
295+ "name": version.version,
296+ "digest": version.digest,
297+ "size_in_bytes": version.size,
298+ "download_count": version.downloads.unwrap_or(0),
299+ "tags": version.tags,
300+ "media_type": version.media_type,
301+ "artifact_type": version.artifact_type,
302+ "subject": version.subject,
303+ "platforms": version.platforms,
304+ "published_by": version.published_by,
305+ "created_at": version.published_at,
306+ "deprecated": version.deprecated,
307+ "deleted_at": version.deleted_at,
308+ "deleted_by": version.deleted_by,
309+ "purge_at": version.purge_at,
310+ })
311+}
312+
313+fn access_json(access: &[PackageAccess]) -> Value {
314+ Value::Array(
315+ access
316+ .iter()
317+ .map(|entry| json!({ "type": entry.kind.as_str(), "id": entry.id, "name": entry.name, "role": entry.role.as_str(), "created_at": entry.created_at }))
318+ .collect(),
319+ )
320+}
321+
322+fn actions_json(access: &[ActionsAccess]) -> Value {
323+ Value::Array(
324+ access
325+ .iter()
326+ .map(|entry| json!({ "repository_id": entry.repo_id, "repository": entry.repo, "role": entry.role.as_str(), "linked": entry.linked, "created_at": entry.created_at }))
327+ .collect(),
328+ )
329+}
330+
331+fn mapped<T>(outcome: Outcome<T>, f: impl FnOnce(T) -> Value) -> Outcome<Value> {
332+ match outcome {
333+ Outcome::Ok(value) => Outcome::Ok(f(value)),
334+ Outcome::Fail(refused) => Outcome::Fail(refused),
335+ }
336+}
337+
338+async fn call<T: DeserializeOwned>(services: &Services, method: &str, args: &impl Serialize) -> Result<Outcome<T>> {
339+ g1t_kit::call(&services.packages, method, args).await
340+}
341+
342+/// The person making a change, or the refusal for nobody.
343+fn actor(viewer: &Viewer) -> std::result::Result<User, Outcome<Value>> {
344+ viewer.clone().ok_or_else(|| Outcome::fail(FailureCode::Unauthenticated, "This needs a g1t access token."))
345+}
346+
347+pub async fn run(op: PackagesOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
348+ let site = services.addresses.site.clone();
349+ let Some(workspace) = text(input, "workspace").map(|w| w.to_lowercase()) else {
350+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the workspace's slug."));
351+ };
352+ if op == PackagesOp::ListPackages {
353+ let ecosystem = match text(input, "package_type") {
354+ Some(given) => match ecosystem(&given) {
355+ Some(found) => Some(found),
356+ None => return Ok(Outcome::fail(FailureCode::Invalid, format!("{given} is not a package type: container, npm, cargo, maven, nuget, rubygems or composer."))),
357+ },
358+ None => None,
359+ };
360+ let found: Outcome<Vec<PackageSummary>> = if text(input, "state").as_deref() == Some("deleted") {
361+ call(services, "deleted_packages", &DeletedPackagesArgs { workspace, viewer: viewer.clone() }).await?
362+ } else {
363+ let args = ListPackagesArgs { workspace, viewer: viewer.clone(), ecosystem, repo_id: None, query: text(input, "q") };
364+ call(services, "list_packages", &args).await?
365+ };
366+ return Ok(mapped(found, |list| {
367+ Value::Array(list.iter().filter(|p| ecosystem.is_none_or(|e| e == p.ecosystem)).map(|p| package_json(p, &site)).collect())
368+ }));
369+ }
370+ let Some(given) = text(input, "package_type") else {
371+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the package_type: container, npm, cargo, maven, nuget, rubygems or composer."));
372+ };
373+ let Some(ecosystem) = ecosystem(&given) else {
374+ return Ok(Outcome::fail(FailureCode::Invalid, format!("{given} is not a package type: container, npm, cargo, maven, nuget, rubygems or composer.")));
375+ };
376+ let Some(name) = text(input, "package_name") else {
377+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the package_name."));
378+ };
379+ let surface = Some(services.audit.surface);
380+ let version = text(input, "version_id").unwrap_or_default();
381+ if matches!(op, PackagesOp::GetVersion | PackagesOp::DeleteVersion | PackagesOp::RestoreVersion) && version.is_empty() {
382+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the version_id: the version's id, version, digest or a tag."));
383+ }
384+ let changed = |outcome: Outcome<PackageSummary>| mapped(outcome, |p| package_json(&p, &site));
385+ macro_rules! actor {
386+ () => {
387+ match actor(viewer) {
388+ Ok(actor) => actor,
389+ Err(refused) => return Ok(refused),
390+ }
391+ };
392+ }
393+ Ok(match op {
394+ PackagesOp::ListPackages => unreachable!("answered above"),
395+ PackagesOp::GetPackage => {
396+ let found: Outcome<PackageDetail> = call(services, "get_package", &GetPackageArgs { workspace, ecosystem, name, viewer: viewer.clone() }).await?;
397+ mapped(found, |detail| package_json(&detail.package, &site))
398+ }
399+ PackagesOp::ListVersions => {
400+ let deleted = text(input, "state").as_deref() == Some("deleted");
401+ let found: Outcome<Vec<PackageVersion>> =
402+ call(services, "list_versions", &ListVersionsArgs { workspace, ecosystem, name, viewer: viewer.clone(), deleted }).await?;
403+ mapped(found, |list| Value::Array(list.iter().map(version_json).collect()))
404+ }
405+ PackagesOp::GetVersion => {
406+ let found: Outcome<PackageVersion> = call(services, "get_version", &GetVersionArgs { workspace, ecosystem, name, viewer: viewer.clone(), version }).await?;
407+ mapped(found, |v| version_json(&v))
408+ }
409+ PackagesOp::ListAccess | PackagesOp::ListActionsAccess => {
410+ let found: Outcome<PackageSettings> =
411+ call(services, "package_settings", &PackageSettingsArgs { workspace, ecosystem, name, viewer: viewer.clone() }).await?;
412+ mapped(found, |settings| {
413+ if op == PackagesOp::ListAccess {
414+ json!({ "inherit_access": settings.package.inherit_access, "access": access_json(&settings.access) })
415+ } else {
416+ json!({ "repositories": actions_json(&settings.actions_access) })
417+ }
418+ })
419+ }
420+ PackagesOp::UpdatePackage => {
421+ let visibility = match text(input, "visibility").as_deref() {
422+ None => None,
423+ Some("public") => Some(Visibility::Public),
424+ Some("private") => Some(Visibility::Private),
425+ Some(other) => return Ok(Outcome::fail(FailureCode::Invalid, format!("{other} is not a visibility: public or private."))),
426+ };
427+ let inherit_access = input["inherit_access"].as_bool();
428+ if visibility.is_none() && inherit_access.is_none() {
429+ return Ok(Outcome::fail(FailureCode::Invalid, "Give visibility or inherit_access."));
430+ }
431+ let args = SetPackageArgs { actor: actor!(), workspace, ecosystem, name, visibility, link: None, unlink: false, inherit_access, surface };
432+ changed(call(services, "set_package", &args).await?)
433+ }
434+ PackagesOp::LinkPackage => {
435+ let Some(repository) = text(input, "repository") else {
436+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository: its name, or owner/name."));
437+ };
438+ let args = SetPackageArgs { actor: actor!(), workspace, ecosystem, name, visibility: None, link: Some(repository), unlink: false, inherit_access: None, surface };
439+ changed(call(services, "set_package", &args).await?)
440+ }
441+ PackagesOp::UnlinkPackage => {
442+ let args = SetPackageArgs { actor: actor!(), workspace, ecosystem, name, visibility: None, link: None, unlink: true, inherit_access: None, surface };
443+ changed(call(services, "set_package", &args).await?)
444+ }
445+ PackagesOp::SetAccess => {
446+ let Some(role) = text(input, "role").and_then(|r| PackageRole::parse(&r)) else {
447+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the role: read, write or admin."));
448+ };
449+ let user = text(input, "username").or_else(|| text(input, "user"));
450+ let args = SetPackageAccessArgs { actor: actor!(), workspace, ecosystem, name, user, team: text(input, "team"), role, surface };
451+ mapped(call(services, "set_package_access", &args).await?, |list: Vec<PackageAccess>| access_json(&list))
452+ }
453+ PackagesOp::RemoveAccess => {
454+ let user = text(input, "username").or_else(|| text(input, "user"));
455+ let args = RemovePackageAccessArgs { actor: actor!(), workspace, ecosystem, name, user, team: text(input, "team"), surface };
456+ mapped(call(services, "remove_package_access", &args).await?, |list: Vec<PackageAccess>| access_json(&list))
457+ }
458+ PackagesOp::SetActionsAccess => {
459+ let Some(repo) = text(input, "repository") else {
460+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository: its name, or owner/name."));
461+ };
462+ let role = match text(input, "role").and_then(|r| PackageRole::parse(&r)) {
463+ Some(role @ (PackageRole::Read | PackageRole::Write)) => role,
464+ _ => return Ok(Outcome::fail(FailureCode::Invalid, "Give the role: read or write.")),
465+ };
466+ let args = SetActionsAccessArgs { actor: actor!(), workspace, ecosystem, name, repo, role, surface };
467+ mapped(call(services, "set_actions_access", &args).await?, |list: Vec<ActionsAccess>| json!({ "repositories": actions_json(&list) }))
468+ }
469+ PackagesOp::RemoveActionsAccess => {
470+ let Some(repo) = text(input, "repository") else {
471+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository: its name, or owner/name."));
472+ };
473+ let args = RemoveActionsAccessArgs { actor: actor!(), workspace, ecosystem, name, repo, surface };
474+ mapped(call(services, "remove_actions_access", &args).await?, |list: Vec<ActionsAccess>| json!({ "repositories": actions_json(&list) }))
475+ }
476+ PackagesOp::DeletePackage => {
477+ let args = DeletePackageArgs { actor: actor!(), workspace, ecosystem, name, surface };
478+ mapped(call::<()>(services, "delete_package", &args).await?, |_| json!({ "deleted": true }))
479+ }
480+ PackagesOp::RestorePackage => {
481+ let args = RestorePackageArgs { actor: actor!(), workspace, ecosystem, name, surface };
482+ changed(call(services, "restore_package", &args).await?)
483+ }
484+ PackagesOp::DeleteVersion => {
485+ let args = DeleteVersionArgs { actor: actor!(), workspace, ecosystem, name, version, surface };
486+ mapped(call::<()>(services, "delete_version", &args).await?, |_| json!({ "deleted": true }))
487+ }
488+ PackagesOp::RestoreVersion => {
489+ let args = RestoreVersionArgs { actor: actor!(), workspace, ecosystem, name, version, surface };
490+ mapped(call(services, "restore_version", &args).await?, |v: PackageVersion| version_json(&v))
491+ }
492+ })
493+}
494+
495+#[cfg(test)]
496+mod tests {
497+ use super::*;
498+
499+ fn summary() -> PackageSummary {
500+ PackageSummary {
501+ id: "pkg_1".into(),
502+ workspace: "acme".into(),
503+ ecosystem: Ecosystem::Container,
504+ name: "web/worker".into(),
505+ address: "g1t.sh/acme/web/worker".into(),
506+ visibility: Visibility::Private,
507+ repo: Some(LinkedRepo { id: "rep_1".into(), namespace: "acme".into(), name: "web".into() }),
508+ description: None,
509+ versions: 3,
510+ latest: Some("latest".into()),
511+ size: 1024,
512+ downloads: 7,
513+ created_at: "2026-10-01T00:00:00.000Z".into(),
514+ updated_at: "2026-10-02T00:00:00.000Z".into(),
515+ inherit_access: true,
516+ deleted_at: None,
517+ deleted_by: None,
518+ purge_at: None,
519+ }
520+ }
521+
522+ #[test]
523+ fn a_package_is_snake_case_with_its_type_and_page() {
524+ let shown = package_json(&summary(), "https://g1t.sh/");
525+ assert_eq!(shown["package_type"], "container");
526+ assert_eq!(shown["repository"]["full_name"], "acme/web");
527+ assert_eq!(shown["html_url"], "https://g1t.sh/acme/-/packages/container/web/worker");
528+ assert_eq!(shown["download_count"], 7);
529+ assert!(g1t_kit::wire::camel_case_keys(&shown).is_empty());
530+ }
531+
532+ #[test]
533+ fn package_types_are_read_as_github_writes_them() {
534+ assert_eq!(ecosystem("docker"), Some(Ecosystem::Container));
535+ assert_eq!(ecosystem("NuGet"), Some(Ecosystem::Nuget));
536+ assert_eq!(ecosystem("go"), None, "Go modules are read from git, not managed here");
537+ assert_eq!(ecosystem("pypi"), None);
538+ }
539+
540+ #[test]
541+ fn each_operation_is_described_with_a_schema_and_a_scope() {
542+ use g1t_contracts::scopes::{Level, scope_for};
543+ for op in PackagesOp::ALL {
544+ assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Packages(op)), "{}", op.name());
545+ assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name());
546+ assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name());
547+ let level = scope_for(op.name()).unwrap().level();
548+ assert_eq!(op.writes(), level != Level::Read, "{}", op.name());
549+ }
550+ }
551+}
+461−0
1138811388 "maximum_allowed_days": 90
1138911389 },
1139011390 "notes": "Only artifacts uploaded afterwards are kept the new number of days. A workflow's `retention-days` asks for fewer, never more."
11391+ },
11392+ "list_packages": {
11393+ "params": {
11394+ "workspace": "acme"
11395+ },
11396+ "query": {
11397+ "package_type": "container"
11398+ },
11399+ "response": [
11400+ {
11401+ "id": "pkg_01kq7b3d5f7h9k1m3p5r7t9v1x",
11402+ "name": "web",
11403+ "package_type": "container",
11404+ "workspace": "acme",
11405+ "address": "g1t.sh/acme/web",
11406+ "visibility": "private",
11407+ "repository": {
11408+ "id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11409+ "name": "web",
11410+ "full_name": "acme/web"
11411+ },
11412+ "description": null,
11413+ "version_count": 12,
11414+ "latest": "latest",
11415+ "size_in_bytes": 48211932,
11416+ "download_count": 1840,
11417+ "inherit_access": true,
11418+ "created_at": "2026-09-14T10:02:11.000Z",
11419+ "updated_at": "2026-10-08T14:05:20.000Z",
11420+ "deleted_at": null,
11421+ "deleted_by": null,
11422+ "purge_at": null,
11423+ "html_url": "https://g1t.sh/acme/-/packages/container/web"
11424+ }
11425+ ],
11426+ "notes": "Only packages you may pull are listed. With `state=deleted`, the workspace's deleted packages you administer that can still be restored, each with `deleted_at`, `deleted_by` and `purge_at`."
11427+ },
11428+ "get_package": {
11429+ "params": {
11430+ "workspace": "acme",
11431+ "package_type": "npm",
11432+ "package_name": "ui"
11433+ },
11434+ "response": {
11435+ "id": "pkg_01kq7b4e6g8j0m2p4r6t8v0x2z",
11436+ "name": "ui",
11437+ "package_type": "npm",
11438+ "workspace": "acme",
11439+ "address": "g1t.sh/-/npm/@acme/ui",
11440+ "visibility": "public",
11441+ "repository": {
11442+ "id": "rep_01kpw0b3d5f7h9k1m3p5r7t9v1",
11443+ "name": "ui",
11444+ "full_name": "acme/ui"
11445+ },
11446+ "description": "Acme's design system components.",
11447+ "version_count": 4,
11448+ "latest": "2.1.0",
11449+ "size_in_bytes": 391204,
11450+ "download_count": 9213,
11451+ "inherit_access": true,
11452+ "created_at": "2026-08-02T09:30:00.000Z",
11453+ "updated_at": "2026-10-07T16:44:02.000Z",
11454+ "deleted_at": null,
11455+ "deleted_by": null,
11456+ "purge_at": null,
11457+ "html_url": "https://g1t.sh/acme/-/packages/npm/ui"
11458+ },
11459+ "notes": "A name with a slash is one URL-encoded path segment: `/workspaces/acme/packages/container/web%2Fworker`. Not found when you may not pull it."
11460+ },
11461+ "update_package": {
11462+ "params": {
11463+ "workspace": "acme",
11464+ "package_type": "container",
11465+ "package_name": "web"
11466+ },
11467+ "request": {
11468+ "inherit_access": false
11469+ },
11470+ "response": {
11471+ "id": "pkg_01kq7b3d5f7h9k1m3p5r7t9v1x",
11472+ "name": "web",
11473+ "package_type": "container",
11474+ "workspace": "acme",
11475+ "address": "g1t.sh/acme/web",
11476+ "visibility": "private",
11477+ "repository": {
11478+ "id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11479+ "name": "web",
11480+ "full_name": "acme/web"
11481+ },
11482+ "description": null,
11483+ "version_count": 12,
11484+ "latest": "latest",
11485+ "size_in_bytes": 48211932,
11486+ "download_count": 1840,
11487+ "inherit_access": false,
11488+ "created_at": "2026-09-14T10:02:11.000Z",
11489+ "updated_at": "2026-10-08T15:01:40.000Z",
11490+ "deleted_at": null,
11491+ "deleted_by": null,
11492+ "purge_at": null,
11493+ "html_url": "https://g1t.sh/acme/-/packages/container/web"
11494+ },
11495+ "notes": "With `inherit_access` off, the repository's roles no longer reach the package: only the roles given on it (`set_package_access`) and the workspace's owners. `visibility` is for an unlinked package; a linked one has its repository's."
11496+ },
11497+ "delete_package": {
11498+ "params": {
11499+ "workspace": "acme",
11500+ "package_type": "container",
11501+ "package_name": "web-old"
11502+ },
11503+ "response": {
11504+ "deleted": true
11505+ },
11506+ "notes": "The package and every version are gone from the registry at once. Restore it with `restore_package` within 30 days; until then nobody can publish a package of its name."
11507+ },
11508+ "restore_package": {
11509+ "params": {
11510+ "workspace": "acme",
11511+ "package_type": "container",
11512+ "package_name": "web-old"
11513+ },
11514+ "response": {
11515+ "id": "pkg_01kq7b5f7h9k1m3p5r7t9v1x3z",
11516+ "name": "web-old",
11517+ "package_type": "container",
11518+ "workspace": "acme",
11519+ "address": "g1t.sh/acme/web-old",
11520+ "visibility": "private",
11521+ "repository": null,
11522+ "description": null,
11523+ "version_count": 3,
11524+ "latest": "latest",
11525+ "size_in_bytes": 20112840,
11526+ "download_count": 112,
11527+ "inherit_access": true,
11528+ "created_at": "2026-07-01T08:00:00.000Z",
11529+ "updated_at": "2026-10-08T15:10:00.000Z",
11530+ "deleted_at": null,
11531+ "deleted_by": null,
11532+ "purge_at": null,
11533+ "html_url": "https://g1t.sh/acme/-/packages/container/web-old"
11534+ }
11535+ },
11536+ "list_package_versions": {
11537+ "params": {
11538+ "workspace": "acme",
11539+ "package_type": "container",
11540+ "package_name": "web"
11541+ },
11542+ "response": [
11543+ {
11544+ "id": "ver_01kq9c5f7h9k1m3p5r7t9v1x3z",
11545+ "name": "sha256:9b2e4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b1d3f5a7c9e2b",
11546+ "digest": "sha256:9b2e4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d2f4a6c8e0b1d3f5a7c9e2b",
11547+ "size_in_bytes": 48211932,
11548+ "download_count": 611,
11549+ "tags": [
11550+ "latest",
11551+ "v1.4.0"
11552+ ],
11553+ "media_type": "application/vnd.oci.image.index.v1+json",
11554+ "artifact_type": null,
11555+ "subject": null,
11556+ "platforms": [
11557+ "linux/amd64",
11558+ "linux/arm64"
11559+ ],
11560+ "published_by": "ana",
11561+ "created_at": "2026-10-08T14:05:20.000Z",
11562+ "deprecated": null,
11563+ "deleted_at": null,
11564+ "deleted_by": null,
11565+ "purge_at": null
11566+ }
11567+ ],
11568+ "notes": "A container image's versions are its manifests, named by digest; every other package's by version. `download_count` counts pulls and downloads of the version itself, approximately. With `state=deleted`, the deleted versions that can still be restored, for the package's admins."
11569+ },
11570+ "get_package_version": {
11571+ "params": {
11572+ "workspace": "acme",
11573+ "package_type": "npm",
11574+ "package_name": "ui",
11575+ "version_id": "2.1.0"
11576+ },
11577+ "response": {
11578+ "id": "ver_01kq9c6g8j0m2p4r6t8v0x2z4b",
11579+ "name": "2.1.0",
11580+ "digest": "sha256:3c5e7b9d2f4a6c8e0b1d3f5a7c9e2b4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c5e",
11581+ "size_in_bytes": 97412,
11582+ "download_count": 2204,
11583+ "tags": [
11584+ "latest"
11585+ ],
11586+ "media_type": null,
11587+ "artifact_type": null,
11588+ "subject": null,
11589+ "platforms": [],
11590+ "published_by": "bo",
11591+ "created_at": "2026-10-07T16:44:02.000Z",
11592+ "deprecated": null,
11593+ "deleted_at": null,
11594+ "deleted_by": null,
11595+ "purge_at": null
11596+ },
11597+ "notes": "`version_id` is the version's id (`ver_…`), its version, its digest, or a tag that points to it."
11598+ },
11599+ "delete_package_version": {
11600+ "params": {
11601+ "workspace": "acme",
11602+ "package_type": "npm",
11603+ "package_name": "ui",
11604+ "version_id": "2.0.0-beta.1"
11605+ },
11606+ "response": {
11607+ "deleted": true
11608+ },
11609+ "notes": "The version and its tags are gone from the registry at once. It can be restored for 30 days, and its version cannot be published again until it is purged."
11610+ },
11611+ "restore_package_version": {
11612+ "params": {
11613+ "workspace": "acme",
11614+ "package_type": "npm",
11615+ "package_name": "ui",
11616+ "version_id": "2.0.0-beta.1"
11617+ },
11618+ "response": {
11619+ "id": "ver_01kq9c7h9k1m3p5r7t9v1x3z5c",
11620+ "name": "2.0.0-beta.1",
11621+ "digest": "sha256:7d9f2a4c6e8b0d1f3a5c7e9b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f",
11622+ "size_in_bytes": 96880,
11623+ "download_count": 41,
11624+ "tags": [
11625+ "beta"
11626+ ],
11627+ "media_type": null,
11628+ "artifact_type": null,
11629+ "subject": null,
11630+ "platforms": [],
11631+ "published_by": "bo",
11632+ "created_at": "2026-09-30T11:20:00.000Z",
11633+ "deprecated": null,
11634+ "deleted_at": null,
11635+ "deleted_by": null,
11636+ "purge_at": null
11637+ },
11638+ "notes": "Tags that pointed to it come back with it, unless one was moved to another version meanwhile."
11639+ },
11640+ "link_package": {
11641+ "params": {
11642+ "workspace": "acme",
11643+ "package_type": "container",
11644+ "package_name": "tools"
11645+ },
11646+ "request": {
11647+ "repository": "acme/infra"
11648+ },
11649+ "response": {
11650+ "id": "pkg_01kq7b6g8j0m2p4r6t8v0x2z4b",
11651+ "name": "tools",
11652+ "package_type": "container",
11653+ "workspace": "acme",
11654+ "address": "g1t.sh/acme/tools",
11655+ "visibility": "private",
11656+ "repository": {
11657+ "id": "rep_01kpw0c4e6g8j0m2p4r6t8v0x2",
11658+ "name": "infra",
11659+ "full_name": "acme/infra"
11660+ },
11661+ "description": null,
11662+ "version_count": 2,
11663+ "latest": "latest",
11664+ "size_in_bytes": 7340032,
11665+ "download_count": 88,
11666+ "inherit_access": true,
11667+ "created_at": "2026-09-20T12:00:00.000Z",
11668+ "updated_at": "2026-10-08T15:20:00.000Z",
11669+ "deleted_at": null,
11670+ "deleted_by": null,
11671+ "purge_at": null,
11672+ "html_url": "https://g1t.sh/acme/-/packages/container/tools"
11673+ },
11674+ "notes": "The package takes the repository's visibility. You need the Admin role on the package and on the repository."
11675+ },
11676+ "unlink_package": {
11677+ "params": {
11678+ "workspace": "acme",
11679+ "package_type": "container",
11680+ "package_name": "tools"
11681+ },
11682+ "response": {
11683+ "id": "pkg_01kq7b6g8j0m2p4r6t8v0x2z4b",
11684+ "name": "tools",
11685+ "package_type": "container",
11686+ "workspace": "acme",
11687+ "address": "g1t.sh/acme/tools",
11688+ "visibility": "private",
11689+ "repository": null,
11690+ "description": null,
11691+ "version_count": 2,
11692+ "latest": "latest",
11693+ "size_in_bytes": 7340032,
11694+ "download_count": 88,
11695+ "inherit_access": true,
11696+ "created_at": "2026-09-20T12:00:00.000Z",
11697+ "updated_at": "2026-10-08T15:22:00.000Z",
11698+ "deleted_at": null,
11699+ "deleted_by": null,
11700+ "purge_at": null,
11701+ "html_url": "https://g1t.sh/acme/-/packages/container/tools"
11702+ }
11703+ },
11704+ "list_package_access": {
11705+ "params": {
11706+ "workspace": "acme",
11707+ "package_type": "container",
11708+ "package_name": "web"
11709+ },
11710+ "response": {
11711+ "inherit_access": true,
11712+ "access": [
11713+ {
11714+ "type": "team",
11715+ "id": "team_01kq1a3c5e7g9j1l3n5q7s9u1w",
11716+ "name": "acme/platform",
11717+ "role": "admin",
11718+ "created_at": "2026-10-01T09:00:00.000Z"
11719+ },
11720+ {
11721+ "type": "user",
11722+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
11723+ "name": "dana",
11724+ "role": "read",
11725+ "created_at": "2026-10-02T13:30:00.000Z"
11726+ }
11727+ ]
11728+ },
11729+ "notes": "Only the roles given on the package itself. With `inherit_access` on, a linked package's repository roles count too, and the workspace's owners administer every package."
11730+ },
11731+ "set_package_access": {
11732+ "params": {
11733+ "workspace": "acme",
11734+ "package_type": "container",
11735+ "package_name": "web"
11736+ },
11737+ "request": {
11738+ "username": "dana",
11739+ "role": "write"
11740+ },
11741+ "response": [
11742+ {
11743+ "type": "team",
11744+ "id": "team_01kq1a3c5e7g9j1l3n5q7s9u1w",
11745+ "name": "acme/platform",
11746+ "role": "admin",
11747+ "created_at": "2026-10-01T09:00:00.000Z"
11748+ },
11749+ {
11750+ "type": "user",
11751+ "id": "usr_01kkntcg1eeb98j62xjm7eh09q",
11752+ "name": "dana",
11753+ "role": "write",
11754+ "created_at": "2026-10-02T13:30:00.000Z"
11755+ }
11756+ ],
11757+ "notes": "Give `username` or `team` (a team of the workspace, by slug). The role adds to what the repository or workspace already gives."
11758+ },
11759+ "remove_package_access": {
11760+ "params": {
11761+ "workspace": "acme",
11762+ "package_type": "container",
11763+ "package_name": "web"
11764+ },
11765+ "query": {
11766+ "username": "dana"
11767+ },
11768+ "response": [
11769+ {
11770+ "type": "team",
11771+ "id": "team_01kq1a3c5e7g9j1l3n5q7s9u1w",
11772+ "name": "acme/platform",
11773+ "role": "admin",
11774+ "created_at": "2026-10-01T09:00:00.000Z"
11775+ }
11776+ ],
11777+ "notes": "Over REST, name who in the query: `?username=dana` or `?team=platform`."
11778+ },
11779+ "list_package_actions_access": {
11780+ "params": {
11781+ "workspace": "acme",
11782+ "package_type": "container",
11783+ "package_name": "web"
11784+ },
11785+ "response": {
11786+ "repositories": [
11787+ {
11788+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11789+ "repository": "acme/web",
11790+ "role": "write",
11791+ "linked": true,
11792+ "created_at": null
11793+ },
11794+ {
11795+ "repository_id": "rep_01kpw0d5f7h9k1m3p5r7t9v1x3",
11796+ "repository": "acme/deploy",
11797+ "role": "read",
11798+ "linked": false,
11799+ "created_at": "2026-10-03T10:00:00.000Z"
11800+ }
11801+ ]
11802+ },
11803+ "notes": "A workflow job's `G1T_TOKEN` reaches the package only from these repositories. The linked repository is always listed, with write."
11804+ },
11805+ "set_package_actions_access": {
11806+ "params": {
11807+ "workspace": "acme",
11808+ "package_type": "container",
11809+ "package_name": "web"
11810+ },
11811+ "request": {
11812+ "repository": "deploy",
11813+ "role": "read"
11814+ },
11815+ "response": {
11816+ "repositories": [
11817+ {
11818+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11819+ "repository": "acme/web",
11820+ "role": "write",
11821+ "linked": true,
11822+ "created_at": null
11823+ },
11824+ {
11825+ "repository_id": "rep_01kpw0d5f7h9k1m3p5r7t9v1x3",
11826+ "repository": "acme/deploy",
11827+ "role": "read",
11828+ "linked": false,
11829+ "created_at": "2026-10-03T10:00:00.000Z"
11830+ }
11831+ ]
11832+ }
11833+ },
11834+ "remove_package_actions_access": {
11835+ "params": {
11836+ "workspace": "acme",
11837+ "package_type": "container",
11838+ "package_name": "web",
11839+ "repository": "deploy"
11840+ },
11841+ "response": {
11842+ "repositories": [
11843+ {
11844+ "repository_id": "rep_01kpw0a2c4e6g8j0m2p4r6t8v0",
11845+ "repository": "acme/web",
11846+ "role": "write",
11847+ "linked": true,
11848+ "created_at": null
11849+ }
11850+ ]
11851+ }
1139111852 }
1139211853 }
+2−0
125125 return through::<g1t_contracts::deploy_keys::DeployKey>(op, as_is);
126126 }
127127 Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is),
128+ // Packages are shaped by the API itself, in `snake_case`.
129+ Op::Packages(_) => return as_is,
128130 // Built by the API itself, in `snake_case`.
129131 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
130132 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
+20−0
66 use crate::artifacts::ArtifactsOp;
77 use crate::deploy_keys::DeployKeysOp;
88 use crate::deployments::DeploymentsOp;
9+use crate::packages::PackagesOp;
910 use crate::protection::ProtectionOp;
1011 use crate::token_policy::TokenOp;
1112 use crate::operations::Op;
700701 Op::GetJobLogs,
701702 &[("after", "after")],
702703 ),
704+ // Packages, at GitHub's addresses with the workspace in place of the
705+ // organization. A name with a slash is one URL-encoded segment.
706+ route("GET", "/workspaces/:workspace/packages", Op::Packages(PackagesOp::ListPackages), &[("package_type", "package_type"), ("q", "q"), ("state", "state")]),
707+ route("GET", "/workspaces/:workspace/packages/:package_type/:package_name", Op::Packages(PackagesOp::GetPackage), &[]),
708+ route("PATCH", "/workspaces/:workspace/packages/:package_type/:package_name", Op::Packages(PackagesOp::UpdatePackage), &[]),
709+ route("DELETE", "/workspaces/:workspace/packages/:package_type/:package_name", Op::Packages(PackagesOp::DeletePackage), &[]),
710+ route("POST", "/workspaces/:workspace/packages/:package_type/:package_name/restore", Op::Packages(PackagesOp::RestorePackage), &[]),
711+ route("GET", "/workspaces/:workspace/packages/:package_type/:package_name/versions", Op::Packages(PackagesOp::ListVersions), &[("state", "state")]),
712+ route("GET", "/workspaces/:workspace/packages/:package_type/:package_name/versions/:version_id", Op::Packages(PackagesOp::GetVersion), &[]),
713+ route("DELETE", "/workspaces/:workspace/packages/:package_type/:package_name/versions/:version_id", Op::Packages(PackagesOp::DeleteVersion), &[]),
714+ route("POST", "/workspaces/:workspace/packages/:package_type/:package_name/versions/:version_id/restore", Op::Packages(PackagesOp::RestoreVersion), &[]),
715+ route("PUT", "/workspaces/:workspace/packages/:package_type/:package_name/repository", Op::Packages(PackagesOp::LinkPackage), &[]),
716+ route("DELETE", "/workspaces/:workspace/packages/:package_type/:package_name/repository", Op::Packages(PackagesOp::UnlinkPackage), &[]),
717+ route("GET", "/workspaces/:workspace/packages/:package_type/:package_name/access", Op::Packages(PackagesOp::ListAccess), &[]),
718+ route("PUT", "/workspaces/:workspace/packages/:package_type/:package_name/access", Op::Packages(PackagesOp::SetAccess), &[]),
719+ route("DELETE", "/workspaces/:workspace/packages/:package_type/:package_name/access", Op::Packages(PackagesOp::RemoveAccess), &[("username", "username"), ("team", "team")]),
720+ route("GET", "/workspaces/:workspace/packages/:package_type/:package_name/actions-access", Op::Packages(PackagesOp::ListActionsAccess), &[]),
721+ route("PUT", "/workspaces/:workspace/packages/:package_type/:package_name/actions-access", Op::Packages(PackagesOp::SetActionsAccess), &[]),
722+ route("DELETE", "/workspaces/:workspace/packages/:package_type/:package_name/actions-access/:repository", Op::Packages(PackagesOp::RemoveActionsAccess), &[]),
703723 // Artifacts, at GitHub's addresses. `…/zip` answers with a redirect to
704724 // a signed link (lib.rs).
705725 route("GET", "/repos/:owner/:name/actions/artifacts", Op::Artifacts(ArtifactsOp::ListArtifacts), &[("name", "name"), ("page", "page"), ("per_page", "per_page")]),
+27−1
2222 use crate::artifacts::ArtifactsOp;
2323 use crate::deploy_keys::DeployKeysOp;
2424 use crate::deployments::DeploymentsOp;
25+use crate::packages::PackagesOp;
2526 use crate::protection::ProtectionOp;
2627 use crate::token_policy::TokenOp;
2728 use crate::operations::Op;
265266 ],
266267 },
267268 Tool {
269+ name: "package",
270+ title: "Packages",
271+ description: "A workspace's packages in every registry (container images, npm, Cargo, Maven, NuGet, RubyGems, Composer): their versions and downloads, deleting and restoring them within 30 days, their visibility and repository, who has a role on them, and which repositories' workflows may use them (Manage Actions access). Name one by workspace, package_type and package_name.",
272+ default_action: None,
273+ actions: &[
274+ a("list", Op::Packages(PackagesOp::ListPackages), "A workspace's packages; state deleted for restorable ones"),
275+ a("get", Op::Packages(PackagesOp::GetPackage), "One package: address, visibility, repository, downloads"),
276+ a("versions", Op::Packages(PackagesOp::ListVersions), "Its versions with tags and downloads; state deleted too"),
277+ a("get_version", Op::Packages(PackagesOp::GetVersion), "One version by id, version, digest or tag"),
278+ a("update", Op::Packages(PackagesOp::UpdatePackage), "Set visibility, or inherit_access for a linked one"),
279+ a("link", Op::Packages(PackagesOp::LinkPackage), "Link it to a repository of its workspace"),
280+ a("unlink", Op::Packages(PackagesOp::UnlinkPackage), "Unlink it: the workspace's, private"),
281+ a("access", Op::Packages(PackagesOp::ListAccess), "People and teams with a role on it"),
282+ a("set_access", Op::Packages(PackagesOp::SetAccess), "Give a person or team read, write or admin"),
283+ a("remove_access", Op::Packages(PackagesOp::RemoveAccess), "Take a person's or team's role away"),
284+ a("actions_access", Op::Packages(PackagesOp::ListActionsAccess), "Repositories whose workflows may use it"),
285+ a("set_actions_access", Op::Packages(PackagesOp::SetActionsAccess), "Let a repository's workflows read or write it"),
286+ a("remove_actions_access", Op::Packages(PackagesOp::RemoveActionsAccess), "Stop a repository's workflows using it"),
287+ a("delete", Op::Packages(PackagesOp::DeletePackage), "Delete it; restorable for 30 days"),
288+ a("restore", Op::Packages(PackagesOp::RestorePackage), "Restore a deleted package"),
289+ a("delete_version", Op::Packages(PackagesOp::DeleteVersion), "Delete a version; restorable for 30 days"),
290+ a("restore_version", Op::Packages(PackagesOp::RestoreVersion), "Restore a deleted version"),
291+ ],
292+ },
293+ Tool {
268294 name: "secret",
269295 title: "Secrets and variables",
270296 description: "A repository's or workspace's secrets and variables, read by workflows and deployments. Secret values are never returned.",
764790 assert!(tool.action(default).is_some(), "{}", tool.name);
765791 }
766792 }
767− assert!(TOOLS.len() <= 17, "{} tools", TOOLS.len());
793+ assert!(TOOLS.len() <= 18, "{} tools", TOOLS.len());
768794 }
769795
770796 #[test]
+3−1
3131 // Secret and dependency alerts: list_security_alerts and dismissing them.
3232 { "binding": "SECURITY", "service": "g1t-security" },
3333 // A person's pinned projects: list_pinned_projects and changing them.
34− { "binding": "PROJECTS", "service": "g1t-projects" }
34+ { "binding": "PROJECTS", "service": "g1t-projects" },
35+ // Packages: list_packages, their settings, deleting and restoring them.
36+ { "binding": "PACKAGES", "service": "g1t-packages" }
3537 ],
3638 // GitHub Actions artifacts older runners kept, in chunks, with KV's own
3739 // expiry, read until it passes (and cache
+7−2
232232
233233 On g1t's machines, a job is signed in to g1t's container registry from
234234 the start, with its own `G1T_TOKEN`, so it can push to and pull from its
235−workspace's images without a login step. A run that gets no secrets is
235+repository's images without a login step; images of other repositories
236+need this one added under their
237+[Manage Actions access](/guides/packages/#manage-actions-access). A run that gets no secrets is
236238 not signed in. See [container registry](/guides/containers/#in-workflows).
237239
238240 ```yaml
759761 [API](/reference/api/) or pushes with git:
760762
761763 - It reaches **this repository only**. Every other repository, even one
762− in the same workspace, is refused.
764+ in the same workspace, is refused. So are packages: it reaches this
765+ repository's own, and another package only once the package's admins
766+ add this repository under its
767+ [Manage Actions access](/guides/packages/#manage-actions-access).
763768 - It can do **what its `permissions:` say**, and nothing more.
764769 - It **stops working when the job ends**, however it ends.
765770 - Everything it changes is in the [audit log](/guides/audit-log/) as that
+7−0
5050 | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. |
5151 | `team.member_added`, `team.member_role_changed`, `team.member_removed` | Someone was added to a team, made its maintainer or a member, or taken out of it. |
5252 | `team.repo_added`, `team.repo_role_changed`, `team.repo_removed` | A team was given a role on a repository, had it changed, or lost it. |
53+| `package.delete`, `package.restore`, `package.purged` | A [package](/guides/packages/#delete-and-restore) was deleted, restored, or removed for good 30 days after it was deleted. |
54+| `package.delete_version`, `package.restore_version` | One of its versions was deleted or restored. |
55+| `package.access_added`, `package.access_role_changed`, `package.access_removed` | A person or team was given a role on a package itself, had it changed, or lost it. |
56+| `package.actions_access_added`, `package.actions_access_role_changed`, `package.actions_access_removed` | A repository's workflows were given access to a package under [Manage Actions access](/guides/packages/#manage-actions-access), had it changed, or lost it. |
57+| `package.inherit_access_changed` | Inheriting access from the linked repository was turned on or off. |
58+| `package.visibility_changed` | A package was made public or private. |
59+| `package.linked`, `package.unlinked` | A package was linked to a repository (from its settings, or by an image's source label), or unlinked. |
5360 | `workspace.residency_changed` | An owner changed where the workspace's new repositories are stored. See [data residency](/guides/workspaces/#data-residency). |
5461 | `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). |
5562
+2−2
529529 | `security:read` | See [secret scanning](/guides/security/secret-protection/), [code scanning](/guides/security/code-scanning/) and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings |
530530 | `security:write` | Dismiss and reopen alerts, bypass push protection, review bypass requests, manage custom patterns, upload SARIF and change security settings |
531531 | `packages:read` | Pull container images and install private [packages](/guides/packages/). Public ones need no scope. |
532−| `packages:write` | Push container images and publish packages |
533−| `packages:delete` | Delete packages and their versions |
532+| `packages:write` | Push container images and publish packages; with the Admin role on a package, change its settings |
533+| `packages:delete` | Delete and restore packages and their versions |
534534 | `issues:read` | Read issues, comments and plans |
535535 | `issues:write` | Open, edit, close and comment on issues |
536536 | `pull_requests:read` | Read pull requests, their changes, sessions and merge queues |
+41−6
3838 follows its visibility and roles; pushing needs Write on it. Otherwise it is
3939 the workspace's, private, and needs the workspace's Write base permission.
4040
41+### Link an image with its source label
42+
43+An image can name the repository it is built from, whatever the image is
44+called, with the `org.opencontainers.image.source` label:
45+
46+```dockerfile
47+LABEL org.opencontainers.image.source=https://g1t.sh/acme/web
48+```
49+
50+or as an annotation on the manifest
51+(`docker buildx build --annotation "org.opencontainers.image.source=https://g1t.sh/acme/web"`).
52+g1t reads the manifest's annotations first, then the image config's labels.
53+When the address is a repository of the image's own workspace
54+(`https://g1t.sh/<workspace>/<repo>`, with or without `.git`) and whoever
55+pushes has the Write role on that repository, the image is linked to it:
56+
57+| When | What happens |
58+| --- | --- |
59+| The image's first push | It is linked to the repository the label names, in place of the one its name starts with. |
60+| A later push of an image that is not linked | It is linked to the repository the label names. |
61+| A later push of a linked image | Nothing changes. Link it elsewhere from its [settings](/guides/packages/#package-settings). |
62+| The label names another workspace's repository, another host, or a repository you cannot write to | It is ignored, and the image is linked by its name, as above. |
63+
64+Linking gives the image the repository's visibility and, unless its
65+admins turn inheriting off, its roles. The link is recorded in the
66+[audit log](/guides/audit-log/) as `package.linked`.
67+
4168 Pushing a tag again moves it to the new image. Layers already on g1t are
4269 not uploaded again, and images in the same workspace share them.
4370
5582
5683 ## In workflows
5784
58−A workflow's `G1T_TOKEN`, [the job's own token](/guides/actions/#the-jobs-token), can pull the workspace's images, and
59−push them with `packages: write` in its [`permissions:`](/guides/actions/#the-jobs-token):
85+A workflow's `G1T_TOKEN`, [the job's own token](/guides/actions/#the-jobs-token), pulls and pushes the images
86+linked to its own repository, and with `packages: write` in its
87+[`permissions:`](/guides/actions/#the-jobs-token) pushes new ones. An image
88+linked to another repository, or one of the workspace's own that the job
89+did not make, needs that job's repository added under the image's
90+[Manage Actions access](/guides/packages/#manage-actions-access):
6091
6192 ```yaml
6293 jobs:
201232
202233 ## Delete
203234
204−Deleting needs Admin on the linked repository, or for an unlinked image, an
205−owner of the workspace; a token needs `packages:delete`.
235+Deleting needs the Admin role on the image (Admin on the linked repository
236+while it inherits access, a role given on the image itself, or an owner of
237+the workspace); a token needs `packages:delete`.
206238
207239 The registry protocol's `DELETE` removes a tag, or a whole version by its
208−digest (with every tag that points to it):
240+digest (with every tag that points to it). A deleted version can be
241+[restored](/guides/packages/#delete-and-restore) for 30 days, and until
242+then a manifest with its digest cannot be pushed again:
209243
210244 ```sh
211245 TOKEN=$(curl -s -u <you>:<token> "https://g1t.sh/v2/token?scope=repository:acme/web:delete" | jq -r .token)
213247 curl -X DELETE -H "Authorization: Bearer $TOKEN" https://g1t.sh/v2/acme/web/manifests/sha256:…
214248 ```
215249
216−Layers no version uses any more are deleted from storage a day later.
250+A deleted version keeps its layers until it is purged. Layers no version
251+uses any more are deleted from storage a day later.
217252
218253 ## Errors
219254
+159−28
3030
3131 ## Who can see and publish a package
3232
33−A package is linked to a repository, or belongs to its workspace.
33+Everyone has one role on a package, the highest of what reaches them:
3434
35−- **Linked.** The first push of an image whose name starts with a
36− repository's name (`acme/web`, `acme/web/worker` for the repository
37− `acme/web`) links it to that repository; so does the first publish of
38− an npm package whose `package.json` `repository` is a g1t.sh repository
39− of the workspace, or which is named like one (`@acme/web`), and of a
40− crate whose `Cargo.toml` `repository` is one, or which is named like
41− one. Maven artifacts (by artifactId, or the POM's `<scm><url>`), NuGet
42− packages (by `RepositoryUrl`, or their id) and gems (by
43− `source_code_uri`, or their name) are linked the same way. It then has the repository's visibility and [roles](/guides/access-and-roles/):
35+| Role | Lets them |
36+| --- | --- |
37+| Read | Pull and install it. Anyone, signed in or not, may on a public package. |
38+| Write | Also publish new versions and tags. |
39+| Admin | Also delete and restore it and its versions, and change its [settings](#package-settings). |
4440
45− | | Needs |
46− | --- | --- |
47− | Pull | Read: on a public repository, anyone, signed in or not |
48− | Push a new version or tag | Write |
49− | Delete versions and the package, change its settings | Admin |
41+A package is linked to a repository, or belongs to its workspace, and that
42+decides where its roles come from:
5043
44+- **Linked.** The first push of an image whose name starts with a
45+ repository's name (`acme/web`, `acme/web/worker` for the repository
46+ `acme/web`) links it to that repository, unless its
47+ [source label](/guides/containers/#link-an-image-with-its-source-label)
48+ names another; so does the first publish of an npm package whose
49+ `package.json` `repository` is a g1t.sh repository of the workspace, or
50+ which is named like one (`@acme/web`), and of a crate whose `Cargo.toml`
51+ `repository` is one, or which is named like one. Maven artifacts (by
52+ artifactId, or the POM's `<scm><url>`), NuGet packages (by
53+ `RepositoryUrl`, or their id) and gems (by `source_code_uri`, or their
54+ name) are linked the same way. It has the repository's visibility and,
55+ while it **inherits access** (the default), its
56+ [roles](/guides/access-and-roles/): Read and Triage pull, Write and
57+ Maintain publish, Admin administers.
5158 - **Unlinked.** A package whose name matches no repository is the
5259 workspace's. It is private: members pull and push it by the workspace's
5360 [base permission](/guides/access-and-roles/#the-base-permission) (Read pulls,
54− Write pushes), and only owners delete it or change its settings. An owner
61+ Write pushes), and an Admin base permission still only pushes. An admin
5562 can make it public, and then anyone can pull it.
5663
57−A linked package can be unlinked, and an unlinked one linked to a
58−repository of its workspace by someone with Admin on that repository.
64+On top of that:
65+
66+- **The workspace's owners** are admins of every package.
67+- **People and teams given a role on the package itself**, under
68+ [Manage access](#manage-access), have that role, or the one their
69+ repository or base permission gives them if it is higher. A team's role
70+ reaches its child teams' people too.
71+- **Turning off inheriting** on a linked package leaves only the roles
72+ given on the package itself, and the workspace's owners: the
73+ repository's roles no longer reach it. Its visibility is still the
74+ repository's.
5975
6076 Private packages look exactly like ones that do not exist to anyone who may
6177 not pull them.
6278
79+## Package settings
80+
81+A package's admins see a **Settings** tab on its page, at
82+`g1t.sh/<workspace>/-/packages/<type>/<name>?tab=settings`:
83+
84+| Setting | What it does |
85+| --- | --- |
86+| Inherit access from the linked repository | On (the default), the repository's roles reach the package. Off, only the roles below and the workspace's owners do. Linked packages only. |
87+| Manage access | People (by username) and teams of the workspace (by slug), each with the Read, Write or Admin role on the package. |
88+| Manage Actions access | The repositories whose workflows may use the package, each with Read or Write. See [below](#manage-actions-access). |
89+| Visibility | Public or private, for an unlinked package. A linked one has its repository's. |
90+| Repository | Link it to a repository of its workspace (Admin on that repository is needed too), or unlink it: it is then the workspace's, and private. |
91+| Deleted versions | Versions deleted in the last 30 days, each with **Restore**. |
92+| Delete this package | Delete it, with every version. See [deleting and restoring](#delete-and-restore). |
93+
94+### Manage access
95+
96+Add a person by username, or a team of the workspace by its slug, with a
97+role; change a role from its row, or remove it. What a repository or the
98+workspace gives someone stays when the package's own role is removed.
99+
100+### Manage Actions access
101+
102+A workflow job's [`G1T_TOKEN`](/guides/actions/#the-jobs-token) reaches a
103+package only from these repositories:
104+
105+| Repository | Its workflows may |
106+| --- | --- |
107+| The one the package is linked to | Pull and publish it, always. It is listed, and cannot be removed: unlink the package instead. |
108+| One added with Read | Pull it |
109+| One added with Write | Pull and publish it |
110+| Any other | Nothing: the job is refused, with a message naming the package and saying to add its repository here. A public package still pulls. |
111+
112+A package a workflow job makes that is not linked to the job's repository
113+(a workspace package, say `g1t.sh/acme/tools` pushed from `acme/web`) is
114+given its repository with Write, so the workflow that made it keeps
115+publishing it. The job's token is still held to the job's own
116+`permissions:`, never deletes a package and never changes its settings.
117+
118+## Delete and restore
119+
120+Deleting a package, or one version, hides it from every registry at once:
121+pulls and installs get "not found", as for one that never existed. It is
122+kept for **30 days**, and until then:
123+
124+- An admin of the package can restore it: a version from the package's
125+ **Settings → Deleted versions**, a package from **Deleted packages** on
126+ the workspace's Packages page (`g1t.sh/<workspace>/-/packages?view=deleted`),
127+ listed for whoever administers it. A restored version comes back with
128+ the tags that still pointed to it.
129+- A deleted package's name stays taken: nobody can publish a package of
130+ that name in the workspace. A deleted version's version (for a container
131+ image, its digest) cannot be published again.
132+- Its files are kept, and do not count toward the workspace's storage.
133+
134+After 30 days it is purged for good, and files nothing else uses are
135+removed a day later. Composer versions follow their repository's tags and
136+branches, so they are deleted there, not here.
137+
138+Deleting through a registry's own protocol (`docker` and the OCI `DELETE`,
139+`npm unpublish`) works the same way.
140+
63141 ## Tokens
64142
65143 Sign in to a registry with your username and an
70148 | --- | --- |
71149 | `packages:read` | Pull private packages. Public ones need no scope. |
72150 | `packages:write` | Push and publish. Includes `packages:read`. |
73−| `packages:delete` | Delete versions and packages |
151+| `packages:delete` | Delete and restore versions and packages |
74152
75−Tokens with full access, and tokens made before scopes, have all three. A
76−token never does more than its owner could: `packages:delete` alone does not
77−let a member delete an owner's package.
153+Tokens with full access, and tokens made before scopes, have all three.
154+Changing a package's settings needs `packages:write` and the Admin role on
155+it. A token never does more than its owner could: `packages:delete` alone
156+does not let a member delete an owner's package.
78157
79−In [workflows](/guides/actions/#secrets-and-variables), `G1T_TOKEN` is the
80−workspace's own token for the run: it pushes and pulls the workspace's
81−packages with no setup. A g1t agent at work on a repository may push the
82−packages of that repository, as it may push its code, and never deletes
83−them.
158+| Token | Reaches |
159+| --- | --- |
160+| A personal access token | What its owner may do, limited by its scopes. |
161+| A fine-grained token | Packages linked to a repository in its selection, and the unlinked packages of its resource owner. Elsewhere it pulls public packages only. |
162+| A workspace's own token | The workspace's packages, as a member with Write; with Admin when an owner gave the token Admin. |
163+| A workflow job's `G1T_TOKEN` | Its own repository's packages, and those that list its repository under [Manage Actions access](#manage-actions-access), as the job's `permissions:` allow. |
164+| A deploy key | No packages. |
84165
166+In [workflows](/guides/actions/#the-jobs-token), `G1T_TOKEN` is the job's
167+own token: it pushes and pulls its repository's packages with no setup, and
168+any other package once its admins add the repository under Manage Actions
169+access. A g1t agent at work on a repository may push the packages of that
170+repository, as it may push its code, and never deletes them or changes their
171+settings.
172+
85173 ## Storage
86174
87175 Every file is kept once, by its content: two images that share a layer
97185 storage past those amounts is charged instead. See
98186 [storage and pull limits](/guides/containers/#storage-and-pull-limits).
99187
188+## Downloads
189+
190+Each package's page shows its pulls or downloads, and each version's own,
191+counted approximately: an image's manifest fetched by tag or digest, an npm
192+tarball, a crate, a Maven artifact (not its POM or signatures), a NuGet
193+`.nupkg`, a gem, and a Composer zip.
194+
195+## The API
196+
197+The [REST API](/reference/api/) and the `package` tool of the
198+[MCP server](/reference/mcp/) manage packages. A package is named by its
199+type (`container`, `npm`, `cargo`, `maven`, `nuget`, `rubygems` or
200+`composer`) and its name, URL-encoded where it holds a slash
201+(`web%2Fworker`):
202+
203+| Route | Operation | Scope |
204+| --- | --- | --- |
205+| `GET /workspaces/{workspace}/packages` | `list_packages` (`state=deleted` for deleted ones) | `packages:read` |
206+| `GET /workspaces/{workspace}/packages/{package_type}/{package_name}` | `get_package` | `packages:read` |
207+| `PATCH …/{package_name}` | `update_package`: `visibility`, `inherit_access` | `packages:write` |
208+| `DELETE …/{package_name}` | `delete_package` | `packages:delete` |
209+| `POST …/{package_name}/restore` | `restore_package` | `packages:delete` |
210+| `GET …/{package_name}/versions` | `list_package_versions` (`state=deleted` for deleted ones) | `packages:read` |
211+| `GET …/versions/{version_id}` | `get_package_version` | `packages:read` |
212+| `DELETE …/versions/{version_id}` | `delete_package_version` | `packages:delete` |
213+| `POST …/versions/{version_id}/restore` | `restore_package_version` | `packages:delete` |
214+| `PUT …/{package_name}/repository` | `link_package` | `packages:write` |
215+| `DELETE …/{package_name}/repository` | `unlink_package` | `packages:write` |
216+| `GET …/{package_name}/access` | `list_package_access` | `packages:read` |
217+| `PUT …/{package_name}/access` | `set_package_access`: `username` or `team`, and `role` | `packages:write` |
218+| `DELETE …/{package_name}/access?username=…` or `?team=…` | `remove_package_access` | `packages:write` |
219+| `GET …/{package_name}/actions-access` | `list_package_actions_access` | `packages:read` |
220+| `PUT …/{package_name}/actions-access` | `set_package_actions_access`: `repository` and `role` | `packages:write` |
221+| `DELETE …/{package_name}/actions-access/{repository}` | `remove_package_actions_access` | `packages:write` |
222+
223+`version_id` is a version's id (`ver_…`), its version, its digest, or a tag
224+that points to it. Reading access and deleted versions, and every change,
225+also needs the Admin role on the package. Bodies and answers are
226+snake_case.
227+
100228 ## Events and the audit log
101229
102230 Publishing a version, deleting a version and deleting a package are
103231 [audit log](/guides/audit-log/) entries (so are deprecating an npm version,
104232 yanking or unyanking a crate version, unlisting or listing a NuGet version,
105−pushing a NuGet version's symbols and yanking a gem version), and the events
233+pushing a NuGet version's symbols and yanking a gem version), as are
234+restoring them, purging them after 30 days, every change to who has access
235+and to Manage Actions access, changing the visibility, and linking and
236+unlinking (see the [audit log's list](/guides/audit-log/)). The events
106237 `package.published`, `package.version_deleted`, `package.deleted` and
107238 `package.visibility_changed`, which [webhooks](/guides/webhooks/) can be
108239 sent: a linked package's go to its repository's webhooks and its
+32−2
33 description: The g1t MCP server's resource tools, each action they take with its required inputs and scope, and how to call them.
44 ---
55
6−The MCP server at `https://mcp.g1t.sh` exposes 17 tools, one per kind of
6+The MCP server at `https://mcp.g1t.sh` exposes 18 tools, one per kind of
77 thing on g1t: `search`, `repository`, `issue`, `pull_request`, `agent`,
8−`plan`, `memory`, `workflow`, `secret`, `security`, `webhook`, `access`,
8+`plan`, `memory`, `workflow`, `package`, `secret`, `security`, `webhook`, `access`,
99 `team`, `workspace`, `billing`, `notifications` and `account`. Each tool takes an `action` that says what to do. Every
1010 action is the same operation as a route of the [REST API](/reference/api/),
1111 with the same inputs, permissions and results, so the two always agree.
466466 | [`get_runner_settings`](/reference/api/runners/get-runner-settings-for-workspace/) | Whether agent work runs on self-hosted runners and on which labels, and whether pull requests from forks may use them. | `workspace` or `repo` | `runners:read` |
467467 | [`update_runner_settings`](/reference/api/runners/update-runner-settings-for-workspace/) | Change them: `agents_on_self_hosted`, `agent_labels`, `fork_pull_requests`, or `inherit` for a repository. | `workspace` or `repo` | `runners:admin` |
468468
469+## `package`
470+
471+A workspace's [packages](/guides/packages/) in every registry: their
472+versions and downloads, deleting and restoring them within 30 days, their
473+visibility and repository, the people and teams with a role on them, and
474+which repositories' workflows may use them. Every action takes
475+`workspace`; all but `list` also take `package_type` (`container`, `npm`,
476+`cargo`, `maven`, `nuget`, `rubygems` or `composer`) and `package_name`.
477+Reading access and every change also need the Admin role on the package.
478+
479+| Action | What it does | Required | Scope |
480+| --- | --- | --- | --- |
481+| [`list`](/reference/api/packages/list-packages/) | The workspace's packages you may pull, by `package_type` and `q`; with `state` `deleted`, its deleted ones you administer. | `workspace` | `packages:read` |
482+| [`get`](/reference/api/packages/get-package/) | One package: address, visibility, repository, versions, downloads. | `package_type`, `package_name` | `packages:read` |
483+| [`versions`](/reference/api/packages/list-package-versions/) | Its versions with tags and downloads, newest first; `state` `deleted` for deleted ones. | `package_type`, `package_name` | `packages:read` |
484+| [`get_version`](/reference/api/packages/get-package-version/) | One version by id, version, digest or tag. | `version_id` | `packages:read` |
485+| [`update`](/reference/api/packages/update-package/) | Set `visibility` (unlinked packages), or `inherit_access` (linked ones). | `package_type`, `package_name` | `packages:write` |
486+| [`link`](/reference/api/packages/link-package/) | Link it to a `repository` of its workspace; Admin on the repository too. | `repository` | `packages:write` |
487+| [`unlink`](/reference/api/packages/unlink-package/) | Unlink it: the workspace's, private. | `package_type`, `package_name` | `packages:write` |
488+| [`access`](/reference/api/packages/list-package-access/) | People and teams with a role on it, and `inherit_access`. | `package_type`, `package_name` | `packages:read` |
489+| [`set_access`](/reference/api/packages/set-package-access/) | Give `username` or `team` a `role`: `read`, `write` or `admin`. | `role` | `packages:write` |
490+| [`remove_access`](/reference/api/packages/remove-package-access/) | Take `username`'s or `team`'s role away. | `package_type`, `package_name` | `packages:write` |
491+| [`actions_access`](/reference/api/packages/list-package-actions-access/) | Repositories whose workflows may use it; the linked one is always listed. | `package_type`, `package_name` | `packages:read` |
492+| [`set_actions_access`](/reference/api/packages/set-package-actions-access/) | Let a `repository`'s workflows use it with `role` `read` or `write`. | `repository`, `role` | `packages:write` |
493+| [`remove_actions_access`](/reference/api/packages/remove-package-actions-access/) | Stop a `repository`'s workflows using it. | `repository` | `packages:write` |
494+| [`delete`](/reference/api/packages/delete-package/) | Delete it, restorable for 30 days; its name stays taken until then. | `package_type`, `package_name` | `packages:delete` |
495+| [`restore`](/reference/api/packages/restore-package/) | Restore a deleted package. | `package_type`, `package_name` | `packages:delete` |
496+| [`delete_version`](/reference/api/packages/delete-package-version/) | Delete a version, restorable for 30 days. | `version_id` | `packages:delete` |
497+| [`restore_version`](/reference/api/packages/restore-package-version/) | Restore a deleted version. | `version_id` | `packages:delete` |
498+
469499 ## `secret`
470500
471501 A repository's or a workspace's secrets and variables, which workflows and
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.