feat(release): add named release anchors with codenames and roadmap integration
- Add release_names.json mapping version ranges to codenames - Add release_name property to Settings in app/config.py - Update build metadata script to include codename in RUNTIME_INFO - Display release codename in status dashboard and page footer - Inject release_name globally via template response wrapper - Update ROADMAP.md with codenames for all milestone releases - Add docs/ReleaseNaming.md with naming guide and best practices - Add comprehensive tests for release name resolution Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
+22
-9
@@ -7,7 +7,20 @@
|
|||||||
|
|
||||||
DocuElevate aims to be the premier open-source intelligent document processing platform, providing seamless integration with cloud storage providers, advanced AI-powered metadata extraction, and enterprise-grade security and scalability.
|
DocuElevate aims to be the premier open-source intelligent document processing platform, providing seamless integration with cloud storage providers, advanced AI-powered metadata extraction, and enterprise-grade security and scalability.
|
||||||
|
|
||||||
## Current Status (v0.5.0)
|
## Release Naming
|
||||||
|
|
||||||
|
Each major milestone release carries a codename to anchor key project moments. These names appear in the status dashboard, build metadata, and changelog. For details, see [docs/ReleaseNaming.md](docs/ReleaseNaming.md).
|
||||||
|
|
||||||
|
| Version Range | Codename | Theme |
|
||||||
|
|---------------|---------------|--------------------------------------------------|
|
||||||
|
| 0.5.x | **Foundation** | Core platform, multi-provider storage, AI, UI |
|
||||||
|
| 0.6.x | **Clarity** | Enhanced search, filtering, UI/UX improvements |
|
||||||
|
| 0.7.x | **Conductor** | Workflow automation, pipelines, rule-based logic |
|
||||||
|
| 1.0.x | **Summit** | Enterprise features, multi-tenancy, RBAC |
|
||||||
|
| 1.1.x | **Bridge** | Collaboration, sharing, analytics |
|
||||||
|
| 2.0.x | **Horizon** | On-premise AI, platform expansion |
|
||||||
|
|
||||||
|
## Current Status (v0.5.0 "Foundation")
|
||||||
|
|
||||||
### Core Features ✅
|
### Core Features ✅
|
||||||
- Multi-provider document storage (Dropbox, Google Drive, OneDrive, Nextcloud, S3, etc.)
|
- Multi-provider document storage (Dropbox, Google Drive, OneDrive, Nextcloud, S3, etc.)
|
||||||
@@ -23,7 +36,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p
|
|||||||
- Celery-based async task processing
|
- Celery-based async task processing
|
||||||
- OAuth2 authentication via Authentik with admin group support
|
- OAuth2 authentication via Authentik with admin group support
|
||||||
|
|
||||||
## Short-term Goals (Q1-Q2 2026) - v0.4.x to v0.5.x
|
## Short-term Goals (Q1-Q2 2026) - v0.4.x to v0.5.x "Foundation"
|
||||||
|
|
||||||
### Quality & Stability 🎯
|
### Quality & Stability 🎯
|
||||||
- **Test Coverage** (High Priority)
|
- **Test Coverage** (High Priority)
|
||||||
@@ -53,7 +66,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p
|
|||||||
- [x] Integrate Docker builds with releases
|
- [x] Integrate Docker builds with releases
|
||||||
|
|
||||||
### Features - v0.4.0
|
### Features - v0.4.0
|
||||||
- **Enhanced Search & Filtering**
|
- **Enhanced Search & Filtering** → _preparing for v0.6.0 "Clarity"_
|
||||||
- [ ] Full-text search across documents
|
- [ ] Full-text search across documents
|
||||||
- [ ] Advanced filtering by metadata, tags, date ranges
|
- [ ] Advanced filtering by metadata, tags, date ranges
|
||||||
- [ ] Saved search queries
|
- [ ] Saved search queries
|
||||||
@@ -67,8 +80,8 @@ DocuElevate aims to be the premier open-source intelligent document processing p
|
|||||||
- [ ] Progress indicators for long-running tasks
|
- [ ] Progress indicators for long-running tasks
|
||||||
- [ ] Real-time notifications via WebSocket
|
- [ ] Real-time notifications via WebSocket
|
||||||
|
|
||||||
### Features - v0.5.0
|
### Features - v0.5.0 "Foundation"
|
||||||
- **Workflow Automation**
|
- **Workflow Automation** → _evolving into v0.7.0 "Conductor"_
|
||||||
- [ ] Custom processing pipelines
|
- [ ] Custom processing pipelines
|
||||||
- [ ] Conditional routing based on document type
|
- [ ] Conditional routing based on document type
|
||||||
- [ ] Scheduled batch processing
|
- [ ] Scheduled batch processing
|
||||||
@@ -82,9 +95,9 @@ DocuElevate aims to be the premier open-source intelligent document processing p
|
|||||||
- [ ] Automatic duplicate detection
|
- [ ] Automatic duplicate detection
|
||||||
- [ ] Intelligent document splitting
|
- [ ] Intelligent document splitting
|
||||||
|
|
||||||
## Medium-term Goals (Q3-Q4 2026) - v1.0.x
|
## Medium-term Goals (Q3-Q4 2026) - v1.0.x "Summit"
|
||||||
|
|
||||||
### Enterprise Features - v1.0.0
|
### Enterprise Features - v1.0.0 "Summit"
|
||||||
- **Multi-tenancy**
|
- **Multi-tenancy**
|
||||||
- [ ] Organization/team management
|
- [ ] Organization/team management
|
||||||
- [ ] Role-based access control (RBAC)
|
- [ ] Role-based access control (RBAC)
|
||||||
@@ -106,7 +119,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p
|
|||||||
- [ ] Custom webhook receivers
|
- [ ] Custom webhook receivers
|
||||||
- [ ] GraphQL API
|
- [ ] GraphQL API
|
||||||
|
|
||||||
### Features - v1.1.0
|
### Features - v1.1.0 "Bridge"
|
||||||
- **Collaboration**
|
- **Collaboration**
|
||||||
- [ ] Document sharing with expiring links
|
- [ ] Document sharing with expiring links
|
||||||
- [ ] Comments and annotations
|
- [ ] Comments and annotations
|
||||||
@@ -121,7 +134,7 @@ DocuElevate aims to be the premier open-source intelligent document processing p
|
|||||||
- [ ] Cost analysis per provider
|
- [ ] Cost analysis per provider
|
||||||
- [ ] Export reports (PDF, CSV, Excel)
|
- [ ] Export reports (PDF, CSV, Excel)
|
||||||
|
|
||||||
## Long-term Goals (2027+) - v2.0+
|
## Long-term Goals (2027+) - v2.0+ "Horizon"
|
||||||
|
|
||||||
### Strategic Initiatives
|
### Strategic Initiatives
|
||||||
- **On-Premise AI Models**
|
- **On-Premise AI Models**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, List, Optional, Union
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
@@ -599,5 +600,51 @@ class Settings(BaseSettings):
|
|||||||
# Return basic info if file not found
|
# Return basic info if file not found
|
||||||
return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}"
|
return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def release_name(self) -> str | None:
|
||||||
|
"""Get the release codename for the current version from release_names.json.
|
||||||
|
|
||||||
|
Looks up the current version's minor version prefix (e.g., '0.5' for '0.5.3')
|
||||||
|
in release_names.json to find the associated codename. Returns None if no
|
||||||
|
codename is defined for the current version.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The release codename string, or None if not found.
|
||||||
|
"""
|
||||||
|
version = self.version
|
||||||
|
if not version or version == "unknown":
|
||||||
|
return None
|
||||||
|
|
||||||
|
release_names_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json")
|
||||||
|
if not os.path.exists(release_names_file):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(release_names_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
releases = data.get("releases", {})
|
||||||
|
|
||||||
|
# Try exact version match first (e.g., "0.5.0")
|
||||||
|
if version in releases:
|
||||||
|
return releases[version].get("codename")
|
||||||
|
|
||||||
|
# Try minor version prefix (e.g., "0.5" for "0.5.3")
|
||||||
|
parts = version.split(".")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
minor_prefix = f"{parts[0]}.{parts[1]}"
|
||||||
|
if minor_prefix in releases:
|
||||||
|
return releases[minor_prefix].get("codename")
|
||||||
|
|
||||||
|
# Try major version prefix (e.g., "1" for "1.0.0")
|
||||||
|
if len(parts) >= 1:
|
||||||
|
major_prefix = parts[0]
|
||||||
|
if major_prefix in releases:
|
||||||
|
return releases[major_prefix].get("codename")
|
||||||
|
|
||||||
|
return None
|
||||||
|
except (json.JSONDecodeError, KeyError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def template_response_with_version(*args, **kwargs):
|
|||||||
# If context dict is provided, add version to it
|
# If context dict is provided, add version to it
|
||||||
if len(args) >= 2 and isinstance(args[1], dict):
|
if len(args) >= 2 and isinstance(args[1], dict):
|
||||||
args[1].setdefault("version", settings.version)
|
args[1].setdefault("version", settings.version)
|
||||||
|
args[1].setdefault("release_name", getattr(settings, "release_name", None))
|
||||||
# Inject CSRF token from request state when available
|
# Inject CSRF token from request state when available
|
||||||
req = args[1].get("request")
|
req = args[1].get("request")
|
||||||
if req is not None and hasattr(req.state, "csrf_token"):
|
if req is not None and hasattr(req.state, "csrf_token"):
|
||||||
@@ -38,6 +39,7 @@ def template_response_with_version(*args, **kwargs):
|
|||||||
args[1].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
|
args[1].setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
|
||||||
elif "context" in kwargs and isinstance(kwargs["context"], dict):
|
elif "context" in kwargs and isinstance(kwargs["context"], dict):
|
||||||
kwargs["context"].setdefault("version", settings.version)
|
kwargs["context"].setdefault("version", settings.version)
|
||||||
|
kwargs["context"].setdefault("release_name", getattr(settings, "release_name", None))
|
||||||
req = kwargs["context"].get("request")
|
req = kwargs["context"].get("request")
|
||||||
if req is not None and hasattr(req.state, "csrf_token"):
|
if req is not None and hasattr(req.state, "csrf_token"):
|
||||||
kwargs["context"].setdefault("csrf_token", req.state.csrf_token)
|
kwargs["context"].setdefault("csrf_token", req.state.csrf_token)
|
||||||
|
|||||||
@@ -73,12 +73,16 @@ async def status_dashboard(request: Request):
|
|||||||
# Get notification URLs for the notification box
|
# Get notification URLs for the notification box
|
||||||
notification_urls = getattr(settings, "notification_urls", [])
|
notification_urls = getattr(settings, "notification_urls", [])
|
||||||
|
|
||||||
|
# Get release codename if available
|
||||||
|
release_name = getattr(settings, "release_name", None)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"status_dashboard.html",
|
"status_dashboard.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
|
"release_name": release_name,
|
||||||
"build_date": build_date,
|
"build_date": build_date,
|
||||||
"debug_enabled": getattr(settings, "debug", False),
|
"debug_enabled": getattr(settings, "debug", False),
|
||||||
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Release Naming Guide
|
||||||
|
|
||||||
|
DocuElevate uses **automated semantic versioning** via [python-semantic-release](https://github.com/python-semantic-release/python-semantic-release) combined with **named release anchors** (codenames) for milestone releases. This guide explains how the two systems work together.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Automated Versioning (Patch & Minor Releases)
|
||||||
|
|
||||||
|
Every merge to `main` is analyzed by `python-semantic-release`:
|
||||||
|
|
||||||
|
- **`feat:` commits** → minor version bump (e.g., 0.5.0 → 0.6.0)
|
||||||
|
- **`fix:` / `perf:` commits** → patch version bump (e.g., 0.5.0 → 0.5.1)
|
||||||
|
- **`docs:` / `chore:` / etc.** → no version bump
|
||||||
|
|
||||||
|
This happens automatically — no manual intervention needed.
|
||||||
|
|
||||||
|
### Named Release Anchors (Codenames)
|
||||||
|
|
||||||
|
Major milestone releases carry a **codename** that anchors the release in project history. Codenames:
|
||||||
|
|
||||||
|
- Are defined in [`release_names.json`](../release_names.json) at the project root
|
||||||
|
- Map to **minor version ranges** (e.g., all `0.5.x` releases share the codename "Foundation")
|
||||||
|
- Appear in the status dashboard, build metadata, and footer
|
||||||
|
- Do **not** interfere with automatic version numbering
|
||||||
|
|
||||||
|
### Current Release Names
|
||||||
|
|
||||||
|
| Version Range | Codename | Description |
|
||||||
|
|---------------|----------------|----------------------------------------------------------|
|
||||||
|
| 0.5.x | **Foundation** | Core platform with multi-provider storage, AI, and UI |
|
||||||
|
| 0.6.x | **Clarity** | Enhanced search, filtering, and improved UI/UX |
|
||||||
|
| 0.7.x | **Conductor** | Workflow automation, custom pipelines, rule-based logic |
|
||||||
|
| 1.0.x | **Summit** | Enterprise-ready: multi-tenancy, RBAC, horizontal scaling|
|
||||||
|
| 1.1.x | **Bridge** | Collaboration features, document sharing, analytics |
|
||||||
|
| 2.0.x | **Horizon** | On-premise AI, advanced management, platform expansion |
|
||||||
|
|
||||||
|
## Adding a New Release Name
|
||||||
|
|
||||||
|
1. **Edit `release_names.json`** at the project root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"releases": {
|
||||||
|
"0.8": {
|
||||||
|
"codename": "YourCodename",
|
||||||
|
"description": "Short description of what this release series focuses on",
|
||||||
|
"milestone": "v0.8.0 - Your Milestone Name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update `ROADMAP.md`** to include the codename in the appropriate milestone section.
|
||||||
|
|
||||||
|
3. **Update this documentation** to add the new entry to the table above.
|
||||||
|
|
||||||
|
The codename will automatically appear in:
|
||||||
|
- The application footer (all pages)
|
||||||
|
- The status dashboard (`/status`)
|
||||||
|
- Build metadata (`RUNTIME_INFO` file)
|
||||||
|
|
||||||
|
## How the Lookup Works
|
||||||
|
|
||||||
|
The application resolves codenames using a cascading lookup against the current version:
|
||||||
|
|
||||||
|
1. **Exact match**: Checks if the full version (e.g., `0.5.3`) has an entry
|
||||||
|
2. **Minor prefix**: Checks the minor version prefix (e.g., `0.5`)
|
||||||
|
3. **Major prefix**: Checks the major version prefix (e.g., `0`)
|
||||||
|
|
||||||
|
This means all patch releases within a minor version series inherit the same codename.
|
||||||
|
|
||||||
|
## Codename Naming Conventions
|
||||||
|
|
||||||
|
When choosing codenames, follow these guidelines:
|
||||||
|
|
||||||
|
- **Use single, evocative words** that relate to the release's theme
|
||||||
|
- **Keep names professional** — they appear in user-facing UI
|
||||||
|
- **Pick names that hint at the milestone's focus** (e.g., "Foundation" for core platform, "Conductor" for workflow automation)
|
||||||
|
- **Avoid names that could become dated** or reference external products
|
||||||
|
- **Ensure uniqueness** — no two releases should share a codename
|
||||||
|
|
||||||
|
## Integration with Milestones
|
||||||
|
|
||||||
|
Each codename maps to a GitHub milestone. The `milestone` field in `release_names.json` matches the milestone title used for issue tracking:
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.7.0 - Workflow Automation → codename: "Conductor"
|
||||||
|
v1.0.0 - Enterprise → codename: "Summit"
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a clear link between planning (milestones), delivery (releases), and communication (codenames).
|
||||||
|
|
||||||
|
## Best Practices: Blending Automated and Named Releases
|
||||||
|
|
||||||
|
### Do
|
||||||
|
|
||||||
|
- ✅ Let semantic-release handle all version numbering automatically
|
||||||
|
- ✅ Use codenames for **milestone releases** (minor/major versions), not every patch
|
||||||
|
- ✅ Reference codenames in release notes and changelogs for major versions
|
||||||
|
- ✅ Keep `release_names.json` in sync with `ROADMAP.md`
|
||||||
|
- ✅ Announce codenames in GitHub Release descriptions for milestone versions
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
|
||||||
|
- ❌ Manually edit the `VERSION` file — it's managed by semantic-release
|
||||||
|
- ❌ Create codenames for every patch release (0.5.1, 0.5.2, etc.)
|
||||||
|
- ❌ Use codenames that conflict with version numbers
|
||||||
|
- ❌ Skip updating `release_names.json` when adding a new milestone to the roadmap
|
||||||
|
|
||||||
|
## Where Codenames Appear
|
||||||
|
|
||||||
|
| Location | Format |
|
||||||
|
|-------------------------|-----------------------------------------|
|
||||||
|
| Status dashboard | `App Version: 0.5.3 "Foundation"` |
|
||||||
|
| Page footer | `Version 0.5.3 "Foundation"` |
|
||||||
|
| RUNTIME_INFO metadata | `Release Name: Foundation` |
|
||||||
|
| ROADMAP.md | Section headers include codenames |
|
||||||
|
|
||||||
|
## File Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---------------------------|--------------------------------------------|
|
||||||
|
| `release_names.json` | Source of truth for version-to-codename map|
|
||||||
|
| `app/config.py` | `release_name` property reads the JSON |
|
||||||
|
| `scripts/generate_build_metadata.sh` | Includes codename in RUNTIME_INFO |
|
||||||
|
| `app/views/base.py` | Injects `release_name` into all templates |
|
||||||
|
| `ROADMAP.md` | Displays codenames alongside milestones |
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
<a href="/license" class="text-blue-500 hover:underline">License</a> -
|
<a href="/license" class="text-blue-500 hover:underline">License</a> -
|
||||||
<a href="/attribution" class="text-blue-500 hover:underline">Attributions</a>
|
<a href="/attribution" class="text-blue-500 hover:underline">Attributions</a>
|
||||||
</nav> -
|
</nav> -
|
||||||
<span class="text-xs">Version {{ app_version|default(version, true) }}</span>
|
<span class="text-xs">Version {{ app_version|default(version, true) }}{% if release_name %} "{{ release_name }}"{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
This dashboard shows the status of all configured integrations and targets.
|
This dashboard shows the status of all configured integrations and targets.
|
||||||
</p>
|
</p>
|
||||||
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
|
<div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
|
||||||
<p><strong>App Version:</strong> {{ app_version }}</p>
|
<p><strong>App Version:</strong> {{ app_version }}{% if release_name %} <span class="italic">"{{ release_name }}"</span>{% endif %}</p>
|
||||||
<p><strong>Build Date:</strong> {{ build_date }}</p>
|
<p><strong>Build Date:</strong> {{ build_date }}</p>
|
||||||
<p><strong>Debug Mode:</strong> {{ "Enabled" if debug_enabled else "Disabled" }}</p>
|
<p><strong>Debug Mode:</strong> {{ "Enabled" if debug_enabled else "Disabled" }}</p>
|
||||||
{% if last_check %}
|
{% if last_check %}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"_description": "Maps version patterns to release codenames. Used by the application at runtime and in build metadata.",
|
||||||
|
"_format": "Each key is a version string (e.g., '0.5.0') or a minor version prefix (e.g., '0.5'). The value is an object with 'codename' and optionally 'description' and 'milestone'.",
|
||||||
|
"releases": {
|
||||||
|
"0.5": {
|
||||||
|
"codename": "Foundation",
|
||||||
|
"description": "Core platform with multi-provider storage, AI extraction, and web UI",
|
||||||
|
"milestone": "v0.5.0 - Core Platform"
|
||||||
|
},
|
||||||
|
"0.6": {
|
||||||
|
"codename": "Clarity",
|
||||||
|
"description": "Enhanced search, filtering, and improved UI/UX",
|
||||||
|
"milestone": "v0.6.0 - Search & UX"
|
||||||
|
},
|
||||||
|
"0.7": {
|
||||||
|
"codename": "Conductor",
|
||||||
|
"description": "Workflow automation, custom pipelines, and rule-based processing",
|
||||||
|
"milestone": "v0.7.0 - Workflow Automation"
|
||||||
|
},
|
||||||
|
"1.0": {
|
||||||
|
"codename": "Summit",
|
||||||
|
"description": "Enterprise-ready with multi-tenancy, RBAC, and horizontal scaling",
|
||||||
|
"milestone": "v1.0.0 - Enterprise"
|
||||||
|
},
|
||||||
|
"1.1": {
|
||||||
|
"codename": "Bridge",
|
||||||
|
"description": "Collaboration features, document sharing, and analytics",
|
||||||
|
"milestone": "v1.1.0 - Collaboration"
|
||||||
|
},
|
||||||
|
"2.0": {
|
||||||
|
"codename": "Horizon",
|
||||||
|
"description": "On-premise AI, advanced document management, and platform expansion",
|
||||||
|
"milestone": "v2.0.0 - Next Generation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,19 +68,54 @@ else
|
|||||||
VERSION="unknown"
|
VERSION="unknown"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Look up release codename from release_names.json
|
||||||
|
RELEASE_NAME=""
|
||||||
|
if [ -f "release_names.json" ] && command -v python3 > /dev/null 2>&1; then
|
||||||
|
MINOR_PREFIX=$(echo "${VERSION}" | cut -d. -f1-2)
|
||||||
|
RELEASE_NAME=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
with open('release_names.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
releases = data.get('releases', {})
|
||||||
|
version = '${VERSION}'
|
||||||
|
minor = '${MINOR_PREFIX}'
|
||||||
|
codename = None
|
||||||
|
if version in releases:
|
||||||
|
codename = releases[version].get('codename')
|
||||||
|
elif minor in releases:
|
||||||
|
codename = releases[minor].get('codename')
|
||||||
|
if codename:
|
||||||
|
print(codename)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${RELEASE_NAME}" ]; then
|
||||||
|
echo "✓ Release Name: ${RELEASE_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Generate RUNTIME_INFO with combined metadata
|
# Generate RUNTIME_INFO with combined metadata
|
||||||
cat > RUNTIME_INFO << EOF
|
RUNTIME_INFO_CONTENT="DocuElevate Build Information
|
||||||
DocuElevate Build Information
|
|
||||||
==============================
|
==============================
|
||||||
Version: ${VERSION}
|
Version: ${VERSION}
|
||||||
Build Date: ${BUILD_DATE}
|
Build Date: ${BUILD_DATE}
|
||||||
Git Commit: ${GIT_FULL_SHA}
|
Git Commit: ${GIT_FULL_SHA}
|
||||||
Git Short SHA: ${GIT_SHA}
|
Git Short SHA: ${GIT_SHA}
|
||||||
Git Branch: ${GIT_BRANCH}
|
Git Branch: ${GIT_BRANCH}
|
||||||
Commit Date: ${GIT_COMMIT_DATE}
|
Commit Date: ${GIT_COMMIT_DATE}"
|
||||||
|
|
||||||
|
if [ -n "${RELEASE_NAME}" ]; then
|
||||||
|
RUNTIME_INFO_CONTENT="${RUNTIME_INFO_CONTENT}
|
||||||
|
Release Name: ${RELEASE_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUNTIME_INFO_CONTENT="${RUNTIME_INFO_CONTENT}
|
||||||
Build Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
Build Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||||
==============================
|
=============================="
|
||||||
EOF
|
|
||||||
|
echo "${RUNTIME_INFO_CONTENT}" > RUNTIME_INFO
|
||||||
|
|
||||||
echo "✓ RUNTIME_INFO generated"
|
echo "✓ RUNTIME_INFO generated"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Tests for release naming functionality in app/config.py and app/views/status.py."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReleaseNameProperty:
|
||||||
|
"""Tests for the Settings.release_name property."""
|
||||||
|
|
||||||
|
def test_release_name_returns_codename_for_minor_prefix(self, tmp_path):
|
||||||
|
"""Test release_name returns codename matching minor version prefix."""
|
||||||
|
release_data = {
|
||||||
|
"releases": {
|
||||||
|
"0.5": {"codename": "Foundation", "description": "Core platform"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release_file = tmp_path / "release_names.json"
|
||||||
|
release_file.write_text(json.dumps(release_data))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.config.Settings.version", new_callable=lambda: property(lambda self: "0.5.3")),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
# Directly call the property with mocked file path
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.3")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result == "Foundation"
|
||||||
|
|
||||||
|
def test_release_name_returns_codename_for_exact_match(self, tmp_path):
|
||||||
|
"""Test release_name returns codename for exact version match."""
|
||||||
|
release_data = {
|
||||||
|
"releases": {
|
||||||
|
"1.0.0": {"codename": "Summit"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release_file = tmp_path / "release_names.json"
|
||||||
|
release_file.write_text(json.dumps(release_data))
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "1.0.0")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result == "Summit"
|
||||||
|
|
||||||
|
def test_release_name_returns_none_when_no_match(self, tmp_path):
|
||||||
|
"""Test release_name returns None when version has no codename."""
|
||||||
|
release_data = {
|
||||||
|
"releases": {
|
||||||
|
"0.5": {"codename": "Foundation"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release_file = tmp_path / "release_names.json"
|
||||||
|
release_file.write_text(json.dumps(release_data))
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.9.1")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_release_name_returns_none_when_file_missing(self):
|
||||||
|
"""Test release_name returns None when release_names.json doesn't exist."""
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value="/nonexistent/release_names.json"),
|
||||||
|
patch("app.config.os.path.exists", return_value=False),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_release_name_returns_none_for_unknown_version(self):
|
||||||
|
"""Test release_name returns None when version is 'unknown'."""
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with patch.object(type(s), "version", new_callable=lambda: property(lambda self: "unknown")):
|
||||||
|
result = s.release_name
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_release_name_returns_none_for_empty_version(self):
|
||||||
|
"""Test release_name returns None when version is empty."""
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with patch.object(type(s), "version", new_callable=lambda: property(lambda self: "")):
|
||||||
|
result = s.release_name
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_release_name_handles_invalid_json(self, tmp_path):
|
||||||
|
"""Test release_name handles corrupt JSON gracefully."""
|
||||||
|
release_file = tmp_path / "release_names.json"
|
||||||
|
release_file.write_text("{invalid json")
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_release_name_handles_missing_releases_key(self, tmp_path):
|
||||||
|
"""Test release_name handles JSON without 'releases' key."""
|
||||||
|
release_file = tmp_path / "release_names.json"
|
||||||
|
release_file.write_text(json.dumps({"something_else": {}}))
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_release_name_prefers_exact_over_minor(self, tmp_path):
|
||||||
|
"""Test release_name prefers exact version match over minor prefix."""
|
||||||
|
release_data = {
|
||||||
|
"releases": {
|
||||||
|
"0.5.0": {"codename": "ExactMatch"},
|
||||||
|
"0.5": {"codename": "MinorMatch"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release_file = tmp_path / "release_names.json"
|
||||||
|
release_file.write_text(json.dumps(release_data))
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
s = Settings.__new__(Settings)
|
||||||
|
with (
|
||||||
|
patch.object(type(s), "version", new_callable=lambda: property(lambda self: "0.5.0")),
|
||||||
|
patch("app.config.os.path.dirname"),
|
||||||
|
patch("app.config.os.path.join", return_value=str(release_file)),
|
||||||
|
patch("app.config.os.path.exists", return_value=True),
|
||||||
|
):
|
||||||
|
result = s.release_name
|
||||||
|
assert result == "ExactMatch"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReleaseNameInStatusView:
|
||||||
|
"""Tests for release name display in status dashboard."""
|
||||||
|
|
||||||
|
@patch("app.views.status.get_provider_status")
|
||||||
|
@patch("app.views.status.templates")
|
||||||
|
@patch("app.views.status.settings")
|
||||||
|
@patch("app.views.status.os.path.exists")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_dashboard_includes_release_name(
|
||||||
|
self, mock_exists, mock_settings, mock_templates, mock_providers
|
||||||
|
):
|
||||||
|
"""Test status dashboard passes release_name to template context."""
|
||||||
|
from app.views.status import status_dashboard
|
||||||
|
|
||||||
|
mock_exists.return_value = False
|
||||||
|
mock_providers.return_value = {}
|
||||||
|
mock_settings.version = "0.5.3"
|
||||||
|
mock_settings.build_date = "2024-01-01"
|
||||||
|
mock_settings.debug = False
|
||||||
|
mock_settings.git_sha = "abc123"
|
||||||
|
mock_settings.notification_urls = []
|
||||||
|
mock_settings.release_name = "Foundation"
|
||||||
|
|
||||||
|
mock_request = Mock()
|
||||||
|
await status_dashboard(mock_request)
|
||||||
|
|
||||||
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
|
context = call_args[0][1]
|
||||||
|
assert context["release_name"] == "Foundation"
|
||||||
|
|
||||||
|
@patch("app.views.status.get_provider_status")
|
||||||
|
@patch("app.views.status.templates")
|
||||||
|
@patch("app.views.status.settings")
|
||||||
|
@patch("app.views.status.os.path.exists")
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_dashboard_handles_missing_release_name(
|
||||||
|
self, mock_exists, mock_settings, mock_templates, mock_providers
|
||||||
|
):
|
||||||
|
"""Test status dashboard handles missing release_name attribute gracefully."""
|
||||||
|
from app.views.status import status_dashboard
|
||||||
|
|
||||||
|
mock_exists.return_value = False
|
||||||
|
mock_providers.return_value = {}
|
||||||
|
mock_settings.version = "0.9.0"
|
||||||
|
mock_settings.build_date = "2024-01-01"
|
||||||
|
mock_settings.debug = False
|
||||||
|
mock_settings.git_sha = "abc123"
|
||||||
|
mock_settings.notification_urls = []
|
||||||
|
# Simulate settings without release_name attribute
|
||||||
|
del mock_settings.release_name
|
||||||
|
|
||||||
|
mock_request = Mock()
|
||||||
|
await status_dashboard(mock_request)
|
||||||
|
|
||||||
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
|
context = call_args[0][1]
|
||||||
|
assert context["release_name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestReleaseNamesJson:
|
||||||
|
"""Tests for the release_names.json data file."""
|
||||||
|
|
||||||
|
def test_release_names_json_is_valid(self):
|
||||||
|
"""Test that release_names.json is valid JSON."""
|
||||||
|
release_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json")
|
||||||
|
with open(release_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
assert "releases" in data
|
||||||
|
assert isinstance(data["releases"], dict)
|
||||||
|
|
||||||
|
def test_release_names_json_entries_have_codename(self):
|
||||||
|
"""Test that all entries in release_names.json have a codename."""
|
||||||
|
release_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json")
|
||||||
|
with open(release_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for version, entry in data["releases"].items():
|
||||||
|
assert "codename" in entry, f"Missing codename for version {version}"
|
||||||
|
assert isinstance(entry["codename"], str), f"Codename for {version} must be a string"
|
||||||
|
assert len(entry["codename"]) > 0, f"Codename for {version} must not be empty"
|
||||||
|
|
||||||
|
def test_release_names_json_codenames_are_unique(self):
|
||||||
|
"""Test that all codenames in release_names.json are unique."""
|
||||||
|
release_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "release_names.json")
|
||||||
|
with open(release_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
codenames = [entry["codename"] for entry in data["releases"].values()]
|
||||||
|
assert len(codenames) == len(set(codenames)), "Codenames must be unique"
|
||||||
Reference in New Issue
Block a user