diff --git a/.github/workflows/cicd-2-main-branch.yaml b/.github/workflows/cicd-2-main-branch.yaml index 1fa4f676..27e77282 100644 --- a/.github/workflows/cicd-2-main-branch.yaml +++ b/.github/workflows/cicd-2-main-branch.yaml @@ -4,6 +4,8 @@ on: push: branches: - main + tags: + - 'v*' workflow_dispatch: concurrency: @@ -11,7 +13,7 @@ concurrency: cancel-in-progress: true permissions: - contents: read + contents: write id-token: write attestations: write security-events: write @@ -26,7 +28,73 @@ jobs: uses: ./.github/workflows/stage-2-test.yaml secrets: inherit - build-stage: + release-stage: needs: test-stage + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + new_release_published: ${{ steps.release.outputs.new_release_published }} + new_release_version: ${{ steps.release.outputs.new_release_version }} + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read tool versions + id: tool-versions + run: | + echo "python=$(awk '/^python / {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "uv=$(awk '/^uv / {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: ${{ steps.tool-versions.outputs.python }} + + - name: Install uv + uses: astral-sh/setup-uv@887a942a15af3a7626099df99e897a18d9e5ab3a # v5.1.0 + with: + version: ${{ steps.tool-versions.outputs.uv }} + enable-cache: true + + - name: Run semantic-release + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + uv pip install python-semantic-release --system + + # Detect next version (--print exits without side effects) + VERSION=$(semantic-release version --print 2>/dev/null) || true + + if [[ -n "$VERSION" ]] && ! git ls-remote --tags origin "refs/tags/v$VERSION" | grep -q .; then + echo "Next version detected: $VERSION" + + # Create and push the tag (no commit needed, tag push is not blocked by branch protection) + git tag "v$VERSION" + git push origin "v$VERSION" + + echo "new_release_published=true" >> "$GITHUB_OUTPUT" + echo "new_release_version=v$VERSION" >> "$GITHUB_OUTPUT" + else + echo "No new release needed (version: ${VERSION:-none}, tag may already exist)" + echo "new_release_published=false" >> "$GITHUB_OUTPUT" + echo "new_release_version=" >> "$GITHUB_OUTPUT" + fi + + build-stage: + needs: [test-stage, release-stage] + if: | + always() && + needs.test-stage.result == 'success' && + ( + github.ref_type == 'tag' || + needs.release-stage.outputs.new_release_published == 'true' + ) uses: ./.github/workflows/stage-3-build.yaml + with: + version: ${{ needs.release-stage.outputs.new_release_version }} + new_release_published: ${{ needs.release-stage.outputs.new_release_published == 'true' }} secrets: inherit diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 00000000..71ad30be --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,32 @@ +name: "CodeQL Analysis" + +on: + pull_request: + branches: [main] + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: CodeQL Analysis + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: ['python'] + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@8c78abb9b62512e3c45dea6559ffd924ed8549c8 # v3.28.0 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@8c78abb9b62512e3c45dea6559ffd924ed8549c8 # v3.28.0 diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index fc02b17a..03b0ce1f 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -5,7 +5,6 @@ on: permissions: contents: read - security-events: write jobs: scan-secrets: @@ -21,22 +20,3 @@ jobs: - name: Scan secrets uses: ./.github/actions/scan-secrets - - analyze: - name: CodeQL Analysis - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - steps: - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Initialize CodeQL - uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 30cbc186..ae526675 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -2,9 +2,19 @@ name: "Stage 3: Build" on: workflow_call: + inputs: + version: + description: "Version to build (overrides detection)" + required: false + type: string + new_release_published: + description: "Whether a new release was published by the release stage" + required: false + type: boolean + default: false permissions: - contents: read + contents: write id-token: write attestations: write @@ -15,16 +25,24 @@ jobs: steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 - name: Determine version id: version run: | - SHORT_HASH="$(git rev-parse --short HEAD)" - echo "short_hash=${SHORT_HASH}" >> "$GITHUB_OUTPUT" - echo "artifact_name=gateway-${SHORT_HASH}.zip" >> "$GITHUB_OUTPUT" + if [[ -n "${{ inputs.version }}" ]]; then + VERSION="${{ inputs.version }}" + elif [[ "${{ github.ref_type }}" == "tag" ]]; then + VERSION="${{ github.ref_name }}" + else + VERSION="$(git rev-parse --short HEAD)" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "artifact_name=gateway-${VERSION}.zip" >> "$GITHUB_OUTPUT" - name: Build package - run: ./scripts/bash/package_release.sh "${{ runner.temp }}/dist" + run: ./scripts/bash/package_release.sh "${{ runner.temp }}/dist" "${{ steps.version.outputs.version }}" - name: Read tool versions id: tool-versions @@ -58,6 +76,24 @@ jobs: print('All service modules imported successfully') " + - name: Create or Update Release + if: github.ref_type == 'tag' || inputs.new_release_published + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + # Create the release if it doesn't exist. If it does, ignore the error. + gh release create "$VERSION" \ + --title "$VERSION" \ + --generate-notes \ + 2>/dev/null || echo "Release already exists, uploading assets..." + + # Upload or overwrite assets + gh release upload "$VERSION" \ + ${{ runner.temp }}/dist/gateway-*.zip \ + ${{ runner.temp }}/dist/gateway-*.zip.sha256 \ + --clobber + - name: Upload artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: diff --git a/README.md b/README.md index 71cfd89d..3d5d9f57 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,16 @@ Run manually: make githooks-run ``` +## Deployment + +### Windows Service + +The gateway is designed to run as a set of Windows Services on-premises. + +- **Deployment Guide**: [docs/deployment/windows-service-deploy.md](docs/deployment/windows-service-deploy.md) +- **Deployment Script**: `scripts/powershell/deploy.ps1` +- **Strategy**: Blue/Green with automatic rollback and health checks. + ## Architecture This gateway implements a lightweight DICOM service architecture: diff --git a/docs/deployment/16-Deploy-Script-Analysis.md b/docs/deployment/16-Deploy-Script-Analysis.md new file mode 100644 index 00000000..8b1174dd --- /dev/null +++ b/docs/deployment/16-Deploy-Script-Analysis.md @@ -0,0 +1,283 @@ +# 16. Deploy Script — Step-by-Step Analysis + +> **System**: manage-breast-screening-gateway +> **Script**: `scripts/powershell/deploy.ps1` +> **Strategy**: Blue/Green deployment with automatic rollback +> **Cross-references**: [Doc 15 - Deployment & Rollback Runbook](./15-Deployment-Rollback-Runbook.md) | [Doc 7 - DR/Backup](./7-DR-Backup-Strategy.md) + +--- + +## Overview + +The deploy script automates the full lifecycle of deploying the Manage Breast Screening Gateway onto a Windows Server VM. By default, it downloads the release package directly from GitHub Releases, with an override to use a local file instead. It follows a blue/green deployment model where a new release is prepared in isolation, services are atomically switched via a filesystem junction, and an automatic rollback is triggered if any service fails to start or crashes shortly after launch. + +--- + +## Step-by-Step Breakdown + +### Step 1: Logging Configuration + +```text +Lines 49–68 +``` + +**What it does**: Creates a `logs\deployments` directory under the install path and opens a timestamped log file. All subsequent operations write to both the console (colour-coded) and this file. + +**Why it matters**: Every deployment leaves a persistent audit trail. If a deployment fails at 2 AM and the operator investigates the next morning, the log file contains the exact sequence of events with millisecond timestamps. The dual-output approach (file + console) means nothing is lost if the terminal session disconnects. + +--- + +### Step 2: Helper Functions + +```text +Lines 70–163 +``` + +**What it does**: Defines internal helpers: +- `Invoke-Nssm` — Wraps every NSSM call and checks `$LASTEXITCODE`. Throws immediately with the full command string if NSSM returns a non-zero exit code. +- `Get-PythonVersionFromPyproject` — Parses `requires-python` from `pyproject.toml`. +- `Get-PythonVersionFromZip` — Reads `pyproject.toml` directly from a ZIP archive. +- `Stop-AllServices` — Gracefully stops all components with individual timeout enforcement. + +**Why it matters**: NSSM is a native executable and PowerShell does not treat its non-zero exit codes as errors by default. Without explicit checking, a failed `nssm install` or `nssm set` would silently proceed, creating a service that is misconfigured. The helper eliminates this class of bug across all ~12 NSSM invocations. + +--- + +### Step 3: Bootstrap (Optional) + +```text +Lines 165–206 +``` + +**What it does**: When `-Bootstrap` is passed, installs Chocolatey (if absent), Python, uv, and NSSM. Each tool is only installed if not already present in PATH. The Python version is resolved from a chain of sources: `-PythonVersion` parameter, local `pyproject.toml`, or `pyproject.toml` read directly from the ZIP archive. + +**Why it matters**: +- **Idempotent**: Running bootstrap twice does not reinstall tools that are already present (including Python). This makes the script safe to re-run after a partial failure without triggering Chocolatey "already installed" errors. +- **Dynamic Python version**: Rather than hardcoding a Python version, the script resolves it from a three-level chain: explicit parameter, local `pyproject.toml`, or the `pyproject.toml` inside the deployment archive. This means the packaging pipeline is the single source of truth. +- **Fails fast**: If no version can be determined from any source, the script throws before attempting any installation. + +--- + +### Step 4: Package Acquisition (GitHub Release Download) + +```text +Lines 208–258 +``` + +**What it does**: If no `-ZipPath` is provided, the script downloads the release package from GitHub Releases. It queries the GitHub API for the specified release tag (or `latest` by default), locates the `gateway-*.zip` asset and its `.sha256` checksum, and downloads both to a local `downloads` directory under the install path. When `-ZipPath` is provided, this step is skipped entirely. + +**Why it matters**: +- **Zero-touch default**: Operators can deploy with a single command (`.\deploy.ps1`) without manually downloading or transferring files. This removes a common source of human error — wrong version, corrupted transfer, or stale artifact. +- **Private repo support**: The optional `-GitHubToken` parameter adds a `Bearer` token to API requests, enabling downloads from private repositories without installing the `gh` CLI on the VM. +- **No external tooling**: Uses `Invoke-RestMethod` and `Invoke-WebRequest`, both built into PowerShell 5.1. No `gh` CLI, `curl`, or `wget` required on the target machine. +- **TLS enforcement**: Explicitly enables TLS 1.2+ before the API call, which is necessary on older Windows Server builds where PowerShell defaults to TLS 1.0. +- **Override escape hatch**: The `-ZipPath` parameter completely bypasses downloading, supporting air-gapped environments, manual testing, or CI pipelines that pre-stage the artifact. + +--- + +### Step 5: Prerequisite Validation + +```text +Lines 260–282 +``` + +**What it does**: Verifies that `python.exe`, `uv.exe`, and `nssm.exe` are in PATH, and that `ZipPath` points to an existing file (whether downloaded or provided). If a `.sha256` sidecar file exists, verifies the archive's SHA256 hash. + +**Why it matters**: +- **Uses `throw` instead of `exit 1`**: The script uses `throw` for all failures, which is safer than `exit 1`. When a PowerShell script using `exit` is dot-sourced or called from another script, `exit` terminates the entire host process. `throw` propagates an error that the caller can catch with `try/catch`, making the script composable. +- **SHA256 verification**: Catches corrupted downloads or tampered archives before any extraction occurs. This is a defense-in-depth measure against supply chain issues. + +--- + +### Step 6: Directory Structure Preparation + +```text +Lines 284–302 +``` + +**What it does**: Ensures the `releases`, `data`, and `logs` directories exist under the base install path. + +**Why it matters**: The `data` directory persists across deployments (it is never inside a release folder), so application databases and state survive upgrades. The separation of `releases` (immutable, versioned) from `data` (mutable, persistent) is a core tenet of the blue/green model. + +--- + +### Step 7: Package Extraction + +```text +Lines 304–367 +``` + +**What it does**: +1. Extracts the outer ZIP to a temporary staging directory. +2. Looks for an inner ZIP. If found, verifies its SHA256 (if a checksum file is present), then extracts it to the version directory. +3. Falls back to treating the outer ZIP as the application if no inner ZIP exists. +4. Handles nested folder structures (single top-level directory inside the archive). +5. Validates that `pyproject.toml` and `uv.lock` are present in the extracted package. + +**Why it matters**: +- **Staging directory**: Extraction happens in `%TEMP%`, not in the install path. If extraction fails, the install directory is untouched. The staging directory is cleaned up in a `finally` block, so it never leaks even on error. +- **Locked file handling**: When re-deploying the same version, the previous release directory may contain locked `.pyd` (native DLL) files from Python packages. The script clears file attributes before removal, preventing "Access denied" errors on compiled extension modules. +- **Double integrity verification**: Both the outer wrapper and the inner application archive can be checksummed independently. This catches corruption at the transport layer and at the packaging layer. +- **`uv.lock` validation**: The subsequent `uv sync --frozen` command requires a lockfile. Without this check, the script would fail with a confusing uv error message deep in the dependency resolution phase. Validating early gives a clear, actionable error. + +--- + +### Step 8: Virtual Environment Setup + +```text +Lines 368–390 +``` + +**What it does**: Creates a Python virtual environment inside the version directory using `uv venv`, then installs dependencies with `uv sync --frozen`. After dependency installation, pre-compiles the Python bytecache for both the application source and the standard library. Both external commands check `$LASTEXITCODE` and throw on failure. + +**Why it matters**: +- **Isolated per release**: Each release gets its own `.venv`. The previous release's environment is never modified. This is what makes rollback possible — the old environment is still intact. +- **Frozen sync**: `--frozen` means uv installs exactly what is in `uv.lock`, with no resolution. This guarantees the deployed dependencies are identical to what CI tested. No "works on my machine" drift. +- **Bytecache pre-compilation**: Python 3.14 compiles `.pyc` files for the standard library on first import. Without pre-compilation, this happens when services start under NSSM, taking long enough for NSSM to consider the service hung. Running `compileall` during deployment eliminates this first-run penalty. +- **Error propagation**: The `catch` block logs the error and re-throws (not `exit 1`), preserving the call stack for the caller. + +--- + +### Step 9: Service Helper Generation + +```text +Lines 392–404 +``` + +**What it does**: Generates `.bat` wrapper scripts for each of the four Gateway services (Relay, PACS, MWL, Upload). Each script sets the working directory, configures `PYTHONPATH=src`, and launches the Python entry point via the release's own virtual environment. + +**Why it matters**: +- **`PYTHONPATH=src`**: The entry points (e.g., `pacs_main.py`) import from sibling modules in `src/`. Without setting `PYTHONPATH`, Python cannot resolve these imports when running from the release root directory. This mirrors how the CI smoke test runs. +- **Location-independent**: By using `%~dp0` (the directory of the batch file itself), the scripts work whether accessed via the junction or directly. +- **Isolated environment**: The `.venv\Scripts\python.exe` path ensures the correct per-release virtual environment is always used. + +--- + +### Step 10: Capture Previous Version for Rollback + +```text +Lines 411–420 +``` + +**What it does**: Before any destructive action, reads the current junction's target path (if it exists) and stores it in `$previousVersionDir`. + +**Why it matters**: This is the safety net. If anything goes wrong during cutover, the script knows exactly where the last known-good release lives. Without this, a failed deployment would leave the system with no path back to a working state. + +--- + +### Step 11: Service Stop with Timeout + +```text +Lines 422–423 +``` + +**What it does**: Calls `Stop-AllServices` to gracefully stop each component with individual timeout enforcement. If a timeout is exceeded, the deployment aborts before touching the junction. + +**Why it matters**: +- **Graceful shutdown**: Sends a stop signal and gives the service time to finish in-flight DICOM transfers. +- **Abort before damage**: The junction has not been modified yet at this point. If stop fails, the system is still running the old version and no rollback is needed — the deployment simply aborts cleanly. + +--- + +### Step 12: Junction Swap (Atomic Cutover) + +```text +Lines 457–458 +``` + +**What it does**: Deletes the old `current` junction and creates a new one pointing to the new version directory. + +**Why it matters**: This is the atomic switch. From the perspective of NSSM and all service configurations, `current` is a stable path that never changes — only its target changes. This means services do not need to be reconfigured with new absolute paths on every deployment. + +--- + +### Step 13: Service Registration and Start + +```text +Lines 460–495 +``` + +**What it does**: For each service, removes any existing registration and creates a fresh one with NSSM. Configures stdout/stderr logging. Starts each service and tracks which ones were started successfully. + +**Why it matters**: +- **Clean registration**: Rather than trying to update existing services in-place, the script removes and re-registers each service on every deployment. This eliminates NSSM's internal "recently crashed" throttle counter that persists from previous failed deployments. +- **SCM synchronisation (Race Condition Protection)**: After removing a service, the script enters a polling loop that queries the Windows Service Control Manager (SCM) until the service is fully deregistered. In Windows, service deletion is asynchronous; attempting to `install` a service immediately after `remove` often fails with "service marked for deletion." This loop guarantees the SCM is ready before the next step. +- **Tracked starts**: `$startedServices` records which services were started. If a failure occurs partway through, only the services that were started are stopped during rollback — avoiding errors from trying to stop services that were never started. +- **Break on first failure**: If any service fails to start, the loop breaks immediately rather than continuing to start services that depend on a broken state. + +--- + +### Step 14: Post-Start Health Checks + +```text +Lines 497–519 +``` + +**What it does**: After all services start, polls each one repeatedly (default: 5 attempts, 2s apart) to verify it remains in `Running` state. If a service crashes (e.g., import error, missing config), the health check detects it. + +**Why it matters**: `Start-Service` succeeding only means the service control manager accepted the start request. The actual process could crash within milliseconds. The health check window catches: +- Missing Python dependencies despite `uv sync` +- Configuration errors (missing `.env` variables) +- Port conflicts with another process +- Import errors in the application code + +Without this check, a deployment that appears successful could leave the gateway fully down. + +--- + +### Step 15: Automatic Rollback + +```text +Lines 521–551 +``` + +**What it does**: If either service start or health check failed: +1. Stops all services started in the failed attempt +2. Re-points the junction to the previous release directory +3. Updates NSSM paths to point through the restored junction +4. Restarts all services with the previous version +5. Throws an error so the caller knows the deployment failed + +**Why it matters**: This is the core production-readiness guarantee. In a hospital environment, a non-functional gateway means mammography images cannot be transferred. Automatic rollback minimises the window of unavailability from "until an operator notices and acts" to "a few seconds while the script restores the previous version." + +If no previous version exists (first-ever deployment), the script logs a clear warning instead of crashing during rollback. + +--- + +### Step 16: Release Cleanup & Deferred Trash + +```text +Lines 425–454 +``` + +**What it does**: Before switching the junction, the script identifies old releases to prune (keeping the latest 3). If a directory cannot be deleted because a file is locked (common with `.pyd` native modules or antivirus scans), the script **moves** the directory to a `.trash-` folder. On the *next* deployment, the script first attempts to empty all `.trash` folders. + +**Why it matters**: +- **Zero-fail pruning**: Windows filesystem locks often cause "Access Denied" during recursive deletes. By moving locked folders to a "trash" area, the script ensures the primary `releases` directory stays clean and the deployment continues even if an old version is stubborn. +- **Disk management**: Disk space on hospital VMs is finite. Each release contains a full `.venv` which can be hundreds of megabytes. Keeping 3 releases provides: + - The current running version + - One previous version (for manual rollback investigation) + - One additional version for safety margin +- **Strategic timing**: Cleanup happens while services are stopped but before new ones start, maximizing the chance that file locks have been released. + +--- + +## Reliability Properties Summary + +| Property | How the Script Achieves It | +|----------|----------------------------| +| **Atomic cutover** | Filesystem junction swap — services always reference `current\`, only the target changes | +| **Automatic rollback** | Previous junction target is captured before cutover; restored if any service fails start or health check | +| **Automated acquisition** | Downloads from GitHub Releases by default; no manual file transfer needed. Supports private repos via token. `-ZipPath` override for air-gapped environments | +| **Integrity verification** | SHA256 checksums verified at both outer and inner archive levels | +| **Bytecache pre-compilation** | Runs `compileall` on source and stdlib after `uv sync`, eliminating Python 3.14's slow first-run `.pyc` compilation that causes NSSM to consider services hung | +| **Idempotent** | Bootstrap skips installed tools (including Python); service registration does clean remove + reinstall to clear NSSM throttle state | +| **Fail-fast** | All validation (prerequisites, archive integrity, `pyproject.toml`, `uv.lock`) happens before any destructive action | +| **Reproducible builds** | `uv sync --frozen` installs exact lockfile versions — no resolution, no drift | +| **Isolated releases** | Each version has its own `.venv` — old releases are never modified, enabling clean rollback | +| **Auditable** | Every operation is written to a timestamped log file with millisecond precision | +| **Composable** | Uses `throw` instead of `exit 1` — safe to call from other scripts, pipelines, or orchestrators | +| **Timeout-protected** | Service stops have configurable deadlines; deployment aborts before cutover if a service won't stop | +| **External tool safety** | Every NSSM invocation checks `$LASTEXITCODE` via `Invoke-Nssm` helper; SCM polling prevents race conditions during service re-installation | +| **Cleanup** | Staging directories are cleaned in `finally` blocks; old releases are pruned with a **deferred trash** system to handle Windows file locks | +| **ASCII-safe** | All script content is pure ASCII, preventing encoding corruption when files are transferred to Windows VMs with non-UTF-8 default encodings | diff --git a/docs/deployment/runbooks/cleanup.md b/docs/deployment/runbooks/cleanup.md new file mode 100644 index 00000000..c92a4f2b --- /dev/null +++ b/docs/deployment/runbooks/cleanup.md @@ -0,0 +1,127 @@ +# Cleanup Runbook + +> **Script**: `scripts/powershell/cleanup.ps1` +> **When to use**: Full VM reset for re-provisioning or test environment teardown +> **Destructive**: Yes -- removes all services, releases, data, and logs + +--- + +## When to Use + +| Situation | Action | +|-----------|--------| +| Test environment needs a fresh start | **Cleanup** | +| VM is being decommissioned | **Cleanup** | +| Deployment is corrupted beyond repair | **Cleanup**, then redeploy with `-Bootstrap` | +| Normal version upgrade | Use `deploy.ps1` instead -- do NOT clean up | +| Need to revert to a previous version | Use `rollback.ps1` instead -- do NOT clean up | + +## What Gets Removed + +| Item | Path | +|------|------| +| All `Gateway-*` Windows Services | Service Control Manager | +| All `DicomGatewayMock` services (if present) | Service Control Manager | +| Gateway installation directory | `C:\Program Files\NHS\ManageBreastScreeningGateway` | +| Mock installation directory (if present) | `C:\Apps\DicomGatewayMock` | +| Temporary staging directories | `%TEMP%\gateway-deploy-staging-*` | + +The cleanup does **not** remove: +- Chocolatey, Python, uv, or NSSM (installed system-wide) +- Any data outside the installation directories + +## Prerequisites + +- PowerShell must be running as Administrator +- No open file explorers, log viewers, or shells inside the installation directories + +## Procedure + +### 1. Confirm intent + +This is destructive and cannot be undone. Verify you are on the correct VM: + +```powershell +hostname +Get-Service Gateway-* | Format-Table Name, Status +``` + +### 2. Run cleanup + +```powershell +.\scripts\powershell\cleanup.ps1 +``` + +The script will: +1. Stop and remove all matching Windows Services (via NSSM if available, `sc.exe` as fallback) +2. Remove the `current` junction (junctions must be removed before their parent) +3. Remove all installation directories recursively +4. Remove any leftover staging directories in `%TEMP%` + +### 3. Verify + +```powershell +# No services should remain +Get-Service Gateway-* -ErrorAction SilentlyContinue + +# Installation directory should be gone +Test-Path "C:\Program Files\NHS\ManageBreastScreeningGateway" +``` + +### 4. Redeploy (if needed) + +After cleanup, bootstrap and deploy from scratch: + +```powershell +.\scripts\powershell\deploy.ps1 -Bootstrap +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `-Paths` | string[] | `C:\Program Files\NHS\ManageBreastScreeningGateway`, `C:\Apps\DicomGatewayMock` | Installation directories to remove | +| `-ServicePatterns` | string[] | `Gateway-*`, `DicomGatewayMock` | Service name patterns to stop and remove | + +### Custom paths + +```powershell +.\scripts\powershell\cleanup.ps1 -Paths "D:\CustomPath\Gateway" -ServicePatterns "Gateway-*" +``` + +## Troubleshooting + +### "Could not remove directory -- it may be in use" + +A file inside the directory is locked. Common causes: + +1. **Open PowerShell session** inside the directory -- close it and retry +2. **Log viewer** (e.g., `Get-Content -Wait`) has a file open -- close it +3. **Antivirus scan** on `.pyd` files -- wait a moment and retry +4. **Service still running** -- check for orphan processes: + +```powershell +Get-Process python* | Where-Object { $_.Path -like "*ManageBreastScreeningGateway*" } +# If found, kill them: +Get-Process python* | Where-Object { $_.Path -like "*ManageBreastScreeningGateway*" } | Stop-Process -Force +# Then retry cleanup +.\scripts\powershell\cleanup.ps1 +``` + +### Services still appear after cleanup + +The Service Control Manager can take a few seconds to fully deregister services. Wait and check again: + +```powershell +Start-Sleep -Seconds 5 +Get-Service Gateway-* -ErrorAction SilentlyContinue +``` + +If they persist, remove manually: + +```powershell +sc.exe delete "Gateway-Relay" +sc.exe delete "Gateway-PACS" +sc.exe delete "Gateway-MWL" +sc.exe delete "Gateway-Upload" +``` diff --git a/docs/deployment/runbooks/rollback.md b/docs/deployment/runbooks/rollback.md new file mode 100644 index 00000000..98183005 --- /dev/null +++ b/docs/deployment/runbooks/rollback.md @@ -0,0 +1,153 @@ +# Rollback Runbook + +> **Script**: `scripts/powershell/rollback.ps1` +> **When to use**: Production incident where the current version must be replaced immediately +> **Expected duration**: Under 30 seconds + +--- + +## Decision: Fix-Forward vs Rollback + +| Situation | Action | +|-----------|--------| +| Bug found in testing before it reaches users | Fix-forward -- deploy a corrected version | +| Non-critical issue in production | Fix-forward -- deploy a corrected version | +| Production is down or degraded, fix is not ready | **Rollback** | +| Service crashes immediately after deployment | **Rollback** | + +Rollback is the emergency path. The standard process is always fix-forward. + +## Prerequisites + +- The target version must exist in `\releases\` +- NSSM must be available in PATH +- PowerShell must be running as Administrator + +## Procedure + +### 1. Check current state + +```powershell +# Which version is running? +Split-Path (Get-Item "C:\Program Files\NHS\ManageBreastScreeningGateway\current").Target -Leaf + +# Are services running? +Get-Service Gateway-* | Format-Table Name, Status + +# What versions are available? +Get-ChildItem "C:\Program Files\NHS\ManageBreastScreeningGateway\releases" -Directory | + Sort-Object CreationTime -Descending | + Format-Table Name, CreationTime +``` + +### 2. Execute rollback + +**Roll back to the most recent previous version (default):** + +```powershell +.\scripts\powershell\rollback.ps1 +``` + +**Roll back to a specific version:** + +```powershell +.\scripts\powershell\rollback.ps1 -Version "1.2.0" +``` + +The script will: +1. Identify the current active version +2. Validate the target release has `pyproject.toml` and service `.bat` helpers +3. Stop all running services (with timeout enforcement) +4. Switch the `current` junction to the target release +5. Re-register all services via NSSM (clears throttle state) +6. Start all services +7. Run health checks to confirm services are stable + +### 3. Verify + +```powershell +# Confirm the active version changed +Split-Path (Get-Item "C:\Program Files\NHS\ManageBreastScreeningGateway\current").Target -Leaf + +# Confirm all services are running +Get-Service Gateway-* | Format-Table Name, Status +``` + +### 4. Review logs + +```powershell +# Open the rollback log +Get-ChildItem "C:\Program Files\NHS\ManageBreastScreeningGateway\logs\deployments\rollback-*" | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 | + ForEach-Object { Get-Content $_.FullName } +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `-Version` | string | *(most recent previous)* | Specific release version to roll back to | +| `-BaseInstallPath` | string | `C:\Program Files\NHS\ManageBreastScreeningGateway` | Root installation directory | +| `-ServiceStopTimeoutSeconds` | int | `30` | Maximum seconds to wait for each service to stop | +| `-HealthCheckRetries` | int | `5` | Number of post-start health check attempts | +| `-HealthCheckIntervalSeconds` | int | `2` | Seconds between health check attempts | + +## Automatic Rollback (during deployment) + +`deploy.ps1` triggers an automatic rollback if: + +- Any service fails to start during cutover +- Any service fails the post-start health check (crashes within the check window) + +The automatic rollback: +1. Stops all services started in the failed attempt +2. Re-points the `current` junction to the previous release +3. Updates NSSM service paths +4. Restarts all services with the previous version + +No manual intervention is needed. Check the deployment log for details. + +## Troubleshooting + +### "No previous version available for rollback" + +Only the current version exists in `releases\`. Either this is the first deployment, or old releases were cleaned up. You need to redeploy a known-good version using `deploy.ps1`. + +### "Version 'X' not found" + +The specified version directory does not exist. List available versions: + +```powershell +Get-ChildItem "C:\Program Files\NHS\ManageBreastScreeningGateway\releases" -Directory +``` + +### "Service did not stop within timeout" + +A service is hung. Increase the timeout or force-kill manually: + +```powershell +# Retry with longer timeout +.\scripts\powershell\rollback.ps1 -ServiceStopTimeoutSeconds 120 + +# Or kill the process manually, then retry +Get-Process python* | Where-Object { $_.Path -like "*ManageBreastScreeningGateway*" } | Stop-Process -Force +.\scripts\powershell\rollback.ps1 +``` + +### "NSSM install failed" + +NSSM could not register the service. Check that the `.bat` helper files exist in the target release: + +```powershell +Get-ChildItem "C:\Program Files\NHS\ManageBreastScreeningGateway\releases\\start-Gateway-*.bat" +``` + +If missing, the release is incomplete. Deploy a fresh version instead. + +### Health check fails after rollback + +The rolled-back version may have a configuration issue (missing `.env` variables, port conflict). Check the service log: + +```powershell +Get-Content "C:\Program Files\NHS\ManageBreastScreeningGateway\logs\Gateway-.log" -Tail 50 +``` diff --git a/docs/deployment/windows-service-deploy.md b/docs/deployment/windows-service-deploy.md new file mode 100644 index 00000000..18b83eda --- /dev/null +++ b/docs/deployment/windows-service-deploy.md @@ -0,0 +1,205 @@ +# Deployment Guide + +> **Script**: `scripts/powershell/deploy.ps1` +> **Target OS**: Windows Server (PowerShell 5.1+) +> **Strategy**: Blue/Green with automatic rollback + +--- + +## Prerequisites + +The following tools must be available on the target machine. Use the `-Bootstrap` flag to install them automatically via Chocolatey, or install them manually. + +| Tool | Purpose | +|------|---------| +| Python 3.14+ | Application runtime | +| [uv](https://docs.astral.sh/uv/) | Python package and virtualenv manager | +| [NSSM](https://nssm.cc/) | Non-Sucking Service Manager for Windows Services | + +## Quick Start + +### Deploy the latest GitHub release (default) + +```powershell +.\scripts\powershell\deploy.ps1 +``` + +This downloads the latest release from `NHSDigital/manage-breast-screening-gateway`, verifies its checksum, and deploys it. + +### First-time deployment (bootstraps tools + deploys latest release) + +```powershell +.\scripts\powershell\deploy.ps1 -Bootstrap +``` + +### Deploy a specific release tag + +```powershell +.\scripts\powershell\deploy.ps1 -ReleaseTag "v1.2.0" +``` + +### Deploy from a local package (skip download) + +```powershell +.\scripts\powershell\deploy.ps1 -ZipPath "C:\Packages\gateway-1.0.0.zip" +``` + +When `-ZipPath` is provided, the GitHub download step is skipped entirely. + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `-ZipPath` | string | *(download from GitHub)* | Local path to a deployment archive. When set, skips GitHub download | +| `-GitHubRepo` | string | `NHSDigital/manage-breast-screening-gateway` | GitHub repository in `owner/repo` format | +| `-ReleaseTag` | string | `latest` | GitHub release tag to download (e.g., `v1.2.0`) | +| `-GitHubToken` | string | *(none)* | GitHub personal access token. Required for private repos | +| `-BaseInstallPath` | string | `C:\Program Files\NHS\ManageBreastScreeningGateway` | Root installation directory | +| `-Bootstrap` | switch | `$false` | Install Chocolatey, Python, uv, and NSSM if missing | +| `-PythonVersion` | string | *(from pyproject.toml)* | Override the Python version installed during bootstrap (e.g., `3.14`) | +| `-KeepReleases` | int | `3` | Number of previous release directories to retain | +| `-ServiceStopTimeoutSeconds` | int | `30` | Maximum seconds to wait for each service to stop | +| `-HealthCheckRetries` | int | `5` | Number of post-start health check attempts per service | +| `-HealthCheckIntervalSeconds` | int | `2` | Seconds between health check attempts | + +## Directory Layout + +After a successful deployment the installation directory has this structure: + +```text +C:\Program Files\NHS\ManageBreastScreeningGateway\ + current\ # Junction (symlink) pointing to the active release + releases\ + 1.0.0\ # Versioned release directory + 1.1.0\ # Each contains its own .venv, source, and .bat helpers + downloads\ # GitHub release artifacts downloaded by the script + data\ # Persistent application data (survives upgrades) + logs\ + deployments\ # Timestamped deployment and rollback log files + Gateway-Relay.log # Per-service stdout/stderr captured by NSSM + Gateway-PACS.log + Gateway-MWL.log + Gateway-Upload.log +``` + +## Windows Services + +The script manages four Windows Services via NSSM: + +| Service Name | Entry Point | Description | +|--------------|-------------|-------------| +| `Gateway-Relay` | `src/relay_listener.py` | Azure Relay listener for worklist actions | +| `Gateway-PACS` | `src/pacs_main.py` | DICOM PACS server (C-STORE SCP) | +| `Gateway-MWL` | `src/mwl_main.py` | Modality Worklist server (C-FIND SCP) | +| `Gateway-Upload` | `src/upload_main.py` | Image upload processor | + +Each service runs via a generated `.bat` wrapper that sets `PYTHONPATH=src` and launches the entry point using the release's own `.venv\Scripts\python.exe`. + +All services are registered with `SERVICE_AUTO_START` so they survive reboots. On each deployment, existing services are removed and re-registered from scratch to avoid NSSM throttle state issues from previous failed deployments. + +## Package Format + +The script accepts two package formats: + +1. **Wrapper ZIP** (recommended) -- A ZIP containing an inner application ZIP and its `.sha256` checksum. Both layers are integrity-verified. +2. **Direct ZIP** -- A ZIP containing the application source directly (`pyproject.toml` at root). + +The archive filename determines the version string. Expected naming convention: + +``` +gateway-.zip # e.g. gateway-1.2.3.zip +gateway-.zip.sha256 # Optional outer checksum +``` + +The package **must** include both `pyproject.toml` and `uv.lock` at its root (after extraction). + +## Integrity Verification + +SHA256 checksums are verified at two levels: + +1. **Outer archive** -- If a `.sha256` file exists alongside the ZIP, the script verifies the outer archive before extraction. +2. **Inner archive** -- If the wrapper ZIP contains an inner `.sha256` file, the inner application ZIP is verified before final extraction. + +If either check fails, the deployment aborts immediately. + +## Rollback + +The standard process is **fix-forward** -- deploy a corrected version using `deploy.ps1`. For urgent situations, use the standalone rollback script. See the [Rollback Runbook](./runbooks/rollback.md) for full procedures. + +`deploy.ps1` also rolls back automatically if any service fails to start or fails its post-start health check. + +## Cleanup + +To completely reset the VM, see the [Cleanup Runbook](./runbooks/cleanup.md). + +## Logs + +Every deployment creates a timestamped log file at: + +``` +\logs\deployments\deployment-YYYYMMDD-HHmmss.log +``` + +Rollback operations write to `rollback-YYYYMMDD-HHmmss.log` in the same directory. + +Each line includes a timestamp, severity level (`INFO`, `WARNING`, `ERROR`, `SUCCESS`), and message. Check these logs first when troubleshooting. + +## Useful Commands + +```powershell +# Check which version is currently active +Split-Path (Get-Item "C:\Program Files\NHS\ManageBreastScreeningGateway\current").Target -Leaf + +# Check service status +Get-Service Gateway-* | Format-Table Name, Status + +# View recent deployment logs +Get-ChildItem "C:\Program Files\NHS\ManageBreastScreeningGateway\logs\deployments" | + Sort-Object LastWriteTime -Descending | Select-Object -First 5 +``` + +## Examples + +### Deploy latest release (simplest) + +```powershell +.\scripts\powershell\deploy.ps1 +``` + +### Deploy a specific tagged release + +```powershell +.\scripts\powershell\deploy.ps1 -ReleaseTag "v2.0.0" +``` + +### Deploy from a private repo + +```powershell +.\scripts\powershell\deploy.ps1 -GitHubToken $env:GITHUB_TOKEN +``` + +### Deploy from a local package (skip download) + +```powershell +.\scripts\powershell\deploy.ps1 -ZipPath "C:\Packages\gateway-2.0.0.zip" +``` + +### Bootstrap with explicit Python version + +```powershell +.\scripts\powershell\deploy.ps1 -Bootstrap -PythonVersion "3.14" +``` + +### Deploy with custom health check settings + +```powershell +.\scripts\powershell\deploy.ps1 ` + -HealthCheckRetries 10 ` + -HealthCheckIntervalSeconds 5 +``` + +### Keep more release history + +```powershell +.\scripts\powershell\deploy.ps1 -KeepReleases 5 +``` diff --git a/pyproject.toml b/pyproject.toml index b89f16e5..eab32a0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dev = [ "pyright>=1.1.390", "ipdb>=0.13.13,<0.14", "pytest-asyncio>=1.3.0,<1.4.0", + "python-semantic-release>=10.5.3", ] [tool.uv] @@ -35,6 +36,35 @@ package = false requires = ["hatchling"] build-backend = "hatchling.build" +[tool.semantic_release] +version_variable = ["pyproject.toml:version"] +upload_to_release = true +build_command = "true" # We use our own build script in Stage 3 + +[tool.semantic_release.branches.main] +match = "main" + + +[tool.semantic_release.changelog] +template_dir = "templates" +exclude_commit_patterns = [ + "chore\\(release\\):", + "chore\\(deps-dev\\):", + "chore\\(deps\\):", +] + +[tool.semantic_release.changelog.default_templates] +changelog_file = "CHANGELOG.md" + +[tool.semantic_release.commit_author] +env = "GIT_COMMIT_AUTHOR" +default = "github-actions " + +[tool.semantic_release.commit_parser_options] +allowed_tags = ["build", "chore", "ci", "docs", "feat", "fix", "perf", "style", "refactor", "test"] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/scripts/bash/package_release.sh b/scripts/bash/package_release.sh index d2520081..2b892f6f 100755 --- a/scripts/bash/package_release.sh +++ b/scripts/bash/package_release.sh @@ -45,15 +45,17 @@ if [[ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]]; then echo "" fi -# ── Version from Git ────────────────────────────────────────────────────────── +# ── Version from Git or Argument ────────────────────────────────────────────── SHORT_HASH="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" -ARTIFACT_NAME="gateway-${SHORT_HASH}.zip" +VERSION_NAME="${2:-$SHORT_HASH}" +ARTIFACT_NAME="gateway-${VERSION_NAME}.zip" echo "========================================" echo "Packaging Gateway Release" echo "========================================" echo "Commit: ${SHORT_HASH}" +echo "Version: ${VERSION_NAME}" echo "Output: ${OUTPUT_DIR}/${ARTIFACT_NAME}" echo "" diff --git a/scripts/powershell/cleanup.ps1 b/scripts/powershell/cleanup.ps1 new file mode 100644 index 00000000..d631f2d7 --- /dev/null +++ b/scripts/powershell/cleanup.ps1 @@ -0,0 +1,80 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Reset the VM by removing all Gateway and Mock services and directories. +.DESCRIPTION + Stops and deletes Windows services (using NSSM/sc) and removes installation folders. +#> + +[CmdletBinding()] +param( + [Parameter()] + [string[]]$Paths = @( + "C:\Program Files\NHS\ManageBreastScreeningGateway", + "C:\Apps\DicomGatewayMock" + ), + + [Parameter()] + [string[]]$ServicePatterns = @( + "Gateway-*", + "DicomGatewayMock" + ) +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Continue" # Continue on errors to ensure we try to clean everything + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "VM Reset / Cleanup Started" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +# 1. Identify and Stop Services +foreach ($pattern in $ServicePatterns) { + $services = Get-Service -Name $pattern -ErrorAction SilentlyContinue + foreach ($svc in $services) { + Write-Host "Processing Service: $($svc.Name)" -ForegroundColor Yellow + + if ($svc.Status -eq 'Running') { + Write-Host " Stopping service..." -ForegroundColor Gray + Stop-Service -Name $svc.Name -Force -ErrorAction SilentlyContinue + } + + Write-Host " Removing service..." -ForegroundColor Gray + # Try removing with nssm first (if available) as it cleans up registry better + $nssm = Get-Command nssm.exe -ErrorAction SilentlyContinue + if ($nssm) { + & $nssm.Source remove $svc.Name confirm | Out-Null + } else { + # Fallback to standard sc.exe + & sc.exe delete $svc.Name | Out-Null + } + } +} + +# 2. Remove Installation Directories +foreach ($path in $Paths) { + if (Test-Path $path) { + Write-Host "Removing Directory: $path" -ForegroundColor Yellow + # Junctions can be tricky; remove the 'current' junction first if it exists + $current = Join-Path $path "current" + if (Test-Path $current) { + Write-Host " Removing junction..." -ForegroundColor Gray + Remove-Item -Path $current -Force -ErrorAction SilentlyContinue + } + + try { + Remove-Item -Path $path -Recurse -Force + Write-Host " Directory removed successfully." -ForegroundColor Green + } catch { + Write-Host " Warning: Could not remove $path. It may be in use. Check for open shells or log viewers." -ForegroundColor Red + } + } +} + +# 3. Clean Staging / Temp +$tempStaging = Join-Path $env:TEMP "gateway-deploy-staging-*" +Get-Item $tempStaging -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "`n========================================" -ForegroundColor Green +Write-Host "Cleanup Complete. The VM is ready for a fresh deployment." -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green diff --git a/scripts/powershell/deploy.ps1 b/scripts/powershell/deploy.ps1 new file mode 100644 index 00000000..2d4ddb49 --- /dev/null +++ b/scripts/powershell/deploy.ps1 @@ -0,0 +1,554 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Deploy the Manage Breast Screening Gateway using a Blue/Green strategy. +.DESCRIPTION + Automates environment bootstrapping (Choco, Python, uv), package extraction, + virtual environment setup, and Windows Service management for the Gateway. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$ZipPath, + + [Parameter()] + [string]$BaseInstallPath = "C:\Program Files\NHS\ManageBreastScreeningGateway", + + [Parameter()] + [switch]$Bootstrap, + + [Parameter()] + [int]$KeepReleases = 3, + + [Parameter()] + [string]$PythonVersion, + + [Parameter()] + [int]$ServiceStopTimeoutSeconds = 30, + + [Parameter()] + [int]$HealthCheckRetries = 5, + + [Parameter()] + [int]$HealthCheckIntervalSeconds = 2, + + [Parameter()] + [string]$GitHubRepo = "NHSDigital/manage-breast-screening-gateway", + + [Parameter()] + [string]$ReleaseTag = "latest", + + [Parameter()] + [string]$GitHubToken +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# -- Logging ------------------------------------------------------------------ + +$deploymentLogsDir = Join-Path $BaseInstallPath "logs\deployments" +if (-not (Test-Path $deploymentLogsDir)) { + New-Item -ItemType Directory -Path $deploymentLogsDir -Force | Out-Null +} +$deploymentLogFile = Join-Path $deploymentLogsDir "deployment-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" + +function Write-Log { + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $logEntry = "[$timestamp] [$Level] $Message" + Add-Content -Path $deploymentLogFile -Value $logEntry + switch ($Level) { + "ERROR" { Write-Host $logEntry -ForegroundColor Red } + "WARNING" { Write-Host $logEntry -ForegroundColor Yellow } + "SUCCESS" { Write-Host $logEntry -ForegroundColor Green } + default { Write-Host $logEntry -ForegroundColor Gray } + } +} + +# -- Helpers ------------------------------------------------------------------ + +function Invoke-Nssm { + param([string]$NssmPath, [string[]]$Arguments, [string]$Description) + & $NssmPath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "NSSM failed (exit $LASTEXITCODE): $Description -- nssm $($Arguments -join ' ')" + } +} + +function Get-PythonVersionFromPyproject { + param([string]$PyprojectPath) + if (-not (Test-Path $PyprojectPath)) { return $null } + $content = Get-Content $PyprojectPath -Raw + if ($content -match 'requires-python\s*=\s*">=(\d+\.\d+)') { return $Matches[1] } + return $null +} + +function Get-PythonVersionFromZip { + param([string]$ArchivePath) + if (-not $ArchivePath -or -not (Test-Path $ArchivePath)) { return $null } + Add-Type -Assembly System.IO.Compression.FileSystem + $zip = $null + try { + $zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) + $entry = $zip.Entries | Where-Object { $_.Name -eq "pyproject.toml" } | Select-Object -First 1 + + if ($entry) { + $reader = New-Object System.IO.StreamReader($entry.Open()) + $content = $reader.ReadToEnd() + $reader.Close() + if ($content -match 'requires-python\s*=\s*">=(\d+\.\d+)') { return $Matches[1] } + } + + # Check for inner ZIP (wrapper package from CI artifacts) + $innerZipEntry = $zip.Entries | Where-Object { $_.FullName -like "*.zip" } | Select-Object -First 1 + if ($innerZipEntry) { + $zip.Dispose(); $zip = $null + $tempInner = Join-Path $env:TEMP "deploy-pyver-$([guid]::NewGuid().ToString().Substring(0,8)).zip" + try { + $outerZip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) + $innerEntry = $outerZip.Entries | Where-Object { $_.FullName -eq $innerZipEntry.FullName } | Select-Object -First 1 + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($innerEntry, $tempInner, $true) + $outerZip.Dispose() + + $innerZip = [System.IO.Compression.ZipFile]::OpenRead($tempInner) + $innerPyproject = $innerZip.Entries | Where-Object { $_.Name -eq "pyproject.toml" } | Select-Object -First 1 + if ($innerPyproject) { + $reader = New-Object System.IO.StreamReader($innerPyproject.Open()) + $content = $reader.ReadToEnd() + $reader.Close() + if ($content -match 'requires-python\s*=\s*">=(\d+\.\d+)') { + $innerZip.Dispose() + return $Matches[1] + } + } + $innerZip.Dispose() + } finally { + if (Test-Path $tempInner) { Remove-Item $tempInner -Force -ErrorAction SilentlyContinue } + } + } + } catch { + Write-Log "Could not read Python version from archive: $_" "WARNING" + return $null + } finally { + if ($zip) { $zip.Dispose() } + } + return $null +} + +function Stop-AllServices { + param([array]$Services, [int]$TimeoutSeconds) + foreach ($svc in $Services) { + $status = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if (-not $status -or $status.Status -eq 'Stopped') { continue } + + Write-Log "Stopping $($svc.Name) (timeout: ${TimeoutSeconds}s)..." "INFO" + Stop-Service -Name $svc.Name -Force + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $current = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if (-not $current -or $current.Status -eq 'Stopped') { break } + Start-Sleep -Milliseconds 500 + } + $stopwatch.Stop() + + $finalStatus = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($finalStatus -and $finalStatus.Status -ne 'Stopped') { + throw "Service $($svc.Name) did not stop within ${TimeoutSeconds}s (state: $($finalStatus.Status))." + } + Write-Log "$($svc.Name) stopped in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s." "INFO" + } +} + +# -- Bootstrap ---------------------------------------------------------------- + +if ($Bootstrap) { + Write-Log "Bootstrapping system environment..." "INFO" + + if (-not (Get-Command choco.exe -ErrorAction SilentlyContinue)) { + Write-Log "Installing Chocolatey..." "INFO" + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + $env:Path += ";$env:ALLUSERSPROFILE\chocolatey\bin" + } + + $existingPython = Get-Command python.exe -ErrorAction SilentlyContinue + if ($existingPython) { + Write-Log "Python already installed: $($existingPython.Source)" "INFO" + } else { + $targetPythonVersion = $PythonVersion + if (-not $targetPythonVersion) { + $targetPythonVersion = Get-PythonVersionFromPyproject (Join-Path $PSScriptRoot "..\..\pyproject.toml") + } + if (-not $targetPythonVersion -and $ZipPath) { + Write-Log "Reading Python version from package archive..." "INFO" + $targetPythonVersion = Get-PythonVersionFromZip $ZipPath + } + if (-not $targetPythonVersion) { + throw "Python version could not be determined. Supply -PythonVersion, ensure pyproject.toml is accessible, or provide a ZipPath containing pyproject.toml." + } + Write-Log "Installing Python $targetPythonVersion..." "INFO" + choco install python --version "$targetPythonVersion.0" -y --no-progress + } + + if (-not (Get-Command uv.exe -ErrorAction SilentlyContinue)) { + Write-Log "Installing uv..." "INFO" + choco install uv -y --no-progress + } + + if (-not (Get-Command nssm.exe -ErrorAction SilentlyContinue)) { + Write-Log "Installing NSSM..." "INFO" + choco install nssm -y --no-progress + } +} + +# -- Package Acquisition ------------------------------------------------------ + +$downloadDir = Join-Path $BaseInstallPath "downloads" +if (-not (Test-Path $downloadDir)) { + New-Item -ItemType Directory -Path $downloadDir -Force | Out-Null +} + +if (-not $ZipPath) { + Write-Log "Downloading from GitHub release..." "INFO" + + if ($ReleaseTag -eq "latest") { + $apiUrl = "https://api.github.com/repos/$GitHubRepo/releases/latest" + } else { + $apiUrl = "https://api.github.com/repos/$GitHubRepo/releases/tags/$ReleaseTag" + } + + $headers = @{ "Accept" = "application/vnd.github+json"; "User-Agent" = "Gateway-Deploy-Script" } + if ($GitHubToken) { $headers["Authorization"] = "Bearer $GitHubToken" } + + Write-Log "Querying release: $apiUrl" "INFO" + try { + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + $release = Invoke-RestMethod -Uri $apiUrl -Headers $headers -ErrorAction Stop + } catch { + throw "Could not retrieve release from $apiUrl. If the repo is private, supply -GitHubToken. Error: $_" + } + + Write-Log "Release found: $($release.tag_name) - $($release.name)" "INFO" + + $zipAsset = $release.assets | Where-Object { $_.name -like "gateway-*.zip" -and $_.name -notlike "*.sha256" } | Select-Object -First 1 + if (-not $zipAsset) { + throw "No gateway-*.zip asset in release $($release.tag_name). Available: $(($release.assets | ForEach-Object { $_.name }) -join ', ')" + } + + $shaAsset = $release.assets | Where-Object { $_.name -eq "$($zipAsset.name).sha256" } | Select-Object -First 1 + + $ZipPath = Join-Path $downloadDir $zipAsset.name + $downloadHeaders = @{ "Accept" = "application/octet-stream"; "User-Agent" = "Gateway-Deploy-Script" } + if ($GitHubToken) { $downloadHeaders["Authorization"] = "Bearer $GitHubToken" } + + $sizeMB = [math]::Round($zipAsset.size / 1MB, 1) + Write-Log "Downloading $($zipAsset.name) ($sizeMB MB)..." "INFO" + Invoke-WebRequest -Uri $zipAsset.browser_download_url -Headers $downloadHeaders -OutFile $ZipPath -UseBasicParsing -ErrorAction Stop + Write-Log "Downloaded to $ZipPath" "SUCCESS" + + if ($shaAsset) { + $shaDownloadPath = Join-Path $downloadDir $shaAsset.name + Invoke-WebRequest -Uri $shaAsset.browser_download_url -Headers $downloadHeaders -OutFile $shaDownloadPath -UseBasicParsing -ErrorAction Stop + Write-Log "Downloaded checksum" "SUCCESS" + } +} + +# -- Validation --------------------------------------------------------------- + +$pythonExe = Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +if (-not $pythonExe) { throw "Python not found in PATH. Run with -Bootstrap or install manually." } + +$uvExe = Get-Command uv.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +if (-not $uvExe) { throw "uv not found in PATH. Run with -Bootstrap or install manually." } + +$nssmExe = Get-Command nssm.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +if (-not $nssmExe) { throw "NSSM not found in PATH. Run with -Bootstrap or install manually." } + +if (-not (Test-Path $ZipPath)) { throw "Package not found at $ZipPath." } + +$shaPath = "$ZipPath.sha256" +if (Test-Path $shaPath) { + Write-Log "Verifying archive integrity..." "INFO" + $expectedHash = (Get-Content $shaPath).Split(' ')[0].Trim() + $actualHash = (Get-FileHash -Path $ZipPath -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $expectedHash.ToLower()) { + throw "SHA256 mismatch. Expected: $expectedHash, Actual: $actualHash" + } + Write-Log "Integrity check passed." "SUCCESS" +} + +# -- Prepare Structure -------------------------------------------------------- + +$releasesDir = Join-Path $BaseInstallPath "releases" +$dataDir = Join-Path $BaseInstallPath "data" +$logsDir = Join-Path $BaseInstallPath "logs" +$currentJunction = Join-Path $BaseInstallPath "current" + +foreach ($dir in @($releasesDir, $dataDir, $logsDir)) { + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } +} + +$services = @( + @{ Name = "Gateway-Relay"; Script = "relay_listener.py" }, + @{ Name = "Gateway-PACS"; Script = "pacs_main.py" }, + @{ Name = "Gateway-MWL"; Script = "mwl_main.py" }, + @{ Name = "Gateway-Upload"; Script = "upload_main.py" } +) + +# -- Extraction --------------------------------------------------------------- + +$version = [System.IO.Path]::GetFileNameWithoutExtension($ZipPath) -replace 'gateway-', '' +$versionDir = Join-Path $releasesDir $version + +Write-Log "Deploying version: $version" "INFO" + +# If redeploying the same version, stop services to release .pyd file locks +if (Test-Path $versionDir) { + Write-Log "Version directory exists. Stopping services to release file locks..." "WARNING" + Stop-AllServices -Services $services -TimeoutSeconds $ServiceStopTimeoutSeconds +} + +$stagingDir = Join-Path $env:TEMP "gateway-deploy-staging-$([guid]::NewGuid().ToString().Substring(0,8))" +New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null + +Add-Type -Assembly System.IO.Compression.FileSystem + +try { + Write-Log "Extracting package..." "INFO" + [System.IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $stagingDir) + + $innerZip = Get-ChildItem -Path $stagingDir -Filter "*.zip" | Select-Object -First 1 + + if ($innerZip) { + Write-Log "Detected inner package: $($innerZip.Name)" "INFO" + + # Verify inner archive integrity if checksum present + $innerSha = Get-ChildItem -Path $stagingDir -Filter "$($innerZip.Name).sha256" | Select-Object -First 1 + if ($innerSha) { + $expectedHash = (Get-Content $innerSha.FullName).Split(' ')[0].Trim() + $actualHash = (Get-FileHash -Path $innerZip.FullName -Algorithm SHA256).Hash.ToLower() + if ($actualHash -ne $expectedHash.ToLower()) { + throw "Inner archive SHA256 mismatch. Expected: $expectedHash, Actual: $actualHash" + } + Write-Log "Inner integrity check passed." "SUCCESS" + } + + if (Test-Path $versionDir) { Remove-Item -Path $versionDir -Recurse -Force -Confirm:$false } + [System.IO.Compression.ZipFile]::ExtractToDirectory($innerZip.FullName, $versionDir) + } else { + if (Test-Path $versionDir) { Remove-Item -Path $versionDir -Recurse -Force -Confirm:$false } + Move-Item -Path $stagingDir -Destination $versionDir + } +} finally { + if (Test-Path $stagingDir) { Remove-Item -Path $stagingDir -Recurse -Force -Confirm:$false } +} + +# Flatten nested folder structure (single top-level directory inside archive) +$extractedItems = Get-ChildItem -Path $versionDir +if ($extractedItems.Count -eq 1 -and $extractedItems[0].PSIsContainer) { + Write-Log "Flattening nested folder structure..." "INFO" + $nestedPath = $extractedItems[0].FullName + Get-ChildItem -Path $nestedPath | Move-Item -Destination $versionDir + Remove-Item -Path $nestedPath -Force +} + +if (-not (Test-Path (Join-Path $versionDir "pyproject.toml"))) { + throw "pyproject.toml not found in extracted package at $versionDir." +} +if (-not (Test-Path (Join-Path $versionDir "uv.lock"))) { + throw "uv.lock not found in extracted package at $versionDir." +} + +# -- Environment Setup -------------------------------------------------------- + +Write-Log "Setting up virtual environment..." "INFO" +Push-Location $versionDir +try { + & $uvExe venv --python $pythonExe + if ($LASTEXITCODE -ne 0) { throw "uv venv failed (exit $LASTEXITCODE)" } + + & $uvExe sync --frozen + if ($LASTEXITCODE -ne 0) { throw "uv sync failed (exit $LASTEXITCODE)" } + + # Pre-compile bytecache to avoid slow first-run compilation under NSSM + Write-Log "Pre-compiling bytecache..." "INFO" + $venvPythonExe = Join-Path $versionDir ".venv\Scripts\python.exe" + $srcDir = Join-Path $versionDir "src" + & $venvPythonExe -m compileall -q $srcDir + & $venvPythonExe -c "import compileall; compileall.compile_path(quiet=1)" +} catch { + Write-Log "Environment setup failed: $_" "ERROR" + throw +} finally { + Pop-Location +} + +# -- Service Helpers ---------------------------------------------------------- + +foreach ($svc in $services) { + $batPath = Join-Path $versionDir "start-$($svc.Name).bat" + $scriptPath = Join-Path "src" $svc.Script + $batContent = @( + '@echo off', + 'cd /d "%~dp0"', + 'set "PYTHONPATH=src"', + ('".venv\Scripts\python.exe" "' + $scriptPath + '"') + ) -join "`r`n" + [System.IO.File]::WriteAllText($batPath, $batContent, [System.Text.Encoding]::ASCII) +} + +# -- Cutover ------------------------------------------------------------------ + +Write-Log "Starting cutover..." "INFO" +$cutoverStart = Get-Date + +# Capture previous junction target for rollback +$previousVersionDir = $null +if (Test-Path $currentJunction) { + $junctionItem = Get-Item $currentJunction + if ($junctionItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + $previousVersionDir = $junctionItem.Target + if ($previousVersionDir -is [array]) { $previousVersionDir = $previousVersionDir[0] } + Write-Log "Previous version: $previousVersionDir" "INFO" + } +} + +# Stop services (skips already-stopped services from redeploy path) +Stop-AllServices -Services $services -TimeoutSeconds $ServiceStopTimeoutSeconds + +# -- Cleanup (while services are stopped -- no .pyd locks) -------------------- + +# Remove .trash directories from previous deployments +Get-ChildItem -Path $releasesDir -Directory -Filter ".trash-*" -ErrorAction SilentlyContinue | ForEach-Object { + Remove-Item -Path $_.FullName -Recurse -Force -Confirm:$false -ErrorAction SilentlyContinue +} + +$cleanupProtected = @($versionDir) +if ($previousVersionDir) { $cleanupProtected += $previousVersionDir } + +$oldReleases = Get-ChildItem -Path $releasesDir -Directory | + Where-Object { $_.Name -notlike ".trash-*" } | + Sort-Object CreationTime -Descending | + Select-Object -Skip $KeepReleases +foreach ($rel in $oldReleases) { + if ($rel.FullName -in $cleanupProtected) { continue } + Write-Log "Removing old release: $($rel.Name)" "INFO" + try { + Remove-Item -Path $rel.FullName -Recurse -Force -Confirm:$false + } catch { + $trashName = ".trash-$($rel.Name)-$([guid]::NewGuid().ToString().Substring(0,8))" + $trashPath = Join-Path $releasesDir $trashName + try { + [System.IO.Directory]::Move($rel.FullName, $trashPath) + Write-Log "Deferred cleanup of $($rel.Name) to next deployment." "WARNING" + } catch { + Write-Log "Could not remove $($rel.Name): $_" "WARNING" + } + } +} + +# Switch junction +if (Test-Path $currentJunction) { (Get-Item $currentJunction).Delete() } +New-Item -ItemType Junction -Path $currentJunction -Target $versionDir -Force | Out-Null + +# Register and start services +$startedServices = @() +$cutoverFailed = $false + +foreach ($svc in $services) { + $batPath = Join-Path $currentJunction "start-$($svc.Name).bat" + + # Remove+reinstall to clear NSSM throttle state from previous failures + $existingSvc = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($existingSvc) { + & $nssmExe remove $svc.Name confirm 2>&1 | Out-Null + $retries = 10 + while ((Get-Service -Name $svc.Name -ErrorAction SilentlyContinue) -and $retries -gt 0) { + Start-Sleep -Milliseconds 500 + $retries-- + } + } + + Invoke-Nssm -NssmPath $nssmExe -Arguments @("install", $svc.Name, "$batPath") -Description "install $($svc.Name)" + Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "AppDirectory", "$currentJunction") -Description "set AppDirectory" + Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "Description", "Manage Breast Screening Gateway - $($svc.Name)") -Description "set Description" + Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "Start", "SERVICE_AUTO_START") -Description "set Start" + + $svcLog = Join-Path $logsDir "$($svc.Name).log" + Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "AppStdout", "$svcLog") -Description "set AppStdout" + Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "AppStderr", "$svcLog") -Description "set AppStderr" + + try { + Start-Service -Name $svc.Name + $startedServices += $svc.Name + } catch { + Write-Log "Failed to start $($svc.Name): $_" "ERROR" + $cutoverFailed = $true + break + } +} + +# -- Health Check ------------------------------------------------------------- + +if (-not $cutoverFailed) { + Write-Log "Running health checks..." "INFO" + foreach ($svcName in $startedServices) { + $healthy = $false + for ($i = 1; $i -le $HealthCheckRetries; $i++) { + Start-Sleep -Seconds $HealthCheckIntervalSeconds + $svcStatus = Get-Service -Name $svcName -ErrorAction SilentlyContinue + if ($svcStatus -and $svcStatus.Status -eq 'Running') { + $healthy = $true + Write-Log "$svcName healthy (check $i/$HealthCheckRetries)." "SUCCESS" + break + } + Write-Log "$svcName check $i/$($HealthCheckRetries) - status: $($svcStatus.Status)" "WARNING" + } + if (-not $healthy) { + Write-Log "$svcName failed health check." "ERROR" + $cutoverFailed = $true + break + } + } +} + +# -- Rollback on Failure ------------------------------------------------------ + +if ($cutoverFailed) { + Write-Log "Deployment failed. Rolling back..." "ERROR" + + foreach ($svcName in $startedServices) { + Stop-Service -Name $svcName -Force -ErrorAction SilentlyContinue + } + + if ($previousVersionDir -and (Test-Path $previousVersionDir)) { + if (Test-Path $currentJunction) { (Get-Item $currentJunction).Delete() } + New-Item -ItemType Junction -Path $currentJunction -Target $previousVersionDir -Force | Out-Null + + foreach ($svc in $services) { + $batPath = Join-Path $currentJunction "start-$($svc.Name).bat" + if (Test-Path $batPath) { + & $nssmExe set $svc.Name Application "$batPath" 2>&1 | Out-Null + & $nssmExe set $svc.Name AppDirectory "$currentJunction" 2>&1 | Out-Null + } + } + + foreach ($svc in $services) { + Start-Service -Name $svc.Name -ErrorAction SilentlyContinue + } + Write-Log "Rolled back to previous version." "WARNING" + } else { + Write-Log "No previous version for rollback. Services are stopped." "ERROR" + } + + throw "Deployment of version $version failed. Rollback was attempted." +} + +$cutoverDuration = ((Get-Date) - $cutoverStart).TotalSeconds +Write-Log "Deployment of version $version completed in $([math]::Round($cutoverDuration, 2))s." "SUCCESS" diff --git a/scripts/powershell/rollback.ps1 b/scripts/powershell/rollback.ps1 new file mode 100644 index 00000000..6d352c54 --- /dev/null +++ b/scripts/powershell/rollback.ps1 @@ -0,0 +1,272 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Roll back the Manage Breast Screening Gateway to a previous version. +.DESCRIPTION + Switches the directory junction to a previous release. By default, rolls + back to the most recent release that is not the currently active one. + Use -Version to target a specific release directory. + Designed for urgent fixes; the standard process is fix-forward via deploy.ps1. +#> + +[CmdletBinding()] +param( + [Parameter()] + [string]$BaseInstallPath = "C:\Program Files\NHS\ManageBreastScreeningGateway", + + [Parameter()] + [string]$Version, + + [Parameter()] + [int]$ServiceStopTimeoutSeconds = 30, + + [Parameter()] + [int]$HealthCheckRetries = 5, + + [Parameter()] + [int]$HealthCheckIntervalSeconds = 2 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# -- Logging ------------------------------------------------------------------ + +$deploymentLogsDir = Join-Path $BaseInstallPath "logs\deployments" +if (-not (Test-Path $deploymentLogsDir)) { + New-Item -ItemType Directory -Path $deploymentLogsDir -Force | Out-Null +} +$logFile = Join-Path $deploymentLogsDir "rollback-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" + +function Write-Log { + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $entry = "[$timestamp] [$Level] $Message" + Add-Content -Path $logFile -Value $entry + switch ($Level) { + "ERROR" { Write-Host $entry -ForegroundColor Red } + "WARNING" { Write-Host $entry -ForegroundColor Yellow } + "SUCCESS" { Write-Host $entry -ForegroundColor Green } + default { Write-Host $entry -ForegroundColor Gray } + } +} + +# -- Paths -------------------------------------------------------------------- + +$releasesDir = Join-Path $BaseInstallPath "releases" +$currentJunction = Join-Path $BaseInstallPath "current" +$logsDir = Join-Path $BaseInstallPath "logs" + +$services = @( + @{ Name = "Gateway-Relay"; Script = "relay_listener.py" }, + @{ Name = "Gateway-PACS"; Script = "pacs_main.py" }, + @{ Name = "Gateway-MWL"; Script = "mwl_main.py" }, + @{ Name = "Gateway-Upload"; Script = "upload_main.py" } +) + +# -- Resolve current version -------------------------------------------------- + +Write-Log "Starting rollback" "INFO" +Write-Log "Base install path: $BaseInstallPath" "INFO" + +if (-not (Test-Path $currentJunction)) { + throw "No current junction found at $currentJunction. Nothing to roll back." +} + +$junctionItem = Get-Item $currentJunction +if (-not ($junctionItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + throw "$currentJunction exists but is not a junction. Cannot determine current version." +} + +$currentTarget = $junctionItem.Target +if ($currentTarget -is [array]) { $currentTarget = $currentTarget[0] } +$currentVersionName = Split-Path $currentTarget -Leaf + +Write-Log "Current version: $currentVersionName (target: $currentTarget)" "INFO" + +# -- Resolve rollback target -------------------------------------------------- + +if (-not (Test-Path $releasesDir)) { + throw "Releases directory not found at $releasesDir." +} + +if ($Version) { + # Explicit version requested + $targetDir = Join-Path $releasesDir $Version + if (-not (Test-Path $targetDir)) { + $available = (Get-ChildItem -Path $releasesDir -Directory | ForEach-Object { $_.Name }) -join ", " + throw "Version '$Version' not found in $releasesDir. Available: $available" + } +} else { + # Default: most recent release that is not the current one + $candidates = Get-ChildItem -Path $releasesDir -Directory | + Where-Object { $_.FullName -ne $currentTarget } | + Sort-Object CreationTime -Descending + if ($candidates.Count -eq 0) { + throw "No previous version available for rollback. Only the current version exists." + } + $targetDir = $candidates[0].FullName +} + +$targetVersionName = Split-Path $targetDir -Leaf + +if ($targetDir -eq $currentTarget) { + Write-Log "Target version ($targetVersionName) is already the active version. Nothing to do." "WARNING" + return +} + +Write-Log "Rollback target: $targetVersionName ($targetDir)" "INFO" + +# Verify the target has the expected structure +if (-not (Test-Path (Join-Path $targetDir "pyproject.toml"))) { + throw "Target version directory is missing pyproject.toml. The release may be corrupted." +} + +$hasBatFiles = $true +foreach ($svc in $services) { + if (-not (Test-Path (Join-Path $targetDir "start-$($svc.Name).bat"))) { + $hasBatFiles = $false + break + } +} +if (-not $hasBatFiles) { + throw "Target version directory is missing service .bat helpers. The release may be incomplete." +} + +# -- List available versions for operator awareness --------------------------- + +Write-Log "Available versions:" "INFO" +Get-ChildItem -Path $releasesDir -Directory | Sort-Object CreationTime -Descending | ForEach-Object { + $marker = "" + if ($_.FullName -eq $currentTarget) { $marker = " (current)" } + if ($_.FullName -eq $targetDir) { $marker = " <-- rollback target" } + Write-Log " $($_.Name)$marker" "INFO" +} + +# -- NSSM check --------------------------------------------------------------- + +$nssmExe = Get-Command nssm.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +if (-not $nssmExe) { + throw "NSSM not found in PATH. Cannot manage services." +} + +# -- Stop services ------------------------------------------------------------ + +Write-Log "Stopping services..." "INFO" +$rollbackStart = Get-Date + +foreach ($svc in $services) { + $status = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($status -and $status.Status -ne 'Stopped') { + Write-Log "Stopping $($svc.Name) (timeout: ${ServiceStopTimeoutSeconds}s)..." "INFO" + Stop-Service -Name $svc.Name -Force + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $ServiceStopTimeoutSeconds) { + $current = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if (-not $current -or $current.Status -eq 'Stopped') { break } + Start-Sleep -Milliseconds 500 + } + $stopwatch.Stop() + + $finalStatus = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($finalStatus -and $finalStatus.Status -ne 'Stopped') { + throw "Service $($svc.Name) did not stop within ${ServiceStopTimeoutSeconds}s. Aborting rollback." + } + Write-Log "$($svc.Name) stopped." "INFO" + } else { + Write-Log "$($svc.Name) already stopped or not registered." "INFO" + } +} + +# -- Switch junction ---------------------------------------------------------- + +Write-Log "Switching junction to $targetVersionName..." "INFO" + +(Get-Item $currentJunction).Delete() +New-Item -ItemType Junction -Path $currentJunction -Target $targetDir -Force | Out-Null + +Write-Log "Junction updated." "SUCCESS" + +# -- Re-register and start services ------------------------------------------ + +Write-Log "Re-registering and starting services..." "INFO" +$startedServices = @() +$rollbackFailed = $false + +foreach ($svc in $services) { + $batPath = Join-Path $currentJunction "start-$($svc.Name).bat" + + # Remove and reinstall to clear NSSM throttle state + $existingSvc = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($existingSvc) { + & $nssmExe remove $svc.Name confirm 2>&1 | Out-Null + $retries = 10 + while ((Get-Service -Name $svc.Name -ErrorAction SilentlyContinue) -and $retries -gt 0) { + Start-Sleep -Milliseconds 500 + $retries-- + } + } + + & $nssmExe install $svc.Name "$batPath" + if ($LASTEXITCODE -ne 0) { + Write-Log "NSSM install failed for $($svc.Name) (exit code $LASTEXITCODE)" "ERROR" + $rollbackFailed = $true + break + } + + & $nssmExe set $svc.Name AppDirectory "$currentJunction" 2>&1 | Out-Null + & $nssmExe set $svc.Name Description "Manage Breast Screening Gateway - $($svc.Name)" 2>&1 | Out-Null + & $nssmExe set $svc.Name Start SERVICE_AUTO_START 2>&1 | Out-Null + + $svcLog = Join-Path $logsDir "$($svc.Name).log" + & $nssmExe set $svc.Name AppStdout "$svcLog" 2>&1 | Out-Null + & $nssmExe set $svc.Name AppStderr "$svcLog" 2>&1 | Out-Null + + try { + Start-Service -Name $svc.Name + $startedServices += $svc.Name + Write-Log "$($svc.Name) started." "SUCCESS" + } catch { + Write-Log "Failed to start $($svc.Name): $_" "ERROR" + $rollbackFailed = $true + break + } +} + +# -- Health check ------------------------------------------------------------- + +if (-not $rollbackFailed) { + Write-Log "Running health checks..." "INFO" + foreach ($svcName in $startedServices) { + $healthy = $false + for ($i = 1; $i -le $HealthCheckRetries; $i++) { + Start-Sleep -Seconds $HealthCheckIntervalSeconds + $svcStatus = Get-Service -Name $svcName -ErrorAction SilentlyContinue + if ($svcStatus -and $svcStatus.Status -eq 'Running') { + $healthy = $true + Write-Log "$svcName healthy (check $i/$HealthCheckRetries)." "SUCCESS" + break + } + Write-Log "$svcName not yet running (check $i/$HealthCheckRetries, status: $($svcStatus.Status))..." "WARNING" + } + if (-not $healthy) { + Write-Log "$svcName failed health check." "ERROR" + $rollbackFailed = $true + break + } + } +} + +# -- Result ------------------------------------------------------------------- + +$rollbackDuration = ((Get-Date) - $rollbackStart).TotalSeconds + +if ($rollbackFailed) { + Write-Log "Rollback to $targetVersionName completed with errors. Manual intervention required." "ERROR" + throw "Rollback encountered failures. Check logs: $logFile" +} + +Write-Log "Rollback complete: $currentVersionName --> $targetVersionName" "SUCCESS" +Write-Log "Duration: $([math]::Round($rollbackDuration, 2)) seconds" "SUCCESS" +Write-Log "Log: $logFile" "INFO" diff --git a/uv.lock b/uv.lock index 1a47b0f9..51fd42f3 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.14, <4.0.0" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "asttokens" version = "3.0.1" @@ -45,6 +54,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click-option-group" +version = "0.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222, upload-time = "2025-10-09T09:38:01.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553, upload-time = "2025-10-09T09:38:00.066Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -98,6 +131,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "dotty-dict" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699, upload-time = "2022-07-09T18:50:57.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -107,6 +161,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -116,6 +194,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -183,6 +270,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "manage-breast-screening-gateway" version = "0.1.0" @@ -206,6 +305,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "python-semantic-release" }, { name = "ruff" }, ] @@ -229,9 +329,52 @@ dev = [ { name = "pytest", specifier = ">=8.4.1,<10" }, { name = "pytest-asyncio", specifier = ">=1.3.0,<1.4.0" }, { name = "pytest-cov", specifier = ">=7.0.0,<8" }, + { name = "python-semantic-release", specifier = ">=10.5.3" }, { name = "ruff", specifier = ">=0.14.1,<0.15" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -244,6 +387,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -384,6 +536,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + [[package]] name = "pydicom" version = "3.0.1" @@ -507,6 +713,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-gitlab" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/bd/b30f1d3b303cb5d3c72e2d57a847d699e8573cbdfd67ece5f1795e49da1c/python_gitlab-6.5.0.tar.gz", hash = "sha256:97553652d94b02de343e9ca92782239aa2b5f6594c5482331a9490d9d5e8737d", size = 400591, upload-time = "2025-10-17T21:40:02.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" }, +] + +[[package]] +name = "python-semantic-release" +version = "10.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "click-option-group" }, + { name = "deprecated" }, + { name = "dotty-dict" }, + { name = "gitpython" }, + { name = "importlib-resources" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "python-gitlab" }, + { name = "requests" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/3a/7332b822825ed0e902c6e950e0d1e90e8f666fd12eb27855d1c8b6677eff/python_semantic_release-10.5.3.tar.gz", hash = "sha256:de4da78635fa666e5774caaca2be32063cae72431eb75e2ac23b9f2dfd190785", size = 618034, upload-time = "2025-12-14T22:37:29.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/01/ada29a1215df601bded0a2efd3b6d53864a0a9e0a9ea52aeaebe14fd03fd/python_semantic_release-10.5.3-py3-none-any.whl", hash = "sha256:1be0e07c36fa1f1ec9da4f438c1f6bbd7bc10eb0d6ac0089b0643103708c2823", size = 152716, upload-time = "2025-12-14T22:37:28.089Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -522,6 +765,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + [[package]] name = "ruff" version = "0.14.10" @@ -548,6 +816,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -562,6 +848,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "traitlets" version = "5.14.3" @@ -580,6 +875,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -606,3 +913,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "wrapt" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311, upload-time = "2026-02-03T02:12:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805, upload-time = "2026-02-03T02:11:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308, upload-time = "2026-02-03T02:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688, upload-time = "2026-02-03T02:11:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115, upload-time = "2026-02-03T02:12:39.068Z" }, + { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484, upload-time = "2026-02-03T02:11:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/29/0630280cdd2bd8f86f35cb6854abee1c9d6d1a28a0c6b6417cd15d378325/wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b", size = 58514, upload-time = "2026-02-03T02:11:58.616Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/5bed84f9089ed2065f6aeda5dfc4f043743f642bc871454b261c3d7d322b/wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19", size = 60763, upload-time = "2026-02-03T02:12:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/b967f2f9669e4249b4fe82e630d2a01bc6b9e362b9b12ed91bbe23ae8df4/wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23", size = 59051, upload-time = "2026-02-03T02:11:29.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059, upload-time = "2026-02-03T02:12:06.368Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618, upload-time = "2026-02-03T02:12:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544, upload-time = "2026-02-03T02:11:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700, upload-time = "2026-02-03T02:12:30.684Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561, upload-time = "2026-02-03T02:11:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188, upload-time = "2026-02-03T02:11:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/e2f273b6d70d41f98d0739aa9a269d0b633684a5fb17b9229709375748d4/wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4", size = 60425, upload-time = "2026-02-03T02:11:35.007Z" }, + { url = "https://files.pythonhosted.org/packages/1e/06/b500bfc38a4f82d89f34a13069e748c82c5430d365d9e6b75afb3ab74457/wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3", size = 63855, upload-time = "2026-02-03T02:12:15.47Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443, upload-time = "2026-02-03T02:11:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, +]