Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
68 changes: 58 additions & 10 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,7 +26,12 @@ Expected output:
python -m counter_risk.build.release --version-file VERSION --output-dir release --force
```

This creates `release/<version>/...` including the bundled executable and metadata files.
This creates `release/<version>/...` 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

Expand All @@ -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/<version>/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/<version>/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:
Expand All @@ -74,9 +117,14 @@ scripts/verify_release_workflow_dispatch.sh release.yml <branch-or-tag>
## 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`
24 changes: 17 additions & 7 deletions src/counter_risk/build/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion src/counter_risk/build/xlsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
54 changes: 39 additions & 15 deletions tests/test_release_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties/>',
)
return buf.getvalue()

_FORBIDDEN_RUNNER_ENTRYPOINT_PATTERN = re.compile(
r"\bpython(?:\.exe)?\b|\bpy(?:\.exe)?\b|\.py\b",
re.IGNORECASE,
Expand All @@ -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"
)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)