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
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.git/
.github/
.idea/
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
tests/
data/
*.md
!README.md
47 changes: 47 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# EditorConfig — https://editorconfig.org
# Top-level config; all editor plugins must stop traversing here.
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 100

# Python — ruff is the source of truth for formatting and import order
[*.py]
indent_size = 4
ij_formatter_enabled = false

# Data and config formats
[*.{json,yml,yaml}]
indent_size = 2
ij_formatter_enabled = false

[*.toml]
indent_size = 4

# Markdown — preserve trailing whitespace for hard line breaks
[*.md]
indent_size = 2
trim_trailing_whitespace = false
max_line_length = off
ij_formatter_enabled = false

# Makefiles require tabs
[Makefile]
indent_style = tab

# Dockerfiles
[{Dockerfile,Dockerfile.*}]
indent_size = 2

# DSV test fixtures are byte-exact inputs — an editor must not touch them.
[*.dsv[5-8]]
trim_trailing_whitespace = false
insert_final_newline = false
max_line_length = off
ij_formatter_enabled = false
93 changes: 93 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# =============================================================================
# CI — every check runs through the Makefile, so `make` locally and CI cannot
# diverge.
#
# Pull requests targeting main only. It deliberately does NOT run on push to
# main: branch protection requires this workflow to pass before a PR can merge,
# so re-running the identical commit on main would validate nothing new.
#
# Branch protection requires exactly one check from this file — `CI passed` (the
# `ci-ok` job). Add or rename the real work below without touching the ruleset.
# =============================================================================
name: CI

on:
pull_request:
branches: [main] # only PRs onto main, not PRs onto feature branches

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: dsv-parser
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: astral-sh/setup-uv@v9.0.0
with:
python-version-file: .python-version
enable-cache: true
cache-dependency-glob: uv.lock

- name: Install (uv sync)
run: make install

- name: Format check
run: make format-check

- name: Lint
run: make lint

- name: Typecheck
run: make typecheck

- name: Test + coverage
run: make test-it

- name: Audit
run: make audit

image:
name: image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max

# ---------------------------------------------------------------------------
# The single required status check. Branch protection requires THIS job, never
# the individual ones — so jobs can be added, split or renamed above without
# editing the ruleset. Nothing here is conditional, so every job must actually
# succeed: a skip means something went wrong upstream, not "nothing to do".
# ---------------------------------------------------------------------------
ci-ok:
name: CI passed
if: always()
needs: [build, image]
runs-on: ubuntu-latest
steps:
- name: Verify every CI job succeeded
env:
RESULTS: ${{ needs.build.result }} ${{ needs.image.result }}
run: |
set -euo pipefail
for r in $RESULTS; do
if [ "$r" != "success" ]; then
echo "::error::A CI job ended with '$r'."
exit 1
fi
done
echo "All CI jobs passed."
154 changes: 154 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# =============================================================================
# Release — version, changelog and GitHub Release, driven by Release Please.
#
# Two things happen in this one workflow, both on a push to main:
#
# 1. Ordinary merge to main → Release Please reads the Conventional Commits
# since the last tag and opens (or updates) a single "Release PR" that
# bumps the version everywhere and writes the CHANGELOG entry. No tag, no
# release yet — the PR is the proposal.
# 2. The Release PR itself is merged → Release Please recognises its own
# commit, pushes the `vX.Y.Z` tag and publishes the GitHub Release with the
# changelog section as its body. Nothing else to approve.
#
# So the whole cycle is: land features on main, merge the Release PR when you
# want to cut a version. `fix:` → patch, `feat:` → minor, `feat!:`/`BREAKING
# CHANGE:` → major. `chore:`/`docs:`/`ci:` alone never produce a release.
#
# What gets bumped is declared in release-please-config.json, not here.
#
# On the run that actually cuts the release — and only then — a second job builds
# the sdist and the wheel from the freshly pushed tag and uploads them to PyPI, so
# `pip install dsv-parser` tracks the GitHub Releases with no separate manual step.
#
# Everything mutating runs under a GitHub App token, not GITHUB_TOKEN: resources
# created by GITHUB_TOKEN do not trigger further workflows, so a Release PR opened
# with it would never get its required `CI passed` check and could only be merged
# by an admin overriding the ruleset. Under the App token the PR is a normal PR —
# CI runs, the check reports, auto-merge works.
#
# Prerequisites (GitHub-side, not expressible in-repo):
# * A GitHub App (contents: write, pull requests: write, issues: write — Release
# Please labels its PR through the issues API — metadata: read), installed on
# this repository. Its identifier is the RELEASE_PLEASE_APP_CLIENT_ID repository
# *variable* (an App id is not a credential on its own); only the private key is
# a secret, RELEASE_PLEASE_APP_PRIVATE_KEY.
# * Settings → General → Pull Requests → "Allow auto-merge" enabled.
# * A PyPI trusted publisher for the project `dsv-parser`, pointing at owner
# `nilskntl`, repository `dsv-parser`, workflow `release-please.yml`,
# environment `pypi`. Trusted publishing exchanges the job's OIDC token for a
# short-lived upload token, so there is no PyPI API token to store or rotate.
# Before the first release the publisher is registered as a *pending* one —
# the project does not exist on PyPI yet; the first upload creates it.
# =============================================================================
name: Release

