From f4ced408b2ab3c965120f050999076e963f32aa9 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Thu, 15 Jan 2026 22:25:24 +0000 Subject: [PATCH] Add separate version tracking for artifacts - Add package_versions table with immutable version records - Support version detection from explicit param, metadata, or filename - Add version API endpoints (list, get, delete) - Update ref resolution to check versions before tags - Add version column to tags table in frontend UI - Add Create Tag form for pointing tags at existing artifacts - Update seed data with version records - Add 16 integration tests for version functionality --- CHANGELOG.md | 13 + README.md | 60 ++- backend/app/metadata.py | 5 +- backend/app/models.py | 36 ++ backend/app/routes.py | 432 +++++++++++++++++- backend/app/schemas.py | 35 ++ backend/app/seed.py | 33 +- backend/tests/factories.py | 4 + .../tests/integration/test_versions_api.py | 412 +++++++++++++++++ frontend/src/api.ts | 47 +- frontend/src/pages/PackagePage.css | 80 ++++ frontend/src/pages/PackagePage.tsx | 80 +++- frontend/src/types.ts | 18 + migrations/007_package_versions.sql | 67 +++ 14 files changed, 1288 insertions(+), 34 deletions(-) create mode 100644 backend/tests/integration/test_versions_api.py create mode 100644 migrations/007_package_versions.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aca96b..d28e608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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 Helm values file with persistence enabled (20Gi PostgreSQL, 100Gi MinIO) (#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) ### 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) - Increased deploy job timeout from 5m to 10m (#63) - Added `--atomic` flag to Helm deployments for automatic rollback on failure diff --git a/README.md b/README.md index 4d711c0..b43c019 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Orchard is a centralized binary artifact storage system that provides content-ad - **Package** - Named collection within a project - **Artifact** - Specific content instance identified by SHA256 - **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.) - **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 @@ -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 | | `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/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/consumers` | List consumers of a package | | `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: -- `latest` - Tag name directly -- `v1.0.0` - Version tag +- `latest` - Implicit lookup (checks version first, then tag, then artifact ID) +- `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 -- `version:2024.1` - Version reference - `artifact:a3f5d8e12b4c6789...` - Direct SHA256 hash reference +**Resolution order for implicit refs:** version → tag → artifact ID + ## Quick Start ### Prerequisites @@ -230,9 +236,16 @@ curl "http://localhost:8080/api/v1/project/my-project/packages/releases?include_ ### Upload an Artifact ```bash +# Upload with tag only (version auto-detected from filename) curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload \ -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: @@ -242,7 +255,9 @@ Response: "size": 1048576, "project": "my-project", "package": "releases", - "tag": "v1.0.0", + "tag": "latest", + "version": "1.0.0", + "version_source": "explicit", "format_metadata": { "format": "tarball", "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). +### 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 ```bash @@ -614,7 +661,8 @@ See `helm/orchard/values.yaml` for all configuration options. - **projects** - Top-level organizational containers - **packages** - Collections within projects - **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 - **uploads** - Upload event records - **consumers** - Dependency tracking diff --git a/backend/app/metadata.py b/backend/app/metadata.py index b598f99..91a97f9 100644 --- a/backend/app/metadata.py +++ b/backend/app/metadata.py @@ -245,9 +245,10 @@ def extract_tarball_metadata(file: BinaryIO, filename: str) -> Dict[str, Any]: break # Try to split name and version + # Handle optional 'v' prefix on version (e.g., package-v1.0.0) patterns = [ - r"^(.+)-(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name-version - r"^(.+)_(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name_version + r"^(.+)-v?(\d+\.\d+(?:\.\d+)?(?:[-_]\w+)?)$", # name-version or name-vversion + r"^(.+)_v?(\d+\.\d+(?:\.\d+)?(?:[-_]\w+)?)$", # name_version or name_vversion ] for pattern in patterns: diff --git a/backend/app/models.py b/backend/app/models.py index 17ea3cd..1ba4365 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -72,6 +72,9 @@ class Package(Base): consumers = relationship( "Consumer", back_populates="package", cascade="all, delete-orphan" ) + versions = relationship( + "PackageVersion", back_populates="package", cascade="all, delete-orphan" + ) __table_args__ = ( Index("idx_packages_project_id", "project_id"), @@ -113,6 +116,7 @@ class Artifact(Base): tags = relationship("Tag", back_populates="artifact") uploads = relationship("Upload", back_populates="artifact") + versions = relationship("PackageVersion", back_populates="artifact") @property 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): __tablename__ = "uploads" diff --git a/backend/app/routes.py b/backend/app/routes.py index 5c9e821..91fcb5b 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -16,7 +16,7 @@ from fastapi import ( ) from fastapi.responses import StreamingResponse, RedirectResponse from sqlalchemy.orm import Session -from sqlalchemy import or_, func, text +from sqlalchemy import or_, and_, func, text from typing import List, Optional, Literal import math import io @@ -46,6 +46,7 @@ from .models import ( AuditLog, User, AccessPermission, + PackageVersion, ) from .schemas import ( ProjectCreate, @@ -116,6 +117,8 @@ from .schemas import ( OIDCConfigUpdate, OIDCStatusResponse, OIDCLoginResponse, + PackageVersionResponse, + PackageVersionDetailResponse, ) from .metadata import extract_metadata from .config import get_settings @@ -237,6 +240,103 @@ def _decrement_ref_count(db: Session, artifact_id: str) -> int: 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( db: Session, package_id: str, @@ -2147,6 +2247,7 @@ def upload_artifact( request: Request, file: UploadFile = File(...), tag: Optional[str] = Form(None), + version: Optional[str] = Form(None), db: Session = Depends(get_db), storage: S3Storage = Depends(get_storage), 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 ) + # 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 try: storage_result = storage.store(file.file, content_length) @@ -2383,6 +2489,25 @@ def upload_artifact( if tag: _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 if deduplicated: logger.info( @@ -2437,6 +2562,8 @@ def upload_artifact( project=project_name, package=package_name, tag=tag, + version=detected_version, + version_source=version_source, checksum_md5=storage_result.md5, checksum_sha1=storage_result.sha1, s3_etag=storage_result.s3_etag, @@ -2754,15 +2881,30 @@ def _resolve_artifact_ref( package: Package, db: Session, ) -> 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 # Check for explicit prefixes if ref.startswith("artifact:"): artifact_id = ref[9:] artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() - elif ref.startswith("tag:") or ref.startswith("version:"): - tag_name = ref.split(":", 1)[1] + elif ref.startswith("version:"): + 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 = ( db.query(Tag) .filter(Tag.package_id == package.id, Tag.name == tag_name) @@ -2771,15 +2913,25 @@ def _resolve_artifact_ref( if tag: artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first() else: - # Try as tag name first - tag = ( - db.query(Tag).filter(Tag.package_id == package.id, Tag.name == ref).first() + # Implicit ref: try version first, then tag, then artifact ID + # Try as version first + pkg_version = ( + db.query(PackageVersion) + .filter(PackageVersion.package_id == package.id, PackageVersion.version == ref) + .first() ) - if tag: - artifact = db.query(Artifact).filter(Artifact.id == tag.artifact_id).first() + if pkg_version: + artifact = db.query(Artifact).filter(Artifact.id == pkg_version.artifact_id).first() else: - # Try as direct artifact ID - artifact = db.query(Artifact).filter(Artifact.id == ref).first() + # Try as tag name + 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 @@ -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 @router.get( "/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'" ) - # Base query with JOIN to artifact for metadata + # Base query with JOIN to artifact for metadata and LEFT JOIN to version query = ( - db.query(Tag, Artifact) + db.query(Tag, Artifact, PackageVersion.version) .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) ) @@ -3264,9 +3641,9 @@ def list_tags( # Calculate total pages 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 = [] - for tag, artifact in results: + for tag, artifact, version in results: detailed_tags.append( TagDetailResponse( id=tag.id, @@ -3280,6 +3657,7 @@ def list_tags( artifact_original_name=artifact.original_name, artifact_created_at=artifact.created_at, artifact_format_metadata=artifact.format_metadata, + version=version, ) ) @@ -3396,8 +3774,15 @@ def get_tag( raise HTTPException(status_code=404, detail="Package not found") result = ( - db.query(Tag, Artifact) + db.query(Tag, Artifact, PackageVersion.version) .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) .first() ) @@ -3405,7 +3790,7 @@ def get_tag( if not result: raise HTTPException(status_code=404, detail="Tag not found") - tag, artifact = result + tag, artifact, version = result return TagDetailResponse( id=tag.id, package_id=tag.package_id, @@ -3418,6 +3803,7 @@ def get_tag( artifact_original_name=artifact.original_name, artifact_created_at=artifact.created_at, 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. """ query = ( - db.query(Tag, Package, Project, Artifact) + db.query(Tag, Package, Project, Artifact, PackageVersion.version) .join(Package, Tag.package_id == Package.id) .join(Project, Package.project_id == Project.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 @@ -3982,8 +4375,9 @@ def list_all_tags( package_name=pkg.name, artifact_size=artifact.size, 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( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 1b53d7d..1fe395b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -173,6 +173,7 @@ class TagResponse(BaseModel): artifact_id: str created_at: datetime created_by: str + version: Optional[str] = None # Version of the artifact this tag points to class Config: from_attributes = True @@ -187,6 +188,7 @@ class TagDetailResponse(BaseModel): artifact_id: str created_at: datetime created_by: str + version: Optional[str] = None # Version of the artifact this tag points to # Artifact metadata artifact_size: int artifact_content_type: Optional[str] @@ -383,6 +385,7 @@ class GlobalTagResponse(BaseModel): package_name: str artifact_size: Optional[int] = None artifact_content_type: Optional[str] = None + version: Optional[str] = None # Version of the artifact this tag points to class Config: from_attributes = True @@ -396,6 +399,8 @@ class UploadResponse(BaseModel): project: str package: 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_sha1: Optional[str] = None s3_etag: Optional[str] = None @@ -418,6 +423,7 @@ class ResumableUploadInitRequest(BaseModel): content_type: Optional[str] = None size: int tag: Optional[str] = None + version: Optional[str] = None # Explicit version (auto-detected if not provided) @field_validator("expected_hash") @classmethod @@ -484,6 +490,35 @@ class ConsumerResponse(BaseModel): 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 class SearchResultProject(BaseModel): """Project result for global search""" diff --git a/backend/app/seed.py b/backend/app/seed.py index e7fea9a..34cc98e 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -5,7 +5,7 @@ import hashlib import logging 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 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 = [ { "project": "frontend-libs", @@ -83,6 +83,7 @@ TEST_ARTIFACTS = [ "filename": "ui-components-1.0.0.js", "content_type": "application/javascript", "tags": ["v1.0.0", "latest"], + "version": "1.0.0", }, { "project": "frontend-libs", @@ -91,6 +92,7 @@ TEST_ARTIFACTS = [ "filename": "ui-components-1.1.0.js", "content_type": "application/javascript", "tags": ["v1.1.0"], + "version": "1.1.0", }, { "project": "frontend-libs", @@ -99,6 +101,7 @@ TEST_ARTIFACTS = [ "filename": "tokens.json", "content_type": "application/json", "tags": ["v1.0.0", "latest"], + "version": "1.0.0", }, { "project": "backend-services", @@ -107,6 +110,7 @@ TEST_ARTIFACTS = [ "filename": "utils-2.0.0.py", "content_type": "text/x-python", "tags": ["v2.0.0", "stable", "latest"], + "version": "2.0.0", }, { "project": "backend-services", @@ -115,6 +119,7 @@ TEST_ARTIFACTS = [ "filename": "auth-lib-1.0.0.go", "content_type": "text/x-go", "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") - # Create artifacts and tags + # Create artifacts, tags, and versions artifact_count = 0 tag_count = 0 + version_count = 0 for artifact_data in TEST_ARTIFACTS: 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}") 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 artifact = Artifact( id=sha256_hash, @@ -192,7 +203,7 @@ def seed_database(db: Session) -> None: original_name=artifact_data["filename"], created_by="seed-user", s3_key=s3_key, - ref_count=len(artifact_data["tags"]), + ref_count=ref_count, ) db.add(artifact) @@ -206,6 +217,18 @@ def seed_database(db: Session) -> None: db.add(upload) 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 for tag_name in artifact_data["tags"]: tag = Tag( @@ -218,5 +241,5 @@ def seed_database(db: Session) -> None: tag_count += 1 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") diff --git a/backend/tests/factories.py b/backend/tests/factories.py index cd58f2a..a37acce 100644 --- a/backend/tests/factories.py +++ b/backend/tests/factories.py @@ -97,6 +97,7 @@ def upload_test_file( content: bytes, filename: str = "test.bin", tag: Optional[str] = None, + version: Optional[str] = None, ) -> dict: """ Helper function to upload a test file via the API. @@ -108,6 +109,7 @@ def upload_test_file( content: File content as bytes filename: Original filename tag: Optional tag to assign + version: Optional version to assign Returns: The upload response as a dict @@ -116,6 +118,8 @@ def upload_test_file( data = {} if tag: data["tag"] = tag + if version: + data["version"] = version response = client.post( f"/api/v1/project/{project}/{package}/upload", diff --git a/backend/tests/integration/test_versions_api.py b/backend/tests/integration/test_versions_api.py new file mode 100644 index 0000000..89365a1 --- /dev/null +++ b/backend/tests/integration/test_versions_api.py @@ -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") diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a2b5b51..6101cdc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -32,6 +32,7 @@ import { OIDCConfig, OIDCConfigUpdate, OIDCStatus, + PackageVersion, } from './types'; const API_BASE = '/api/v1'; @@ -239,12 +240,21 @@ export async function listPackageArtifacts( } // Upload -export async function uploadArtifact(projectName: string, packageName: string, file: File, tag?: string): Promise { +export async function uploadArtifact( + projectName: string, + packageName: string, + file: File, + tag?: string, + version?: string +): Promise { const formData = new FormData(); formData.append('file', file); if (tag) { formData.append('tag', tag); } + if (version) { + formData.append('version', version); + } const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/upload`, { method: 'POST', @@ -443,3 +453,38 @@ export function getOIDCLoginUrl(returnTo?: string): string { const query = params.toString(); return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`; } + +// Version API +export async function listVersions( + projectName: string, + packageName: string, + params: ListParams = {} +): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions${query}`); + return handleResponse>(response); +} + +export async function getVersion( + projectName: string, + packageName: string, + version: string +): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/versions/${version}`); + return handleResponse(response); +} + +export async function deleteVersion( + projectName: string, + packageName: string, + version: string +): Promise { + 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}`); + } +} diff --git a/frontend/src/pages/PackagePage.css b/frontend/src/pages/PackagePage.css index e4edb78..020a0b4 100644 --- a/frontend/src/pages/PackagePage.css +++ b/frontend/src/pages/PackagePage.css @@ -324,6 +324,86 @@ tr:hover .copy-btn { 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 { display: flex; diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index 698e5e5..740cc66 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; 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 { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; @@ -64,6 +64,9 @@ function PackagePage() { const [uploadSuccess, setUploadSuccess] = useState(null); const [artifactIdInput, setArtifactIdInput] = useState(''); const [accessLevel, setAccessLevel] = useState(null); + const [createTagName, setCreateTagName] = useState(''); + const [createTagArtifactId, setCreateTagArtifactId] = useState(''); + const [createTagLoading, setCreateTagLoading] = useState(false); // Derived permissions const canWrite = accessLevel === 'write' || accessLevel === 'admin'; @@ -154,6 +157,30 @@ function PackagePage() { 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) => { updateParams({ search: value, page: '1' }); }; @@ -182,6 +209,13 @@ function PackagePage() { sortable: true, render: (t: TagDetail) => {t.name}, }, + { + key: 'version', + header: 'Version', + render: (t: TagDetail) => ( + {t.version || '-'} + ), + }, { key: 'artifact_id', header: 'Artifact ID', @@ -433,6 +467,50 @@ function PackagePage() { )} + {user && canWrite && ( +
+

Create / Update Tag

+

Point a tag at any existing artifact by its ID

+
+
+
+ + setCreateTagName(e.target.value)} + placeholder="latest, stable, v1.0.0..." + disabled={createTagLoading} + /> +
+
+ + 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} + /> +
+ +
+ {createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && ( +

Artifact ID must be exactly 64 hex characters ({createTagArtifactId.length}/64)

+ )} +
+
+ )} +

Usage

Download artifacts using:

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f1c2bd3..e537fb6 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -63,6 +63,22 @@ export interface TagDetail extends Tag { artifact_original_name: string | null; artifact_created_at: string; artifact_format_metadata: Record | 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 { @@ -122,6 +138,8 @@ export interface UploadResponse { project: string; package: string; tag: string | null; + version: string | null; + version_source: string | null; } // Global search types diff --git a/migrations/007_package_versions.sql b/migrations/007_package_versions.sql new file mode 100644 index 0000000..acb2916 --- /dev/null +++ b/migrations/007_package_versions.sql @@ -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;