diff --git a/Makefile b/Makefile index 4d4b8db..ba8c339 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,18 @@ PKG_CONFIG ?= pkg-config FLTK_CONFIG ?= fltk-config BUILD_DIR ?= build PREFIX ?= /usr/local +exec_prefix ?= $(PREFIX) +bindir ?= $(exec_prefix)/bin +libdir ?= $(exec_prefix)/lib +includedir ?= $(PREFIX)/include +datarootdir ?= $(PREFIX)/share +datadir ?= $(datarootdir) +docdir ?= $(datarootdir)/doc/xtc +licensedir ?= $(datarootdir)/licenses/xtc +examplesdir ?= $(datadir)/xtc/examples +desktopdir ?= $(datadir)/applications +pkgconfigdir ?= $(libdir)/pkgconfig +INSTALL_LAYOUT = --prefix "$(PREFIX)" --destdir "$(DESTDIR)" --bindir "$(bindir)" --libdir "$(libdir)" --includedir "$(includedir)" --docdir "$(docdir)" --licensedir "$(licensedir)" --examplesdir "$(examplesdir)" --desktopdir "$(desktopdir)" --pkgconfigdir "$(pkgconfigdir)" PYTHON ?= python3 VERSION := $(shell sed -n 's/^\#define XTC_VERSION_STRING "\([^"]*\)"/\1/p' include/xtc/xtc_version.h) @@ -172,27 +184,18 @@ endif # install retains the core-only behavior; install-gui adds the application. .PHONY: install uninstall install-gui dist release test-install test-release test-performance $(BUILD_DIR)/xtc.pc: packaging/xtc.pc.in tools/pkgconfig.py tools/dist.py include/xtc/xtc_version.h FORCE - $(PYTHON) tools/pkgconfig.py --prefix "$(PREFIX)" --jack "$(if $(JACK_AVAILABLE),jack)" --output "$@" + $(PYTHON) tools/pkgconfig.py --prefix "$(PREFIX)" --exec-prefix "$(exec_prefix)" --libdir "$(libdir)" --includedir "$(includedir)" --jack "$(if $(JACK_AVAILABLE),jack)" --output "$@" .PHONY: FORCE FORCE: install: all $(BUILD_DIR)/xtc.pc - install -d "$(DESTDIR)$(PREFIX)/include/xtc" "$(DESTDIR)$(PREFIX)/lib/pkgconfig" "$(DESTDIR)$(PREFIX)/bin" "$(DESTDIR)$(PREFIX)/share/doc/xtc/decisions" "$(DESTDIR)$(PREFIX)/share/licenses/xtc" - install -m 0644 include/xtc/*.h "$(DESTDIR)$(PREFIX)/include/xtc/" - install -m 0644 "$(BUILD_DIR)/libxtc.a" "$(DESTDIR)$(PREFIX)/lib/" - install -m 0644 "$(BUILD_DIR)/xtc.pc" "$(DESTDIR)$(PREFIX)/lib/pkgconfig/" - install -m 0755 "$(BUILD_DIR)/xtc-check" "$(DESTDIR)$(PREFIX)/bin/" - install -m 0644 README.md docs/*.md docs/performance-baseline.json "$(DESTDIR)$(PREFIX)/share/doc/xtc/" - install -m 0644 docs/decisions/*.md "$(DESTDIR)$(PREFIX)/share/doc/xtc/decisions/" - install -m 0644 LICENSE "$(DESTDIR)$(PREFIX)/share/licenses/xtc/" + $(PYTHON) tools/install.py $(INSTALL_LAYOUT) --build-dir "$(BUILD_DIR)" install-gui: install gui - install -d "$(DESTDIR)$(PREFIX)/share/applications" - install -m 0755 "$(BUILD_DIR)/xtc" "$(DESTDIR)$(PREFIX)/bin/" - install -m 0644 packaging/xtc.desktop "$(DESTDIR)$(PREFIX)/share/applications/" + $(PYTHON) tools/install.py $(INSTALL_LAYOUT) --build-dir "$(BUILD_DIR)" --gui uninstall: - $(PYTHON) tools/uninstall.py --root "$(DESTDIR)$(PREFIX)" + $(PYTHON) tools/uninstall.py $(INSTALL_LAYOUT) dist: $(PYTHON) tools/dist.py --output "$(BUILD_DIR)/xtc-$(VERSION).tar.gz" @@ -454,3 +457,7 @@ check-development: test-benchmark-tools .PHONY: test-stress-plugins test-stress-plugins: $(BUILD_DIR)/test_realtime $(BUILD_DIR)/test_reliability $(BUILD_DIR)/test_scale $(BUILD_DIR)/test_portability $(BUILD_DIR)/test_project_diagnostics $(BUILD_DIR)/test_soak $(BACKEND_TEST) $(BUILD_DIR)/test_plugins $(BUILD_DIR)/plugin-fixture.so $(PYTHON) tools/stress.py --build-dir "$(BUILD_DIR)" --rounds "$(STRESS_ROUNDS)" --plugin "$(BUILD_DIR)/plugin-fixture.so" + +.PHONY: test-install-layout +test-install-layout: all $(if $(GUI_AVAILABLE),gui) + $(PYTHON) tests/integration/test_install.py --build-dir "$(abspath $(BUILD_DIR))" --custom-layout $(if $(GUI_AVAILABLE),--gui) diff --git a/docs/BUILD.md b/docs/BUILD.md index 47d6456..5d48d59 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -263,3 +263,12 @@ regressions, scale/virtual endurance, benchmark-gate checks, Clang analysis and ASan/UBSan. Run `make benchmark benchmark-project` separately without contention or sanitizers for timing reports. Optional `make test-stress-plugins` requires installed LADSPA headers; `make test-stress` supports minimal builds too. + +## Phase 30 installation layouts + +`make test-install test-install-layout` verifies default and custom layouts in +throwaway staging directories, C/C++ installed consumers, examples and exact +removal. Override `PREFIX`, `exec_prefix`, `bindir`, `libdir`, `includedir`, +`datarootdir`, `datadir`, `docdir`, `pkgconfigdir`, `licensedir`, `examplesdir` or +`desktopdir` as needed. `DESTDIR` adds staging separately; pass the same layout +to uninstall. See RELEASING.md for commands and RELEASE_NOTES.md for upgrades. diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index e8c6ee4..1b5ed0a 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -53,3 +53,32 @@ an unsupported source file during failed Open. Keep the original when diagnosing post-publication sync errors. Native descriptor ABI compatibility is distinct from project format and is documented in NATIVE_API.md; opaque DSP state is not promised. No public stability guarantee or support SLA is implied by development version 0.1.0. + +## Phase 30 support and triage record + +Verified compilers are GCC/G++ 16.2.1 and Clang/Clang++ 23.1.3 on x86_64 Linux; +these are tested versions, not a promise that arbitrary older/newer versions work. +The code requires C99 plus GCC/Clang lock-free atomics, C++17 for the GUI, FLTK +1.4 and POSIX/glibc APIs. JACK (including PipeWire's JACK compatibility) is the +only live backend. Native Windows/macOS, other architectures and direct ALSA +backends are not qualified. Core builds can disable JACK, FLTK and LADSPA. +See BUILD.md and LICENSES.md for dependency roles and versions. + +Release qualification adds `test-install-layout`, installed example inspection, +all performance report resources, source checksum verification and the current +RELEASE_NOTES.md. Retain the original graph benchmark alert in release records; +a successful compile does not clear timing qualification gaps. + +File reproducible bugs with the repository maintainers using the source commit +and archive SHA-256, `xtc-check --version`/`--diagnose`, distro/kernel and toolchain, +exact commands or GUI steps, expected versus observed behavior and frequency. +Reduce to a redistributable project and state whether it reproduces without +external plugins and with the offline/core tools. Include relevant stderr and +sanitizer traces; preserve original recovery files before experimenting. + +Maintainers should first confirm version/features and reproduce in an isolated +build. Prioritize data loss/security/memory/thread defects, then crashes and +callback/deadline failures, then functional bugs and UI issues. Record severity, +reproduction conditions, affected versions, regression test and verification +command with the fix. Route timing-only alerts through matching-host reports and +repeat measurements; do not treat sanitized timings as realtime evidence. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000..cf76236 --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,53 @@ +# 0.1.0 development release notes + +This remains a development prototype, not a production-qualified DAW. Phase 30 +completes the local release workflow without bumping the application version, +creating a tag or publishing artifacts. The exact source archive SHA-256 and +reviewed Git commit identify a build; version 0.1.0 alone does not identify all +of the development revisions that used that version. + +## Included functionality + +The C99 engine provides bounded graphs, immutable plan publication, stereo audio, +MIDI instruments and controller bindings, track/bus mixing, automation and inline +MIDI editing. The C++17 FLTK application adds arrangement, routing, recording, +undo/redo and a generic LADSPA editor. JACK is the only live audio/MIDI backend; +core validation and offline rendering work without it. Native/LADSPA processors +are trusted in-process code. No proprietary energyXT resources are included. + +Project format v10 reads versions 1–10 transactionally. Syntax diagnostics identify +records and lines. `xtc-check --inspect` lists dependency paths without executing +plugins. Phase 29 adds timing/CPU/RSS reports, repeatable stress runs and virtual +endurance. Phase 30 adds configurable install directories, shared install/remove +inventory, installed MIDI examples and source archive checksum sidecars. + +## Upgrading and removal + +Back up documents and external media before upgrading. Opening an older project +does not rewrite it; saving upgrades it to v10, which older readers may reject. +Use Save As for a separate migrated copy. Moving a project folder preserves media +stored inside it via relative references. Absolute media and plugin module paths +must be restored separately. There is no bundled plugin/media migration or saved +JACK port configuration. See PROJECT_FORMAT.md and RECOVERY.md for exact policies. + +Install to the same layout to repair owned files. Only documented owned filenames +are replaced; unrelated files with other names remain. Use the same source +version and directory variables for uninstall. It removes owned files and empty +owned directories, never recordings or recursively deleting a prefix. A changed +layout or an older release with a different inventory may require removing that +old version before installing the new one. No database/package-manager registration +or automatic cleanup of historical inventories is performed. + +## Known qualification limits + +Physical devices, multi-hour wall-clock endurance, power-cut behavior and other +Linux distributions remain unqualified. There is no disk streaming, crash autosave, +plugin sandbox, latency compensation or stable shared-library ABI. A trusted +plugin can allocate, block or crash the process. Metadata inspection checks file +availability only. See KNOWN_LIMITATIONS.md for the complete list. + +The retained graph performance gate has a documented small-case timing alert on +the shared development host; the project timing comparison passed. This alert +was not hidden by changing the baseline. See PERFORMANCE.md before using these +measurements as latency expectations. Sanitizers and virtual endurance qualify +software paths, not hardware timing. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 145814f..c40a940 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -72,3 +72,37 @@ policy. Build diagnostics make no server/plugin calls. There is no asynchronous crash uploader or signal-handler cleanup: inspect OS core/debugger evidence on the control side, preserving project/media recovery paths. No release is publicly published by these commands, and human/device/listening checks remain pending. + +## Phase 30 release workflow + +Installation now shares one explicit file inventory with uninstallation. GNU-style +`exec_prefix`, `bindir`, `libdir`, `includedir`, `datarootdir`, `datadir`, `docdir`, +`pkgconfigdir` and the xtc-specific `licensedir`, `examplesdir`, `desktopdir` can be +overridden alongside `PREFIX`/`DESTDIR`. Paths must be absolute POSIX paths; +spaces are supported. Do not include DESTDIR in a directory variable: staging is +applied separately and never embedded in pkg-config metadata. Staging refuses +parent symlinks that escape DESTDIR. Use exactly the same variables for removal. + +```sh +make BUILD_DIR=build/release-check all test gui +make BUILD_DIR=build/release-check test-install test-install-layout +make PREFIX=/usr DESTDIR=/tmp/xtc-stage libdir=/usr/lib64 install-gui +make PREFIX=/usr DESTDIR=/tmp/xtc-stage libdir=/usr/lib64 uninstall +make test-release test-package +``` + +Core install includes the static library, public headers, `xtc-check`, pkg-config +metadata, Markdown documentation, all checked-in performance reports, license and +`share/xtc/examples` (a media/plugin-free MIDI demo). GUI install adds the desktop +entry and `xtc`. No desktop MIME association is claimed. Package archives keep +their fixed `/usr` layout and relocatable pkg-config metadata; custom live layouts +are tested separately. Existing owned file symlinks are replaced, not followed; +installation is atomic per file, not a transaction over the entire prefix. + +`make dist` now writes a `.sha256` sidecar as well as the normalized source archive. +Record that hash, the reviewed commit, version and build/dependency conditions +with release logs. `test-release` verifies the hash, builds/tests two fresh source +exports and compares artifacts and repacks. It validates exported tracked content, +including staged work; it does not attest that the caller's Git tree is clean. +For public release use a clean reviewed checkout and inspect `git status` first. +RELEASE_NOTES.md supplies upgrade guidance and remaining qualification limits. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c5c5539..c1d486f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -846,6 +846,20 @@ Turn the application into a maintainable release that users can build, install, - The release includes documentation, known limitations, and a reproducible version identifier. - The project has a repeatable process for preparing and validating releases. +### Implementation status +Completed: configurable standard install directories with one shared owned-file +inventory, staged file/symlink safety checks, installed metadata-only MIDI example +and all performance resources, source SHA-256 sidecars, release notes, upgrades, +support matrix and maintenance/triage guidance. Existing version/diagnostic modes, +desktop metadata and normalized source/binary packaging are retained. + +Acceptance verified with warning-enabled core/GUI builds and tests; default and +custom-layout staged install/repair/uninstall with C/C++ consumers; minimal +no-JACK/no-FLTK/no-LADSPA build; two clean source-export release builds with +byte-identical binaries and repacks; and binary manifest/hash/relocation checks. +No public release, tag, version bump or system install is performed. Physical +qualification and the Phase 29 graph timing alert remain documented limitations. + --- ## Recommended Execution Order diff --git a/examples/README.md b/examples/README.md index 8a55a38..b4402ed 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,3 +9,6 @@ matched raw/smoothed listening fixtures. Run `make quality-preview`; generated WAVs remain in the ignored build directory. See docs/AUDIO_QUALITY.md for the listening sequence and the distinction between automated checks and human listening acceptance. It never starts a backend or plays audio. + +`projects/midi-demo.xtc` is an original media/plugin-free MIDI example installed +under `share/xtc/examples`. See projects/README.md for opening and playback. diff --git a/examples/projects/README.md b/examples/projects/README.md new file mode 100644 index 0000000..080304a --- /dev/null +++ b/examples/projects/README.md @@ -0,0 +1,7 @@ +# MIDI demonstration + +`midi-demo.xtc` is an original MIT-licensed metadata-only example based on the +repository v10 fixture. It contains one A4 MIDI note, a gain node and an output. +Open it in xtc and Play after connecting JACK output ports to hear it. It needs +no media or plugins. `xtc-check --validate midi-demo.xtc` works without JACK. +Save a working copy in your own folder; installed examples may be read-only. diff --git a/examples/projects/midi-demo.xtc b/examples/projects/midi-demo.xtc new file mode 100644 index 0000000..b07a4b9 --- /dev/null +++ b/examples/projects/midi-demo.xtc @@ -0,0 +1,41 @@ +XTC_PROJECT 10 +APP 0 1 0 +TEMPO 120000 +IDS 1 1 +GRAPH 1 3 3 2 +NODE 0 6 00000000 +NODE 1 1 3f000000 +NODE 2 4 00000000 +EDGE 0 1 +EDGE 1 2 +TRACKS 1 +TRACK 0 4b657973 +MIX 3f800000 00000000 0 0 +ROUTE 0 18446744073709551615 +CLIPS 1 +CLIP 0 0 0 960 0 960 5665727365 +ASSET - +MIDI 1 +AUDIO 3f800000 0 0 +NOTES 1 1 +NOTE 0 0 0 480 69 100 0 +AUTOMATION 1 +POINT 0 0 0 3f800000 0 +CONTROL_MAPS 16 +MAP 0 7 10 64 +MAP 1 7 10 64 +MAP 2 7 10 64 +MAP 3 7 10 64 +MAP 4 7 10 64 +MAP 5 7 10 64 +MAP 6 7 10 64 +MAP 7 7 10 64 +MAP 8 7 10 64 +MAP 9 7 10 64 +MAP 10 7 10 64 +MAP 11 7 10 64 +MAP 12 7 10 64 +MAP 13 7 10 64 +MAP 14 7 10 64 +MAP 15 7 10 64 +END diff --git a/packaging/xtc.pc.in b/packaging/xtc.pc.in index d109aff..5877398 100644 --- a/packaging/xtc.pc.in +++ b/packaging/xtc.pc.in @@ -1,7 +1,7 @@ prefix=@PREFIX@ -exec_prefix=${prefix} -libdir=${prefix}/lib -includedir=${prefix}/include +exec_prefix=@EXEC_PREFIX@ +libdir=@LIBDIR@ +includedir=@INCLUDEDIR@ Name: xtc Description: Modular C audio engine and project library diff --git a/tests/integration/test_install.py b/tests/integration/test_install.py index 29d09bf..fe26141 100644 --- a/tests/integration/test_install.py +++ b/tests/integration/test_install.py @@ -17,6 +17,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--build-dir', type=Path, required=True) parser.add_argument('--gui', action='store_true') + parser.add_argument('--custom-layout', action='store_true') args = parser.parse_args() source = Path.cwd() with tempfile.TemporaryDirectory(prefix='xtc-install-') as directory: @@ -30,28 +31,48 @@ def main(): # MAKEFLAGS jobserver descriptors do not cross Python subprocesses. env = dict(os.environ, MAKEFLAGS='', MFLAGS='') variables = [f'BUILD_DIR={args.build_dir}', f'PREFIX={prefix}', f'DESTDIR={stage}'] + if args.custom_layout: + variables += [f'exec_prefix={prefix}/runtime', f'libdir={prefix}/lib64', + f'includedir={prefix}/headers', f'datarootdir={prefix}/resources', + f'docdir={prefix}/manual', f'pkgconfigdir={prefix}/metadata'] + binaries = root / ('runtime/bin' if args.custom_layout else 'bin') + headers = root / ('headers' if args.custom_layout else 'include') + resources = root / ('resources' if args.custom_layout else 'share') + docs = root / ('manual' if args.custom_layout else 'share/doc/xtc') + metadata = root / ('metadata' if args.custom_layout else 'lib/pkgconfig') run(make + ['install'] + variables, env=env) - assert not (root / 'bin/xtc').exists() - assert (root / 'share/licenses/xtc/LICENSE').read_bytes() == (source / 'LICENSE').read_bytes() - assert (root / 'share/doc/xtc/PROJECT_FORMAT.md').is_file() - assert os.access(root / 'bin/xtc-check', os.X_OK) - run([str(root / 'bin/xtc-check')]) - diagnose = subprocess.check_output([str(root / 'bin/xtc-check'), '--diagnose'], text=True) + assert not (binaries / 'xtc').exists() + assert (resources / 'licenses/xtc/LICENSE').read_bytes() == (source / 'LICENSE').read_bytes() + assert (docs / 'PROJECT_FORMAT.md').is_file() + assert os.access(binaries / 'xtc-check', os.X_OK) + run([str(binaries / 'xtc-check')]) + for filename in ('performance-baseline.json', 'project-performance-baseline.json', 'graph-performance-phase29.json'): + assert (docs / filename).read_bytes() == (source / 'docs' / filename).read_bytes() + example = resources / 'xtc/examples/midi-demo.xtc' + run([str(binaries / 'xtc-check'), '--inspect', str(example)]) + assert (resources / 'xtc/examples/README.md').is_file() + diagnose = subprocess.check_output([str(binaries / 'xtc-check'), '--diagnose'], text=True) assert 'Project formats: 1..10' in diagnose and 'JACK compiled:' in diagnose + # Replacing a file symlink must not overwrite an unrelated target. + protected = stage / 'unrelated-target' + protected.write_text('keep unrelated target\n') + (binaries / 'xtc-check').unlink() + (binaries / 'xtc-check').symlink_to(protected) # Upgrade/repair repeats the same owned-file install and keeps unrelated data. - (root / 'include/xtc/xtc.h').write_text('old installation marker\n') + (headers / 'xtc/xtc.h').write_text('old installation marker\n') run(make + ['install'] + variables, env=env) - assert (root / 'include/xtc/xtc.h').read_bytes() == (source / 'include/xtc/xtc.h').read_bytes() + assert (headers / 'xtc/xtc.h').read_bytes() == (source / 'include/xtc/xtc.h').read_bytes() assert sentinel.read_text() == 'keep\n' - version = subprocess.check_output([str(root / 'bin/xtc-check'), '--version'], text=True) + assert protected.read_text() == 'keep unrelated target\n' + version = subprocess.check_output([str(binaries / 'xtc-check'), '--version'], text=True) assert version.startswith('xtc ') valid = stage / 'valid.xtc' valid.write_text('XTC_PROJECT 4\nAPP 0 1 0\nTEMPO 120000\nIDS 0 0\nGRAPH 1 0 0 0\nTRACKS 0\nCLIPS 0\nNOTES 0 0\nAUTOMATION 0\nEND\n') - run([str(root / 'bin/xtc-check'), '--validate', str(valid)]) + run([str(binaries / 'xtc-check'), '--validate', str(valid)]) valid.write_text('damaged\n') - assert subprocess.run([str(root / 'bin/xtc-check'), '--validate', str(valid)], + assert subprocess.run([str(binaries / 'xtc-check'), '--validate', str(valid)], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode != 0 - pkg_env = dict(env, PKG_CONFIG_PATH=str(root / 'lib/pkgconfig') + ':' + env.get('PKG_CONFIG_PATH', ''), + pkg_env = dict(env, PKG_CONFIG_PATH=str(metadata) + ':' + env.get('PKG_CONFIG_PATH', ''), PKG_CONFIG_SYSROOT_DIR=str(stage)) pkg = shlex.split(os.environ.get('PKG_CONFIG', 'pkg-config')) # A no-JACK test can be invoked with PKG_CONFIG=false; package consumer @@ -87,10 +108,30 @@ int main(int argc, char **argv) { if args.gui: run(make + ['install-gui'] + variables, env=env) gui_env = dict(env, DISPLAY='', WAYLAND_DISPLAY='') - assert subprocess.check_output([str(root / 'bin/xtc'), '--version'], env=gui_env, text=True) == version - run([str(root / 'bin/xtc'), '--help'], env=gui_env) + assert subprocess.check_output([str(binaries / 'xtc'), '--version'], env=gui_env, text=True) == version + run([str(binaries / 'xtc'), '--help'], env=gui_env) if shutil.which('desktop-file-validate'): - run(['desktop-file-validate', str(root / 'share/applications/xtc.desktop')]) + run(['desktop-file-validate', str(resources / 'applications/xtc.desktop')]) + # Both operations must reject staging paths redirected outside DESTDIR. + with tempfile.TemporaryDirectory(prefix='xtc-install-outside-') as outside: + escape = root / 'escape' + escape.symlink_to(outside, target_is_directory=True) + redirected = variables + [f'docdir={prefix}/escape/docs'] + for operation in ('install', 'uninstall'): + refusal = subprocess.run(make + [operation] + redirected, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert refusal.returncode != 0 and not list(Path(outside).iterdir()) + assert (headers / 'xtc/xtc.h').is_file() + escape.unlink() + # A directory at an owned file path must fail the whole removal preflight. + saved_binary = stage / 'saved-check' + (binaries / 'xtc-check').rename(saved_binary) + (binaries / 'xtc-check').mkdir() + refusal = subprocess.run(make + ['uninstall'] + variables, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + assert refusal.returncode != 0 and (headers / 'xtc/xtc.h').is_file() + (binaries / 'xtc-check').rmdir() + saved_binary.rename(binaries / 'xtc-check') run(make + ['uninstall'] + variables, env=env) run(make + ['uninstall'] + variables, env=env) remaining = sorted(p.relative_to(root).as_posix() for p in root.rglob('*') if p.is_file() or p.is_symlink()) diff --git a/tests/integration/test_package.py b/tests/integration/test_package.py index f1240fe..6b2e0ba 100644 --- a/tests/integration/test_package.py +++ b/tests/integration/test_package.py @@ -44,6 +44,11 @@ def main(): flags = subprocess.check_output(['pkg-config', '--cflags', '--libs', '--static', 'xtc'], env=env, text=True) assert str(prefix / 'include') in flags or str(prefix / 'lib/pkgconfig/../../include') in flags assert (prefix / 'share/doc/xtc/USER_MANUAL.md').exists() + assert (prefix / 'share/doc/xtc/RELEASE_NOTES.md').exists() + subprocess.run([str(prefix / 'bin/xtc-check'), '--inspect', + str(prefix / 'share/xtc/examples/midi-demo.xtc')], check=True) + for name in ('project-performance-baseline.json', 'graph-performance-phase29.json'): + assert (prefix / 'share/doc/xtc' / name).is_file() if a.build_dir: again = root / 'repacked.tar.gz' command = [sys.executable, 'tools/package.py', '--build-dir', str(a.build_dir), '--output', str(again)] diff --git a/tests/integration/test_release.py b/tests/integration/test_release.py index b437479..ac5d642 100644 --- a/tests/integration/test_release.py +++ b/tests/integration/test_release.py @@ -20,6 +20,7 @@ def main(): parser.add_argument('--gui', action='store_true') args = parser.parse_args() archive = args.archive.resolve() + assert archive.with_name(archive.name + '.sha256').read_text().split()[0] == digest(archive) with tempfile.TemporaryDirectory(prefix='xtc-release-') as directory: root = Path(directory) with tarfile.open(archive) as source: diff --git a/tools/dist.py b/tools/dist.py index 96e70ea..ba5f12c 100644 --- a/tools/dist.py +++ b/tools/dist.py @@ -2,6 +2,7 @@ """Deterministic source archive using indexed files, or an exported manifest.""" import argparse import gzip +import hashlib import io import json import os @@ -70,7 +71,9 @@ def archive(root, output): finally: if os.path.exists(temporary): os.unlink(temporary) - print(output) + sha = hashlib.sha256(output.read_bytes()).hexdigest() + output.with_name(output.name + '.sha256').write_text(f'{sha} {output.name}\n') + print(f'{output}: {sha}') if __name__ == '__main__': diff --git a/tools/install.py b/tools/install.py new file mode 100644 index 0000000..f6aabde --- /dev/null +++ b/tools/install.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Install the explicit owned inventory; replace files without following symlinks.""" +import argparse +import os +from pathlib import Path +import shutil +import tempfile +from install_layout import arguments, inventory, layout, staged + +parser = argparse.ArgumentParser(description=__doc__) +arguments(parser) +parser.add_argument('--build-dir', type=Path, required=True) +parser.add_argument('--gui', action='store_true') +args = parser.parse_args() +source = Path(__file__).resolve().parent.parent +entries = [] +for destination, original in inventory(source, layout(args)): + name = destination.name + if name == 'BINARY_MANIFEST.json' or (not args.gui and name in ('xtc', 'xtc.desktop')): + continue + if original is None: + original = args.build_dir / name + destination = staged(destination, args.destdir) + if not original.is_file(): + raise ValueError(f'Missing install input: {original}') + if destination.exists() and not (destination.is_file() or destination.is_symlink()): + raise ValueError(f'Refusing non-file destination: {destination}') + entries.append((destination, original)) +for destination, original in entries: + destination.parent.mkdir(parents=True, exist_ok=True) + fd, pending = tempfile.mkstemp(prefix='.xtc-install-', dir=destination.parent) + try: + with os.fdopen(fd, 'wb') as output, original.open('rb') as input_file: + shutil.copyfileobj(input_file, output) + os.fchmod(output.fileno(), 0o755 if destination.name in ('xtc', 'xtc-check') else 0o644) + os.replace(pending, destination) + finally: + Path(pending).unlink(missing_ok=True) diff --git a/tools/install_layout.py b/tools/install_layout.py new file mode 100644 index 0000000..81db6c7 --- /dev/null +++ b/tools/install_layout.py @@ -0,0 +1,55 @@ +"""Shared install inventory and configurable POSIX directory layout.""" +from pathlib import Path + +KEYS = ('bindir', 'libdir', 'includedir', 'docdir', 'licensedir', 'examplesdir', 'desktopdir', 'pkgconfigdir') + +def arguments(parser): + parser.add_argument('--prefix', default='/usr/local') + parser.add_argument('--destdir', default='') + for key in KEYS: + parser.add_argument('--' + key) + + +def layout(args): + prefix = directory(args.prefix) + defaults = dict(bindir=prefix / 'bin', libdir=prefix / 'lib', includedir=prefix / 'include', + docdir=prefix / 'share/doc/xtc', licensedir=prefix / 'share/licenses/xtc', + examplesdir=prefix / 'share/xtc/examples', desktopdir=prefix / 'share/applications', + pkgconfigdir=prefix / 'lib/pkgconfig') + return {key: directory(getattr(args, key) or value) for key, value in defaults.items()} + + +def directory(value): + text = str(value) + path = Path(text) + if not path.is_absolute() or '..' in path.parts or any(c in text for c in '\n\r\0"$\\'): + raise ValueError('Install directories must be absolute without .., controls, quotes, $, or backslashes') + return path + + +def inventory(source, paths): + entries = [('libdir', 'libxtc.a', None), ('pkgconfigdir', 'xtc.pc', None), + ('bindir', 'xtc-check', None), ('bindir', 'xtc', None), + ('desktopdir', 'xtc.desktop', source / 'packaging/xtc.desktop'), + ('licensedir', 'LICENSE', source / 'LICENSE'), + ('docdir', 'README.md', source / 'README.md'), + ('docdir', 'BINARY_MANIFEST.json', None)] + entries += [('includedir', 'xtc/' + p.name, p) for p in sorted((source / 'include/xtc').glob('*.h'))] + entries += [('docdir', p.name, p) for p in sorted((source / 'docs').glob('*.md'))] + entries += [('docdir', p.name, p) for p in sorted((source / 'docs').glob('*performance*.json'))] + entries += [('docdir', 'decisions/' + p.name, p) for p in sorted((source / 'docs/decisions').glob('*.md'))] + entries += [('examplesdir', p.name, p) for p in sorted((source / 'examples/projects').glob('*')) if p.is_file()] + result = [(paths[key] / name, original) for key, name, original in entries] + if len({path for path, _ in result}) != len(result): + raise ValueError('Install layout has overlapping file destinations') + return result + + +def staged(path, destdir): + if not destdir: + return path + root = directory(destdir).resolve() + result = root / path.relative_to('/') + if not result.parent.resolve().is_relative_to(root): + raise ValueError(f'Install directory symlink escapes DESTDIR: {result}') + return result diff --git a/tools/pkgconfig.py b/tools/pkgconfig.py index bfe23c4..3bb0739 100644 --- a/tools/pkgconfig.py +++ b/tools/pkgconfig.py @@ -3,14 +3,23 @@ import argparse from pathlib import Path from dist import version +from install_layout import directory parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', required=True) +parser.add_argument('--exec-prefix') +parser.add_argument('--libdir') +parser.add_argument('--includedir') parser.add_argument('--jack', default='') parser.add_argument('--output', type=Path, required=True) args = parser.parse_args() -if not args.prefix.startswith('/') or any(c in args.prefix for c in '\n\r\0"$\\'): - parser.error('PREFIX must be absolute, without newlines, quotes, $, or backslashes') -text = Path('packaging/xtc.pc.in').read_text().replace('@PREFIX@', args.prefix) +prefix = directory(args.prefix) +exec_prefix = directory(args.exec_prefix or prefix) +libdir = directory(args.libdir or exec_prefix / 'lib') +includedir = directory(args.includedir or prefix / 'include') +def reference(path): + return '${prefix}/' + str(path.relative_to(prefix)) if path != prefix and prefix in path.parents else str(path) +text = Path('packaging/xtc.pc.in').read_text().replace('@PREFIX@', str(prefix)) +text = text.replace('@EXEC_PREFIX@', reference(exec_prefix)).replace('@LIBDIR@', reference(libdir)).replace('@INCLUDEDIR@', reference(includedir)) text = text.replace('@VERSION@', version(Path.cwd())).replace('@JACK@', args.jack) args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(text) diff --git a/tools/uninstall.py b/tools/uninstall.py index a3544f4..1d97e30 100644 --- a/tools/uninstall.py +++ b/tools/uninstall.py @@ -1,26 +1,19 @@ #!/usr/bin/env python3 -"""Remove only this source version's installed files; never recurse directories.""" +"""Remove only this source version's inventory; never recurse directories.""" import argparse from pathlib import Path import stat +from install_layout import arguments, inventory, layout, staged -def owned_files(source): - files = ['lib/libxtc.a', 'lib/pkgconfig/xtc.pc', 'bin/xtc-check', 'bin/xtc', - 'share/applications/xtc.desktop', 'share/licenses/xtc/LICENSE', - 'share/doc/xtc/README.md', 'share/doc/xtc/performance-baseline.json', - 'share/doc/xtc/BINARY_MANIFEST.json'] - files += ['include/xtc/' + p.name for p in sorted((source / 'include/xtc').glob('*.h'))] - files += ['share/doc/xtc/' + p.name for p in sorted((source / 'docs').glob('*.md'))] - files += ['share/doc/xtc/decisions/' + p.name for p in sorted((source / 'docs/decisions').glob('*.md'))] - return files - - -def uninstall(root, source): - if not root.is_absolute() or root == Path('/'): - raise ValueError('Installation root must be absolute and not /') - files = [root / name for name in owned_files(source)] - # Preflight before changing anything; never unlink a directory at a file path. +def main(): + parser = argparse.ArgumentParser(description=__doc__) + arguments(parser) + parser.add_argument('--root', help='legacy alias for --prefix') + args = parser.parse_args() + if args.root: + args.prefix = args.root + files = [staged(path, args.destdir) for path, _ in inventory(Path(__file__).resolve().parent.parent, layout(args))] for path in files: try: mode = path.lstat().st_mode @@ -30,16 +23,14 @@ def uninstall(root, source): raise ValueError(f'Refusing non-file: {path}') for path in files: path.unlink(missing_ok=True) - parents = {parent for path in files for parent in path.parents if parent == root or root in parent.parents} + # Only try the immediate owned directories, never walk up to / or DESTDIR. + parents = {p.parent for p in files} for parent in sorted(parents, key=lambda p: len(p.parts), reverse=True): try: parent.rmdir() - except (FileNotFoundError, OSError): - pass # Shared/nonempty directories remain untouched. + except OSError: + pass if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--root', type=Path, required=True) - args = parser.parse_args() - uninstall(args.root, Path(__file__).resolve().parent.parent) + main()