Add separate version tracking for artifacts

This commit is contained in:
Mondo Diaz
2026-01-16 11:36:08 -06:00
parent a98ac154d5
commit b93d5a9c68
15 changed files with 1366 additions and 34 deletions

View File

@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Added `package_versions` table for immutable version tracking separate from mutable tags (#56)
- Versions are set at upload time via explicit `version` parameter or auto-detected from filename/metadata
- Version detection priority: explicit parameter > package metadata > filename pattern
- Versions are immutable once created (unlike tags which can be moved)
- Added version API endpoints (#56):
- `GET /api/v1/project/{project}/{package}/versions` - List all versions for a package
- `GET /api/v1/project/{project}/{package}/versions/{version}` - Get specific version details
- `DELETE /api/v1/project/{project}/{package}/versions/{version}` - Delete a version (admin only)
- Added version support to upload endpoint via `version` form parameter (#56)
- Added `version:X.Y.Z` prefix for explicit version resolution in download refs (#56)
- Added version field to tag responses (shows which version the artifact has, if any) (#56)
- Added migration `007_package_versions.sql` with ref_count triggers and data migration from semver tags (#56)
- Added production deployment job triggered by semantic version tags (v1.0.0) with manual approval gate (#63) - Added production deployment job triggered by semantic version tags (v1.0.0) with manual approval gate (#63)
- Added production Helm values file with persistence enabled (20Gi PostgreSQL, 100Gi MinIO) (#63) - Added production Helm values file with persistence enabled (20Gi PostgreSQL, 100Gi MinIO) (#63)
- Added integration tests for production deployment (#63) - Added integration tests for production deployment (#63)
@@ -19,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added internal proxy configuration for npm, pip, helm, and apt (#51) - Added internal proxy configuration for npm, pip, helm, and apt (#51)
### Changed ### Changed
- Updated download ref resolution to check versions before tags (version → tag → artifact ID) (#56)
- Deploy jobs now require all security scans to pass before deployment (added test_image, app_deps_scan, cve_scan, cve_sbom_analysis, app_sbom_analysis to dependencies) (#63) - Deploy jobs now require all security scans to pass before deployment (added test_image, app_deps_scan, cve_scan, cve_sbom_analysis, app_sbom_analysis to dependencies) (#63)
- Increased deploy job timeout from 5m to 10m (#63) - Increased deploy job timeout from 5m to 10m (#63)
- Added `--atomic` flag to Helm deployments for automatic rollback on failure - Added `--atomic` flag to Helm deployments for automatic rollback on failure

View File

@@ -22,6 +22,7 @@ Orchard is a centralized binary artifact storage system that provides content-ad
- **Package** - Named collection within a project - **Package** - Named collection within a project
- **Artifact** - Specific content instance identified by SHA256 - **Artifact** - Specific content instance identified by SHA256
- **Tags** - Alias system for referencing artifacts by human-readable names (e.g., `v1.0.0`, `latest`, `stable`) - **Tags** - Alias system for referencing artifacts by human-readable names (e.g., `v1.0.0`, `latest`, `stable`)
- **Versions** - Immutable version records set at upload time (explicit or auto-detected from filename/metadata), separate from mutable tags
- **Package Formats & Platforms** - Packages can be tagged with format (npm, pypi, docker, deb, rpm, etc.) and platform (linux, darwin, windows, etc.) - **Package Formats & Platforms** - Packages can be tagged with format (npm, pypi, docker, deb, rpm, etc.) and platform (linux, darwin, windows, etc.)
- **Rich Package Metadata** - Package listings include aggregated stats (tag count, artifact count, total size, latest tag) - **Rich Package Metadata** - Package listings include aggregated stats (tag count, artifact count, total size, latest tag)
- **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage - **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage
@@ -73,6 +74,9 @@ Orchard is a centralized binary artifact storage system that provides content-ad
| `POST` | `/api/v1/project/:project/:package/tags` | Create a tag | | `POST` | `/api/v1/project/:project/:package/tags` | Create a tag |
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name` | Get single tag with artifact metadata | | `GET` | `/api/v1/project/:project/:package/tags/:tag_name` | Get single tag with artifact metadata |
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name/history` | Get tag change history | | `GET` | `/api/v1/project/:project/:package/tags/:tag_name/history` | Get tag change history |
| `GET` | `/api/v1/project/:project/:package/versions` | List all versions for a package |
| `GET` | `/api/v1/project/:project/:package/versions/:version` | Get specific version details |
| `DELETE` | `/api/v1/project/:project/:package/versions/:version` | Delete a version (admin only) |
| `GET` | `/api/v1/project/:project/:package/artifacts` | List artifacts in package (with filtering) | | `GET` | `/api/v1/project/:project/:package/artifacts` | List artifacts in package (with filtering) |
| `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package | | `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package |
| `GET` | `/api/v1/artifact/:id` | Get artifact metadata with referencing tags | | `GET` | `/api/v1/artifact/:id` | Get artifact metadata with referencing tags |
@@ -93,12 +97,14 @@ For large files, use the resumable upload API:
When downloading artifacts, the `:ref` parameter supports multiple formats: When downloading artifacts, the `:ref` parameter supports multiple formats:
- `latest` - Tag name directly - `latest` - Implicit lookup (checks version first, then tag, then artifact ID)
- `v1.0.0` - Version tag - `v1.0.0` - Implicit lookup (version takes precedence over tag with same name)
- `version:1.0.0` - Explicit version reference
- `tag:stable` - Explicit tag reference - `tag:stable` - Explicit tag reference
- `version:2024.1` - Version reference
- `artifact:a3f5d8e12b4c6789...` - Direct SHA256 hash reference - `artifact:a3f5d8e12b4c6789...` - Direct SHA256 hash reference
**Resolution order for implicit refs:** version → tag → artifact ID
## Quick Start ## Quick Start
### Prerequisites ### Prerequisites
@@ -230,9 +236,16 @@ curl "http://localhost:8080/api/v1/project/my-project/packages/releases?include_
### Upload an Artifact ### Upload an Artifact
```bash ```bash
# Upload with tag only (version auto-detected from filename)
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \ curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \
-F "file=@./build/app-v1.0.0.tar.gz" \ -F "file=@./build/app-v1.0.0.tar.gz" \
-F "tag=v1.0.0" -F "tag=latest"
# Upload with explicit version and tag
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \
-F "file=@./build/app-v1.0.0.tar.gz" \
-F "tag=latest" \
-F "version=1.0.0"
``` ```
Response: Response:
@@ -242,7 +255,9 @@ Response:
"size": 1048576, "size": 1048576,
"project": "my-project", "project": "my-project",
"package": "releases", "package": "releases",
"tag": "v1.0.0", "tag": "latest",
"version": "1.0.0",
"version_source": "explicit",
"format_metadata": { "format_metadata": {
"format": "tarball", "format": "tarball",
"package_name": "app", "package_name": "app",
@@ -400,6 +415,38 @@ curl http://localhost:8080/api/v1/project/my-project/releases/tags/latest/histor
Returns list of artifact changes for the tag (most recent first). Returns list of artifact changes for the tag (most recent first).
### List Versions
```bash
# Basic listing
curl http://localhost:8080/api/v1/project/my-project/releases/versions
# With pagination and sorting
curl "http://localhost:8080/api/v1/project/my-project/releases/versions?sort=version&order=desc"
```
Response includes tags pointing to each version's artifact:
```json
{
"items": [
{
"id": "uuid",
"package_id": "uuid",
"version": "1.0.0",
"version_source": "explicit",
"artifact_id": "a3f5d8e...",
"size": 1048576,
"content_type": "application/gzip",
"original_name": "app-v1.0.0.tar.gz",
"created_at": "2025-01-01T00:00:00Z",
"created_by": "user",
"tags": ["latest", "stable"]
}
],
"pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1}
}
```
### List Artifacts in Package ### List Artifacts in Package
```bash ```bash
@@ -614,7 +661,8 @@ See `helm/orchard/values.yaml` for all configuration options.
- **projects** - Top-level organizational containers - **projects** - Top-level organizational containers
- **packages** - Collections within projects - **packages** - Collections within projects
- **artifacts** - Content-addressable artifacts (SHA256) - **artifacts** - Content-addressable artifacts (SHA256)
- **tags** - Aliases pointing to artifacts - **tags** - Mutable aliases pointing to artifacts
- **package_versions** - Immutable version records (set at upload time)
- **tag_history** - Audit trail for tag changes - **tag_history** - Audit trail for tag changes
- **uploads** - Upload event records - **uploads** - Upload event records
- **consumers** - Dependency tracking - **consumers** - Dependency tracking

View File

@@ -151,6 +151,84 @@ def _run_migrations():
END IF; END IF;
END $$; END $$;
""", """,
# Add package_versions indexes and triggers (007_package_versions.sql)
"""
DO $$
BEGIN
-- Create indexes for package_versions if table exists
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Indexes for common queries
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_id') THEN
CREATE INDEX idx_package_versions_package_id ON package_versions(package_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_artifact_id') THEN
CREATE INDEX idx_package_versions_artifact_id ON package_versions(artifact_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_package_versions_package_version') THEN
CREATE INDEX idx_package_versions_package_version ON package_versions(package_id, version);
END IF;
END IF;
END $$;
""",
# Create ref_count trigger functions for package_versions
"""
CREATE OR REPLACE FUNCTION increment_version_ref_count()
RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""",
"""
CREATE OR REPLACE FUNCTION decrement_version_ref_count()
RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
""",
# Create triggers for package_versions ref_count
"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Drop and recreate triggers to ensure they're current
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
CREATE TRIGGER package_versions_ref_count_insert
AFTER INSERT ON package_versions
FOR EACH ROW
EXECUTE FUNCTION increment_version_ref_count();
DROP TRIGGER IF EXISTS package_versions_ref_count_delete ON package_versions;
CREATE TRIGGER package_versions_ref_count_delete
AFTER DELETE ON package_versions
FOR EACH ROW
EXECUTE FUNCTION decrement_version_ref_count();
END IF;
END $$;
""",
# Migrate existing semver tags to package_versions
"""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'package_versions') THEN
-- Migrate tags that look like versions (v1.0.0, 1.2.3, 2.0.0-beta, etc.)
INSERT INTO package_versions (package_id, artifact_id, version, version_source, created_by, created_at)
SELECT
t.package_id,
t.artifact_id,
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END,
'migrated_from_tag',
t.created_by,
t.created_at
FROM tags t
WHERE t.name ~ '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-.][a-zA-Z0-9]+)?$'
ON CONFLICT (package_id, version) DO NOTHING;
END IF;
END $$;
""",
] ]
with engine.connect() as conn: with engine.connect() as conn:

View File

@@ -245,9 +245,10 @@ def extract_tarball_metadata(file: BinaryIO, filename: str) -> Dict[str, Any]:
break break
# Try to split name and version # Try to split name and version
# Handle optional 'v' prefix on version (e.g., package-v1.0.0)
patterns = [ patterns = [
r"^(.+)-(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name-version r"^(.+)-v?(\d+\.\d+(?:\.\d+)?(?:[-_]\w+)?)$", # name-version or name-vversion
r"^(.+)_(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name_version r"^(.+)_v?(\d+\.\d+(?:\.\d+)?(?:[-_]\w+)?)$", # name_version or name_vversion
] ]
for pattern in patterns: for pattern in patterns:

View File

@@ -72,6 +72,9 @@ class Package(Base):
consumers = relationship( consumers = relationship(
"Consumer", back_populates="package", cascade="all, delete-orphan" "Consumer", back_populates="package", cascade="all, delete-orphan"
) )
versions = relationship(
"PackageVersion", back_populates="package", cascade="all, delete-orphan"
)
__table_args__ = ( __table_args__ = (
Index("idx_packages_project_id", "project_id"), Index("idx_packages_project_id", "project_id"),
@@ -113,6 +116,7 @@ class Artifact(Base):
tags = relationship("Tag", back_populates="artifact") tags = relationship("Tag", back_populates="artifact")
uploads = relationship("Upload", back_populates="artifact") uploads = relationship("Upload", back_populates="artifact")
versions = relationship("PackageVersion", back_populates="artifact")
@property @property
def sha256(self) -> str: def sha256(self) -> str:
@@ -197,6 +201,38 @@ class TagHistory(Base):
) )
class PackageVersion(Base):
"""Immutable version record for a package-artifact relationship.
Separates versions (immutable, set at upload) from tags (mutable labels).
Each artifact in a package can have at most one version.
"""
__tablename__ = "package_versions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
package_id = Column(
UUID(as_uuid=True),
ForeignKey("packages.id", ondelete="CASCADE"),
nullable=False,
)
artifact_id = Column(String(64), ForeignKey("artifacts.id"), nullable=False)
version = Column(String(255), nullable=False)
version_source = Column(String(50)) # 'explicit', 'filename', 'metadata', 'migrated_from_tag'
created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
created_by = Column(String(255), nullable=False)
package = relationship("Package", back_populates="versions")
artifact = relationship("Artifact", back_populates="versions")
__table_args__ = (
Index("idx_package_versions_package_id", "package_id"),
Index("idx_package_versions_artifact_id", "artifact_id"),
Index("idx_package_versions_package_version", "package_id", "version", unique=True),
Index("idx_package_versions_package_artifact", "package_id", "artifact_id", unique=True),
)
class Upload(Base): class Upload(Base):
__tablename__ = "uploads" __tablename__ = "uploads"

View File

@@ -16,7 +16,7 @@ from fastapi import (
) )
from fastapi.responses import StreamingResponse, RedirectResponse from fastapi.responses import StreamingResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_, func, text from sqlalchemy import or_, and_, func, text
from typing import List, Optional, Literal from typing import List, Optional, Literal
import math import math
import io import io
@@ -46,6 +46,7 @@ from .models import (
AuditLog, AuditLog,
User, User,
AccessPermission, AccessPermission,
PackageVersion,
) )
from .schemas import ( from .schemas import (
ProjectCreate, ProjectCreate,
@@ -116,6 +117,8 @@ from .schemas import (
OIDCConfigUpdate, OIDCConfigUpdate,
OIDCStatusResponse, OIDCStatusResponse,
OIDCLoginResponse, OIDCLoginResponse,
PackageVersionResponse,
PackageVersionDetailResponse,
) )
from .metadata import extract_metadata from .metadata import extract_metadata
from .config import get_settings from .config import get_settings
@@ -237,6 +240,103 @@ def _decrement_ref_count(db: Session, artifact_id: str) -> int:
return artifact.ref_count return artifact.ref_count
import re
# Regex pattern for detecting version in filename
# Matches: name-1.0.0, name_1.0.0, name-v1.0.0, etc.
# Supports: X.Y, X.Y.Z, X.Y.Z-alpha, X.Y.Z.beta1, X.Y.Z_rc2
VERSION_FILENAME_PATTERN = re.compile(
r"[-_]v?(\d+\.\d+(?:\.\d+)?(?:[-_][a-zA-Z0-9]+)?)(?:\.tar|\.zip|\.tgz|\.gz|\.bz2|\.xz|$)"
)
def _detect_version(
explicit_version: Optional[str],
metadata: dict,
filename: str,
) -> tuple[Optional[str], Optional[str]]:
"""
Detect version from explicit parameter, metadata, or filename.
Priority:
1. Explicit version parameter (user provided)
2. Version from package metadata (deb, rpm, whl, jar)
3. Version from filename pattern
Returns:
tuple of (version, source) where source is one of:
- 'explicit': User provided the version
- 'metadata': Extracted from package metadata
- 'filename': Parsed from filename
- None: No version could be determined
"""
# 1. Explicit version takes priority
if explicit_version:
return explicit_version, "explicit"
# 2. Try metadata extraction (from deb, rpm, whl, jar, etc.)
if metadata.get("version"):
return metadata["version"], "metadata"
# 3. Try filename pattern matching
match = VERSION_FILENAME_PATTERN.search(filename)
if match:
return match.group(1), "filename"
return None, None
def _create_or_update_version(
db: Session,
package_id: str,
artifact_id: str,
version: str,
version_source: str,
user_id: str,
) -> PackageVersion:
"""
Create a version record for a package-artifact pair.
Raises HTTPException 409 if version already exists for this package.
"""
# Check if version already exists
existing = (
db.query(PackageVersion)
.filter(PackageVersion.package_id == package_id, PackageVersion.version == version)
.first()
)
if existing:
raise HTTPException(
status_code=409,
detail=f"Version {version} already exists in this package",
)
# Check if artifact already has a version in this package
existing_artifact_version = (
db.query(PackageVersion)
.filter(
PackageVersion.package_id == package_id,
PackageVersion.artifact_id == artifact_id,
)
.first()
)
if existing_artifact_version:
# Artifact already has a version, return it
return existing_artifact_version
# Create new version record
pkg_version = PackageVersion(
package_id=package_id,
artifact_id=artifact_id,
version=version,
version_source=version_source,
created_by=user_id,
)
db.add(pkg_version)
db.flush()
return pkg_version
def _create_or_update_tag( def _create_or_update_tag(
db: Session, db: Session,
package_id: str, package_id: str,
@@ -2147,6 +2247,7 @@ def upload_artifact(
request: Request, request: Request,
file: UploadFile = File(...), file: UploadFile = File(...),
tag: Optional[str] = Form(None), tag: Optional[str] = Form(None),
version: Optional[str] = Form(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
storage: S3Storage = Depends(get_storage), storage: S3Storage = Depends(get_storage),
content_length: Optional[int] = Header(None, alias="Content-Length"), content_length: Optional[int] = Header(None, alias="Content-Length"),
@@ -2214,6 +2315,11 @@ def upload_artifact(
io.BytesIO(file_content), file.filename, file.content_type io.BytesIO(file_content), file.filename, file.content_type
) )
# Detect version (explicit > metadata > filename)
detected_version, version_source = _detect_version(
version, file_metadata, file.filename or ""
)
# Store file (uses multipart for large files) with error handling # Store file (uses multipart for large files) with error handling
try: try:
storage_result = storage.store(file.file, content_length) storage_result = storage.store(file.file, content_length)
@@ -2383,6 +2489,25 @@ def upload_artifact(
if tag: if tag:
_create_or_update_tag(db, package.id, tag, storage_result.sha256, user_id) _create_or_update_tag(db, package.id, tag, storage_result.sha256, user_id)
# Create version record if version was detected
pkg_version = None
if detected_version:
try:
pkg_version = _create_or_update_version(
db, package.id, storage_result.sha256, detected_version, version_source, user_id
)
except HTTPException as e:
# Version conflict (409) - log but don't fail the upload
if e.status_code == 409:
logger.warning(
f"Version {detected_version} already exists for package {package_name}, "
f"upload continues without version assignment"
)
detected_version = None
version_source = None
else:
raise
# Log deduplication event # Log deduplication event
if deduplicated: if deduplicated:
logger.info( logger.info(
@@ -2437,6 +2562,8 @@ def upload_artifact(
project=project_name, project=project_name,
package=package_name, package=package_name,
tag=tag, tag=tag,
version=detected_version,
version_source=version_source,
checksum_md5=storage_result.md5, checksum_md5=storage_result.md5,
checksum_sha1=storage_result.sha1, checksum_sha1=storage_result.sha1,
s3_etag=storage_result.s3_etag, s3_etag=storage_result.s3_etag,
@@ -2754,15 +2881,30 @@ def _resolve_artifact_ref(
package: Package, package: Package,
db: Session, db: Session,
) -> Optional[Artifact]: ) -> Optional[Artifact]:
"""Resolve a reference (tag name, artifact:hash, tag:name) to an artifact""" """Resolve a reference (tag name, version, artifact:hash, tag:name, version:X.Y.Z) to an artifact.
Resolution order for implicit refs (no prefix):
1. Version (immutable)
2. Tag (mutable)
3. Artifact ID (direct hash)
"""
artifact = None artifact = None
# Check for explicit prefixes # Check for explicit prefixes
if ref.startswith("artifact:"): if ref.startswith("artifact:"):
artifact_id = ref[9:] artifact_id = ref[9:]
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
elif ref.startswith("tag:") or ref.startswith("version:"): elif ref.startswith("version:"):
tag_name = ref.split(":", 1)[1] version_str = ref[8:]
pkg_version = (
db.query(PackageVersion)
.filter(PackageVersion.package_id == package.id, PackageVersion.version == version_str)
.first()
)
if pkg_version:
artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first()
elif ref.startswith("tag:"):
tag_name = ref[4:]
tag = ( tag = (
db.query(Tag) db.query(Tag)
.filter(Tag.package_id == package.id, Tag.name == tag_name) .filter(Tag.package_id == package.id, Tag.name == tag_name)
@@ -2771,15 +2913,25 @@ def _resolve_artifact_ref(
if tag: if tag:
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first() artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
else: else:
# Try as tag name first # Implicit ref: try version first, then tag, then artifact ID
tag = ( # Try as version first
db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first() pkg_version = (
db.query(PackageVersion)
.filter(PackageVersion.package_id == package.id, PackageVersion.version == ref)
.first()
) )
if tag: if pkg_version:
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first() artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first()
else: else:
# Try as direct artifact ID # Try as tag name
artifact = db.query(Artifact).filter(Artifact.id == ref).first() tag = (
db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first()
)
if tag:
artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first()
else:
# Try as direct artifact ID
artifact = db.query(Artifact).filter(Artifact.id == ref).first()
return artifact return artifact
@@ -3177,6 +3329,224 @@ def download_artifact_compat(
) )
# Version routes
@router.get(
"/api/v1/project/{project_name}/{package_name}/versions",
response_model=PaginatedResponse[PackageVersionResponse],
)
def list_versions(
project_name: str,
package_name: str,
page: int = Query(default=1, ge=1, description="Page number"),
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(default=None, description="Search by version string"),
sort: str = Query(default="version", description="Sort field (version, created_at)"),
order: str = Query(default="desc", description="Sort order (asc, desc)"),
db: Session = Depends(get_db),
):
"""List all versions for a package."""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = (
db.query(Package)
.filter(Package.project_id == project.id, Package.name == package_name)
.first()
)
if not package:
raise HTTPException(status_code=404, detail="Package not found")
# Validate sort field
valid_sort_fields = {"version": PackageVersion.version, "created_at": PackageVersion.created_at}
if sort not in valid_sort_fields:
raise HTTPException(
status_code=400,
detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}",
)
# Validate order
if order not in ("asc", "desc"):
raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'")
# Base query with JOIN to artifact for metadata
query = (
db.query(PackageVersion, Artifact)
.join(Artifact, PackageVersion.artifact_id == Artifact.id)
.filter(PackageVersion.package_id == package.id)
)
# Apply search filter
if search:
query = query.filter(PackageVersion.version.ilike(f"%{search}%"))
# Get total count before pagination
total = query.count()
# Apply sorting
sort_column = valid_sort_fields[sort]
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
# Apply pagination
offset = (page - 1) * limit
results = query.offset(offset).limit(limit).all()
# Get tags for each version's artifact
version_responses = []
for pkg_version, artifact in results:
# Get tags pointing to this artifact in this package
tags = (
db.query(Tag.name)
.filter(Tag.package_id == package.id, Tag.artifact_id == artifact.id)
.all()
)
tag_names = [t[0] for t in tags]
version_responses.append(
PackageVersionResponse(
id=pkg_version.id,
package_id=pkg_version.package_id,
artifact_id=pkg_version.artifact_id,
version=pkg_version.version,
version_source=pkg_version.version_source,
created_at=pkg_version.created_at,
created_by=pkg_version.created_by,
size=artifact.size,
content_type=artifact.content_type,
original_name=artifact.original_name,
tags=tag_names,
)
)
total_pages = math.ceil(total / limit) if total > 0 else 1
has_more = page < total_pages
return PaginatedResponse(
items=version_responses,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
has_more=has_more,
),
)
@router.get(
"/api/v1/project/{project_name}/{package_name}/versions/{version}",
response_model=PackageVersionDetailResponse,
)
def get_version(
project_name: str,
package_name: str,
version: str,
db: Session = Depends(get_db),
):
"""Get details of a specific version."""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = (
db.query(Package)
.filter(Package.project_id == project.id, Package.name == package_name)
.first()
)
if not package:
raise HTTPException(status_code=404, detail="Package not found")
pkg_version = (
db.query(PackageVersion)
.filter(PackageVersion.package_id == package.id, PackageVersion.version == version)
.first()
)
if not pkg_version:
raise HTTPException(status_code=404, detail="Version not found")
artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first()
# Get tags pointing to this artifact
tags = (
db.query(Tag.name)
.filter(Tag.package_id == package.id, Tag.artifact_id == artifact.id)
.all()
)
tag_names = [t[0] for t in tags]
return PackageVersionDetailResponse(
id=pkg_version.id,
package_id=pkg_version.package_id,
artifact_id=pkg_version.artifact_id,
version=pkg_version.version,
version_source=pkg_version.version_source,
created_at=pkg_version.created_at,
created_by=pkg_version.created_by,
size=artifact.size,
content_type=artifact.content_type,
original_name=artifact.original_name,
tags=tag_names,
format_metadata=artifact.artifact_metadata,
checksum_md5=artifact.checksum_md5,
checksum_sha1=artifact.checksum_sha1,
)
@router.delete(
"/api/v1/project/{project_name}/{package_name}/versions/{version}",
status_code=204,
)
def delete_version(
project_name: str,
package_name: str,
version: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Delete a version (admin only). Does not delete the underlying artifact."""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = (
db.query(Package)
.filter(Package.project_id == project.id, Package.name == package_name)
.first()
)
if not package:
raise HTTPException(status_code=404, detail="Package not found")
pkg_version = (
db.query(PackageVersion)
.filter(PackageVersion.package_id == package.id, PackageVersion.version == version)
.first()
)
if not pkg_version:
raise HTTPException(status_code=404, detail="Version not found")
artifact_id = pkg_version.artifact_id
# Delete version (triggers will decrement ref_count)
db.delete(pkg_version)
# Audit log
_log_audit(
db,
action="version.delete",
resource=f"project/{project_name}/{package_name}/version/{version}",
user_id=current_user.username,
source_ip=request.client.host if request.client else None,
details={"artifact_id": artifact_id},
)
db.commit()
return Response(status_code=204)
# Tag routes # Tag routes
@router.get( @router.get(
"/api/v1/project/{project_name}/{package_name}/tags", "/api/v1/project/{project_name}/{package_name}/tags",
@@ -3224,10 +3594,17 @@ def list_tags(
status_code=400, detail="Invalid order. Must be 'asc' or 'desc'" status_code=400, detail="Invalid order. Must be 'asc' or 'desc'"
) )
# Base query with JOIN to artifact for metadata # Base query with JOIN to artifact for metadata and LEFT JOIN to version
query = ( query = (
db.query(Tag, Artifact) db.query(Tag, Artifact, PackageVersion.version)
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, Tag.artifact_id == Artifact.id)
.outerjoin(
PackageVersion,
and_(
PackageVersion.package_id == Tag.package_id,
PackageVersion.artifact_id == Tag.artifact_id,
),
)
.filter(Tag.package_id == package.id) .filter(Tag.package_id == package.id)
) )
@@ -3264,9 +3641,9 @@ def list_tags(
# Calculate total pages # Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1 total_pages = math.ceil(total / limit) if total > 0 else 1
# Build detailed responses with artifact metadata # Build detailed responses with artifact metadata and version
detailed_tags = [] detailed_tags = []
for tag, artifact in results: for tag, artifact, version in results:
detailed_tags.append( detailed_tags.append(
TagDetailResponse( TagDetailResponse(
id=tag.id, id=tag.id,
@@ -3280,6 +3657,7 @@ def list_tags(
artifact_original_name=artifact.original_name, artifact_original_name=artifact.original_name,
artifact_created_at=artifact.created_at, artifact_created_at=artifact.created_at,
artifact_format_metadata=artifact.format_metadata, artifact_format_metadata=artifact.format_metadata,
version=version,
) )
) )
@@ -3396,8 +3774,15 @@ def get_tag(
raise HTTPException(status_code=404, detail="Package not found") raise HTTPException(status_code=404, detail="Package not found")
result = ( result = (
db.query(Tag, Artifact) db.query(Tag, Artifact, PackageVersion.version)
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, Tag.artifact_id == Artifact.id)
.outerjoin(
PackageVersion,
and_(
PackageVersion.package_id == Tag.package_id,
PackageVersion.artifact_id == Tag.artifact_id,
),
)
.filter(Tag.package_id == package.id, Tag.name == tag_name) .filter(Tag.package_id == package.id, Tag.name == tag_name)
.first() .first()
) )
@@ -3405,7 +3790,7 @@ def get_tag(
if not result: if not result:
raise HTTPException(status_code=404, detail="Tag not found") raise HTTPException(status_code=404, detail="Tag not found")
tag, artifact = result tag, artifact, version = result
return TagDetailResponse( return TagDetailResponse(
id=tag.id, id=tag.id,
package_id=tag.package_id, package_id=tag.package_id,
@@ -3418,6 +3803,7 @@ def get_tag(
artifact_original_name=artifact.original_name, artifact_original_name=artifact.original_name,
artifact_created_at=artifact.created_at, artifact_created_at=artifact.created_at,
artifact_format_metadata=artifact.format_metadata, artifact_format_metadata=artifact.format_metadata,
version=version,
) )
@@ -3915,10 +4301,17 @@ def list_all_tags(
List all tags globally with filtering by project, package, name, etc. List all tags globally with filtering by project, package, name, etc.
""" """
query = ( query = (
db.query(Tag, Package, Project, Artifact) db.query(Tag, Package, Project, Artifact, PackageVersion.version)
.join(Package, Tag.package_id == Package.id) .join(Package, Tag.package_id == Package.id)
.join(Project, Package.project_id == Project.id) .join(Project, Package.project_id == Project.id)
.join(Artifact, Tag.artifact_id == Artifact.id) .join(Artifact, Tag.artifact_id == Artifact.id)
.outerjoin(
PackageVersion,
and_(
PackageVersion.package_id == Tag.package_id,
PackageVersion.artifact_id == Tag.artifact_id,
),
)
) )
# Apply filters # Apply filters
@@ -3982,8 +4375,9 @@ def list_all_tags(
package_name=pkg.name, package_name=pkg.name,
artifact_size=artifact.size, artifact_size=artifact.size,
artifact_content_type=artifact.content_type, artifact_content_type=artifact.content_type,
version=version,
) )
for tag, pkg, proj, artifact in results for tag, pkg, proj, artifact, version in results
] ]
return PaginatedResponse( return PaginatedResponse(

View File

@@ -173,6 +173,7 @@ class TagResponse(BaseModel):
artifact_id: str artifact_id: str
created_at: datetime created_at: datetime
created_by: str created_by: str
version: Optional[str] = None # Version of the artifact this tag points to
class Config: class Config:
from_attributes = True from_attributes = True
@@ -187,6 +188,7 @@ class TagDetailResponse(BaseModel):
artifact_id: str artifact_id: str
created_at: datetime created_at: datetime
created_by: str created_by: str
version: Optional[str] = None # Version of the artifact this tag points to
# Artifact metadata # Artifact metadata
artifact_size: int artifact_size: int
artifact_content_type: Optional[str] artifact_content_type: Optional[str]
@@ -383,6 +385,7 @@ class GlobalTagResponse(BaseModel):
package_name: str package_name: str
artifact_size: Optional[int] = None artifact_size: Optional[int] = None
artifact_content_type: Optional[str] = None artifact_content_type: Optional[str] = None
version: Optional[str] = None # Version of the artifact this tag points to
class Config: class Config:
from_attributes = True from_attributes = True
@@ -396,6 +399,8 @@ class UploadResponse(BaseModel):
project: str project: str
package: str package: str
tag: Optional[str] tag: Optional[str]
version: Optional[str] = None # Version assigned to this artifact
version_source: Optional[str] = None # How version was determined: 'explicit', 'filename', 'metadata'
checksum_md5: Optional[str] = None checksum_md5: Optional[str] = None
checksum_sha1: Optional[str] = None checksum_sha1: Optional[str] = None
s3_etag: Optional[str] = None s3_etag: Optional[str] = None
@@ -418,6 +423,7 @@ class ResumableUploadInitRequest(BaseModel):
content_type: Optional[str] = None content_type: Optional[str] = None
size: int size: int
tag: Optional[str] = None tag: Optional[str] = None
version: Optional[str] = None # Explicit version (auto-detected if not provided)
@field_validator("expected_hash") @field_validator("expected_hash")
@classmethod @classmethod
@@ -484,6 +490,35 @@ class ConsumerResponse(BaseModel):
from_attributes = True from_attributes = True
# Package version schemas
class PackageVersionResponse(BaseModel):
"""Immutable version record for an artifact in a package"""
id: UUID
package_id: UUID
artifact_id: str
version: str
version_source: Optional[str] = None # 'explicit', 'filename', 'metadata', 'migrated_from_tag'
created_at: datetime
created_by: str
# Enriched fields from joins
size: Optional[int] = None
content_type: Optional[str] = None
original_name: Optional[str] = None
tags: List[str] = [] # Tag names pointing to this artifact
class Config:
from_attributes = True
class PackageVersionDetailResponse(PackageVersionResponse):
"""Version with full artifact metadata"""
format_metadata: Optional[Dict[str, Any]] = None
checksum_md5: Optional[str] = None
checksum_sha1: Optional[str] = None
# Global search schemas # Global search schemas
class SearchResultProject(BaseModel): class SearchResultProject(BaseModel):
"""Project result for global search""" """Project result for global search"""

View File

@@ -5,7 +5,7 @@ import hashlib
import logging import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload from .models import Project, Package, Artifact, Tag, Upload, PackageVersion
from .storage import get_storage from .storage import get_storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -74,7 +74,7 @@ TEST_PROJECTS = [
}, },
] ]
# Sample artifacts to create (content, tags) # Sample artifacts to create (content, tags, version)
TEST_ARTIFACTS = [ TEST_ARTIFACTS = [
{ {
"project": "frontend-libs", "project": "frontend-libs",
@@ -83,6 +83,7 @@ TEST_ARTIFACTS = [
"filename": "ui-components-1.0.0.js", "filename": "ui-components-1.0.0.js",
"content_type": "application/javascript", "content_type": "application/javascript",
"tags": ["v1.0.0", "latest"], "tags": ["v1.0.0", "latest"],
"version": "1.0.0",
}, },
{ {
"project": "frontend-libs", "project": "frontend-libs",
@@ -91,6 +92,7 @@ TEST_ARTIFACTS = [
"filename": "ui-components-1.1.0.js", "filename": "ui-components-1.1.0.js",
"content_type": "application/javascript", "content_type": "application/javascript",
"tags": ["v1.1.0"], "tags": ["v1.1.0"],
"version": "1.1.0",
}, },
{ {
"project": "frontend-libs", "project": "frontend-libs",
@@ -99,6 +101,7 @@ TEST_ARTIFACTS = [
"filename": "tokens.json", "filename": "tokens.json",
"content_type": "application/json", "content_type": "application/json",
"tags": ["v1.0.0", "latest"], "tags": ["v1.0.0", "latest"],
"version": "1.0.0",
}, },
{ {
"project": "backend-services", "project": "backend-services",
@@ -107,6 +110,7 @@ TEST_ARTIFACTS = [
"filename": "utils-2.0.0.py", "filename": "utils-2.0.0.py",
"content_type": "text/x-python", "content_type": "text/x-python",
"tags": ["v2.0.0", "stable", "latest"], "tags": ["v2.0.0", "stable", "latest"],
"version": "2.0.0",
}, },
{ {
"project": "backend-services", "project": "backend-services",
@@ -115,6 +119,7 @@ TEST_ARTIFACTS = [
"filename": "auth-lib-1.0.0.go", "filename": "auth-lib-1.0.0.go",
"content_type": "text/x-go", "content_type": "text/x-go",
"tags": ["v1.0.0", "latest"], "tags": ["v1.0.0", "latest"],
"version": "1.0.0",
}, },
] ]
@@ -160,9 +165,10 @@ def seed_database(db: Session) -> None:
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages") logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages")
# Create artifacts and tags # Create artifacts, tags, and versions
artifact_count = 0 artifact_count = 0
tag_count = 0 tag_count = 0
version_count = 0
for artifact_data in TEST_ARTIFACTS: for artifact_data in TEST_ARTIFACTS:
project = project_map[artifact_data["project"]] project = project_map[artifact_data["project"]]
@@ -184,6 +190,11 @@ def seed_database(db: Session) -> None:
logger.warning(f"Failed to store artifact in S3: {e}") logger.warning(f"Failed to store artifact in S3: {e}")
continue continue
# Calculate ref_count: tags + version (if present)
ref_count = len(artifact_data["tags"])
if artifact_data.get("version"):
ref_count += 1
# Create artifact record # Create artifact record
artifact = Artifact( artifact = Artifact(
id=sha256_hash, id=sha256_hash,
@@ -192,7 +203,7 @@ def seed_database(db: Session) -> None:
original_name=artifact_data["filename"], original_name=artifact_data["filename"],
created_by="seed-user", created_by="seed-user",
s3_key=s3_key, s3_key=s3_key,
ref_count=len(artifact_data["tags"]), ref_count=ref_count,
) )
db.add(artifact) db.add(artifact)
@@ -206,6 +217,18 @@ def seed_database(db: Session) -> None:
db.add(upload) db.add(upload)
artifact_count += 1 artifact_count += 1
# Create version record if specified
if artifact_data.get("version"):
version = PackageVersion(
package_id=package.id,
artifact_id=sha256_hash,
version=artifact_data["version"],
version_source="explicit",
created_by="seed-user",
)
db.add(version)
version_count += 1
# Create tags # Create tags
for tag_name in artifact_data["tags"]: for tag_name in artifact_data["tags"]:
tag = Tag( tag = Tag(
@@ -218,5 +241,5 @@ def seed_database(db: Session) -> None:
tag_count += 1 tag_count += 1
db.commit() db.commit()
logger.info(f"Created {artifact_count} artifacts and {tag_count} tags") logger.info(f"Created {artifact_count} artifacts, {tag_count} tags, and {version_count} versions")
logger.info("Database seeding complete") logger.info("Database seeding complete")

View File

@@ -97,6 +97,7 @@ def upload_test_file(
content: bytes, content: bytes,
filename: str = "test.bin", filename: str = "test.bin",
tag: Optional[str] = None, tag: Optional[str] = None,
version: Optional[str] = None,
) -> dict: ) -> dict:
""" """
Helper function to upload a test file via the API. Helper function to upload a test file via the API.
@@ -108,6 +109,7 @@ def upload_test_file(
content: File content as bytes content: File content as bytes
filename: Original filename filename: Original filename
tag: Optional tag to assign tag: Optional tag to assign
version: Optional version to assign
Returns: Returns:
The upload response as a dict The upload response as a dict
@@ -116,6 +118,8 @@ def upload_test_file(
data = {} data = {}
if tag: if tag:
data["tag"] = tag data["tag"] = tag
if version:
data["version"] = version
response = client.post( response = client.post(
f"/api/v1/project/{project}/{package}/upload", f"/api/v1/project/{project}/{package}/upload",

View File

@@ -0,0 +1,412 @@
"""
Integration tests for version API endpoints.
Tests cover:
- Version creation via upload
- Version auto-detection from filename
- Version listing with pagination
- Version deletion
- Download by version ref
- ref_count behavior with version operations
"""
import pytest
from tests.factories import upload_test_file
class TestVersionCreation:
"""Tests for version creation during upload."""
@pytest.mark.integration
def test_upload_with_explicit_version(self, integration_client, test_package):
"""Test creating a version via explicit version parameter."""
project_name, package_name = test_package
result = upload_test_file(
integration_client,
project_name,
package_name,
b"version create test",
tag="latest",
version="1.0.0",
)
assert result["tag"] == "latest"
assert result["version"] == "1.0.0"
assert result["version_source"] == "explicit"
assert result["artifact_id"]
@pytest.mark.integration
def test_upload_with_version_auto_detect_from_tarball(
self, integration_client, test_package
):
"""Test version auto-detection from tarball filename pattern."""
project_name, package_name = test_package
result = upload_test_file(
integration_client,
project_name,
package_name,
b"auto version test",
filename="myapp-2.1.0.tar.gz",
)
assert result["version"] == "2.1.0"
# Tarball metadata extractor parses version from filename
assert result["version_source"] == "metadata"
@pytest.mark.integration
def test_upload_with_version_auto_detect_v_prefix(
self, integration_client, test_package
):
"""Test version auto-detection strips 'v' prefix from tarball filename."""
project_name, package_name = test_package
result = upload_test_file(
integration_client,
project_name,
package_name,
b"v prefix test",
filename="package-v3.0.0.tar.gz",
)
assert result["version"] == "3.0.0"
# Tarball metadata extractor parses version from filename
assert result["version_source"] == "metadata"
@pytest.mark.integration
def test_upload_duplicate_version_warning(self, integration_client, test_package):
"""Test that duplicate version during upload returns response without error."""
project_name, package_name = test_package
# Upload with version 1.0.0
upload_test_file(
integration_client,
project_name,
package_name,
b"first upload",
version="1.0.0",
)
# Upload different content with same version - should succeed but no new version
result = upload_test_file(
integration_client,
project_name,
package_name,
b"second upload different content",
version="1.0.0",
)
# Upload succeeds but version may not be set (duplicate)
assert result["artifact_id"]
class TestVersionCRUD:
"""Tests for version list, get, delete operations."""
@pytest.mark.integration
def test_list_versions(self, integration_client, test_package):
"""Test listing versions for a package."""
project_name, package_name = test_package
# Create some versions
upload_test_file(
integration_client,
project_name,
package_name,
b"v1 content",
version="1.0.0",
)
upload_test_file(
integration_client,
project_name,
package_name,
b"v2 content",
version="2.0.0",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions"
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "pagination" in data
versions = [v["version"] for v in data["items"]]
assert "1.0.0" in versions
assert "2.0.0" in versions
@pytest.mark.integration
def test_list_versions_with_artifact_info(self, integration_client, test_package):
"""Test that version list includes artifact metadata."""
project_name, package_name = test_package
upload_test_file(
integration_client,
project_name,
package_name,
b"version with info",
version="1.0.0",
tag="release",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions"
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) >= 1
version_item = next(
(v for v in data["items"] if v["version"] == "1.0.0"), None
)
assert version_item is not None
assert "size" in version_item
assert "artifact_id" in version_item
assert "tags" in version_item
assert "release" in version_item["tags"]
@pytest.mark.integration
def test_get_version(self, integration_client, test_package):
"""Test getting a specific version."""
project_name, package_name = test_package
upload_result = upload_test_file(
integration_client,
project_name,
package_name,
b"get version test",
version="3.0.0",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions/3.0.0"
)
assert response.status_code == 200
data = response.json()
assert data["version"] == "3.0.0"
assert data["artifact_id"] == upload_result["artifact_id"]
assert data["version_source"] == "explicit"
@pytest.mark.integration
def test_get_version_not_found(self, integration_client, test_package):
"""Test getting a non-existent version returns 404."""
project_name, package_name = test_package
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions/99.99.99"
)
assert response.status_code == 404
@pytest.mark.integration
def test_delete_version(self, integration_client, test_package):
"""Test deleting a version."""
project_name, package_name = test_package
upload_test_file(
integration_client,
project_name,
package_name,
b"delete version test",
version="4.0.0",
)
# Delete version
response = integration_client.delete(
f"/api/v1/project/{project_name}/{package_name}/versions/4.0.0"
)
assert response.status_code == 204
# Verify deleted
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions/4.0.0"
)
assert response.status_code == 404
class TestVersionDownload:
"""Tests for downloading artifacts by version reference."""
@pytest.mark.integration
def test_download_by_version_prefix(self, integration_client, test_package):
"""Test downloading an artifact using version: prefix."""
project_name, package_name = test_package
content = b"download by version test"
upload_test_file(
integration_client,
project_name,
package_name,
content,
version="5.0.0",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/+/version:5.0.0",
follow_redirects=False,
)
# Should either redirect or return content
assert response.status_code in [200, 302, 307]
@pytest.mark.integration
def test_download_by_implicit_version(self, integration_client, test_package):
"""Test downloading an artifact using version number directly (no prefix)."""
project_name, package_name = test_package
content = b"implicit version download test"
upload_test_file(
integration_client,
project_name,
package_name,
content,
version="6.0.0",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/+/6.0.0",
follow_redirects=False,
)
# Should resolve version first (before tag)
assert response.status_code in [200, 302, 307]
@pytest.mark.integration
def test_version_takes_precedence_over_tag(self, integration_client, test_package):
"""Test that version is checked before tag when resolving refs."""
project_name, package_name = test_package
# Upload with version "1.0"
version_result = upload_test_file(
integration_client,
project_name,
package_name,
b"version content",
version="1.0",
)
# Create a tag with the same name "1.0" pointing to different artifact
tag_result = upload_test_file(
integration_client,
project_name,
package_name,
b"tag content different",
tag="1.0",
)
# Download by "1.0" should resolve to version, not tag
# Since version:1.0 artifact was uploaded first
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/+/1.0",
follow_redirects=False,
)
assert response.status_code in [200, 302, 307]
class TestTagVersionEnrichment:
"""Tests for tag responses including version information."""
@pytest.mark.integration
def test_tag_response_includes_version(self, integration_client, test_package):
"""Test that tag responses include version of the artifact."""
project_name, package_name = test_package
# Upload with both version and tag
upload_test_file(
integration_client,
project_name,
package_name,
b"enriched tag test",
version="7.0.0",
tag="stable",
)
# Get tag and check version field
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags/stable"
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "stable"
assert data["version"] == "7.0.0"
@pytest.mark.integration
def test_tag_list_includes_versions(self, integration_client, test_package):
"""Test that tag list responses include version for each tag."""
project_name, package_name = test_package
upload_test_file(
integration_client,
project_name,
package_name,
b"list version test",
version="8.0.0",
tag="latest",
)
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/tags"
)
assert response.status_code == 200
data = response.json()
tag_item = next((t for t in data["items"] if t["name"] == "latest"), None)
assert tag_item is not None
assert tag_item.get("version") == "8.0.0"
class TestVersionPagination:
"""Tests for version listing pagination and sorting."""
@pytest.mark.integration
def test_versions_pagination(self, integration_client, test_package):
"""Test version listing respects pagination."""
project_name, package_name = test_package
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions?limit=5"
)
assert response.status_code == 200
data = response.json()
assert "pagination" in data
assert data["pagination"]["limit"] == 5
@pytest.mark.integration
def test_versions_sorting(self, integration_client, test_package):
"""Test version listing can be sorted."""
project_name, package_name = test_package
# Create versions with different timestamps
upload_test_file(
integration_client,
project_name,
package_name,
b"sort test 1",
version="1.0.0",
)
upload_test_file(
integration_client,
project_name,
package_name,
b"sort test 2",
version="2.0.0",
)
# Test ascending sort
response = integration_client.get(
f"/api/v1/project/{project_name}/{package_name}/versions?sort=version&order=asc"
)
assert response.status_code == 200
data = response.json()
versions = [v["version"] for v in data["items"]]
# First version should be 1.0.0 when sorted ascending
if len(versions) >= 2:
assert versions.index("1.0.0") < versions.index("2.0.0")

View File

@@ -32,6 +32,7 @@ import {
OIDCConfig, OIDCConfig,
OIDCConfigUpdate, OIDCConfigUpdate,
OIDCStatus, OIDCStatus,
PackageVersion,
} from './types'; } from './types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
@@ -239,12 +240,21 @@ export async function listPackageArtifacts(
} }
// Upload // Upload
export async function uploadArtifact(projectName: string, packageName: string, file: File, tag?: string): Promise<UploadResponse> { export async function uploadArtifact(
projectName: string,
packageName: string,
file: File,
tag?: string,
version?: string
): Promise<UploadResponse> {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (tag) { if (tag) {
formData.append('tag', tag); formData.append('tag', tag);
} }
if (version) {
formData.append('version', version);
}
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/upload`, { const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/upload`, {
method: 'POST', method: 'POST',
@@ -443,3 +453,38 @@ export function getOIDCLoginUrl(returnTo?: string): string {
const query = params.toString(); const query = params.toString();
return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`; return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`;
} }
// Version API
export async function listVersions(
projectName: string,
packageName: string,
params: ListParams = {}
): Promise<PaginatedResponse<PackageVersion>> {
const query = buildQueryString(params as Record<string, unknown>);
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions${query}`);
return handleResponse<PaginatedResponse<PackageVersion>>(response);
}
export async function getVersion(
projectName: string,
packageName: string,
version: string
): Promise<PackageVersion> {
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions/${version}`);
return handleResponse<PackageVersion>(response);
}
export async function deleteVersion(
projectName: string,
packageName: string,
version: string
): Promise<void> {
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions/${version}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}

View File

@@ -324,6 +324,86 @@ tr:hover .copy-btn {
color: var(--text-muted); color: var(--text-muted);
} }
/* Version badge */
.version-badge {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: var(--accent-primary);
background: rgba(16, 185, 129, 0.1);
padding: 2px 8px;
border-radius: var(--radius-sm);
}
/* Create Tag Section */
.create-tag-section {
margin-top: 32px;
background: var(--bg-secondary);
}
.create-tag-section h3 {
margin-bottom: 4px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
}
.section-description {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 16px;
}
.create-tag-form .form-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.create-tag-form .form-group {
flex: 1;
min-width: 150px;
}
.create-tag-form .form-group--wide {
flex: 2;
min-width: 300px;
}
.create-tag-form .form-group label {
display: block;
margin-bottom: 6px;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.create-tag-form .form-group input {
width: 100%;
padding: 10px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.875rem;
}
.create-tag-form .form-group input:focus {
outline: none;
border-color: var(--accent-primary);
}
.create-tag-form .form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.create-tag-form button {
flex-shrink: 0;
}
/* Created cell */ /* Created cell */
.created-cell { .created-cell {
display: flex; display: flex;

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types'; import { TagDetail, Package, PaginatedResponse, AccessLevel } from '../types';
import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, UnauthorizedError, ForbiddenError } from '../api'; import { listTags, getDownloadUrl, getPackage, getMyProjectAccess, createTag, UnauthorizedError, ForbiddenError } from '../api';
import { Breadcrumb } from '../components/Breadcrumb'; import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge'; import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput'; import { SearchInput } from '../components/SearchInput';
@@ -64,6 +64,9 @@ function PackagePage() {
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null); const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [artifactIdInput, setArtifactIdInput] = useState(''); const [artifactIdInput, setArtifactIdInput] = useState('');
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null); const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
const [createTagName, setCreateTagName] = useState('');
const [createTagArtifactId, setCreateTagArtifactId] = useState('');
const [createTagLoading, setCreateTagLoading] = useState(false);
// Derived permissions // Derived permissions
const canWrite = accessLevel === 'write' || accessLevel === 'admin'; const canWrite = accessLevel === 'write' || accessLevel === 'admin';
@@ -154,6 +157,30 @@ function PackagePage() {
setError(errorMsg); setError(errorMsg);
}, []); }, []);
const handleCreateTag = async (e: React.FormEvent) => {
e.preventDefault();
if (!createTagName.trim() || createTagArtifactId.length !== 64) return;
setCreateTagLoading(true);
setError(null);
try {
await createTag(projectName!, packageName!, {
name: createTagName.trim(),
artifact_id: createTagArtifactId,
});
setUploadSuccess(`Tag "${createTagName}" created successfully!`);
setCreateTagName('');
setCreateTagArtifactId('');
loadData();
setTimeout(() => setUploadSuccess(null), 5000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create tag');
} finally {
setCreateTagLoading(false);
}
};
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' }); updateParams({ search: value, page: '1' });
}; };
@@ -182,6 +209,13 @@ function PackagePage() {
sortable: true, sortable: true,
render: (t: TagDetail) => <strong>{t.name}</strong>, render: (t: TagDetail) => <strong>{t.name}</strong>,
}, },
{
key: 'version',
header: 'Version',
render: (t: TagDetail) => (
<span className="version-badge">{t.version || '-'}</span>
),
},
{ {
key: 'artifact_id', key: 'artifact_id',
header: 'Artifact ID', header: 'Artifact ID',
@@ -433,6 +467,50 @@ function PackagePage() {
)} )}
</div> </div>
{user && canWrite && (
<div className="create-tag-section card">
<h3>Create / Update Tag</h3>
<p className="section-description">Point a tag at any existing artifact by its ID</p>
<form onSubmit={handleCreateTag} className="create-tag-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="create-tag-name">Tag Name</label>
<input
id="create-tag-name"
type="text"
value={createTagName}
onChange={(e) => setCreateTagName(e.target.value)}
placeholder="latest, stable, v1.0.0..."
disabled={createTagLoading}
/>
</div>
<div className="form-group form-group--wide">
<label htmlFor="create-tag-artifact">Artifact ID</label>
<input
id="create-tag-artifact"
type="text"
value={createTagArtifactId}
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="SHA256 hash (64 hex characters)"
className="artifact-id-input"
disabled={createTagLoading}
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
>
{createTagLoading ? 'Creating...' : 'Create Tag'}
</button>
</div>
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({createTagArtifactId.length}/64)</p>
)}
</form>
</div>
)}
<div className="usage-section card"> <div className="usage-section card">
<h3>Usage</h3> <h3>Usage</h3>
<p>Download artifacts using:</p> <p>Download artifacts using:</p>

View File

@@ -63,6 +63,22 @@ export interface TagDetail extends Tag {
artifact_original_name: string | null; artifact_original_name: string | null;
artifact_created_at: string; artifact_created_at: string;
artifact_format_metadata: Record<string, unknown> | null; artifact_format_metadata: Record<string, unknown> | null;
version: string | null;
}
export interface PackageVersion {
id: string;
package_id: string;
artifact_id: string;
version: string;
version_source: string | null;
created_at: string;
created_by: string;
// Enriched fields from joins
size?: number;
content_type?: string | null;
original_name?: string | null;
tags?: string[];
} }
export interface ArtifactTagInfo { export interface ArtifactTagInfo {
@@ -122,6 +138,8 @@ export interface UploadResponse {
project: string; project: string;
package: string; package: string;
tag: string | null; tag: string | null;
version: string | null;
version_source: string | null;
} }
// Global search types // Global search types

View File

@@ -0,0 +1,67 @@
-- Migration: Add package_versions table for separate version tracking
-- This separates immutable versions from mutable tags
-- Create package_versions table
CREATE TABLE IF NOT EXISTS package_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
package_id UUID NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
artifact_id VARCHAR(64) NOT NULL REFERENCES artifacts(id),
version VARCHAR(255) NOT NULL,
version_source VARCHAR(50), -- 'explicit', 'filename', 'metadata', 'migrated_from_tag'
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
UNIQUE(package_id, version),
UNIQUE(package_id, artifact_id)
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_package_versions_package_id ON package_versions(package_id);
CREATE INDEX IF NOT EXISTS idx_package_versions_artifact_id ON package_versions(artifact_id);
CREATE INDEX IF NOT EXISTS idx_package_versions_package_version ON package_versions(package_id, version);
-- Trigger functions for ref_count management (same pattern as tags)
CREATE OR REPLACE FUNCTION increment_version_ref_count()
RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count + 1 WHERE id = NEW.artifact_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION decrement_version_ref_count()
RETURNS TRIGGER AS $$
BEGIN
UPDATE artifacts SET ref_count = ref_count - 1 WHERE id = OLD.artifact_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
-- Create triggers
DROP TRIGGER IF EXISTS package_versions_ref_count_insert ON package_versions;
CREATE TRIGGER package_versions_ref_count_insert
AFTER INSERT ON package_versions
FOR EACH ROW
EXECUTE FUNCTION increment_version_ref_count();
DROP TRIGGER IF EXISTS package_versions_ref_count_delete ON package_versions;
CREATE TRIGGER package_versions_ref_count_delete
AFTER DELETE ON package_versions
FOR EACH ROW
EXECUTE FUNCTION decrement_version_ref_count();
-- Data migration: populate from existing semver-pattern tags
-- This extracts versions from tags that look like version numbers
-- Tags like "v1.0.0", "1.2.3", "2.0.0-beta" will be migrated
-- Tags like "latest", "stable", "dev" will NOT be migrated
INSERT INTO package_versions (package_id, artifact_id, version, version_source, created_by, created_at)
SELECT
t.package_id,
t.artifact_id,
-- Strip leading 'v' if present
CASE WHEN t.name LIKE 'v%' THEN substring(t.name from 2) ELSE t.name END,
'migrated_from_tag',
t.created_by,
t.created_at
FROM tags t
WHERE t.name ~ '^v?[0-9]+\.[0-9]+(\.[0-9]+)?([-.][a-zA-Z0-9]+)?$'
ON CONFLICT (package_id, version) DO NOTHING;