diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c35b4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI, release and publish + +# Tests run on every push (any branch) and every PR. Only a push to `main` +# can ever produce a release: python-semantic-release inspects the +# Conventional Commits since the last vX.Y.Z tag, and if they warrant a +# release it bumps waypoint/__init__.py, updates CHANGELOG.md, and pushes a +# new tag + GitHub Release. The multi-arch Docker image is only built and +# published to GHCR when that actually happened -- pushes to `develop` (or +# any other branch) and PRs only ever run the test job. + +on: + push: + branches: ["main", "develop"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + test: + name: Run test suite + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + - name: Run pytest + run: python -m pytest -q + + release: + name: Determine next version + needs: test + # Only main can cut a release -- develop/PRs never reach this job. + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Python Semantic Release + id: release + uses: python-semantic-release/python-semantic-release@v9 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + build: + name: Build and push image + needs: [test, release] + # Only runs off the back of an actual release just cut above -- + # i.e. only when main was pushed to *and* semantic-release decided the + # accumulated commits warrant a new version. + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.release.outputs.tag }} + + - name: Set up QEMU (for linux/arm64) + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata (tags + OCI labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=${{ needs.release.outputs.tag }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.tag }} + type=raw,value=latest + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 8c61c69..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Build and publish Docker image - -# Runs the test suite on every push and pull request. The image is only -# built for linux/amd64+linux/arm64 and pushed to GHCR when a `vX.Y.Z` tag -# is pushed -- pull requests get a build-only check (no push), so a broken -# Dockerfile is caught before merge without ever touching the registry. - -on: - push: - branches: ["**"] - tags: ["v*.*.*"] - pull_request: - workflow_dispatch: - -permissions: - contents: read - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - test: - name: Run test suite - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: pip install -r requirements-dev.txt - - - name: Run pytest - run: python -m pytest -q - - build: - name: Build and push image - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Set up QEMU (for linux/arm64) - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - if: startsWith(github.ref, 'refs/tags/v') - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract image metadata (tags + OCI labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - # Build always runs, on every push and PR, as a "does the Dockerfile - # still build (and pass its embedded test stage)?" check. Only pushes - # of a vX.Y.Z tag actually publish to the registry. - - name: Build image (and push on version tags) - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ startsWith(github.ref, 'refs/tags/v') }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..64d1a18 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +All notable changes to this project are documented in this file, generated automatically by +[python-semantic-release](https://python-semantic-release.readthedocs.io/) from +[Conventional Commits](https://www.conventionalcommits.org/) merged into `main`. diff --git a/CLAUDE.md b/CLAUDE.md index 46c19e5..3de979b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,18 @@ failing fails the whole build) → `runtime` (non-root user `waypoint` at fixed the test stage, never test files themselves). `HEALTHCHECK` hits `/healthz` with a Python `urllib` one-liner (no `curl`/`wget` in `python:3.12-slim`). +### Branching, versioning & release pipeline + +`develop` is the integration branch (feature branches → PR → `develop`); `main` only moves via a +PR from `develop`. `.github/workflows/ci.yml` runs the `test` job on every push/PR regardless of +branch, but the `release` (python-semantic-release, `pyproject.toml`'s `[tool.semantic_release]`) +and `build` (multi-arch Docker build + push to GHCR) jobs are gated on `github.ref == +'refs/heads/main'` and on `release` actually having cut a new version, respectively — so pushing to +`develop` never builds or publishes anything. The version lives in `waypoint/__init__.py` +(`__version__`) and is bumped automatically from Conventional Commit prefixes (`fix:`, `feat:`, +`BREAKING CHANGE:`) since the last `vX.Y.Z` tag; don't hand-edit it or hand-create release tags — +that's what causes the version and the Docker tags to drift apart. + ## Invariants to preserve when editing - Every branch through `EmailMonitor.process_new_emails` must still move the processed UID to diff --git a/README.md b/README.md index deb2d51..c6bbba6 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,32 @@ The test suite covers the pure link-extraction and page-rendering logic (using a Garmin LiveTrack email fixture, see `tests/fixtures/`), the persisted state, IMAP config validation, and the web routes (via Flask's test client) — all without needing a real IMAP or SMTP server. +### Branching and releases + +- `develop` is the integration branch — feature branches and day-to-day work target `develop` via + PR. Pushes here (and PRs) only run the test suite; nothing gets built or published. +- `main` only ever moves forward via a PR from `develop`. A push to `main` additionally runs + [python-semantic-release](https://python-semantic-release.readthedocs.io/), which inspects the + [Conventional Commits](https://www.conventionalcommits.org/) merged in since the last `vX.Y.Z` + tag and, if they warrant a release, bumps `waypoint/__init__.py`, updates `CHANGELOG.md`, and + pushes the new tag + GitHub Release. +- Only that release step (never a plain push) triggers the multi-arch Docker build and publish to + GHCR — see `.github/workflows/ci.yml`. +- Commit messages therefore need a Conventional Commits prefix for the release step to pick them + up correctly: `fix:` → patch, `feat:` → minor, `BREAKING CHANGE:` (in the footer) → major. + Anything else (`chore:`, `docs:`, `test:`, ...) is ignored for versioning purposes. + +## Troubleshooting + +**Browser console shows `net::ERR_BLOCKED_BY_CLIENT`.** This is not a server/reverse-proxy issue — +Waypoint doesn't ship or require any nginx config, and this error never comes from the server; the +browser generates it locally, before the request even leaves the machine. It means a client-side +ad/tracker blocker (uBlock Origin, Brave Shields, Pi-hole-style browser extensions, ...) intercepted +a request. Garmin's `livetrack.garmin.com` URLs contain the substring `track`, which matches generic +tracker-blocklist rules in some filter lists, so the extension blocks the iframe/redirect target +itself. Check the failing request's URL in the Network tab to confirm, then allow-list +`livetrack.garmin.com` in the blocker — there's nothing to change in Waypoint's own config for this. + ## Security notes - Credentials are only ever read from environment variables (typically via `.env`, which is diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c9435c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.semantic_release] +version_variables = ["waypoint/__init__.py:__version__"] +tag_format = "v{version}" +commit_message = "chore(release): v{version} [skip ci]" +build_command = "" +major_on_zero = false + +[tool.semantic_release.branches.main] +match = "main" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" + +[tool.semantic_release.remote] +type = "github" diff --git a/waypoint/__init__.py b/waypoint/__init__.py index f36f81e..dcd58ec 100644 --- a/waypoint/__init__.py +++ b/waypoint/__init__.py @@ -1,2 +1,6 @@ """Garmin LiveTrack monitor: watches an inbox for LiveTrack emails and serves the current session link as a small self-contained web service.""" + +# Bumped automatically by python-semantic-release based on Conventional +# Commits (fix:/feat:/BREAKING CHANGE) merged into main -- don't hand-edit. +__version__ = "0.1.0"