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
166 changes: 166 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
name: Release

# Triggers on any tag that looks like a version (v0.2.0, v1.0.0, etc.)
# The version-guard job fails the entire workflow if the tag does not match
# the version declared in pyproject.toml, so a mis-tagged push never reaches PyPI.
on:
push:
tags:
- 'v[0-9]*'

# Least-privilege default for GITHUB_TOKEN. Checkout-using jobs (verify-version,
# build, test-wheel) need only read access. The publish job declares its own
# permissions block (id-token: write) and so does NOT inherit this default.
permissions:
contents: read

jobs:
# ── Step 1: Verify the git tag matches pyproject.toml ─────────────────────
verify-version:
name: Guard — tag must match pyproject.toml
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.12'

- name: Check tag equals pyproject.toml version
run: |
TAG="${GITHUB_REF#refs/tags/}" # e.g. v0.2.0
TAG_VERSION="${TAG#v}" # e.g. 0.2.0
PYPROJECT_VERSION=$(python -c \
"import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
echo "Git tag : $TAG"
echo "Tag version : $TAG_VERSION"
echo "pyproject : $PYPROJECT_VERSION"
if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
echo ""
echo "ERROR: git tag '$TAG' does not match pyproject.toml version '$PYPROJECT_VERSION'."
echo "Fix: bump pyproject.toml to $TAG_VERSION, commit,"
echo " delete the tag, re-tag, and push again."
exit 1
fi
echo "Version guard passed: $TAG_VERSION == $PYPROJECT_VERSION"

# ── Step 2: Build the wheel from the tagged commit ────────────────────────
build:
name: Build wheel
needs: verify-version
runs-on: ubuntu-latest
outputs:
wheel-filename: ${{ steps.find-wheel.outputs.wheel-filename }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.11'

- name: Install build toolchain
run: pip install build

- name: Build wheel and sdist
run: python -m build

- name: Record wheel filename
id: find-wheel
run: |
WHEEL=$(ls dist/*.whl)
echo "wheel-filename=$WHEEL" >> "$GITHUB_OUTPUT"
echo "Built: $WHEEL"

- name: Upload wheel artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dist-wheel
path: dist/
retention-days: 7

# ── Step 3: Install the wheel in a fresh venv and run the full test suite ─
# Invoked from the venv's pytest, NOT the source tree's pip-editable install.
# --import-mode=importlib prevents pytest from prepending the source root to
# sys.path so imports resolve to the installed wheel, not the local directory.
test-wheel:
name: Test wheel — Python ${{ matrix.python-version }}
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}

- name: Download wheel artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: dist-wheel
path: dist/

- name: Install wheel into fresh venv (no source editable install)
run: |
python -m venv /tmp/wheel_test_venv
/tmp/wheel_test_venv/bin/pip install --upgrade pip
/tmp/wheel_test_venv/bin/pip install dist/*.whl "pytest>=7" pytest-cov

# The two checks below must run from /tmp, NOT the checked-out repo root:
# `python -c` sets sys.path[0] to the cwd, which would otherwise shadow
# the wheel-installed packages with the source tree (root-layout trap).
- name: Confirm installed version matches the tag
working-directory: /tmp
run: |
INSTALLED=$(/tmp/wheel_test_venv/bin/python -c \
"import hmdaanalyzer; print(hmdaanalyzer.__version__)")
TAG="${GITHUB_REF#refs/tags/v}"
echo "Installed version : $INSTALLED"
echo "Expected (tag) : $TAG"
if [ "$INSTALLED" != "$TAG" ]; then
echo "ERROR: installed __version__ does not match the git tag."
exit 1
fi

- name: Assert hmdaanalyzer resolves to site-packages (not source tree)
working-directory: /tmp
run: |
/tmp/wheel_test_venv/bin/python -c "
import hmdaanalyzer, hmda_analyzer
for mod in (hmdaanalyzer, hmda_analyzer):
assert 'site-packages' in mod.__file__, \
f'{mod.__name__} resolves to {mod.__file__!r} — expected site-packages, got source tree'
print('site-packages check passed:', mod.__file__)
"

- name: Run test suite against installed wheel
run: |
/tmp/wheel_test_venv/bin/pytest tests/ \
-v -m "not live" --tb=short \
--no-header \
--import-mode=importlib

# ── Step 4: Publish to PyPI — only after guard + build + all tests pass ───
publish:
name: Publish to PyPI
needs: [verify-version, build, test-wheel]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/project/hmda-analyzer/
permissions:
id-token: write # required for OIDC trusted publishing

steps:
- name: Download wheel artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: dist-wheel
path: dist/

- name: Publish to PyPI (trusted publishing — no API token needed)
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
40 changes: 40 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Tests

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

# Least-privilege default for GITHUB_TOKEN. The test job only checks out and
# runs the suite, so read access is sufficient.
permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: ${{ matrix.python-version }}

- name: Install package and dev dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests (excluding live API tests)
run: pytest -v -m "not live" --tb=short

- name: Verify dual-import shim
run: |
python -c "import hmdaanalyzer; print('hmdaanalyzer OK:', hmdaanalyzer.__version__)"
python -c "import hmda_analyzer; print('hmda_analyzer OK:', hmda_analyzer.__version__)"
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,75 @@
# CHANGELOG

## [Unreleased]

## [0.2.1] - 2026-05-29

### Fixed

- **`denial_reasons_by_race()` returned empty on every live CFPB dataset.** The CFPB Data
Browser CSV names enumerated fields with hyphens (`denial_reason-1`, `applicant_race-1`,
etc.), but `_clean()` only lowercased and stripped column names — the hyphen survived,
the underscore name `denial_reason_1` that the analysis code expected never matched, and
the function silently returned an empty DataFrame. The existing synthetic test was
falsely green because `load_sample()` emitted the underscore form directly, skipping
the normalization gap. `_clean()` now replaces hyphens with underscores so live data and
synthetic data take the same path.

### Changed

- **`load_sample()` now generates the raw `denial_reason-1` field with a hyphen**, matching
the CFPB Data Browser CSV format. After `_clean()`, the observable output column is still
`denial_reason_1` (underscore), so this is a fidelity-only change with no consumer-visible
effect. The other enumerated fields are intentionally left on underscore form in this
release; broader fixture fidelity is a tracked follow-up.

- **Strengthened `test_denial_reasons_by_race`.** The previous assertion was
`isinstance(result, pd.DataFrame)`, which passed even when the function returned empty
on every live dataset. The test now asserts the result is non-empty, has the documented
columns, and that mapped denial-reason labels (not "Unknown") are present.

- **Added `test_denial_reasons_by_race_handles_cfpb_hyphenated_columns`** — a regression
test that builds a raw frame with the hyphenated CFPB column name, runs it through
`_clean()`, and asserts the analysis returns mapped, non-empty results. This is the test
that would have caught the v0.2.0 bug.

### Added

- **Release CI** (`.github/workflows/release.yml`): tag-triggered pipeline with four gates —
`verify-version` (tag vs. `pyproject.toml` via `tomllib`), `build` (uploads wheel as
artifact), `test-wheel` (installs the wheel into a fresh venv on Python 3.9–3.12, asserts
`hmdaanalyzer.__file__` resolves under site-packages so tests can't accidentally import
the source tree, then runs `pytest -m "not live" --import-mode=importlib`), and `publish`
(OIDC trusted publishing). All five third-party actions are SHA-pinned.

- **Test CI** (`.github/workflows/test.yml`): push/PR matrix across Python 3.9–3.12, plus a
dual-import shim check (`import hmdaanalyzer` and `import hmda_analyzer` both work and
report the same version).

- **`CONTRIBUTING.md`**: release runbook documenting the bump → tag → push flow, the
single-source version invariant, OIDC trusted-publisher setup, the yank policy, and the
anti-patterns the CI guards against.

### Internal

- **Single version source of truth.** `pyproject.toml` is now canonical; `setup.py` is
removed, and `hmdaanalyzer/__init__.py` derives `__version__` at import time via
`importlib.metadata.version("hmda-analyzer")`. The previous three-place hardcoded
version (pyproject, setup.py, `__init__`) made tag/version drift easy; only
`pyproject.toml` is now editable. The `hmda_analyzer` shim continues to re-export
`__version__` unchanged.

- Package discovery moved from `setup.py`'s `find_packages()` into
`[tool.setuptools.packages.find]` in `pyproject.toml`, with explicit `include` for both
`hmda_analyzer*` and `hmdaanalyzer*`.

- `pyproject.toml` license field updated to the SPDX-string form
(`license = "MIT"`), requiring `setuptools>=77`.

- Pytest configured with `--import-mode=importlib` so the source tree is not implicitly
prepended to `sys.path` — the wheel-test job needs this to verify imports resolve to
site-packages.

## [0.2.0] — 2026-05-19

### Fixed
Expand Down
Loading
Loading