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
90 changes: 5 additions & 85 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
name: CI, release and publish
name: Test

# 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.
# Runs the test suite on every push (any branch) and every PR. Versioning
# and publishing are handled by the separate release-please.yml and
# publish.yml workflows -- this one only ever answers "does the test suite
# pass right now?".

on:
push:
Expand All @@ -17,10 +14,6 @@ on:
permissions:
contents: read

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
test:
name: Run test suite
Expand All @@ -37,76 +30,3 @@ jobs:

- 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
69 changes: 69 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Build and publish Docker image

# Fires only on a vX.Y.Z tag -- which only ever exists once release-please
# (see release-please.yml) has cut an actual release by merging its release
# PR. A plain push to main/develop never reaches this workflow at all, so
# the multi-arch build only ever runs for a real release.

on:
push:
tags: ["v*.*.*"]
workflow_dispatch:

permissions:
contents: read

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build:
name: Build and push image
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
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# docker/metadata-action reads the semver straight off the triggering
# tag ref (github.ref), so no manual version wiring is needed here --
# unlike the previous single-workflow setup where the build job had to
# check out a tag computed by an earlier job in the same run.
- 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 as a "does the Dockerfile still build (and pass
# its embedded test stage)?" check; push is unconditional here since
# this workflow itself only ever runs for a real vX.Y.Z tag.
- 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
25 changes: 25 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Release Please

# Watches pushes to main and maintains a standing "release PR" that
# accumulates Conventional Commits into the next semantic version +
# CHANGELOG.md entry. Nothing is tagged or released automatically -- only
# merging that PR (a normal, reviewable PR merge, not a bot pushing straight
# to main) makes release-please cut the vX.Y.Z tag + GitHub Release. That
# tag push is what publish.yml reacts to.

on:
push:
branches: ["main"]

permissions:
contents: write
pull-requests: write

jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
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"
}
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +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
[Release Please](https://github.com/googleapis/release-please) from
[Conventional Commits](https://www.conventionalcommits.org/) merged into `main`.
39 changes: 31 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,21 @@ the test stage, never test files themselves). `HEALTHCHECK` hits `/healthz` with
### 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.
PR from `develop`. Three separate workflows split the concerns:

- `.github/workflows/ci.yml` — the `test` job, on every push (`main`/`develop`) and every PR.
- `.github/workflows/release-please.yml` — runs
[Release Please](https://github.com/googleapis/release-please) (config:
`release-please-config.json` + `.release-please-manifest.json`) on every push to `main`. It never
pushes to `main` directly: it maintains a standing release PR (version bump + `CHANGELOG.md`)
from Conventional Commits (`fix:`, `feat:`, `BREAKING CHANGE:`) and only cuts the `vX.Y.Z` tag +
GitHub Release once that PR is actually merged.
- `.github/workflows/publish.yml` — triggers only on a `vX.Y.Z` tag push (i.e. only once
release-please has cut a release) and does the multi-arch Docker build + push to GHCR.

The version lives in `waypoint/__init__.py` (`__version__`, marked with a trailing
`# x-release-please-version` comment that release-please's generic file updater matches on) — don't
hand-edit it or hand-create release tags, or the version, the tag, and `CHANGELOG.md` drift apart.

## Invariants to preserve when editing

Expand All @@ -134,3 +141,19 @@ that's what causes the version and the Docker tags to drift apart.
- `probe_iframe_embeddable` must never raise and must default to `False` (redirect) on any
ambiguity (network error, timeout, non-2xx, unparseable header) — the redirect page is the one
path that always works, so failure must never accidentally select the iframe path instead.
- `render_page` must keep escaping `link` for HTML-attribute contexts (`html.escape(..., quote=True)`
→ `link_attr`) and JSON-encoding it for the inline-JS context (`json.dumps` → `link_js`) — never
interpolate the raw string again. The admin override isn't guaranteed to match
`LIVETRACK_LINK_RE`, so this is the only thing standing between an admin-set value containing
quotes/`<>` and breaking out of an attribute or the JS string literal.
- `/admin/link` must keep rejecting (`400`) any submitted link that doesn't fullmatch
`LIVETRACK_LINK_RE` before it ever reaches `state.set_link`/`probe()` — this is what keeps the
admin override from being usable to probe/redirect to arbitrary internal URLs.
- `_check_auth` must keep using `secrets.compare_digest`, not `==`, for both the username and
password comparison.
- `__main__.py` starts the server via `waitress.create_server(...)` + `server.run()`, not the
`serve()` shortcut — the signal handler needs a `server` handle to call `.close()` on. Without
it, SIGTERM is silently swallowed by `handle_signal` and the container only stops on Docker's
SIGKILL, skipping `EmailMonitor`'s clean IMAP logout.
- The `Dockerfile`'s `HEALTHCHECK` reads `WEB_PORT` from the environment at check time (defaulting
to `8080`) rather than hard-coding a port — keep it that way if `WEB_PORT` stays configurable.
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ USER waypoint
EXPOSE 8080
VOLUME ["/data"]

# Reads WEB_PORT at check time (falling back to 8080) rather than
# hard-coding it, since the CMD's JSON form doesn't get shell/env
# substitution -- only the python process it execs does.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=3).status == 200 else 1)"]
CMD ["python", "-c", "import os,sys,urllib.request; port=os.environ.get('WEB_PORT','8080'); sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{port}/healthz', timeout=3).status == 200 else 1)"]

CMD ["python", "-m", "waypoint"]
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ At `/admin` (Basic Auth):
- **Status dashboard** — current link, when it was last updated, IMAP connection status, last
error.
- **Manual override** — set the current link directly, or clear it (e.g. to show the "offline"
page when no activity is live).
page when no activity is live). The link must match the same
`https://livetrack.garmin.com/session/.../token/...` shape as an email-extracted one (`400` on
anything else).
- **Recent history** — the last 20 links that were published, with timestamp and source
(`email` or `admin`), including whether each was served as an iframe or a redirect.

Expand Down Expand Up @@ -175,17 +177,20 @@ and the web routes (via Flask's test client) — all without needing a real IMAP
### 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.
PR. Pushes here (and PRs) only run the test suite (`.github/workflows/ci.yml`); nothing gets
built or released.
- `main` only ever moves forward via a PR from `develop`. Every push to `main` runs
[Release Please](https://github.com/googleapis/release-please)
(`.github/workflows/release-please.yml`), which inspects the
[Conventional Commits](https://www.conventionalcommits.org/) merged in since the last release and
keeps a standing **release PR** up to date with the accumulated version bump and `CHANGELOG.md`
entry — it never pushes to `main` directly.
- Merging that release PR (like any other PR, via review) is what actually cuts the `vX.Y.Z` tag +
GitHub Release. Only that tag push triggers `.github/workflows/publish.yml`, which builds the
multi-arch image and publishes it to GHCR — a plain commit landing on `main` never does.
- Commit messages need a Conventional Commits prefix for Release Please to pick them up correctly:
`fix:` → patch, `feat:` → minor, `BREAKING CHANGE:` (in the footer) → major. Anything else
(`chore:`, `docs:`, `test:`, ...) is included in the changelog but doesn't bump the version.

## Troubleshooting

Expand Down
16 changes: 0 additions & 16 deletions pyproject.toml

This file was deleted.

15 changes: 15 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "simple",
"packages": {
".": {
"changelog-path": "CHANGELOG.md",
"extra-files": [
{
"type": "generic",
"path": "waypoint/__init__.py"
}
]
}
}
}
Loading
Loading