diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..b1a62d0 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,142 @@ +name: Package + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: package-${{ github.ref }} + cancel-in-progress: true + +# plot is header-only and dependency-free, so the package payload is just the +# amalgamated single header (plot.hpp) + LICENSE. Each job configures with +# -DPLOT_INSTALL_FULL=OFF and builds only the `plot-amalgamate` target. +env: + CMAKE_ARGS: >- + -DCMAKE_BUILD_TYPE=Release + -DPLOT_INSTALL_FULL=OFF + -DPLOTS_BUILD_TESTS=OFF + -DPLOTS_BUILD_EXAMPLES=OFF + +jobs: + # ── Linux: DEB (Debian) + RPM (RedHat) + tar.gz + zip ─────────────────────── + package-linux: + name: linux / DEB + RPM + tar.gz + zip + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install rpmbuild + run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends rpm + - name: Configure + run: cmake -B build ${{ env.CMAKE_ARGS }} + - name: Build amalgamation + run: cmake --build build --target plot-amalgamate + - name: Package + run: | + cd build + cpack -G DEB + cpack -G RPM + cpack -G TGZ + cpack -G ZIP + - uses: actions/upload-artifact@v4 + with: + name: packages-linux + path: | + build/*.deb + build/*.rpm + build/*.tar.gz + build/*.zip + if-no-files-found: error + + # ── macOS: productbuild (.pkg) ────────────────────────────────────────────── + package-macos: + name: macos / pkg + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Configure + run: cmake -B build ${{ env.CMAKE_ARGS }} + - name: Build amalgamation + run: cmake --build build --target plot-amalgamate + - name: Package + run: cd build && cpack -G productbuild + - uses: actions/upload-artifact@v4 + with: + name: packages-macos + path: build/*.pkg + if-no-files-found: error + + # ── Windows: NSIS (.exe) ──────────────────────────────────────────────────── + package-windows: + name: windows / NSIS + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install NSIS + shell: powershell + run: | + # CPack -G NSIS needs makensis on PATH; chocolatey installs it. + choco install nsis -y --no-progress + $nsisDir = "C:\Program Files (x86)\NSIS" + if (-not (Test-Path "$nsisDir\makensis.exe")) { $nsisDir = "C:\Program Files\NSIS" } + if (-not (Test-Path "$nsisDir\makensis.exe")) { throw "makensis.exe not found after NSIS install" } + Add-Content -Path $env:GITHUB_PATH -Value $nsisDir + - name: Configure + run: cmake -B build ${{ env.CMAKE_ARGS }} + - name: Build amalgamation + run: cmake --build build --target plot-amalgamate --config Release + - name: Package + run: cd build && cpack -G NSIS + - uses: actions/upload-artifact@v4 + with: + name: packages-windows + path: build/*.exe + if-no-files-found: error + + # ── Publish to the GitHub release ─────────────────────────────────────────── + # Runs only for a tag ref (push of v* or a workflow_dispatch with the tag + # selected as the ref). Creates the release if absent, else uploads/replaces + # assets so a hand-edited release body is preserved. + publish: + name: Publish release + needs: [package-linux, package-macos, package-windows] + runs-on: ubuntu-22.04 + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: packages-* + merge-multiple: true + path: dist/ + - name: List artifacts + run: ls -lh dist/ + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euxo pipefail + tag="${{ github.ref_name }}" + repo="${{ github.repository }}" + if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then + gh release upload "$tag" dist/* --clobber --repo "$repo" + else + gh release create "$tag" dist/* \ + --title "plot $tag" \ + --generate-notes \ + --repo "$repo" + fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 405d8d3..2bd0209 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ # _________________________________________________________ cmake_minimum_required(VERSION 3.16 FATAL_ERROR) -project(plot VERSION 0.1.0 LANGUAGES CXX) +project(plot VERSION 1.0.0 LANGUAGES CXX) add_library(plot INTERFACE) add_library(plot::plot ALIAS plot) @@ -39,31 +39,128 @@ if(DOXYGEN_EXECUTABLE) endif() include(GNUInstallDirs) -install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) -install(TARGETS plot EXPORT plot-targets - INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} -) -include(CMakePackageConfigHelpers) -set(PLOT_INSTALL_CMAKEDIR ${CMAKE_INSTALL_DATADIR}/cmake/plot) -configure_package_config_file( - ${CMAKE_CURRENT_SOURCE_DIR}/cmake/plot-config.cmake.in - ${CMAKE_CURRENT_BINARY_DIR}/plot-config.cmake - INSTALL_DESTINATION ${PLOT_INSTALL_CMAKEDIR} -) -write_basic_package_version_file( - ${CMAKE_CURRENT_BINARY_DIR}/plot-config-version.cmake - VERSION ${PROJECT_VERSION} - COMPATIBILITY AnyNewerVersion - ARCH_INDEPENDENT -) -install(EXPORT plot-targets - FILE plot-targets.cmake - NAMESPACE plot:: - DESTINATION ${PLOT_INSTALL_CMAKEDIR} -) -install(FILES - ${CMAKE_CURRENT_BINARY_DIR}/plot-config.cmake - ${CMAKE_CURRENT_BINARY_DIR}/plot-config-version.cmake - DESTINATION ${PLOT_INSTALL_CMAKEDIR} -) +# ─── Single-header amalgamation (plot.hpp) ─────────────────────────────────── +# Generate a self-contained plot.hpp from include/plot/ (order taken from the +# `all` umbrella). This is the payload shipped in the release packages. The +# target is built as part of ALL when a Python 3 interpreter is available; +# without one the rest of the build (tests/examples/find_package) is unaffected. +find_package(Python3 COMPONENTS Interpreter QUIET) +if(Python3_Interpreter_FOUND) + file(GLOB PLOT_HEADERS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/include/plot/*.hpp) + set(PLOT_AMALGAMATED_HEADER ${CMAKE_CURRENT_BINARY_DIR}/plot.hpp) + add_custom_command( + OUTPUT ${PLOT_AMALGAMATED_HEADER} + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/scripts/amalgamate.py + ${CMAKE_CURRENT_SOURCE_DIR}/include/plot ${PLOT_AMALGAMATED_HEADER} + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/scripts/amalgamate.py + ${CMAKE_CURRENT_SOURCE_DIR}/include/plot/all ${PLOT_HEADERS} + COMMENT "Amalgamating plot headers into plot.hpp" + VERBATIM + ) + add_custom_target(plot-amalgamate ALL DEPENDS ${PLOT_AMALGAMATED_HEADER}) + # Ship plot.hpp alongside the tree so #include works ambiently. + install(FILES ${PLOT_AMALGAMATED_HEADER} DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +else() + message(WARNING "Python3 not found — plot.hpp amalgamation disabled.") +endif() + +# LICENSE travels in every package (and find_package install). +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR}) + +# ─── Full header tree + CMake package config (find_package(plot)) ──────────── +# ON for source installs; the release packages set -DPLOT_INSTALL_FULL=OFF so +# the package payload is just the single header + LICENSE. +option(PLOT_INSTALL_FULL "Install the full include/plot tree and CMake package config" ON) +if(PLOT_INSTALL_FULL) + install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + install(TARGETS plot EXPORT plot-targets + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + ) + + include(CMakePackageConfigHelpers) + set(PLOT_INSTALL_CMAKEDIR ${CMAKE_INSTALL_DATADIR}/cmake/plot) + configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/plot-config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/plot-config.cmake + INSTALL_DESTINATION ${PLOT_INSTALL_CMAKEDIR} + ) + write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/plot-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY AnyNewerVersion + ARCH_INDEPENDENT + ) + install(EXPORT plot-targets + FILE plot-targets.cmake + NAMESPACE plot:: + DESTINATION ${PLOT_INSTALL_CMAKEDIR} + ) + install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/plot-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/plot-config-version.cmake + DESTINATION ${PLOT_INSTALL_CMAKEDIR} + ) +endif() + +# ─── CPack: H5CPP-style release packages ───────────────────────────────────── +# Header-only ⇒ arch-independent (DEB all / RPM noarch). The package payload is +# the single header + LICENSE (configure the release build with +# -DPLOT_INSTALL_FULL=OFF). Generators: DEB, RPM, NSIS (Windows), productbuild +# (macOS), plus TGZ + ZIP archives. +set(CPACK_PACKAGE_NAME "plot-dev") +set(CPACK_PACKAGE_VENDOR "Varga Labs") +set(CPACK_PACKAGE_CONTACT "steven.varga@gmail.com") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Header-only, dependency-free C++ SVG plotting library") +set(CPACK_PACKAGE_HOMEPAGE_URL "https://vargalabs.github.io/plots") +set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set(CPACK_PACKAGE_VERSION_MAJOR "${PROJECT_VERSION_MAJOR}") +set(CPACK_PACKAGE_VERSION_MINOR "${PROJECT_VERSION_MINOR}") +set(CPACK_PACKAGE_VERSION_PATCH "${PROJECT_VERSION_PATCH}") + +# productbuild (macOS) rejects license files without a .rtf/.html/.txt +# extension; the canonical LICENSE is extensionless, so stage a .txt copy. +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + "${CMAKE_CURRENT_BINARY_DIR}/LICENSE.txt" + COPYONLY) +set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_CURRENT_BINARY_DIR}/LICENSE.txt") +set(CPACK_STRIP_FILES OFF) + +# Headers land at /include/plot.hpp — ambient, no -I needed. Linux uses +# /usr (apt/yum convention); macOS uses /usr/local (Apple/Homebrew). Windows +# NSIS ignores this and lets the user pick a target dir. +if(APPLE) + set(CPACK_PACKAGING_INSTALL_PREFIX "/usr/local") +else() + set(CPACK_PACKAGING_INSTALL_PREFIX "/usr") +endif() + +# DEB (Debian/Ubuntu) +set(CPACK_DEBIAN_PACKAGE_NAME "plot-dev") +set(CPACK_DEBIAN_PACKAGE_SECTION "libdevel") +set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "all") # header-only +set(CPACK_DEBIAN_FILE_NAME "DEB-DEFAULT") + +# RPM (RedHat/Fedora) +set(CPACK_RPM_PACKAGE_NAME "plot-devel") +set(CPACK_RPM_PACKAGE_LICENSE "MIT") +set(CPACK_RPM_PACKAGE_GROUP "Development/Libraries") +set(CPACK_RPM_PACKAGE_ARCHITECTURE "noarch") # header-only +set(CPACK_RPM_FILE_NAME "RPM-DEFAULT") + +# NSIS (Windows installer) +set(CPACK_NSIS_PACKAGE_NAME "plot ${PROJECT_VERSION}") +set(CPACK_NSIS_DISPLAY_NAME "plot ${PROJECT_VERSION}") +set(CPACK_NSIS_URL_INFO_ABOUT "${CPACK_PACKAGE_HOMEPAGE_URL}") +set(CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON) + +# productbuild (macOS .pkg) +set(CPACK_PRODUCTBUILD_IDENTIFIER "org.vargalabs.plot") + +# Distinct, lowercase per-OS filenames for NSIS/productbuild/TGZ/ZIP. DEB/RPM +# keep their own conventional *-DEFAULT names set above. +set(CPACK_PACKAGE_FILE_NAME "plot-v${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}") +string(TOLOWER "${CPACK_PACKAGE_FILE_NAME}" CPACK_PACKAGE_FILE_NAME) + +include(CPack) diff --git a/README.md b/README.md index a0dda3c..e5940ea 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,28 @@ find_package(plot CONFIG REQUIRED) target_link_libraries(my_app PRIVATE plot::plot) ``` +### Releases & the single-header drop + +Each tagged release ships prebuilt packages (built by `.github/workflows/package.yml`): + +| Platform | Artifact | +|----------|----------| +| Debian / Ubuntu | `plot-dev__all.deb` | +| RedHat / Fedora | `plot-devel-.noarch.rpm` | +| Windows | `plot-v-windows.exe` (NSIS) | +| macOS | `plot-v-darwin.pkg` | +| any | `plot-v-.tar.gz` / `.zip` | + +The payload is a single amalgamated header `plot.hpp` (generated from +`include/plot/` by `scripts/amalgamate.py`). Once installed — or just dropped in +your tree — it is all you need: + +```cpp +#include // no -I plot/, no other files +``` + +Regenerate it manually with `python3 scripts/amalgamate.py include/plot plot.hpp`. + ## Examples `-DPLOTS_BUILD_EXAMPLES=ON` builds the demos under `examples/`; each writes an diff --git a/scripts/amalgamate.py b/scripts/amalgamate.py new file mode 100755 index 0000000..edddb34 --- /dev/null +++ b/scripts/amalgamate.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Steven Varga, Toronto, ON, Canada +# MIT License — see LICENSE +""" +Amalgamate the plot headers into a single self-contained plot.hpp. + +Usage: + python3 scripts/amalgamate.py + +Example: + python3 scripts/amalgamate.py include/plot build/plot.hpp + +The include order is read from the `all` umbrella header (the canonical, +dependency-correct order). Each listed header is inlined with its internal +quoted includes ("foo.hpp") and `#pragma once` stripped; system includes +(, …) are kept (they are idempotent under their own guards). Per-file +`#ifndef` guards are kept — harmless, and they keep each block self-describing. +The whole thing is wrapped in one PLOT_HPP guard. +""" + +import re +import sys +from pathlib import Path + +QUOTED_INCLUDE = re.compile(r'^\s*#\s*include\s+"[^"]+"') + + +def ordered_headers(plot_dir: Path) -> list[str]: + """Return the header filenames in `all`-umbrella order.""" + all_file = plot_dir / "all" + if not all_file.exists(): + sys.exit(f"Error: umbrella header {all_file} not found.") + names = [] + for line in all_file.read_text(encoding="utf-8").splitlines(): + m = re.match(r'\s*#\s*include\s+"([^"]+)"', line) + if m: + names.append(m.group(1)) + return names + + +def process(filepath: Path) -> str: + """Strip `#pragma once` and internal quoted includes from a header.""" + out = [] + for line in filepath.read_text(encoding="utf-8").splitlines(): + if line.strip() == "#pragma once": + continue + if QUOTED_INCLUDE.match(line): + continue + out.append(line) + return "\n".join(out) + + +def main() -> None: + if len(sys.argv) != 3: + print(__doc__) + sys.exit(1) + + plot_dir = Path(sys.argv[1]) + output = Path(sys.argv[2]) + + headers = ordered_headers(plot_dir) + + parts = [ + "/*", + " * plot — single-header amalgamated distribution.", + " * Header-only, dependency-free C++ SVG plotting library.", + " * Copyright (c) 2026 Steven Varga — MIT License.", + " *", + " * Generated by scripts/amalgamate.py — do not edit by hand.", + " */", + "#ifndef PLOT_HPP", + "#define PLOT_HPP", + "", + ] + + count = 0 + for name in headers: + fp = plot_dir / name + if not fp.exists(): + print(f"Warning: {fp} not found, skipping.") + continue + parts.append(f"/* ===== plot/{name} ===== */") + parts.append(process(fp)) + parts.append("") + count += 1 + + parts.append("#endif /* PLOT_HPP */") + parts.append("") + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(parts), encoding="utf-8") + print(f"Generated {output} ({count} headers amalgamated)") + + +if __name__ == "__main__": + main()