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
25 changes: 22 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
# - macOS (AppleClang)
# - Windows (MSVC)
#
# Two extra Ubuntu/clang jobs:
# - sanitizers: the same test suite under ASan + UBSan
# - fuzz-smoke: a short libFuzzer run over geo::decode
# Extra Ubuntu jobs:
# - sanitizers: the same test suite under ASan + UBSan (clang)
# - single-header: generate the amalgamation and build the example with it
# - fuzz-smoke: a short libFuzzer run over geo::decode (clang)
#
# Triggered automatically on push/PR to master/main; can also be run manually
# from the Actions tab in GitHub UI.
Expand Down Expand Up @@ -118,6 +119,24 @@ jobs:
env:
UBSAN_OPTIONS: print_stacktrace=1

# The single-header amalgamation must stay generatable and usable — the
# release workflow (release-assets.yml) attaches it to every release, and
# a failure there would surface only after the release is public.
single-header:
name: single-header-smoke
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Generate and smoke-test
run: |
python3 tools/amalgamate.py -o geo.hpp
mkdir -p smoke/geo
cp geo.hpp smoke/geo/geo.hpp
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror -Ismoke \
examples/gps_track.cpp -o smoke/gps_track
./smoke/gps_track

# Short libFuzzer run over geo::decode (the only parser of untrusted
# input): memory safety under ASan/UBSan plus the encode/decode
# round-trip property. A smoke check, not a soak — 60 seconds.
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/release-assets.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Attaches the single-header amalgamation (geo.hpp) to releases: generate
# from the release tag, smoke-test it by building and running the end-to-end
# example against it, then upload.
#
# Triggers on `created` (fires for drafts too): this repository uses
# immutable releases, so assets can only be attached BEFORE publishing —
# create the release as a draft, let this workflow attach the header, then
# flip the draft to published (see RELEASING.md).

name: Release assets

on:
release:
types: [created]

permissions:
contents: write

jobs:
single-header:
runs-on: ubuntu-latest
env:
TAG: ${{ github.event.release.tag_name }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.release.tag_name }}

- name: Generate the single header
run: python3 tools/amalgamate.py -o geo.hpp

- name: Smoke-test the amalgamation
run: |
mkdir -p smoke/geo
cp geo.hpp smoke/geo/geo.hpp
g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror -Ismoke \
examples/gps_track.cpp -o smoke/gps_track
./smoke/gps_track

- name: Upload to the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "$TAG" geo.hpp --clobber
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ location: https://pkg.cppget.org/1/testing

### Manual

