diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..ec050d84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +# Builds a versioned Windows operator bundle using PyInstaller. +# Downloads the artifact from the GitHub Actions run summary page. +# +# Trigger modes: +# - Manual dispatch (workflow_dispatch) +# - Push of a version tag (e.g. v0.1.0) + +name: Release + +on: + workflow_dispatch: + inputs: + ref: + description: "Branch, tag, or SHA to build from (defaults to the current branch)" + required: false + default: "" + push: + tags: + - "v*.*.*" + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-windows: + name: Build Windows bundle + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.ref }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[dev]" + + - name: Read version + id: version + shell: bash + run: echo "version=$(cat VERSION | tr -d '\r\n')" >> "$GITHUB_OUTPUT" + + - name: Assemble release bundle + run: > + python -m counter_risk.build.release + --version-file VERSION + --output-dir release + --force + + - name: Verify bundle contents + shell: bash + run: | + set -euo pipefail + VERSION="${{ steps.version.outputs.version }}" + BUNDLE="release/${VERSION}" + for path in \ + "${BUNDLE}/VERSION" \ + "${BUNDLE}/manifest.json" \ + "${BUNDLE}/counter_risk_runner.xlsm" \ + "${BUNDLE}/run_counter_risk.cmd" \ + "${BUNDLE}/request_counter_risk_remote.cmd" \ + "${BUNDLE}/process_counter_risk_remote.cmd" \ + "${BUNDLE}/remote_trigger_testing.md" \ + "${BUNDLE}/README_HOW_TO_RUN.md" \ + "${BUNDLE}/bin/counter-risk.exe" + do + if [ ! -e "$path" ]; then + echo "MISSING: $path" && exit 1 + fi + echo "OK: $path" + done + + - name: Upload bundle artifact + uses: actions/upload-artifact@v4 + with: + name: counter-risk-${{ steps.version.outputs.version }}-windows + path: release/${{ steps.version.outputs.version }}/ + if-no-files-found: error + retention-days: 30 diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index f1c83abb..1946431f 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -1,5 +1,14 @@ # Release Checklist +## Prerequisites + +- Run all steps on **Windows** if producing an operator bundle — PyInstaller + produces a platform-native binary. A Windows build is required for the + work PC deployment. +- Ensure `assets/templates/counter_risk_template.xlsm` is present and up to + date. The release assembler builds `counter_risk_runner.xlsm` from this + template automatically. + ## Build the executable with PyInstaller ```bash @@ -17,7 +26,12 @@ Expected output: python -m counter_risk.build.release --version-file VERSION --output-dir release --force ``` -This creates `release//...` including the bundled executable and metadata files. +This creates `release//...` including: +- The bundled executable +- `counter_risk_runner.xlsm` built from `assets/templates/counter_risk_template.xlsm` + with version metadata injected +- Remote trigger scripts and documentation +- Config, templates, fixtures, and metadata files ## Validate bundle contents @@ -29,27 +43,56 @@ test -f "${BUNDLE_DIR}/manifest.json" test -d "${BUNDLE_DIR}/templates" test -f "${BUNDLE_DIR}/config/fixture_replay.yml" test -f "${BUNDLE_DIR}/run_counter_risk.cmd" +test -f "${BUNDLE_DIR}/counter_risk_runner.xlsm" +test -f "${BUNDLE_DIR}/request_counter_risk_remote.cmd" +test -f "${BUNDLE_DIR}/process_counter_risk_remote.cmd" +test -f "${BUNDLE_DIR}/remote_trigger_testing.md" test -f "${BUNDLE_DIR}/README_HOW_TO_RUN.md" test -f "${BUNDLE_DIR}/bin/counter-risk" || test -f "${BUNDLE_DIR}/bin/counter-risk.exe" ``` +## Update config paths for the target environment + +The config YAML files (`config/all_programs.yml`, `config/ex_trend.yml`, +`config/trend.yml`) contain paths to source data files. Before deploying to +the work PC, update these to point to the actual data location: + +```yaml +# Example — replace with real paths for the operator's machine +mosers_all_programs_xlsx: "N:\\Data\\MOSERS Counterparty Risk Summary 12-31-2025 - All Programs.xlsx" +output_root: "C:\\CounterRisk\\runs\\all_programs" +``` + +Config files can be edited directly in `release//config/` after +assembly without rebuilding. + ## Validate Runner XLSM macros manually (Windows Excel) Use the manual checklist in [runner_xlsm_macro_manual_verification.md](runner_xlsm_macro_manual_verification.md). Minimum required release validation: -1. Build the runner workbook with the release version from `VERSION`. - ```bash - python -m counter_risk.build.xlsm --template-path assets/templates/counter_risk_template.xlsm --output-path dist/counter_risk_runner.xlsm --version "$(cat VERSION | tr -d '\n\r')" +1. Inspect the built workbook in the bundle: + ``` + release//counter_risk_runner.xlsm ``` 2. Follow the **Manual Macro/Button Check** section and record results. -3. Rebuild with a bumped version value (for example, `--version 1.2.4`). +3. To test a version bump, build a fresh workbook explicitly: ```bash - python -m counter_risk.build.xlsm --template-path assets/templates/counter_risk_template.xlsm --output-path dist/counter_risk_runner.vnext.xlsm --version 1.2.4 + python -m counter_risk.build.xlsm \ + --template-path assets/templates/counter_risk_template.xlsm \ + --output-path dist/counter_risk_runner.vnext.xlsm \ + --version 1.2.4 ``` 4. Follow the **Version Bump Regression Check** section and record results. +## Confirm macro trust on the work PC + +Before the first run on the operator machine, confirm one of: +- The bundle folder is added to Excel's **Trusted Locations**, or +- The workbook is signed with a trusted code-signing certificate, or +- The operator is prompted and clicks **Enable Content** on first open + ## Run the release workflow in CI Trigger from GitHub CLI: @@ -74,9 +117,14 @@ scripts/verify_release_workflow_dispatch.sh release.yml ## Expected bundle contents - `bin/counter-risk` (or `bin/counter-risk.exe` on Windows) -- `run_counter_risk.cmd` -- `templates/` -- `config/fixture_replay.yml` (default config file) +- `counter_risk_runner.xlsm` — operator Excel entrypoint (versioned) +- `run_counter_risk.cmd` — fallback CLI launcher +- `request_counter_risk_remote.cmd` — remote request submission script +- `process_counter_risk_remote.cmd` — remote request worker script +- `remote_trigger_testing.md` — remote trigger flow documentation +- `templates/` — PPT and XLSM output templates +- `config/` — workflow YAML configs (update paths before deployment) +- `fixtures/` — test fixture artifacts - `VERSION` - `manifest.json` -- `README_HOW_TO_RUN.md` (title includes `How to run`) +- `README_HOW_TO_RUN.md` diff --git a/src/counter_risk/build/release.py b/src/counter_risk/build/release.py index 11abf69f..ea870fb8 100644 --- a/src/counter_risk/build/release.py +++ b/src/counter_risk/build/release.py @@ -11,6 +11,8 @@ from datetime import UTC, datetime from pathlib import Path +from counter_risk.build.xlsm import build_xlsm_artifact, template_xlsm_path + RELEASE_NAME_PREFIX = "counter-risk" EXECUTABLE_BASENAME = "counter-risk" LOGGER = logging.getLogger(__name__) @@ -94,15 +96,23 @@ def _copy_tree_filtered( return copied -def _copy_runner_xlsm(root: Path, bundle_dir: Path) -> list[Path]: - src = root / "Runner.xlsm" - if not src.is_file(): +def _build_runner_xlsm(root: Path, bundle_dir: Path, version: str) -> list[Path]: + template = template_xlsm_path(root) + if not template.is_file(): raise ValueError( - f"Required Excel runner not found at '{src}'. " - "Ensure Runner.xlsm is present in the repository root before building a release." + f"Required XLSM template not found at '{template}'. " + "Ensure assets/templates/counter_risk_template.xlsm is present " + "before building a release." ) dst = bundle_dir / "counter_risk_runner.xlsm" - shutil.copy2(src, dst) + run_date = datetime.now(UTC) + build_xlsm_artifact( + template_path=template, + output_path=dst, + as_of_date=run_date.date(), + run_date=run_date, + version=version, + ) return [dst] @@ -330,7 +340,7 @@ def assemble_release(version: str, output_dir: Path, *, force: bool = False) -> bundle_dir.mkdir(parents=True, exist_ok=True) copied: dict[str, list[Path]] = {} - copied["runner_xlsm"] = _copy_runner_xlsm(root, bundle_dir) + copied["runner_xlsm"] = _build_runner_xlsm(root, bundle_dir, version) copied["templates"] = _copy_templates(root, bundle_dir) copied["fixtures"] = _copy_fixture_artifacts(root, bundle_dir) diff --git a/src/counter_risk/build/xlsm.py b/src/counter_risk/build/xlsm.py index b3b5234e..b5d97f6d 100644 --- a/src/counter_risk/build/xlsm.py +++ b/src/counter_risk/build/xlsm.py @@ -113,7 +113,7 @@ def _replace_zip_member(zip_path: Path, member_name: str, member_bytes: bytes) - info.compress_type = ZIP_DEFLATED target.writestr(info, member_bytes) - temp_path.replace(zip_path) + shutil.move(str(temp_path), zip_path) finally: temp_path.unlink(missing_ok=True) diff --git a/tests/test_release_bundle.py b/tests/test_release_bundle.py index ad262a14..727dbfe4 100644 --- a/tests/test_release_bundle.py +++ b/tests/test_release_bundle.py @@ -2,18 +2,31 @@ from __future__ import annotations +import io import json import os import re import subprocess import sys from pathlib import Path +from zipfile import ZipFile import pytest from counter_risk.build import release from tests.utils.assertions import assert_numeric_outputs_close + +def _make_minimal_xlsm() -> bytes: + """Return bytes for a minimal valid XLSM (ZIP) with a docProps/core.xml stub.""" + buf = io.BytesIO() + with ZipFile(buf, "w") as zf: + zf.writestr( + "docProps/core.xml", + b'', + ) + return buf.getvalue() + _FORBIDDEN_RUNNER_ENTRYPOINT_PATTERN = re.compile( r"\bpython(?:\.exe)?\b|\bpy(?:\.exe)?\b|\.py\b", re.IGNORECASE, @@ -30,10 +43,11 @@ def _write_fake_repo(root: Path) -> None: (root / "release.spec").write_text("# fake\n", encoding="utf-8") (root / "config" / "fixture_replay.yml").write_text("name: fixture\n", encoding="utf-8") (root / "templates" / "Monthly Counterparty Exposure Report.pptx").write_bytes(b"ppt-template") - (root / "assets" / "templates" / "counter_risk_template.xlsm").write_bytes(b"xlsm-template") + (root / "assets" / "templates" / "counter_risk_template.xlsm").write_bytes( + _make_minimal_xlsm() + ) (root / "tests" / "fixtures" / "fixture.xlsx").write_bytes(b"xlsx") (root / "tests" / "fixtures" / "fixture.pptx").write_bytes(b"fixture-ppt") - (root / "Runner.xlsm").write_bytes(b"fake-runner-xlsm") (root / "scripts" / "windows" / "request_counter_risk_remote.cmd").write_text( "@echo off\necho request\n", encoding="utf-8" ) @@ -396,36 +410,46 @@ def test_release_bundle_executable_runs_fixture_replay_and_matches_numeric_fixtu ) -def test_copy_runner_xlsm_copies_to_bundle_root(tmp_path: Path) -> None: +def test_build_runner_xlsm_produces_bundle_artifact(tmp_path: Path) -> None: repo_root = tmp_path / "repo" - repo_root.mkdir(parents=True, exist_ok=True) - (repo_root / "Runner.xlsm").write_bytes(b"fake-runner") + (repo_root / "assets" / "templates").mkdir(parents=True, exist_ok=True) + (repo_root / "assets" / "templates" / "counter_risk_template.xlsm").write_bytes( + _make_minimal_xlsm() + ) bundle_dir = tmp_path / "bundle" bundle_dir.mkdir(parents=True, exist_ok=True) - result = release._copy_runner_xlsm(repo_root, bundle_dir) + result = release._build_runner_xlsm(repo_root, bundle_dir, "1.2.3") assert len(result) == 1 assert result[0] == bundle_dir / "counter_risk_runner.xlsm" - assert result[0].read_bytes() == b"fake-runner" + assert result[0].is_file() + # Confirm version metadata was injected (file is a valid ZIP) + with ZipFile(result[0]) as zf: + core_xml = zf.read("docProps/core.xml").decode("utf-8") + assert "1.2.3" in core_xml -def test_copy_runner_xlsm_raises_when_missing(tmp_path: Path) -> None: +def test_build_runner_xlsm_raises_when_template_missing(tmp_path: Path) -> None: repo_root = tmp_path / "repo" repo_root.mkdir(parents=True, exist_ok=True) bundle_dir = tmp_path / "bundle" bundle_dir.mkdir(parents=True, exist_ok=True) - with pytest.raises(ValueError, match="Required Excel runner not found"): - release._copy_runner_xlsm(repo_root, bundle_dir) + with pytest.raises(ValueError, match="Required XLSM template not found"): + release._build_runner_xlsm(repo_root, bundle_dir, "1.0.0") def test_copy_remote_scripts_copies_both_cmd_files(tmp_path: Path) -> None: repo_root = tmp_path / "repo" scripts_dir = repo_root / "scripts" / "windows" scripts_dir.mkdir(parents=True, exist_ok=True) - (scripts_dir / "request_counter_risk_remote.cmd").write_text("@echo request\n", encoding="utf-8") - (scripts_dir / "process_counter_risk_remote.cmd").write_text("@echo process\n", encoding="utf-8") + (scripts_dir / "request_counter_risk_remote.cmd").write_text( + "@echo request\n", encoding="utf-8" + ) + (scripts_dir / "process_counter_risk_remote.cmd").write_text( + "@echo process\n", encoding="utf-8" + ) bundle_dir = tmp_path / "bundle" bundle_dir.mkdir(parents=True, exist_ok=True) @@ -467,15 +491,15 @@ def test_assemble_release_includes_runner_xlsm_and_remote_scripts( assert "process_counter_risk_remote.cmd" in manifest["artifacts"]["remote_scripts"][1] -def test_assemble_release_fails_fast_when_runner_xlsm_missing( +def test_assemble_release_fails_fast_when_xlsm_template_missing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: repo_root = tmp_path / "repo" _write_fake_repo(repo_root) - (repo_root / "Runner.xlsm").unlink() + (repo_root / "assets" / "templates" / "counter_risk_template.xlsm").unlink() output_dir = tmp_path / "release" monkeypatch.setattr(release, "repository_root", lambda: repo_root) - with pytest.raises(ValueError, match="Required Excel runner not found"): + with pytest.raises(ValueError, match="Required XLSM template not found"): release.assemble_release("3.0.0", output_dir)