* ci(windows): add the reusable Windows job
29 lines, 12 of them comment. Everything else -- the Nix install pinning, the
binary cache, the target-existence guard, the cold-cache refusal, the PE-format
and import-closure gates, the artifact hand-off and its round-trip check, and
the real-Windows execution -- lives once in logos-co/logos-nix.
The smoke paths carry the target prefix because the staged tree root holds one
directory per target: `stage/lgx/bin/lgx.exe`. There is no `stage/bin`.
Verified against this repo's real cross-built output, and the three commands
were run on a Windows 11 box with no toolchain installed before this was
written -- `--help` prints 988 bytes, `create` prints "Created package:
smoke.lgx", `verify` prints "Package structure is valid", so none of them needs
the `run -q` escape hatch for silent commands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci(windows): run the lgx-cli doctest's exercise half on real Windows
doctests/lgx-cli.test.yaml has 15 `run:` steps and exactly ONE of them invokes
nix. The other 14 only ever drive the built binary, so they are portable as-is
-- and the nix one needs no translation either, because `targets: lgx` in this
workflow already IS it. So the doc-tested packaging lifecycle now runs on a
real windows-latest runner without doctest.py, which cannot run there, having
to change at all.
.github/smoke/lgx-cli.sh is those 14 steps in order with their assertions
intact: create -> manifest -> add a variant -> verify -> extract -> merge two
single-variant packages -> verify the merge. 27 assertions.
Only one step needed a decision rather than a translation. The spec detects the
platform with `uname` and has no Windows arm, exiting 1 there. On this leg the
variant is not unknown -- we built FOR that target -- so it is stated rather
than probed: windows-x86_64 / dll, the names lgpm computes and nix-bundle-lgx
emits. A doctest detects; a smoke test asserts.
A committed file rather than an inline `smoke:` block, which needed
smoke-file support in the reusable workflow. The reasons are the two things
that just happened while writing it:
* It is SHELLCHECK-CLEAN. An inline block cannot be -- actionlint replaces
expression interpolations with a placeholder before linting.
* It RUNS BY HAND against a native build, via the LGX override:
LGX=lgx/bin/lgx bash .github/smoke/lgx-cli.sh
from a directory holding lgx/bin/lgx. That is what caught the one real bug
in it: written against master's lgx it asserted manifest schema 0.3.0, and
this branch bumps it to 0.4.0. Re-running against THIS branch's own binary
turned it red. Confirmed load-bearing by re-running the 0.3.0 version
against the 0.4.0 binary: rc=1.
Four more controls, so the assertions are known to bite rather than assumed to:
a wrong expected string, a missing binary, a deleted payload file each exit
non-zero; the clean run exits 0.
Two things deliberately NOT asserted tightly, both flagged in the file:
`tar` is checked for up front (it is a runner-image fact, not a property of
this repo, and without the check `tar -tzf` fails midway with a message about
the archive); and the extract step matches `extracted` rather than
`./extracted`, because whether lgx.exe prints a `./` or a `.\` prefix on
Windows is not something anyone has measured.
* ci(windows): the reusable workflow moved to logos-co/logos-windows-ci
logos-nix is the fleet's nixpkgs/Qt pin and a flake input of ~36 repos, so the
CI harness now lives in its own repo and can move at its own rate. No behaviour
change here -- the @v1 this file used to name had never existed (logos-nix has
zero tags), so nothing was ever resolving it.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(windows): cross-compile lgx for x86_64-w64-mingw32
Adds the x86_64-windows pseudo-system to `packages` (checks and devShells
stay native: ctest cannot execute PE binaries on the Linux build host, and a
cross devShell offers no way to run what it produces).
Four separate defects, three of which were invisible natively:
* The hand-rolled configurePhases in bin/lib/all.nix never passed
$cmakeFlags, so CMake was configured WITHOUT -DCMAKE_SYSTEM_NAME=Windows
or the cross toolchain file. lib.nix and all.nix additionally did not
inherit `cmakeFlags` at all, so the variable was empty even once
referenced.
* The shared-library install looked for `lgx.dll` + `lgx.lib`, the MSVC
spelling. CMake emits `liblgx.dll` + `liblgx.dll.a` on MinGW, and the
if/elif chain had no else -- an unmatched name installed an EMPTY
$out/lib and the build still succeeded. Now globbed, and fatal when
nothing matches. (nullglob only drops patterns containing wildcards, so
every entry has to be a real glob to get that error branch.)
* `cp build/lgx` had no executable suffix.
* keyring.cpp used gmtime_r, which mingw-w64 does not ship. Its gmtime_s
takes the same two arguments in the OPPOSITE order, so this is a swap
rather than the rename the compiler suggests.
cpp-semver is header-only and now declares platforms.windows too.
Result: bin/lgx.exe, PE32+ x86-64, with its 7 dependency DLLs staged
alongside by nixpkgs' win-dll-link.sh.
* fix(windows): stop lgx.exe's import library clobbering liblgx.dll's
On Windows the executable target `lgx` and the shared target `lgx_shared`
(which carries OUTPUT_NAME "lgx") BOTH generate an import library named
liblgx.dll.a, and both installed it into lib/. Whichever install rule ran
last won, so consumers received either the real 22,744-byte DLL import
library or the 892-byte one belonging to lgx.exe -- at random, varying
between otherwise identical builds.
When the executable's won, the failure landed nowhere near the cause: lgx
itself built and ran perfectly, its DLL exported all 437 symbols, and only
downstream repos broke, with 40+ "undefined reference to `__imp_lgx_*'"
at link time. Renaming the executable's archive removes the collision.
No other platform generates an import library, so this is WIN32-only.
Also replaces lib.nix's hand-rolled copy of build/ artifacts with CMake's
own install(TARGETS lgx_shared) rule. A Windows shared library is two
artifacts that must MATCH -- liblgx.dll and liblgx.dll.a -- and globbing
build/ by hand cannot know which pairs with which; CMake does. The CLI the
install rules also emit is then pruned, since this output is the library.
Both failure modes were silent, so lib.nix now refuses to produce an output
whose liblgx.dll.a exports no __imp_lgx_* symbols.
Verified: native 297/297 tests pass; the Windows import library goes from
2 members / 0 __imp_lgx_ symbols to 36 / 34.
* fix(windows): give the keyring a config directory on Windows
Keyring::defaultDirectory and defaultKeysDirectory read XDG_CONFIG_HOME then
HOME, neither of which exists on Windows -- so both returned an EMPTY path in
the normal Windows case. That is not inert: the trust-key lookup is silently
skipped, and the key-management commands hard-error with "Cannot determine
keyring directory".
APPDATA is the direct analogue of XDG_CONFIG_HOME (per-user roaming config),
with USERPROFILE backing it up the way HOME does on POSIX. The rules were
duplicated verbatim in both functions; they now share one helper, so the
platform logic exists once.
POSIX behaviour is unchanged. Native: 297/297 tests pass.
* fix(windows): never extract a file the extracting user cannot delete
lgx_extract applied the archived mode verbatim. A .lgx built by
nix-bundle-lgx carries Nix store modes, and the store is 0444, so payload
files could arrive read-only. std::filesystem maps a mode with no write bit to
FILE_ATTRIBUTE_READONLY on Windows, and Windows refuses to DELETE such a file
-- POSIX consults only the parent directory's write bit, which is why this
never showed up anywhere else.
The consequence was remote from here and looked nothing like a permissions
problem: the package manager's temp-directory cleanup threw an uncaught
filesystem_error AFTER a successful install, so the install reply was never
sent and the UI showed "Retry" for a package that had installed perfectly.
Uninstall and upgrade failed the same way with "Access is denied".
The producer side (nix-bundle-lgx a9d8be5) and the consumer side
(logos-package-manager 5191972) are both fixed. This is the mechanism itself:
it protects every other caller of lgx_extract, and it covers the packages
ALREADY PUBLISHED with the bit set, which the producer fix cannot reach.
Owner-write is added to the archived mode rather than replacing it, so the
execute bit and group/other bits are still honoured.
* chore(deps): re-pin logos-nix to the merged Windows overlay
The cross overlay landed in logos-nix#2. This branch was locked to a
pre-merge rev, which has no `lib.forAllTargets` and no `lib.mkWindowsPkgs`,
so it could not evaluate standalone -- only against the unmerged branch.
Level 2 of the Windows chain; L1 (logos-nix) is merged.
`icon` moves from a per-variant copy to a single assets/icon.png at the
package root, so a host can read it without unpacking a platform build —
the precondition for showing icons before install. Covered by the Merkle
tree, so it is authenticated by manifest.sig for free.
Adds `lgx add --icon` and Package::setIcon(). Validation (PNG, exactly
256x256) runs at verify/sign, not at create/add — a package is
legitimately incomplete mid-assembly.
Required for type ui_qml only; core modules render no tile.
Manifests below 0.4.0 are exempt: icon: "" was legal at 0.3.0 and is what
lgx create defaulted to, so enforcing it would make every published
package uninstallable
* feat: one semver implementation for the packaging stack
Version handling was duplicated across the packaging repos and the copies
disagreed with each other and with the spec. This makes logos-package the
single home for it.
Precedence is defined by SemVer 2.0.0, so it is delegated to a vendored
library (z4kn4fein/cpp-semver v0.4.0, MIT, single header, C++17):
- numeric pre-release identifiers compare NUMERICALLY, so 1.0.0-rc.2 <
1.0.0-rc.11. Every previous copy got this wrong -- the downloader
compared the whole pre-release tag as one ASCII string, while lgpm and
the package-manager UI dropped the tag entirely (atoi("0-rc1") == 0),
so a pre-release compared EQUAL to its own release.
- build metadata is ignored for precedence (spec 10).
Ranges (^ ~ x * || >= <= > < =) are NOT in the semver spec -- they are an
npm convention the manifests already use -- so that layer is written once,
in include/logos/semver.hpp, on top of the library. It adopts npm's
pre-release rule: a range never matches a pre-release unless the range
itself names one at the same major.minor.patch. Without it `^1.0.0` matches
`2.0.0-alpha`, i.e. an unreleased alpha of the next major satisfies a caret
range on 1.x and can be resolved as a dependency.
Exposed three ways so every consumer reaches the same code:
- include/logos/semver.hpp, header-only and dependency-free (the
package-manager UI is a QML plugin with no native link deps and must be
able to include it without dragging in zlib/ICU/libsodium).
- the lgx_semver_* C ABI.
- `lgx semver compare|sort|satisfies|valid|valid-range`, for the catalog
builder (logos-modules-release-tool's index.py), which is stdlib-only
but already requires lgx -- so it can order versions without growing a
second implementation in Python that could drift.
Manifest range validation now delegates here too; it was a separate regex
that accepted ranges the resolver could not actually evaluate.
Validation is strict but comparison is lenient (parse vs parse_lenient):
real manifests carry partial versions like "1.0", and treating those as
invalid at compare time would sort them below every real version and
silently reorder existing catalogs.
tests/test_semver.cpp covers the spec's own precedence chain verbatim, the
pre-release cases that were previously untested everywhere, and the npm
range rules.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: print full nix build logs
Without -L a failing build is truncated to its last 25 lines, which hides
the first compiler error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: include <cstdint> before cpp-semver (Linux/libstdc++ build)
cpp-semver 0.4.0 uses uint64_t throughout but never includes <cstdint>
itself. libc++ drags it in transitively via <string>/<regex>, so this built
on macOS -- but libstdc++ stopped doing that in GCC 13, and on Linux the
header failed to parse with "'uint64_t' does not name a type". That
collapsed semver::version, which surfaced as a wall of bogus "has no member
named 'major'" errors pointing at our own header rather than the real cause.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: add a headers-only package output
For consumers that need the shared semver implementation
(include/logos/semver.hpp) but must NOT link liblgx.
The package-manager UI is a Qt plugin, and the module builder copies every
*.so/*.dylib an external library ships into the plugin's output lib/.
ui-host then scans that directory and tries to load each file as a Qt
plugin: pointing it at the `lib` output put liblgx.dylib there, ui-host
failed with "is not a Qt plugin", and the entire UI never rendered.
Shipping no library at all is what makes `headers` safe -- there is nothing
to copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: reject silently-widening ranges; install cpp-semver header via FetchContent
Addresses Copilot review on #30.
valid_range accepted '1.x.3', '1..2' and 'x.1', silently widening them to
'1.x' / '1' / '*' — a range claiming more than it means, which breaks the
syntax-validation contract. parse_partial now requires every component after
the first wildcard/empty one to also be a wildcard/empty; trailing wildcards
('1.2.x', '1.x') stay valid. Tests added.
The FetchContent path forced SEMVER_INSTALL off, so a downstream
'cmake --install' would ship logos/semver.hpp without the <semver/semver.hpp>
it includes. Install the fetched header alongside ours. (The find_package /
Nix path already vendors it.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: reject a non-SemVer package version at validate/verify time
Manifest::validate() only checked that 'version' was non-empty, never that it
was a valid version. So a package versioned '0.1.2.3' (four sections), 'v1.0.0'
or '1.0' passed 'lgx verify', got published, and only misbehaved later: it's
unparseable to the comparators, so it sorts BELOW every valid version (can
never be 'latest') and orders against other junk by raw byte comparison
(0.1.2.10 < 0.1.2.9).
validate() now requires a full SemVer 2.0.0 version via the shared
logos::semver::valid(), so 'lgx verify' fails loudly at build/publish time
instead. Confirmed end-to-end: a package with version 0.1.2.3 now fails with
"'version' is not a valid SemVer 2.0.0 version".
Every real module in the workspace already uses X.Y.Z, so nothing is broken.
This closes the gap the comparison layer only worked around: comparison stays
lenient (it must tolerate whatever is already in old catalogs), but validation
gates what new packages may ship.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
lgx merge was dropping display_name because it wasn't in the field
whitelist copied from the reference manifest. Per-variant LGXs from
the bundler include display_name; only the merged multi-variant
artifact lost it.
* add `lgx signature` — dump raw manifest.sig bytes
Symmetric to `lgx manifest --json`: a machine-readable extractor for
the package's signature blob. Until now the only way to get the raw
manifest.sig out of an .lgx was to extract via `tar -O -xzf` — fine in
CI (release.yml does it) but awkward elsewhere.
Out-of-CI tooling that reproduces what `logos-modules-release-action`
records under `sidecar.json#signature` (e.g. a local index.json builder
for a non-GitHub-hosted catalog) needs the raw signature alongside the
manifest. With `lgx manifest --json` + this new `lgx signature`, both
halves of a signed package's envelope are available without shelling
out to `tar` or knowing the archive layout.
Contract:
- Signed package: stdout = manifest.sig bytes verbatim
(byte-identical to the file inside the .lgx), exit 0.
- Unsigned package: stdout empty, exit 0.
- Bad / missing package: stderr message, exit 1.
Callers distinguish "no signature" from "error" via the exit status,
not the stream length — matches the existing convention for
unsigned-package handling elsewhere in the codebase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* address PR #23 review
* Distinguish malformed package from unsigned. `Package::load` accepts
any valid-tar-gzip .lgx, including pathological ones with no
`manifest.json` at all — in which case the previous code exited 0
with empty stdout, indistinguishable from a genuinely unsigned
package. Now: missing manifest.json → exit 1 with a message, so the
"signed-vs-unsigned-vs-error" contract holds for malformed inputs
too.
* Set stdout to binary on Windows before writing the signature bytes
so `\n` doesn't get translated to `\r\n`. POSIX stdout is already
binary; the `#ifdef _WIN32` guard is the whole Windows-only
difference. Without this, the "byte-identical to the file inside
the .lgx" contract silently broke on Windows.
* Add CLI tests in tests/test_cli.cpp covering the three branches of
the documented contract: unsigned → empty + exit 0; signed →
manifest.sig JSON (with `did` + `signature` fields) + exit 0;
missing path → message + non-zero exit. Test infra reuses the
existing CLITest fixture / runLgx helper.
* Clarify the README example that pipes `lgx signature` into `jq` —
it's a signed-package-only pipeline, and the unsigned case would
make jq error on the empty stream. Gate the example accordingly.
Verified locally: `ws test logos-package` PASSes; a hand-crafted
tar+gzip with no manifest.json now produces the explicit
"malformed .lgx — not the same as an unsigned package" error and
exits 1, where previously it silently returned exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add DID-based identity for package signing with comprehensive tests
- Add did:jwk identity layer (base64url, publicKeyToDid, didToPublicKey)
- Replace raw public keys with DID strings in manifest.sig
- Add signer metadata (name, url) and linkedDids placeholder
- JWK private key format (.jwk) replaces PEM (.secret)
- JSON-based keyring with DID lookup
- Make content hashes mandatory (recomputed on every content change)
- Add keygen, keyring, sign commands with DID support
- Add C API (lgx.h) with DID-based signature types
- Add comprehensive test coverage for crypto, keyring, signing, hashes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: replace hashes_valid with package_valid in SignatureInfo
verifySignature() now validates the package (structure + hashes) first
via validatePackage(), removing duplicate hash verification. The
hashes_valid field is replaced by package_valid which reflects the
full package validation result.
- Extract validatePackage() from verify() as non-static instance method
- verify() now delegates to load() + validatePackage()
- verifySignature() calls validatePackage() before checking signature
- Remove duplicate Merkle tree verification from verifySignature()
- Update C API, tests, and downstream consumers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: signPackage validates instead of recomputing hashes
signPackage() now calls validatePackage() to ensure the package is
valid (structure + hashes) before signing. It no longer recomputes
hashes — hashes are already kept up to date by addVariant/removeVariant.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add lgx_keyring_list C API for listing trusted keys
Adds lgx_keyring_list() and lgx_free_keyring_list() to the C API,
enabling downstream consumers to enumerate trusted keys in the keyring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add custom directory options to keygen, keyring, sign, and verify CLI commands
- keygen: --output-dir / -o to specify key output directory
- keyring: --dir / -d to specify keyring directory
- sign: --keys-dir / -d to specify keys directory
- verify: --keyring-dir to specify keyring directory for trust lookup
All default to the standard ~/.config/logos/ paths when not specified.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update spec, project, and README for directory options and TOFU removal
- Add --output-dir, --keys-dir, --dir, --keyring-dir options to CLI docs
- Add signing/keyring C API functions to project.md
- Add test_crypto.cpp and libsodium to project.md
- Remove --tofu from install-time verification docs
- Update lifecycle example with signing and trust management steps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review feedback — security, robustness, and clarity
Security:
- Validate key names in keyring to prevent path traversal attacks
- Add missing <cstdlib> include for std::getenv
Robustness:
- Detect malformed manifest.sig (report as invalid, not unsigned)
- Fail validation when crypto::init() fails instead of skipping hashes
- Check init() return value in sign() and verify()
- Handle zero-length input in base64 encode functions
- Check ensureDirectory() path is actually a directory
Clarity:
- Label signer name/URL as self-asserted in verify output
- Update manifest.cpp comment: hashes for integrity, not just signing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make recomputeHashes return Result to fail loudly on crypto errors
Previously recomputeHashes() silently returned without setting hashes
when crypto::init() failed. Now addVariant/removeVariant propagate
the error so packages are never saved without hashes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>