on:
push:
branches: [main]

# GITHUB_TOKEN needs nothing here — every mutation goes through the App token
# minted in the job below, which carries its own installation permissions.
permissions:
contents: read

# One release run at a time, and never cancelled: a half-cut release (tag pushed,
# GitHub Release missing) is worse than a queued run.
concurrency:
group: release-please
cancel-in-progress: false

jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
tag_name: ${{ steps.rp.outputs.tag_name }}
steps:
- name: Mint GitHub App token
id: app
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.RELEASE_PLEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_APP_PRIVATE_KEY }}

- uses: googleapis/release-please-action@v5
id: rp
with:
token: ${{ steps.app.outputs.token }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

- name: Summarise
env:
CREATED: ${{ steps.rp.outputs.release_created }}
TAG: ${{ steps.rp.outputs.tag_name }}
PR: ${{ steps.rp.outputs.pr }}
run: |
set -euo pipefail
if [ "${CREATED:-}" = "true" ]; then
echo "::notice::Released ${TAG}."
elif [ -n "${PR:-}" ]; then
echo "::notice::Release PR is open and up to date."
else
echo "::notice::Nothing to release — no releasable commits since the last tag."
fi
# ---------------------------------------------------------------------------
# Publish to PyPI.
#
# Runs only on the push that Release Please recognised as its own Release PR
# merge, i.e. the run that pushed the tag — every other push to main leaves
# `release_created` unset and this job is skipped.
#
# It checks out the tag rather than the pushed ref: same commit today, but the
# tag is what the GitHub Release points at, so the artefact and the release
# notes cannot drift apart if the workflow is ever re-run.
#
# No credentials: `id-token: write` lets the job mint an OIDC token that PyPI
# trades for a short-lived upload token under the trusted publisher above. The
# `pypi` environment name is part of what PyPI verifies, so it must match the
# publisher configuration.
# ---------------------------------------------------------------------------
publish:
name: Publish to PyPI
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/dsv-parser
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.release-please.outputs.tag_name }}

- uses: astral-sh/setup-uv@v9.0.0
with:
python-version-file: .python-version
enable-cache: true
cache-dependency-glob: uv.lock

- name: Build sdist and wheel
run: make build

# A version on PyPI is permanent — it can be yanked but never replaced. So
# verify that what hatchling stamped into the artefacts is the version the
# tag promises before anything is uploaded, rather than discovering a stale
# `0.0.0` afterwards.
- name: Verify the built version matches the tag
env:
TAG: ${{ needs.release-please.outputs.tag_name }}
run: |
set -euo pipefail
wheel=$(ls dist/*.whl)
built=$(basename "$wheel" | cut -d- -f2)
if [ "v$built" != "$TAG" ]; then
echo "::error::Built version $built does not match tag $TAG."
exit 1
fi
echo "::notice::Publishing dsv-parser $built to PyPI."

- uses: pypa/gh-action-pypi-publish@release/v1
28 changes: 28 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# --- Python ---------------------------------------------------------------
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/

# --- Tooling caches -------------------------------------------------------
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/

# --- Editors / OS ---------------------------------------------------------
.idea/
.vscode/
.DS_Store
**/*.iml

# --- Local data -----------------------------------------------------------
# Real DSV files carry personal data (athletes, officials, bank details) and must
# never be committed. Put throwaway samples here; the test fixtures are synthetic.
/data/
*.local.*
CLAUDE.md
30 changes: 30 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# =============================================================================
# dsv-parser — pre-commit hooks.
#
# Auto-format ONLY the staged files BEFORE the commit, so CI never fails on
# formatting alone. The hooks invoke the repo's OWN toolchain (`uv run ruff`) on
# exactly the staged paths — no whole-repo sweep. Run `make install` once before
# committing, which you would need anyway.
#
# CI keeps `format-check` + `lint` as the backstop for anyone bypassing the hook.
# =============================================================================
minimum_pre_commit_version: "3.2.0"
default_install_hook_types: [pre-commit]

repos:
- repo: local
hooks:
# Ruff covers import order (the `I` rule, which `ruff format` does NOT
# touch) and formatting in one go — matching `make format`.
- id: ruff-import-order
name: ruff — import order (staged)
entry: uv run ruff check --select I --fix --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
- id: ruff-format
name: ruff — format (staged)
entry: uv run ruff format --force-exclude
language: system
types_or: [python, pyi]
require_serial: true
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.1.0"
}
Loading
Loading