scripticus-server 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scripticus-server
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A package manager and registry for scripts — index service
5
5
  License-Expression: MIT
6
6
  Requires-Dist: fastapi>=0.115
7
7
  Requires-Dist: uvicorn>=0.30
8
8
  Requires-Dist: sqlalchemy>=2.0
9
- Requires-Dist: scripticus-schema>=0.1.1,<0.2.0
9
+ Requires-Dist: httpx>=0.27
10
+ Requires-Dist: python-multipart>=0.0.9
11
+ Requires-Dist: scripticus-schema>=0.1.2,<0.2.0
10
12
  Requires-Python: >=3.11
11
13
  Description-Content-Type: text/markdown
12
14
 
@@ -56,17 +58,40 @@ balancers and container orchestrators.
56
58
 
57
59
  The index database defaults to a local SQLite file
58
60
  (`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
59
- to point elsewhere. Tables are created automatically on first use. Note
60
- that publishing doesn't exist yet, so a fresh index is empty until it
61
- lands.
61
+ to point elsewhere. Tables are created automatically on first use.
62
+
63
+ ### Publishing
64
+
65
+ `POST /packages` publishes a package: a multipart upload of one archive
66
+ (as produced by `scripticus pack`) with your Gitea token in the
67
+ `Authorization` header:
68
+
69
+ ```console
70
+ $ curl -X POST http://localhost:8000/packages \
71
+ -H "Authorization: token <your-gitea-token>" \
72
+ -F archive=@my_tool-1.0.0-linux.macos-bash.tar.gz
73
+ ```
74
+
75
+ The server trusts nothing about the upload: it re-validates the manifest
76
+ and package tree, computes the content hash, checks with Gitea (live)
77
+ that your token may publish to the manifest's namespace — your own
78
+ username, or an organisation you belong to — stores the blob in Gitea's
79
+ generic package registry, and only then commits the index record.
80
+ Versions are immutable; the one addition an existing version accepts is
81
+ an artifact in a new archive format carrying the identical content hash.
82
+ Declared package dependencies must be fully namespaced and already
83
+ present in the index, and a publish that would create a dependency cycle
84
+ is rejected. The `library` namespace is reserved. The Gitea instance is
85
+ configured with `SCRIPTICUS_GITEA_URL` (default `http://localhost:3000`).
62
86
 
63
87
  ### Docker
64
88
 
65
89
  Server releases publish a Docker image to
66
90
  [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
67
- (tagged with the release version and `latest`). The repository ships a
68
- `docker-compose.yml` running it as a single container no checkout
69
- needed:
91
+ (tagged with the release version and `latest`). The repository's
92
+ `docker-compose.yml` is the full registry bundlethe index service plus
93
+ the Gitea instance that provides storage, authentication, and namespace
94
+ ownership — and needs no checkout:
70
95
 
71
96
  ```console
72
97
  $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
@@ -75,15 +100,17 @@ $ curl http://localhost:8000/health
75
100
  {"status":"ok"}
76
101
  ```
77
102
 
78
- The intended v1 deployment pairs the index service with a Gitea instance
79
- that provides storage, authentication, and namespace ownership; Gitea
80
- integration doesn't exist yet, so the compose file is currently
81
- server-only and does not stand up a working registry.
82
-
83
- Once Gitea is part of the bundle: accounts and organisations are managed
84
- in Gitea; a Scripticus namespace is a Gitea user or organisation, claimed
85
- first-come-first-served, and publish rights follow Gitea's own membership
86
- and ACLs. The `library` namespace is reserved.
103
+ First-run Gitea setup: accounts and organisations are managed in Gitea
104
+ (http://localhost:3000), and a Scripticus namespace *is* a Gitea user or
105
+ organisation, claimed first-come-first-served, with publish rights
106
+ following Gitea's own membership and ACLs. So, once the bundle is up:
107
+
108
+ 1. Register your user in the Gitea web UI (the first registered user is
109
+ the instance admin), and create an organisation for any shared
110
+ namespace you want.
111
+ 2. Generate a token under *Settings → Applications → Manage Access
112
+ Tokens* with package write and user read scopes.
113
+ 3. Publish with that token (see above).
87
114
 
88
115
  ## Licence
89
116
 
@@ -44,17 +44,40 @@ balancers and container orchestrators.
44
44
 
45
45
  The index database defaults to a local SQLite file
46
46
  (`scripticus-index.db`); set `SCRIPTICUS_INDEX_DB` to any SQLAlchemy URL
47
- to point elsewhere. Tables are created automatically on first use. Note
48
- that publishing doesn't exist yet, so a fresh index is empty until it
49
- lands.
47
+ to point elsewhere. Tables are created automatically on first use.
48
+
49
+ ### Publishing
50
+
51
+ `POST /packages` publishes a package: a multipart upload of one archive
52
+ (as produced by `scripticus pack`) with your Gitea token in the
53
+ `Authorization` header:
54
+
55
+ ```console
56
+ $ curl -X POST http://localhost:8000/packages \
57
+ -H "Authorization: token <your-gitea-token>" \
58
+ -F archive=@my_tool-1.0.0-linux.macos-bash.tar.gz
59
+ ```
60
+
61
+ The server trusts nothing about the upload: it re-validates the manifest
62
+ and package tree, computes the content hash, checks with Gitea (live)
63
+ that your token may publish to the manifest's namespace — your own
64
+ username, or an organisation you belong to — stores the blob in Gitea's
65
+ generic package registry, and only then commits the index record.
66
+ Versions are immutable; the one addition an existing version accepts is
67
+ an artifact in a new archive format carrying the identical content hash.
68
+ Declared package dependencies must be fully namespaced and already
69
+ present in the index, and a publish that would create a dependency cycle
70
+ is rejected. The `library` namespace is reserved. The Gitea instance is
71
+ configured with `SCRIPTICUS_GITEA_URL` (default `http://localhost:3000`).
50
72
 
51
73
  ### Docker
52
74
 
53
75
  Server releases publish a Docker image to
54
76
  [`kevinchannon/scripticus-server`](https://hub.docker.com/r/kevinchannon/scripticus-server)
55
- (tagged with the release version and `latest`). The repository ships a
56
- `docker-compose.yml` running it as a single container no checkout
57
- needed:
77
+ (tagged with the release version and `latest`). The repository's
78
+ `docker-compose.yml` is the full registry bundlethe index service plus
79
+ the Gitea instance that provides storage, authentication, and namespace
80
+ ownership — and needs no checkout:
58
81
 
59
82
  ```console
60
83
  $ curl -LO https://raw.githubusercontent.com/kevinchannon/scripticus/main/docker-compose.yml
@@ -63,15 +86,17 @@ $ curl http://localhost:8000/health
63
86
  {"status":"ok"}
64
87
  ```
65
88
 
66
- The intended v1 deployment pairs the index service with a Gitea instance
67
- that provides storage, authentication, and namespace ownership; Gitea
68
- integration doesn't exist yet, so the compose file is currently
69
- server-only and does not stand up a working registry.
70
-
71
- Once Gitea is part of the bundle: accounts and organisations are managed
72
- in Gitea; a Scripticus namespace is a Gitea user or organisation, claimed
73
- first-come-first-served, and publish rights follow Gitea's own membership
74
- and ACLs. The `library` namespace is reserved.
89
+ First-run Gitea setup: accounts and organisations are managed in Gitea
90
+ (http://localhost:3000), and a Scripticus namespace *is* a Gitea user or
91
+ organisation, claimed first-come-first-served, with publish rights
92
+ following Gitea's own membership and ACLs. So, once the bundle is up:
93
+
94
+ 1. Register your user in the Gitea web UI (the first registered user is
95
+ the instance admin), and create an organisation for any shared
96
+ namespace you want.
97
+ 2. Generate a token under *Settings → Applications → Manage Access
98
+ Tokens* with package write and user read scopes.
99
+ 3. Publish with that token (see above).
75
100
 
76
101
  ## Licence
77
102
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scripticus-server"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A package manager and registry for scripts — index service"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -9,7 +9,9 @@ dependencies = [
9
9
  "fastapi>=0.115",
10
10
  "uvicorn>=0.30",
11
11
  "sqlalchemy>=2.0",
12
- "scripticus-schema>=0.1.1,<0.2.0",
12
+ "httpx>=0.27",
13
+ "python-multipart>=0.0.9",
14
+ "scripticus-schema>=0.1.2,<0.2.0",
13
15
  ]
14
16
 
15
17
  [tool.uv.sources]
@@ -1,10 +1,9 @@
1
- from collections.abc import Iterator
2
1
  from typing import Literal
3
2
 
4
3
  from fastapi import Depends, FastAPI, HTTPException
5
4
  from pydantic import BaseModel
6
5
  from sqlalchemy import select
7
- from sqlalchemy.orm import Session, sessionmaker
6
+ from sqlalchemy.orm import Session
8
7
 
9
8
  from scripticus_schema.index_api import (
10
9
  PackageSummary,
@@ -14,6 +13,8 @@ from scripticus_schema.index_api import (
14
13
  )
15
14
  from scripticus_schema.semver import semver_key
16
15
  from scripticus_server import __version__, db
16
+ from scripticus_server.db import get_session
17
+ from scripticus_server.publish import router as publish_router
17
18
 
18
19
  app = FastAPI(
19
20
  title="Scripticus index service",
@@ -23,20 +24,7 @@ app = FastAPI(
23
24
  ),
24
25
  version=__version__,
25
26
  )
26
-
27
- _session_factory: sessionmaker | None = None
28
-
29
-
30
- def get_session() -> Iterator[Session]:
31
- # Lazy so that importing the app never touches the database; tables are
32
- # created on first use (D31: create_all until the schema stabilises).
33
- global _session_factory
34
- if _session_factory is None:
35
- engine = db.make_engine()
36
- db.init_db(engine)
37
- _session_factory = sessionmaker(bind=engine)
38
- with _session_factory() as session:
39
- yield session
27
+ app.include_router(publish_router)
40
28
 
41
29
 
42
30
  # Local to the server on purpose: a liveness shape is not part of the
@@ -10,10 +10,18 @@ manifest blob, never independently editable (D21).
10
10
  """
11
11
 
12
12
  import os
13
+ from collections.abc import Iterator
13
14
 
14
15
  from sqlalchemy import ForeignKey, Text, UniqueConstraint, create_engine
15
16
  from sqlalchemy.engine import Engine
16
- from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
17
+ from sqlalchemy.orm import (
18
+ DeclarativeBase,
19
+ Mapped,
20
+ Session,
21
+ mapped_column,
22
+ relationship,
23
+ sessionmaker,
24
+ )
17
25
 
18
26
  DEFAULT_DB_URL = "sqlite:///scripticus-index.db"
19
27
 
@@ -30,6 +38,22 @@ def init_db(engine: Engine) -> None:
30
38
  Base.metadata.create_all(engine)
31
39
 
32
40
 
41
+ _session_factory: sessionmaker | None = None
42
+
43
+
44
+ def get_session() -> Iterator[Session]:
45
+ # FastAPI dependency. Lazy so that importing the app never touches the
46
+ # database; tables are created on first use (D31: create_all until the
47
+ # schema stabilises).
48
+ global _session_factory
49
+ if _session_factory is None:
50
+ engine = make_engine()
51
+ init_db(engine)
52
+ _session_factory = sessionmaker(bind=engine)
53
+ with _session_factory() as session:
54
+ yield session
55
+
56
+
33
57
  class Base(DeclarativeBase):
34
58
  pass
35
59
 
@@ -0,0 +1,115 @@
1
+ """The server's boundary to Gitea (D2): permission checks and blob storage.
2
+
3
+ Auth is pass-through (D32): every publish carries the caller's own Gitea
4
+ token, and this client acts with exactly that token's rights — the index
5
+ service holds no credentials and caches nothing ACL-shaped (D24). The
6
+ narrow surface here exists so tests can fake Gitea; nothing outside this
7
+ module speaks HTTP to it.
8
+ """
9
+
10
+ import os
11
+
12
+ import httpx
13
+ from fastapi import Header, HTTPException
14
+
15
+ DEFAULT_GITEA_URL = "http://localhost:3000"
16
+
17
+
18
+ def gitea_url() -> str:
19
+ return os.environ.get("SCRIPTICUS_GITEA_URL", DEFAULT_GITEA_URL)
20
+
21
+
22
+ class GiteaAuthError(Exception):
23
+ """The token is missing, invalid, or expired."""
24
+
25
+
26
+ class GiteaError(Exception):
27
+ """Gitea is unreachable or answered something unexpected."""
28
+
29
+
30
+ class GiteaClient:
31
+ def __init__(self, base_url: str, token: str):
32
+ self._client = httpx.Client(
33
+ base_url=base_url,
34
+ headers={"Authorization": f"token {token}"},
35
+ timeout=30,
36
+ )
37
+
38
+ def _get(self, path: str) -> httpx.Response:
39
+ try:
40
+ return self._client.get(path)
41
+ except httpx.HTTPError as exc:
42
+ raise GiteaError(f"cannot reach Gitea: {exc}") from exc
43
+
44
+ def authenticated_user(self) -> str:
45
+ """The login of the token's owner; raises GiteaAuthError on a bad token."""
46
+ response = self._get("/api/v1/user")
47
+ if response.status_code in (401, 403):
48
+ raise GiteaAuthError("Gitea rejected the token")
49
+ if response.status_code != 200:
50
+ raise GiteaError(f"unexpected {response.status_code} from Gitea /user")
51
+ return response.json()["login"]
52
+
53
+ def can_publish(self, namespace: str, user: str) -> bool:
54
+ """Live ACL check (D24): the namespace is the user themselves, or an
55
+ organisation the user is a member of. Unknown namespaces are simply
56
+ not publishable — Gitea owns namespace existence.
57
+ """
58
+ if namespace == user:
59
+ return True
60
+ response = self._get(f"/api/v1/orgs/{namespace}/members/{user}")
61
+ if response.status_code == 204:
62
+ return True
63
+ if response.status_code < 500:
64
+ # Non-membership, nonexistent org, and permission refusals all
65
+ # come back as assorted 3xx/4xx depending on Gitea version; every
66
+ # one of them means "cannot publish here".
67
+ return False
68
+ raise GiteaError(f"unexpected {response.status_code} from Gitea org check")
69
+
70
+ def _blob_path(self, namespace: str, name: str, version: str, filename: str) -> str:
71
+ return f"/api/packages/{namespace}/generic/{name}/{version}/{filename}"
72
+
73
+ def upload_blob(
74
+ self, namespace: str, name: str, version: str, filename: str, data: bytes
75
+ ) -> str:
76
+ """Write the archive to Gitea's generic package registry; returns the
77
+ download path (relative to the Gitea base URL) as the artifact pointer.
78
+ """
79
+ path = self._blob_path(namespace, name, version, filename)
80
+ try:
81
+ response = self._client.put(path, content=data)
82
+ except httpx.HTTPError as exc:
83
+ raise GiteaError(f"cannot reach Gitea: {exc}") from exc
84
+ if response.status_code == 201:
85
+ return path
86
+ if response.status_code in (401, 403):
87
+ raise GiteaAuthError("Gitea refused the blob write")
88
+ raise GiteaError(
89
+ f"Gitea blob write failed with {response.status_code}: {response.text}"
90
+ )
91
+
92
+ def delete_blob(
93
+ self, namespace: str, name: str, version: str, filename: str
94
+ ) -> None:
95
+ """Best-effort cleanup of an uploaded blob after a failed publish;
96
+ never raises — the publish error it accompanies matters more.
97
+ """
98
+ try:
99
+ self._client.delete(self._blob_path(namespace, name, version, filename))
100
+ except httpx.HTTPError:
101
+ pass
102
+
103
+
104
+ def get_gitea_client(authorization: str | None = Header(None)) -> GiteaClient:
105
+ """FastAPI dependency: a GiteaClient acting as the request's caller.
106
+
107
+ Accepts ``Authorization: token <t>`` (Gitea's own convention) or
108
+ ``Bearer <t>``.
109
+ """
110
+ if authorization is None:
111
+ raise HTTPException(401, "publishing requires a Gitea token")
112
+ scheme, _, token = authorization.partition(" ")
113
+ if scheme.lower() not in ("token", "bearer") or not token:
114
+ raise HTTPException(401, "malformed Authorization header")
115
+ return GiteaClient(gitea_url(), token)
@@ -0,0 +1,316 @@
1
+ """The write path: atomic server-mediated publish (D8, D32).
2
+
3
+ Order of operations is the atomicity guarantee: every validation and
4
+ permission check happens first, the blob goes to Gitea next, and the index
5
+ record is committed only after Gitea confirms the write. Failure at any
6
+ step rejects the whole publish; a commit failure after the blob write
7
+ triggers a best-effort blob delete so nothing dangles.
8
+
9
+ Nothing the client claims is trusted (D8): identity, variant tags,
10
+ dependencies, and the content hash are all derived from the uploaded
11
+ archive server-side.
12
+ """
13
+
14
+ import re
15
+ import tarfile
16
+ import tempfile
17
+ import zipfile
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile
22
+ from sqlalchemy import select
23
+ from sqlalchemy.exc import IntegrityError
24
+ from sqlalchemy.orm import Session
25
+
26
+ from scripticus_schema.manifest import (
27
+ FORMAT_GROUPS,
28
+ NAMESPACE_RE,
29
+ PACKAGE_NAME_RE,
30
+ Manifest,
31
+ ManifestError,
32
+ commands_of,
33
+ load_manifest,
34
+ )
35
+ from scripticus_schema.publish_api import PublishedArtifact, PublishResult
36
+ from scripticus_schema.treehash import tree_hash
37
+ from scripticus_server import db
38
+ from scripticus_server.db import get_session
39
+ from scripticus_server.gitea import (
40
+ GiteaAuthError,
41
+ GiteaClient,
42
+ GiteaError,
43
+ get_gitea_client,
44
+ )
45
+
46
+ router = APIRouter()
47
+
48
+ RESERVED_NAMESPACES = ("library",)
49
+
50
+ _SAFE_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
51
+
52
+
53
+ def _archive_format(filename: str) -> str:
54
+ if filename.endswith((".tar.gz", ".tgz")):
55
+ return "tar.gz"
56
+ if filename.endswith(".zip"):
57
+ return "zip"
58
+ raise HTTPException(400, f"'{filename}' is not a supported archive (.tar.gz or .zip)")
59
+
60
+
61
+ def _extract_archive(archive: Path, destination: Path) -> Path:
62
+ """Extract and return the single root directory of the package tree.
63
+
64
+ The tar "data" filter (PEP 706) rejects path traversal, links pointing
65
+ outside the tree, and device files; zipfile's extract sanitises
66
+ absolute paths and parent references itself.
67
+ """
68
+ try:
69
+ if archive.name.endswith(".zip"):
70
+ with zipfile.ZipFile(archive) as zf:
71
+ zf.extractall(destination)
72
+ else:
73
+ with tarfile.open(archive) as tar:
74
+ tar.extractall(destination, filter="data")
75
+ except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
76
+ raise HTTPException(400, f"cannot extract archive: {exc}") from exc
77
+
78
+ roots = list(destination.iterdir())
79
+ if len(roots) != 1 or not roots[0].is_dir():
80
+ raise HTTPException(400, "archive does not contain a single package directory")
81
+ return roots[0]
82
+
83
+
84
+ def _artifact_platforms(manifest: Manifest, archive_format: str) -> list[str]:
85
+ group = dict(FORMAT_GROUPS)[archive_format]
86
+ platforms = [os_name for os_name in group if os_name in manifest.platforms.os]
87
+ if not platforms:
88
+ raise HTTPException(
89
+ 422,
90
+ f"a .{archive_format} archive carries {'/'.join(group)} targets, but the"
91
+ f" manifest declares platforms {manifest.platforms.os}",
92
+ )
93
+ return platforms
94
+
95
+
96
+ def _check_dependency_targets(session: Session, manifest: Manifest) -> None:
97
+ """Publish-time dependency rules (D33): targets must be fully namespaced
98
+ and already present in the index.
99
+ """
100
+ missing = []
101
+ for target in sorted(manifest.dependencies.packages):
102
+ namespace, _, name = target.partition("/")
103
+ if not (NAMESPACE_RE.match(namespace) and name and PACKAGE_NAME_RE.match(name)):
104
+ raise HTTPException(
105
+ 422,
106
+ f"dependency '{target}' is not a fully namespaced package"
107
+ " reference (namespace/name)",
108
+ )
109
+ known = session.scalar(
110
+ select(db.Package)
111
+ .join(db.Namespace)
112
+ .where(db.Namespace.name == namespace, db.Package.name == name)
113
+ )
114
+ if known is None or not known.versions:
115
+ missing.append(target)
116
+ if missing:
117
+ raise HTTPException(
118
+ 422,
119
+ "dependencies not present in the index: " + ", ".join(missing),
120
+ )
121
+
122
+
123
+ def _check_no_cycle(session: Session, manifest: Manifest) -> None:
124
+ """Reject a publish that would make the publishing package reachable from
125
+ its own dependencies (D33). Edges are package-level: the union of every
126
+ version's declared dependencies.
127
+ """
128
+ rows = session.execute(
129
+ select(db.Namespace.name, db.Package.name, db.Dependency.target)
130
+ .join(db.Package, db.Package.namespace_id == db.Namespace.id)
131
+ .join(db.PackageVersion, db.PackageVersion.package_id == db.Package.id)
132
+ .join(db.Dependency, db.Dependency.package_version_id == db.PackageVersion.id)
133
+ ).all()
134
+ edges: dict[str, set[str]] = {}
135
+ for namespace, name, target in rows:
136
+ edges.setdefault(f"{namespace}/{name}", set()).add(target)
137
+
138
+ publishing = f"{manifest.package.namespace}/{manifest.package.name}"
139
+ edges.setdefault(publishing, set()).update(manifest.dependencies.packages)
140
+
141
+ seen = set()
142
+ stack = list(edges[publishing])
143
+ while stack:
144
+ node = stack.pop()
145
+ if node == publishing:
146
+ raise HTTPException(
147
+ 422,
148
+ f"publishing would create a dependency cycle back to '{publishing}'",
149
+ )
150
+ if node in seen:
151
+ continue
152
+ seen.add(node)
153
+ stack.extend(edges.get(node, ()))
154
+
155
+
156
+ def _storage_filename(upload_name: str, manifest: Manifest, archive_format: str) -> str:
157
+ """The blob's filename in Gitea. The client's wheel-style name is kept
158
+ when sane (filenames are human-legible redundancy only — the manifest is
159
+ the source of truth), otherwise a minimal name is generated.
160
+ """
161
+ candidate = Path(upload_name).name
162
+ if _SAFE_FILENAME_RE.match(candidate) and candidate.endswith(f".{archive_format}"):
163
+ return candidate
164
+ package = manifest.package
165
+ return (
166
+ f"{package.name.replace('-', '_')}-{package.version.replace('-', '_')}"
167
+ f".{archive_format}"
168
+ )
169
+
170
+
171
+ @router.post("/packages", status_code=201)
172
+ def publish(
173
+ archive: UploadFile,
174
+ session: Session = Depends(get_session),
175
+ gitea: GiteaClient = Depends(get_gitea_client),
176
+ ) -> PublishResult:
177
+ data = archive.file.read()
178
+ archive_format = _archive_format(Path(archive.filename or "").name)
179
+
180
+ with tempfile.TemporaryDirectory(prefix="scripticus-publish-") as staging_dir:
181
+ staging = Path(staging_dir)
182
+ upload_path = staging / f"upload.{archive_format}"
183
+ upload_path.write_bytes(data)
184
+ package_root = _extract_archive(upload_path, staging / "tree")
185
+
186
+ try:
187
+ manifest = load_manifest(package_root)
188
+ except ManifestError as exc:
189
+ raise HTTPException(422, str(exc)) from exc
190
+ meta = manifest.package
191
+
192
+ if meta.namespace in RESERVED_NAMESPACES:
193
+ raise HTTPException(
194
+ 403, f"the '{meta.namespace}' namespace is reserved"
195
+ )
196
+ platforms = _artifact_platforms(manifest, archive_format)
197
+
198
+ try:
199
+ user = gitea.authenticated_user()
200
+ allowed = gitea.can_publish(meta.namespace, user)
201
+ except GiteaAuthError as exc:
202
+ raise HTTPException(401, str(exc)) from exc
203
+ except GiteaError as exc:
204
+ raise HTTPException(502, str(exc)) from exc
205
+ if not allowed:
206
+ raise HTTPException(
207
+ 403, f"'{user}' cannot publish to namespace '{meta.namespace}'"
208
+ )
209
+
210
+ content_hash = tree_hash(package_root)
211
+ manifest_text = (package_root / "meta.toml").read_text()
212
+
213
+ namespace = session.scalar(
214
+ select(db.Namespace).where(db.Namespace.name == meta.namespace)
215
+ )
216
+ package = session.scalar(
217
+ select(db.Package)
218
+ .join(db.Namespace)
219
+ .where(db.Namespace.name == meta.namespace, db.Package.name == meta.name)
220
+ )
221
+ existing = None
222
+ if package is not None:
223
+ existing = next(
224
+ (pv for pv in package.versions if pv.version == meta.version), None
225
+ )
226
+ if existing is not None:
227
+ # The variant rule (D32): more artifacts may join a version only if
228
+ # they carry the identical tree, in a format not yet published.
229
+ recorded_hashes = {a.content_hash for a in existing.artifacts}
230
+ if recorded_hashes and content_hash not in recorded_hashes:
231
+ raise HTTPException(
232
+ 409,
233
+ f"{meta.namespace}/{meta.name} {meta.version} already exists"
234
+ " with different content; versions are immutable",
235
+ )
236
+ if any(a.archive_format == archive_format for a in existing.artifacts):
237
+ raise HTTPException(
238
+ 409,
239
+ f"{meta.namespace}/{meta.name} {meta.version} already has a"
240
+ f" .{archive_format} artifact; duplicate versions are rejected",
241
+ )
242
+ else:
243
+ _check_dependency_targets(session, manifest)
244
+ _check_no_cycle(session, manifest)
245
+
246
+ filename = _storage_filename(archive.filename or "", manifest, archive_format)
247
+ try:
248
+ pointer = gitea.upload_blob(meta.namespace, meta.name, meta.version, filename, data)
249
+ except GiteaAuthError as exc:
250
+ raise HTTPException(401, str(exc)) from exc
251
+ except GiteaError as exc:
252
+ raise HTTPException(502, str(exc)) from exc
253
+
254
+ # Gitea has confirmed the write; only now may the index record go in.
255
+ try:
256
+ if existing is None:
257
+ if namespace is None:
258
+ namespace = db.Namespace(name=meta.namespace)
259
+ if package is None:
260
+ package = db.Package(namespace=namespace, name=meta.name)
261
+ version = db.PackageVersion(
262
+ package=package,
263
+ version=meta.version,
264
+ description=meta.description,
265
+ published_at=datetime.now(timezone.utc).isoformat(),
266
+ publisher=user,
267
+ )
268
+ for target, spec in sorted(manifest.dependencies.packages.items()):
269
+ version.dependencies.append(db.Dependency(target=target, spec=spec))
270
+ for tool in manifest.dependencies.tools.requires:
271
+ version.tool_deps.append(db.ToolDep(name=tool, required=True))
272
+ for tool in manifest.dependencies.tools.optional:
273
+ version.tool_deps.append(db.ToolDep(name=tool, required=False))
274
+ for command, script in sorted(commands_of(manifest).items()):
275
+ version.commands.append(db.Command(name=command, script_path=script))
276
+ db.ManifestBlob(package_version=version, toml=manifest_text)
277
+ else:
278
+ version = existing
279
+ db.Artifact(
280
+ package_version=version,
281
+ platforms=",".join(platforms),
282
+ language=meta.language,
283
+ archive_format=archive_format,
284
+ content_hash=content_hash,
285
+ size=len(data),
286
+ gitea_pointer=pointer,
287
+ )
288
+ session.add(version)
289
+ session.commit()
290
+ except IntegrityError as exc:
291
+ session.rollback()
292
+ gitea.delete_blob(meta.namespace, meta.name, meta.version, filename)
293
+ raise HTTPException(
294
+ 409,
295
+ f"{meta.namespace}/{meta.name} {meta.version} was published"
296
+ " concurrently; duplicate versions are rejected",
297
+ ) from exc
298
+ except Exception:
299
+ session.rollback()
300
+ gitea.delete_blob(meta.namespace, meta.name, meta.version, filename)
301
+ raise
302
+
303
+ return PublishResult(
304
+ namespace=meta.namespace,
305
+ name=meta.name,
306
+ version=meta.version,
307
+ content_hash=content_hash,
308
+ publisher=user,
309
+ artifact=PublishedArtifact(
310
+ filename=filename,
311
+ archive_format=archive_format,
312
+ platforms=platforms,
313
+ language=meta.language,
314
+ size=len(data),
315
+ ),
316
+ )