Copy the `include/` directory into your project and add it to your include path.
Copy the `include/` directory into your project and add it to your include
path. Or grab the single-header `geo.hpp` attached to
[releases](https://github.com/gistrec/geo-utils-cpp/releases) after v1.2.0
— one file, same API as `#include <geo/geo.hpp>`.

### Using it from CMake

Expand Down
12 changes: 10 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ git push origin master # or merge the release PR

- [ ] Wait for **all** workflows on the release commit to go green
(`gh run list --commit <sha>`) — tag only a green commit.
- [ ] Annotated tag + GitHub release:
- [ ] Annotated tag + GitHub release. The repository uses **immutable
releases**: assets can only be attached before publishing, so create
the release as a **draft**, let the `Release assets` workflow attach
the single-header `geo.hpp` (~1 min), then publish:

```sh
git tag -a vX.Y.Z -m "geo-utils-cpp vX.Y.Z — <one-line summary>"
git push origin vX.Y.Z
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file <notes.md> # notes = CHANGELOG section
gh release create vX.Y.Z --draft --title "vX.Y.Z" --notes-file <notes.md> # notes = CHANGELOG section
gh release view vX.Y.Z --json assets --jq '.assets[].name' # wait for geo.hpp
gh release edit vX.Y.Z --draft=false
```

## 3. Hashes of the source tarball
Expand Down Expand Up @@ -119,6 +124,9 @@ maintainer-gated process.

## 5. Afterwards

- [ ] Double-check the published release shows the single-header `geo.hpp`
asset (it must be attached while the release is still a draft — see
step 2; immutable releases cannot gain assets after publishing).
- [ ] Verify the release page, the four registry submissions, and that the
README badges still resolve.
- [ ] When registry PRs merge, spot-check one install path
Expand Down
128 changes: 128 additions & 0 deletions tools/amalgamate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# Copyright 2026 Aleksandr Kovalko
# Licensed under the Apache License, Version 2.0
"""Generates the single-header amalgamation of geo-utils-cpp.

Concatenates the library headers in dependency order into one self-contained
file: per-file license banners, `#pragma once`, and local includes are
dropped; system includes are deduplicated and hoisted to the top. The output
is deterministic for a given source tree.

Usage: tools/amalgamate.py [-o geo.hpp]
"""

import argparse
import re
import sys
from pathlib import Path

# Dependency order: each file only relies on the ones before it.
HEADERS = [
"version.hpp",
"detail/math.hpp",
"latlng.hpp",
"spherical.hpp",
"poly.hpp",
"bounds.hpp",
"encoding.hpp",
]

BANNER = """\
// geo-utils-cpp v{version} — single-header amalgamation (generated, do not edit).
//
// Header-only C++17 library for spherical (lat/lng) geometry on Earth
// coordinates: distance, bearing, polygon area, point-in-polygon,
// snap-to-route, and encoded polylines.
//
// https://github.com/gistrec/geo-utils-cpp
//
// Generated by tools/amalgamate.py from the headers in include/geo/.
// Equivalent to including <geo/geo.hpp> from the multi-header layout.
//
// Copyright 2026 Aleksandr Kovalko
// Licensed under the Apache License, Version 2.0
//
// Portions of this library are based on Google Maps Android Utils:
// https://github.com/googlemaps/android-maps-utils
//
// Original work:
// Copyright 2013 Google Inc.
// Licensed under the Apache License, Version 2.0
//
// This work has been modified from the original,
// including a port from Java to C++.

#ifndef GEO_UTILS_CPP_SINGLE_HEADER_HPP
#define GEO_UTILS_CPP_SINGLE_HEADER_HPP
#pragma once
"""

FOOTER = "#endif // GEO_UTILS_CPP_SINGLE_HEADER_HPP\n"


def parse_version(include_dir: Path) -> str:
text = (include_dir / "version.hpp").read_text()
parts = {}
for key in ("MAJOR", "MINOR", "PATCH"):
m = re.search(rf"#define GEO_UTILS_CPP_VERSION_{key} (\d+)", text)
if not m:
sys.exit(f"error: GEO_UTILS_CPP_VERSION_{key} not found in version.hpp")
parts[key] = m.group(1)
return "{MAJOR}.{MINOR}.{PATCH}".format(**parts)


def strip_header(path: Path) -> tuple[list[str], list[str]]:
"""Returns (system_includes, body_lines) of one header.

Drops everything up to and including `#pragma once` (the license banner),
local `#include "..."` lines, and leading/trailing blank runs.
"""
lines = path.read_text().splitlines()
try:
start = next(i for i, l in enumerate(lines) if l.strip() == "#pragma once") + 1
except StopIteration:
sys.exit(f"error: no '#pragma once' in {path}")

includes, body = [], []
for line in lines[start:]:
stripped = line.strip()
if stripped.startswith('#include "'):
continue
if stripped.startswith("#include <"):
includes.append(stripped)
continue
body.append(line)

while body and not body[0].strip():
body.pop(0)
while body and not body[-1].strip():
body.pop()
return includes, body


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-o", "--output", default="geo.hpp", help="output file")
args = parser.parse_args()

include_dir = Path(__file__).resolve().parent.parent / "include" / "geo"
version = parse_version(include_dir)

all_includes: list[str] = []
sections: list[str] = []
for name in HEADERS:
includes, body = strip_header(include_dir / name)
all_includes.extend(includes)
rule = "-" * (66 - len(name))
sections.append(f"// ---- geo/{name} {rule}\n\n" + "\n".join(body))

out = [BANNER.format(version=version)]
out.append("\n".join(sorted(set(all_includes))))
out.append("\n\n".join(sections))
out.append(FOOTER)
Path(args.output).write_text("\n\n".join(out))
print(f"wrote {args.output} (geo-utils-cpp v{version}, {len(HEADERS)} headers)")


if __name__ == "__main__":
main()
Loading