diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index 8fed7b25..f0c5dd7d 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -635,12 +635,20 @@ jobs: fi python -m pip install --upgrade pip python -m pip install pyyaml cryptography cffi - mapfile -t MANIFESTS < <(find src/specfact_cli/modules -name 'module-package.yaml' -type f) - if [ "${#MANIFESTS[@]}" -eq 0 ]; then - echo "No bundled module manifests found to sign." - exit 0 + python - <<'PY' + import cffi + import cryptography + import yaml + + print("✅ signing dependencies available:", yaml.__version__, cryptography.__version__, cffi.__version__) + PY + BASE_REF="${{ github.event.before }}" + if [ -z "$BASE_REF" ] || [ "$BASE_REF" = "0000000000000000000000000000000000000000" ]; then + BASE_REF="HEAD~1" fi - python scripts/sign-modules.py "${MANIFESTS[@]}" + git rev-parse --verify "$BASE_REF" >/dev/null 2>&1 || BASE_REF="HEAD~1" + echo "Using module-signing base ref: $BASE_REF" + python scripts/sign-modules.py --changed-only --base-ref "$BASE_REF" --bump-version patch - name: Get version from PyPI publish step id: get_version diff --git a/CHANGELOG.md b/CHANGELOG.md index b719fc7d..916a9248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to this project will be documented in this file. **Important:** Changes need to be documented below this block as this is the header section. Each section should be separated by a horizontal rule. Newer changelog entries need to be added on top of prior ones to keep the history chronological with most recent changes first. +--- + +## [0.37.3] - 2026-02-24 + +### Changed + +- Improved bundled module release workflow by adding changed-module-only signing automation (`--changed-only`, `--base-ref`, `--bump-version`) so module versions remain decoupled from CLI version and only changed modules are bumped/signed. +- Updated CI release signing flow in PR orchestrator to use changed-module signing with resilient base-ref resolution and explicit signing dependency checks on GitHub runners. +- Updated developer documentation for module signing to use portable key-file configuration patterns instead of absolute key paths. + +### Fixed + +- Suppressed startup checksum fallback noise in normal CLI operation; fallback diagnostics are now debug-only. +- Improved startup integrity failure UX with user-friendly risk warning and mitigation guidance while preserving raw checksum diagnostics in `--debug` mode. +- Fixed `specfact backlog map-fields` GitHub setup behavior to fail fast when repository issue type IDs are unavailable instead of persisting incomplete type mapping state. + --- ## [0.37.2] - 2026-02-24 diff --git a/docs/guides/module-marketplace.md b/docs/guides/module-marketplace.md index 060bcbd1..d22ec602 100644 --- a/docs/guides/module-marketplace.md +++ b/docs/guides/module-marketplace.md @@ -60,8 +60,11 @@ Additional local hardening: Release signing automation: - `scripts/sign-modules.py` updates manifest integrity metadata (checksum and optional signature) -- Use `python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem ` for local/manual signing -- Wrapper alternative: `bash scripts/sign-module.sh --key-file /secure/path/module-signing-private.pem ` +- Use `KEY_FILE="${SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE:-.specfact/sign-keys/module-signing-private.pem}"` and run `python scripts/sign-modules.py --key-file "$KEY_FILE" ` for local/manual signing +- Use changed-only automation to avoid re-signing all modules: + - `hatch run python scripts/sign-modules.py --key-file "$KEY_FILE" --changed-only --base-ref origin/dev --bump-version patch` + - this bumps/signs only changed modules and keeps module versioning decoupled from CLI package version +- Wrapper alternative: `bash scripts/sign-module.sh --key-file "$KEY_FILE" ` - Without key material, the script fails by default and recommends `--key-file`; checksum-only mode is explicit via `--allow-unsigned` (local testing only) - Encrypted keys are supported with passphrase via `--passphrase`, `--passphrase-stdin`, or `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` - CI workflows inject private key material via `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and passphrase via `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` diff --git a/docs/guides/module-signing-and-key-rotation.md b/docs/guides/module-signing-and-key-rotation.md index d0fc3324..2e865947 100644 --- a/docs/guides/module-signing-and-key-rotation.md +++ b/docs/guides/module-signing-and-key-rotation.md @@ -44,22 +44,23 @@ openssl pkey -in module-signing-private.pem -pubout -out module-signing-public.p Preferred (strict, with private key): ```bash -python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem src/specfact_cli/modules/*/module-package.yaml -python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem modules/*/module-package.yaml +KEY_FILE="${SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE:-.specfact/sign-keys/module-signing-private.pem}" +python scripts/sign-modules.py --key-file "$KEY_FILE" src/specfact_cli/modules/*/module-package.yaml +python scripts/sign-modules.py --key-file "$KEY_FILE" modules/*/module-package.yaml ``` Encrypted private key options: ```bash # Prompt interactively for passphrase (TTY) -python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem modules/backlog-core/module-package.yaml +python scripts/sign-modules.py --key-file "$KEY_FILE" modules/backlog-core/module-package.yaml # Explicit passphrase flag (avoid shell history when possible) -python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem --passphrase '***' modules/backlog-core/module-package.yaml +python scripts/sign-modules.py --key-file "$KEY_FILE" --passphrase '***' modules/backlog-core/module-package.yaml # Passphrase over stdin (CI-safe pattern) printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \ - python scripts/sign-modules.py --key-file /secure/path/module-signing-private.pem --passphrase-stdin modules/backlog-core/module-package.yaml + python scripts/sign-modules.py --key-file "$KEY_FILE" --passphrase-stdin modules/backlog-core/module-package.yaml ``` Versioning guard: @@ -67,14 +68,29 @@ Versioning guard: - The signer enforces module version increments for changed module contents. - If module files changed and version is unchanged, signing fails until version is bumped. - Override exists for exceptional local workflows: `--allow-same-version` (not recommended). +- Module versions are independent from CLI package version; bump only modules whose payload changed. + +Changed-modules automation (recommended for release prep): + +```bash +# Bump changed modules by patch and sign only those modules +hatch run python scripts/sign-modules.py \ + --key-file "$KEY_FILE" \ + --changed-only \ + --base-ref origin/dev \ + --bump-version patch + +# Verify after signing +hatch run python scripts/verify-modules-signature.py --require-signature --enforce-version-bump --version-check-base origin/dev +``` Wrapper for single manifest: ```bash -bash scripts/sign-module.sh --key-file /secure/path/module-signing-private.pem modules/backlog-core/module-package.yaml +bash scripts/sign-module.sh --key-file "$KEY_FILE" modules/backlog-core/module-package.yaml # stdin passphrase: printf '%s' "$SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" | \ - bash scripts/sign-module.sh --key-file /secure/path/module-signing-private.pem --passphrase-stdin modules/backlog-core/module-package.yaml + bash scripts/sign-module.sh --key-file "$KEY_FILE" --passphrase-stdin modules/backlog-core/module-package.yaml ``` Local test-only unsigned mode: diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md index bd5d218d..1a6da1ae 100644 --- a/docs/reference/module-security.md +++ b/docs/reference/module-security.md @@ -37,6 +37,8 @@ Module packages carry **publisher** and **integrity** metadata so installation, - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` (PEM content) - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_FILE` - **Version guard**: Changed module contents must have a bumped module version before signing. Override exists only for controlled local cases via `--allow-same-version`. +- **Changed-only release mode**: `scripts/sign-modules.py --changed-only --base-ref --bump-version ` auto-selects modules with payload changes, bumps versions when unchanged, and signs only those modules. +- **Version decoupling**: module versions are semver-managed per module payload and do not need to track CLI package version. - **CI secrets**: - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md index 23f0ed4b..572aaaa5 100644 --- a/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/CHANGE_VALIDATION.md @@ -1,6 +1,12 @@ # Change Validation Report: backlog-core-05-user-modules-bootstrap - Status: valid -- Validation command: `openspec validate backlog-core-05-user-modules-bootstrap --strict` +- Workflow: `wf-validate-change` (executed via OpenSpec CLI equivalents) +- Validation command(s): + - `openspec status --change "backlog-core-05-user-modules-bootstrap" --json` + - `openspec instructions apply --change "backlog-core-05-user-modules-bootstrap" --json` + - `openspec validate backlog-core-05-user-modules-bootstrap --strict` - Validation result: `Change 'backlog-core-05-user-modules-bootstrap' is valid` -- Notes: CLI validation passed; local environment emitted non-blocking telemetry network flush warnings. +- Notes: + - Status/instructions confirmed spec-driven schema and artifact completeness. + - Validation emitted non-blocking schema warnings from `openspec/config.yaml` rule format, but strict change validation succeeded. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md index a91f6c5d..db6eaf55 100644 --- a/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/TDD_EVIDENCE.md @@ -123,3 +123,84 @@ - Result summary: - `63 passed` across signing-artifacts, module-security, installer, and module command suites. - Formatting checks passed after implementation. + +## Follow-up failing run (integrity fallback log-level noise) + +- Timestamp: 2026-02-24T21:26:13Z +- Command(s): `python -m pytest tests/unit/registry/test_module_installer.py -k "fallback_does_not_emit_info_in_normal_mode or fallback_emits_debug_in_debug_mode" -q` +- Failure summary: + - `test_verify_module_artifact_fallback_does_not_emit_info_in_normal_mode` failed because `verify_module_artifact` emitted fallback details via `logger.info(...)` in non-debug mode. + - `test_verify_module_artifact_fallback_emits_debug_in_debug_mode` failed because fallback diagnostics were not emitted through debug-level logging. + +## Follow-up passing run (integrity fallback log-level noise) + +- Timestamp: 2026-02-24T21:26:13Z +- Command(s): + - `python -m pytest tests/unit/registry/test_module_installer.py -k "fallback_does_not_emit_info_in_normal_mode or fallback_emits_debug_in_debug_mode" -q` + - `python -m pytest tests/unit/registry/test_module_installer.py -q` +- Result summary: + - Targeted fallback-log tests: `2 passed`. + - Full installer test file: `20 passed`. + +## Follow-up failing run (GitHub map-fields missing issue-type IDs) + +- Timestamp: 2026-02-24T21:42:09Z +- Command(s): `python -m pytest tests/unit/commands/test_backlog_commands.py -k "map_fields_github_provider_persists_backlog_config or map_fields_github_provider_fails_when_issue_types_unavailable" -q` +- Failure summary: + - `test_map_fields_github_provider_fails_when_issue_types_unavailable` failed because `backlog map-fields` returned success even when repository issue types were empty/unavailable. + - This left `github_issue_types.type_ids` unconfigured and allowed `backlog add` to keep warning despite setup attempts. + +## Follow-up passing run (GitHub map-fields missing issue-type IDs) + +- Timestamp: 2026-02-24T21:42:09Z +- Command(s): + - `python -m pytest tests/unit/commands/test_backlog_commands.py -k "map_fields_github_provider_persists_backlog_config or map_fields_github_provider_fails_when_issue_types_unavailable" -q` + - `python -m pytest modules/backlog-core/tests/unit/test_add_command.py -k "warns_when_github_issue_type_mapping_missing" -q` +- Result summary: + - GitHub map-fields targeted tests: `2 passed`. + - Backlog add warning path regression check: `1 passed`. + +## Follow-up failing run (startup integrity warning noise) + +- Timestamp: 2026-02-24T22:54:14+01:00 +- Command(s): `hatch run specfact module list` +- Failure summary: + - Startup emitted raw logger warning with checksum internals: + - `Module backlog: Integrity check failed: Checksum mismatch: ...` + - Warning was noisy and not user-guided, and exposed technical checksum detail in normal mode. + +## Follow-up passing run (startup integrity warning UX + debug separation) + +- Timestamp: 2026-02-24T22:57:56+01:00 +- Command(s): + - `python -m pytest tests/unit/registry/test_module_installer.py -k "checksum_mismatch_hides_raw_details_without_debug or checksum_mismatch_logs_raw_details_in_debug" -q` + - `python -m pytest tests/unit/specfact_cli/registry/test_module_packages.py -k "integrity_failure_shows_user_friendly_risk_warning" -q` + - `PYTHONPATH=src python -m specfact_cli.cli module list` + - `PYTHONPATH=src python -m specfact_cli.cli --debug module list` +- Result summary: + - New debug-gating tests: `3 passed`. + - User-warning UX test: `1 passed`. + - CLI startup now shows a concise risk warning with mitigation guidance (`specfact module init`) instead of raw checksum mismatch internals in normal mode. + - With `--debug`, raw checksum mismatch diagnostics are shown for troubleshooting. + +## Follow-up failing run (changed-module release automation) + +- Timestamp: 2026-02-24T23:05:56+01:00 +- Command(s): `python -m pytest tests/unit/specfact_cli/registry/test_signing_artifacts.py -k "changed_module_automation or changed_only_auto_bump" -q` +- Failure summary: + - `test_sign_modules_py_help_mentions_changed_module_automation` failed because signer help did not expose changed-module automation flags. + - `test_sign_modules_py_changed_only_auto_bump_and_sign` failed because `sign-modules.py` did not accept `--changed-only`, `--base-ref`, or `--bump-version`. + +## Follow-up passing run (changed-module release automation) + +- Timestamp: 2026-02-24T23:08:05+01:00 +- Command(s): + - `python -m pytest tests/unit/specfact_cli/registry/test_signing_artifacts.py -k "changed_module_automation or changed_only_auto_bump" -q` + - `python -m pytest tests/unit/specfact_cli/registry/test_signing_artifacts.py -q` + - `python scripts/sign-modules.py --allow-unsigned --changed-only --base-ref HEAD --bump-version patch` + - `python -m pytest tests/unit/registry/test_module_installer.py tests/unit/specfact_cli/registry/test_module_packages.py tests/unit/commands/test_backlog_commands.py -q` +- Result summary: + - New changed-module automation tests: `2 passed`. + - Full signing-artifacts suite: `15 passed`. + - Changed-only automation bumped and re-signed changed bundled manifest (`backlog`), restoring runtime integrity sync. + - Regression safety suites after module re-sign: `95 passed, 1 skipped`. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md index 965b1927..786ee64a 100644 --- a/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/proposal.md @@ -5,6 +5,7 @@ + `specfact backlog add` is still missing in installed-runtime contexts when command discovery depends on repository-local `modules/` folders. This makes behavior vary by working directory and machine. For production usage, shipped modules and their resources should be managed as user-level artifacts. We need a reliable path where `specfact module init` prepares a per-user module root (not repo-local) so command availability is stable. @@ -14,6 +15,7 @@ For production usage, shipped modules and their resources should be managed as u + - **MODIFY**: Add a canonical user module root at `/.specfact/modules` for installed module artifacts. - **MODIFY**: Ensure discovery and installer flows prefer `/.specfact/modules` and stop treating workspace `./modules` as an automatic discovery root. - **MODIFY**: Add workspace-local module discovery only under `/.specfact/modules` to avoid claiming ownership of non-SpecFact repository paths. @@ -31,6 +33,8 @@ For production usage, shipped modules and their resources should be managed as u - **NEW**: Require signature/checksum verification for shipped/bundled modules using release-generated signatures (not publisher-name trust alone). - **NEW**: Add release signing automation for bundled modules in this repository so module signatures are generated during release orchestration without exposing private keys. - **NEW**: Support encrypted signing keys with passphrase input via CLI flag, stdin, or environment variable, and wire CI signing steps to dedicated secrets (`SPECFACT_MODULE_PRIVATE_SIGN_KEY`, `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE`). +- **NEW**: Add changed-module release automation that selects only modules with payload changes, applies module-level semver bump, and performs bump/sign/verify in one workflow. +- **MODIFY**: Treat bundled module versions as independent semver from CLI package version; only changed module payloads require module version increments. - **MODIFY**: Document and codify boundary with `marketplace-02`: this change hardens local/shipped module trust and install safety; online multi-registry ecosystem remains in `marketplace-02`. - **MODIFY**: Add tests for init/module discovery parity that verify `backlog add` availability does not depend on current working directory. - **MODIFY**: Strengthen prompt resource detection/copy tests so `specfact init ide` consistently finds bundled prompt resources and installs them to project target locations. @@ -48,6 +52,6 @@ For production usage, shipped modules and their resources should be managed as u - **GitHub Issue**: #298 - **Issue URL**: -- **Last Synced Status**: implemented +- **Last Synced Status**: proposed - **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/backlog-map-fields/spec.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/backlog-map-fields/spec.md new file mode 100644 index 00000000..4b0214ee --- /dev/null +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/backlog-map-fields/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Provider auth and field discovery checks + +The system SHALL verify auth context and discover provider fields/metadata before accepting mappings. + +#### Scenario: GitHub mapping fails when repository issue types are unavailable + +- **GIVEN** GitHub provider mapping setup is requested +- **AND** repository issue types cannot be discovered (API failure, missing scope, or empty response) +- **WHEN** `specfact backlog map-fields` runs +- **THEN** the command exits non-zero with actionable guidance +- **AND** it does not report successful GitHub type mapping persistence. + +#### Scenario: GitHub mapping persists repository issue-type IDs for add flow + +- **GIVEN** repository issue types are discovered from GitHub metadata +- **WHEN** `specfact backlog map-fields` persists GitHub settings +- **THEN** `.specfact/backlog-config.yaml` includes `backlog_config.providers.github.settings.github_issue_types.type_ids` +- **AND** subsequent `specfact backlog add` can consume those IDs for issue-type updates. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md index 6489d306..a9bf3b10 100644 --- a/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/specs/user-module-root/spec.md @@ -211,3 +211,53 @@ Shipped/bundled modules SHALL be verified by signature/checksum before install/b - **WHEN** user runs `specfact module init` or installs bundled module - **THEN** operation fails for that module - **AND** module is not installed silently. + +#### Scenario: Integrity fallback diagnostics are debug-only + +- **GIVEN** bundled module checksum verification succeeds only after generated-file exclusions fallback +- **WHEN** verification runs in normal mode (without `--debug`) +- **THEN** fallback diagnostic details are not emitted as regular INFO output. + +- **GIVEN** bundled module checksum verification succeeds only after generated-file exclusions fallback +- **WHEN** verification runs with global debug mode enabled (`--debug`) +- **THEN** fallback diagnostic details are emitted as debug-level diagnostics. + +#### Scenario: Startup integrity failure shows user-friendly risk warning + +- **GIVEN** module integrity verification fails during startup command registration +- **WHEN** CLI starts in normal mode (without `--debug`) +- **THEN** output shows a concise user-facing warning that the module was not loaded and may be tampered/outdated +- **AND** output includes mitigation guidance (for example `specfact module init`) +- **AND** raw checksum mismatch internals are not shown in normal startup logs. + +#### Scenario: Startup integrity failure keeps raw diagnostics in debug mode + +- **GIVEN** module integrity verification fails during startup command registration +- **WHEN** CLI starts with global debug mode enabled (`--debug`) +- **THEN** raw verification diagnostics (for example checksum mismatch details) are available in debug logging for troubleshooting. + +### Requirement: Bundled Module Release Versioning and Signing Automation + +Bundled module release tooling SHALL support module-level versioning independent of CLI package version and automate changed-module signing workflow. + +#### Scenario: Changed modules are auto-bumped and signed + +- **GIVEN** one or more bundled modules changed since a chosen git base ref +- **AND** changed module manifest version is unchanged +- **WHEN** release signing runs with changed-module automation enabled +- **THEN** only changed module manifests are selected +- **AND** changed module versions are incremented using configured semver bump strategy +- **AND** selected manifests are re-signed and re-verified in the same workflow. + +#### Scenario: Unchanged modules keep version and signature metadata + +- **GIVEN** bundled modules with no payload changes since selected git base ref +- **WHEN** changed-module automation runs +- **THEN** unchanged modules are not re-versioned and not re-signed. + +#### Scenario: Module versions remain decoupled from CLI package version + +- **GIVEN** CLI package version changes without payload change in a bundled module +- **WHEN** module signing/version checks run +- **THEN** bundled module version does not need to change +- **AND** module versioning is enforced only by module payload change semantics. diff --git a/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md b/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md index a133b53f..026ec53d 100644 --- a/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md +++ b/openspec/changes/backlog-core-05-user-modules-bootstrap/tasks.md @@ -36,6 +36,7 @@ Per `openspec/config.yaml`, tests before code for behavior changes. - [x] 3.13 Add tests for denylist enforcement on `module install` and `module init` bootstrap paths. - [x] 3.14 Add tests for one-time trust prompt/flag behavior for non-official publishers in interactive and non-interactive modes. - [x] 3.15 Add tests for bundled signature/checksum verification during install/bootstrap (pass and fail paths). +- [x] 3.16 Add tests for changed-module release automation (changed-only selection, auto version bump, unchanged-module skip). - [x] 3.4 Run targeted tests and record failing results in `TDD_EVIDENCE.md`. ## 4. Implementation @@ -58,6 +59,8 @@ Per `openspec/config.yaml`, tests before code for behavior changes. - [x] 4.16 Enforce signature/checksum verification for shipped/bundled module install/bootstrap paths. - [x] 4.17 Add release signing script/workflow integration for bundled modules (private key via CI secret; no key material in repo). - [x] 4.18 Add encrypted-key passphrase handling in signing scripts (`--passphrase`, `--passphrase-stdin`, env var) and update CI signing secrets wiring. +- [x] 4.19 Implement changed-module automation in signing tooling (select changed manifests by git base, optional semver bump, re-sign). +- [x] 4.20 Ensure module version enforcement is payload-change based and remains decoupled from CLI package version. ## 5. Validation and docs @@ -71,6 +74,7 @@ Per `openspec/config.yaml`, tests before code for behavior changes. - [x] 5.9 Align marketplace guide with user/project scope roots and legacy root compatibility note. - [x] 5.10 Document denylist/trust prompt/signature verification behavior and automation flags for CI. - [x] 5.11 Document scope boundary with `marketplace-02` (online registry ecosystem vs local/shipped trust hardening). +- [x] 5.12 Document module release workflow for changed-only bump/sign/verify and module-level semver strategy. - [x] 5.4 Run `openspec validate backlog-core-05-user-modules-bootstrap --strict` and update `CHANGE_VALIDATION.md`. ## 6. Delivery diff --git a/pyproject.toml b/pyproject.toml index 9468f34e..408083e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.37.2" +version = "0.37.3" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/sign-modules.py b/scripts/sign-modules.py index 348b8b1a..e656713d 100755 --- a/scripts/sign-modules.py +++ b/scripts/sign-modules.py @@ -157,10 +157,10 @@ def _read_manifest_version(path: Path) -> str | None: return version or None -def _read_manifest_version_from_git(head_ref: str, path: Path) -> str | None: +def _read_manifest_version_from_git(git_ref: str, path: Path) -> str | None: try: output = subprocess.run( - ["git", "show", f"{head_ref}:{path.as_posix()}"], + ["git", "show", f"{git_ref}:{path.as_posix()}"], check=True, capture_output=True, text=True, @@ -180,10 +180,33 @@ def _read_manifest_version_from_git(head_ref: str, path: Path) -> str | None: return version or None -def _module_has_git_changes(module_dir: Path) -> bool: +def _iter_manifests() -> list[Path]: + roots = [Path("src/specfact_cli/modules"), Path("modules")] + manifests: list[Path] = [] + for root in roots: + if root.exists(): + manifests.extend(sorted(root.rglob("module-package.yaml"))) + return manifests + + +def _ensure_valid_git_ref(git_ref: str) -> None: + try: + subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"{git_ref}^{{commit}}"], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + details = (exc.stderr or exc.stdout or "").strip() + suffix = f": {details}" if details else "" + raise ValueError(f"--base-ref is invalid or not resolvable: {git_ref}{suffix}") from exc + + +def _module_has_git_changes_since(module_dir: Path, git_ref: str) -> bool: try: changed = subprocess.run( - ["git", "diff", "--name-only", "HEAD", "--", module_dir.as_posix()], + ["git", "diff", "--name-only", git_ref, "--", module_dir.as_posix()], check=True, capture_output=True, text=True, @@ -199,7 +222,60 @@ def _module_has_git_changes(module_dir: Path) -> bool: return bool(changed or untracked) -def _enforce_version_bump_before_signing(manifest_path: Path, *, allow_same_version: bool) -> None: +def _parse_semver(version: str) -> tuple[int, int, int]: + parts = version.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + raise ValueError(f"Unsupported version format for auto-bump (expected x.y.z): {version}") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def _bump_semver(version: str, bump_type: str) -> str: + major, minor, patch = _parse_semver(version) + if bump_type == "major": + return f"{major + 1}.0.0" + if bump_type == "minor": + return f"{major}.{minor + 1}.0" + if bump_type == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"Unsupported bump type: {bump_type}") + + +def _write_manifest(path: Path, data: dict[str, Any]) -> None: + path.write_text( + yaml.dump( + data, + Dumper=_IndentedSafeDumper, + sort_keys=False, + allow_unicode=False, + default_flow_style=False, + indent=2, + ), + encoding="utf-8", + ) + + +def _auto_bump_manifest_version(manifest_path: Path, *, base_ref: str, bump_type: str) -> bool: + current_version = _read_manifest_version(manifest_path) + if not current_version: + raise ValueError(f"Manifest missing version: {manifest_path}") + + previous_version = _read_manifest_version_from_git(base_ref, manifest_path) + if previous_version is None or current_version != previous_version: + return False + + raw = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"Invalid manifest YAML: {manifest_path}") + bumped = _bump_semver(current_version, bump_type) + raw["version"] = bumped + _write_manifest(manifest_path, raw) + print(f"{manifest_path}: version {current_version} -> {bumped}") + return True + + +def _enforce_version_bump_before_signing( + manifest_path: Path, *, allow_same_version: bool, comparison_ref: str = "HEAD" +) -> None: if allow_same_version: return @@ -207,14 +283,14 @@ def _enforce_version_bump_before_signing(manifest_path: Path, *, allow_same_vers if not current_version: raise ValueError(f"Manifest missing version: {manifest_path}") - previous_version = _read_manifest_version_from_git("HEAD", manifest_path) + previous_version = _read_manifest_version_from_git(comparison_ref, manifest_path) if previous_version is None: return if current_version != previous_version: return module_dir = manifest_path.parent - if not _module_has_git_changes(module_dir): + if not _module_has_git_changes_since(module_dir, comparison_ref): return raise ValueError( @@ -251,17 +327,7 @@ def sign_manifest(manifest_path: Path, private_key: Any | None) -> None: integrity["signature"] = _sign_payload(payload, private_key) raw["integrity"] = integrity - manifest_path.write_text( - yaml.dump( - raw, - Dumper=_IndentedSafeDumper, - sort_keys=False, - allow_unicode=False, - default_flow_style=False, - indent=2, - ), - encoding="utf-8", - ) + _write_manifest(manifest_path, raw) status = "checksum+signature" if "signature" in integrity else "checksum" print(f"{manifest_path}: {status}") @@ -296,7 +362,23 @@ def main() -> int: action="store_true", help="Bypass version-bump enforcement for changed module contents (not recommended).", ) - parser.add_argument("manifests", nargs="+", help="module-package.yaml path(s)") + parser.add_argument( + "--changed-only", + action="store_true", + help="Select only manifests whose module payload changed since --base-ref.", + ) + parser.add_argument( + "--base-ref", + default="HEAD", + help="Git ref used for change detection when --changed-only is set (default: HEAD).", + ) + parser.add_argument( + "--bump-version", + choices=("patch", "minor", "major"), + default="", + help="Auto-bump changed module version when unchanged from --base-ref before signing.", + ) + parser.add_argument("manifests", nargs="*", help="module-package.yaml path(s)") args = parser.parse_args() passphrase = _resolve_passphrase(args) @@ -315,12 +397,36 @@ def main() -> int: "For local testing only, re-run with --allow-unsigned." ) - for manifest in args.manifests: + manifests: list[Path] + if args.manifests: + manifests = [Path(manifest) for manifest in args.manifests] + elif args.changed_only: try: - manifest_path = Path(manifest) + _ensure_valid_git_ref(args.base_ref) + except ValueError as exc: + parser.error(str(exc)) + manifests = [ + manifest for manifest in _iter_manifests() if _module_has_git_changes_since(manifest.parent, args.base_ref) + ] + else: + parser.error("Provide one or more manifests, or use --changed-only.") + + if args.changed_only and not manifests: + print(f"No changed module manifests detected since {args.base_ref}.") + return 0 + + for manifest_path in manifests: + try: + if args.changed_only and args.bump_version: + _auto_bump_manifest_version( + manifest_path, + base_ref=args.base_ref, + bump_type=args.bump_version, + ) _enforce_version_bump_before_signing( manifest_path, allow_same_version=args.allow_same_version, + comparison_ref=args.base_ref if args.changed_only else "HEAD", ) sign_manifest(manifest_path, private_key) except ValueError as exc: diff --git a/setup.py b/setup.py index dcc3f06c..8eb249c0 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.37.2", + version="0.37.3", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 2e1d02e0..188a8022 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.37.2" +__version__ = "0.37.3" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index bfc9a132..75922b3e 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.37.2" +__version__ = "0.37.3" __all__ = ["__version__"] diff --git a/src/specfact_cli/modules/analyze/module-package.yaml b/src/specfact_cli/modules/analyze/module-package.yaml index 6d64b3d5..896f7fff 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -1,5 +1,5 @@ name: analyze -version: 0.37.2 +version: 0.1.0 commands: - analyze command_help: @@ -15,5 +15,5 @@ publisher: description: Analyze codebase quality, contracts, and architecture signals. license: Apache-2.0 integrity: - checksum: sha256:8f0919570eb25f9643f3f4557f40d8137c4754e4422ff71bdbb7ed2aa04e4bff - signature: 6sTJRGUeApach2vdwQJubd3bHaJm2bu7b46DnUFJGAZ95X6dLMCyuCWVdxTRA6kX3YHblYZD8SfFwAPPsl1KAg== + checksum: sha256:49d908578ab91e142cff50a27d9b15fff3a30cf790597eecbea1910e38a754b6 + signature: CqsLSUUx3DYa0a8F56/OW4QFt6TDhx1OueAwI0tYC892S7RlvaF5JEwKcUXujKD2IQRoOKUQ7d0Gdqs8Kwh2Cw== diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index 08ddb9d7..c15b2f14 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -1,5 +1,5 @@ name: auth -version: 0.37.2 +version: 0.1.0 commands: - auth command_help: @@ -15,5 +15,5 @@ publisher: description: Authenticate SpecFact with supported DevOps providers. license: Apache-2.0 integrity: - checksum: sha256:fd0c9ff643c73a25c229e5925b58446ff25af7ec5ab4412d3eff9704b4cc10c1 - signature: 4KKwmf58+3boa+2jMF9m2NvjGcXB6Jdjfu0C6MV5Io+XjF6FUPyOmf7iz2xH9afgTTeVLVdc7X5EjJJGm5tMAQ== + checksum: sha256:561fc420c18f9702b1cbb396cb1c0443909646ad8857f508d532d648fe812a9d + signature: IUFyErHdSMMRtdGCUjDZhkU6hujDv1J5IHQXKYelK4RGqeekYUFer13IeG7S1xZZ5ckmvsgF1592UsTCSV2BAA== diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index 5206569a..e1fd3b15 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -1,5 +1,5 @@ name: backlog -version: 0.37.2 +version: 0.1.0 commands: - backlog command_help: @@ -28,5 +28,5 @@ publisher: description: Manage backlog ceremonies, refinement, and dependency insights. license: Apache-2.0 integrity: - checksum: sha256:6d31c481c40241a744ffa255d29bcf834e5715de310f58f7ebc74e19f1e4fd8d - signature: 20mO8sayOcVV992iqYROkQL/Q7jxjJE3re1fWYbTLIW7HPskNg1CROpCnaeUv9dr629qPMs/YVlzadMl317VDw== + checksum: sha256:1ab19fd9dd206ea5bb12af2f785fda4b56c5e3324902154f31ca24cc88334b4f + signature: UDxeFiE6DafWglCWREHuSZz4ezApHwygKI4wqkHiFIcHFk5CRqnH9KPUdTVyB+Agiys4k3kKNfGb/0Ljit//DA== diff --git a/src/specfact_cli/modules/backlog/src/commands.py b/src/specfact_cli/modules/backlog/src/commands.py index 28494b46..8670fb8d 100644 --- a/src/specfact_cli/modules/backlog/src/commands.py +++ b/src/specfact_cli/modules/backlog/src/commands.py @@ -4176,6 +4176,7 @@ def _github_graphql(query: str, variables: dict[str, Any]) -> dict[str, Any]: "}" ) repo_issue_types: dict[str, str] = {} + repo_issue_types_error: str | None = None try: issue_types_data = _github_graphql(issue_types_query, {"owner": owner, "repo": repo_name}) repository = ( @@ -4191,13 +4192,25 @@ def _github_graphql(query: str, variables: dict[str, Any]) -> dict[str, Any]: type_id = str(node.get("id") or "").strip() if type_name and type_id: repo_issue_types[type_name] = type_id - except (requests.RequestException, ValueError): - # Keep flow resilient; ProjectV2 mapping can still be configured without repository issue type ids. + except (requests.RequestException, ValueError) as error: + repo_issue_types_error = str(error) repo_issue_types = {} if repo_issue_types: discovered = ", ".join(sorted(repo_issue_types.keys())) console.print(f"[cyan]Discovered repository issue types:[/cyan] {discovered}") + else: + console.print( + "[red]Error:[/red] Could not discover repository issue types for this GitHub repository. " + "Automatic issue Type updates require `github_issue_types.type_ids`." + ) + if repo_issue_types_error: + console.print(f"[dim]Details:[/dim] {repo_issue_types_error}") + console.print( + "[yellow]Hint:[/yellow] Re-authenticate with required scopes and rerun mapping: " + "`specfact auth github --scopes repo,read:project,project`." + ) + raise typer.Exit(1) cli_option_map: dict[str, str] = {} for entry in github_type_option: diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index b064803d..de525a14 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -1,5 +1,5 @@ name: contract -version: 0.37.2 +version: 0.1.0 commands: - contract command_help: @@ -15,5 +15,5 @@ publisher: description: Validate and manage API contracts for project bundles. license: Apache-2.0 integrity: - checksum: sha256:5cc204c3bab58765f19862a6fe2127ddb0b6f0b56422bfc078d69beb91398f0d - signature: URMN7NMTIsPqZmtcDW4onpaH1Up4kpZaE49iyb3jaqN4x2KuEGDyU4uudhzu/4Pu1DObWu20c+aVfwtj26LbCQ== + checksum: sha256:ea7526559317a65684db0ecc8eaccd06a60dcb94361c95389e7a35cfd31279d3 + signature: iJC2irFaSiWa9fdFjJYGpHlsyWKRNpoFmQSB+PY0ORS6y+gVHAPNGJL7iP5TFYB3I83szCWfmF+2hLeTyc1XDg== diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index e7060318..57d282a5 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -1,5 +1,5 @@ name: drift -version: 0.37.2 +version: 0.1.0 commands: - drift command_help: @@ -15,5 +15,5 @@ publisher: description: Detect and report drift between code, plans, and specs. license: Apache-2.0 integrity: - checksum: sha256:368d6beaf1b4356741c1dbdd8125800e466abb47d8a18e14e2c0f9c66c94397b - signature: +gU8OTtpxWqIKTk2xoL4Msed1alxTk4soy/HYG4wJuOcwQgaxG7tM/WEWTRMCXHTN4kCiN+3Wqp975FUtW0UDw== + checksum: sha256:0bf406486ada20fa82273f62a46567a63231be00bca1aa0da335405081d868ee + signature: 22k+r94pdCPh7lP4UYZfvlNRlTQaSasXwzDJWE33I1Pzeq1hPRnyXBylx9x7IvdDqACLTnCIz5j6R8XKCu1WAQ== diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index 82ba5319..0777c1d3 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -1,5 +1,5 @@ name: enforce -version: 0.37.2 +version: 0.1.0 commands: - enforce command_help: @@ -16,5 +16,5 @@ publisher: description: Apply governance policies and quality gates to bundles. license: Apache-2.0 integrity: - checksum: sha256:e93b26c59b548af9cc3230b18eb37aff8c3dc85132fed7457f19bdaaaef1e8a0 - signature: TcjUHtOLqZ3TNlUB8cq1gwUJY1qjzQ1z6mh8nokMLp+svvaGVJc6zlkcYTN8InmiCnrUoevfUutuDZvN2gGSDg== + checksum: sha256:444896a5ac47c50cac848e649bb509893ba8c62b382100ccbe2b65660dca6587 + signature: Htf9gy0P5UmGmrDky3oLyl4GgVQoVJ6514f31O1v1Butyns4o49CF6mVZMqbOBh68ToloPUxG96E/1GEzM3QDg== diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index ebf8dced..8f9e54aa 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -1,5 +1,5 @@ name: generate -version: 0.37.2 +version: 0.1.0 commands: - generate command_help: @@ -16,5 +16,5 @@ publisher: description: Generate implementation artifacts from plans and SDD. license: Apache-2.0 integrity: - checksum: sha256:16e7312d299e4c1c4ceedea2fdcead13ffd81e795e810fbd6ffabb5d0352d744 - signature: mkgqkfF8n6gix2oAlV62FJEwIJfZ0ruiiMLcV5KmiH5v9OmuuHFK4NPZxC7nBbRXWoTz2jPD0uEnsU+DIakbCg== + checksum: sha256:c1b01eea7f1de8e71fd61ac02caae27b10e133131683804356f01c9c6171a955 + signature: dKokcDKA0v/xJ4SDWDvFEtCdVFScgyygdoJZlD7LgSQeucpi8csKLMW97XOaOUZCtNUsVm194EWBnIzLf70+CA== diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index 8b0daed8..c61999b8 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -1,5 +1,5 @@ name: import_cmd -version: 0.37.2 +version: 0.1.0 commands: - import command_help: @@ -15,5 +15,5 @@ publisher: description: Import projects and requirements from code and external tools. license: Apache-2.0 integrity: - checksum: sha256:ed7387b3bc4de4fc6e320b976f180befe49961669c337257851de7da385d9ca4 - signature: j2HV/fWLN7nzmTzWseUIPNv+E0gw5Pif4+a8c6Qe8eHimUxnx6OAauLV9HgawdW9GG52YkDIwpTOkAZ6BBLCBg== + checksum: sha256:246f8402200e57947a50f61102ef8057dd75a6ad2768d359774600557daead8b + signature: sCgBiKEtN5r2br/6ZAYmII7XjjZ9Ru8WqqPfXXaE2shn0eQBiDxKKH/ATNBZphtYJbTfXEpQFZg3oVja4jITCg== diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index de084329..f48c594b 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.37.2 +version: 0.1.0 commands: - init command_help: @@ -15,5 +15,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:38a3261dd947f5827b1a67b34970c0ab81c13de6d2657d7cf5af694da6da8436 - signature: khTbNy9iE3eXBNWo0+/Cb2B533U1PrLLNKtPnRiJPKNCQTrlfxhWvG+GEcnnFUinSTnfsjY9Of5GJYOuhJyACA== + checksum: sha256:ebd53092241acd0bf53db40a001529b98ee001244b04223cfe855d7fdcbc607d + signature: NrPV9fl6k7W47hi4hkbNhsS8EX0CfB8zlAFucesUwbMYHgGZl9TvfsBwObeHWh+R1eqskTUf+sxivl6lv/1mAg== diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index c52c78e8..4303eada 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -1,5 +1,5 @@ name: migrate -version: 0.37.2 +version: 0.1.0 commands: - migrate command_help: @@ -15,5 +15,5 @@ publisher: description: Migrate project bundles across supported structure versions. license: Apache-2.0 integrity: - checksum: sha256:365f469ff6b6fe9f80cb1cf078adf4da7acceed04c5d18b3a0b37b8c1e58547b - signature: APKTWd9UPlnBk4ypohLr3qernnOqIWO0sn7ABgozdD62tTqWnZe5lYRk1xJzXFRxCqO+aL+6/Xdcl8M72OfABw== + checksum: sha256:0173eddfed089e7979d608f44d7e3e5505567d0e32b722a56d87a59ea0a5699f + signature: Hoo1BBhAN24s8YpEPsWEyMPv6eL6apeBiX8VCALst5b/77ZpOCCcsSSGFUrNQMWD/L7pxDphTzKhGPP8W1t7DQ== diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml index 565120ca..6d764408 100644 --- a/src/specfact_cli/modules/module_registry/module-package.yaml +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -1,5 +1,5 @@ name: module-registry -version: 0.37.2 +version: 0.1.0 commands: - module command_help: @@ -15,5 +15,5 @@ publisher: description: 'Manage modules: search, list, show, install, and upgrade.' license: Apache-2.0 integrity: - checksum: sha256:ce1d40c3c62dcd8d15181ff263811a6c28a88070dd672fba74e09eea64a830e6 - signature: TIK6aQt7hyoxM11M8XYByKP7s9DlK7+F1h0mVxCRuQyznTV0Pq8c56XD7if+GhBFptqPB94NwQ36rPdjqOCSDw== + checksum: sha256:933aeb8adb353565167a3a63dfda9dd62c15ce4f5574a85198c8007e992780e3 + signature: 8bn3S2Unl2sLaGIF7f0MlLkH4AoplU5h2VY/uDoJBwmO3rl3xweYXuoJftPKmFASBUWcVxxoK+YoGvOzY6fpDA== diff --git a/src/specfact_cli/modules/patch_mode/module-package.yaml b/src/specfact_cli/modules/patch_mode/module-package.yaml index b9faaa58..83a532f4 100644 --- a/src/specfact_cli/modules/patch_mode/module-package.yaml +++ b/src/specfact_cli/modules/patch_mode/module-package.yaml @@ -1,5 +1,5 @@ name: patch-mode -version: 0.37.2 +version: 0.1.0 commands: - patch command_help: @@ -16,5 +16,5 @@ publisher: description: Prepare, review, and apply structured repository patches safely. license: Apache-2.0 integrity: - checksum: sha256:afc1985b0a9385055acf7efdf20d314198429bfdbf33a68bfbe8f8add0476456 - signature: qcy6s/cQQ2WKo1wGB+PmAKyTcuWl+JmfHs4afP06420raW9mKV5fuuSTABcYH8V9hblyeloHV+I9YuwieDZhBg== + checksum: sha256:9f6ceb4ea1a9539cd900d63065bcd36b8681d56d58dfca6835687ba5c58d5272 + signature: 2f8u+wSUKnC5KTIvHt/Qcor0r1J7Pv3FDhdts2OsIEHPCeXtIwoN2XU3CyRlpr+Zyg3+T++OO4Rv7akiWPK1Bw== diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index 55f6c496..250f870f 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -1,5 +1,5 @@ name: plan -version: 0.37.2 +version: 0.1.0 commands: - plan command_help: @@ -16,5 +16,5 @@ publisher: description: Create and manage implementation plans for project execution. license: Apache-2.0 integrity: - checksum: sha256:60f6ea69f2178bb8395ab19743318849b2caea1aad9a880d230da5c87236fd3e - signature: luy557R50WTq4gvm3Equ5SV1RMDJOs+szawTwEpgl8eGTuuDd24Nxakb9erW7qzbV5Cn0COsZffUfKvNzH4jBw== + checksum: sha256:f7d992a44b0bcee0a6cc3cb4857dbe9c57a3bbe314398893f1337c8c5e4070b6 + signature: MI5BELFxfgZNusPlP6lLKSEdRZR5MdjyOE+IsVutMJHWESmCoO9SmlzycZbHYKBdz9v2BI04kcXsy/AI4+fjDQ== diff --git a/src/specfact_cli/modules/policy_engine/module-package.yaml b/src/specfact_cli/modules/policy_engine/module-package.yaml index c8a20fac..e0548fb1 100644 --- a/src/specfact_cli/modules/policy_engine/module-package.yaml +++ b/src/specfact_cli/modules/policy_engine/module-package.yaml @@ -1,5 +1,5 @@ name: policy-engine -version: 0.37.2 +version: 0.1.0 commands: - policy command_help: @@ -19,8 +19,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: oss@nold.ai integrity: - checksum: sha256:a239acb53ba59ca7d1f1063c90d60037a2501813d074aae0a4c4590b70a34648 - signature: lG2M7QVkbaomLebkqLH3rRi64YCDGiABkaZfxl8082TSGfIcZ9K0gPXgNeEQZIKVZcVM9UnJe7NZ/fiQXr/JAw== + checksum: sha256:45d56fe74e32db9713d42ea622143da1c9b4403c7b22d148ada1fda0060226cf + signature: q0kWPGTaqZTTnFglm8OuHqJyngGLtXnAYeKJp69R/gzzX6QIVZ11bo6mtByG4NKX9KmjXKxOI3JVXWCu3sDOAw== dependencies: [] description: Run policy evaluations with recommendation and compliance outputs. license: Apache-2.0 diff --git a/src/specfact_cli/modules/project/module-package.yaml b/src/specfact_cli/modules/project/module-package.yaml index eb902e2e..04b07d24 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -1,5 +1,5 @@ name: project -version: 0.37.2 +version: 0.1.0 commands: - project command_help: @@ -15,5 +15,5 @@ publisher: description: Manage project bundles, contexts, and lifecycle workflows. license: Apache-2.0 integrity: - checksum: sha256:80789cfb916a67bbbcd2c9416ebae21fe79affb60596d0bc218777c6652cf5d7 - signature: RI2XsxN5BBurAWYlAAYm4amcCWsJ/McnJUqSua/2mDGx/KW7WoXVaMD4xccCatjTDqL6Lr7ctkVh4iQ6wTqoDg== + checksum: sha256:bb6a1c0004d242fa6829975d307470f6f9b895690d4052ae6a9d7a64ec9c7a25 + signature: HIX5WUIWEpjcIZ/lYD9bTk0HqmUXaJ68EZmiS3+IIjx3/GiQ0VcW+QtMxOpRZxYA/MnHZvIB5PdQGjOeahA/Bg== diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index d7d6f981..438e0abb 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -1,5 +1,5 @@ name: repro -version: 0.37.2 +version: 0.1.0 commands: - repro command_help: @@ -15,5 +15,5 @@ publisher: description: Run reproducible validation and diagnostics workflows end-to-end. license: Apache-2.0 integrity: - checksum: sha256:c319deb6f08acc010d14202ed0d643309b24aaa5399f1c235dd44a388df16ca1 - signature: rk3tPivhE4DLpxNZ7pSodnehc9VwmDBoVp5+7RYcNFQ/vNXeTJ++fiXU1lI6rHHIEf2MZEPqMcZ2UpCkOKBwAg== + checksum: sha256:b7082bc1c0ed330a20b97ce52baf93f9686854babe28d412063e05821f3fbc62 + signature: pY7zG/pOURam2csn6HH92scnRY553QMhPnNPEkcfiau8L3pfIauaXtdgt8L27Cq3ARteT6hUK8xkUffQujYBDg== diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index e6d04ea2..755f7e7d 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -1,5 +1,5 @@ name: sdd -version: 0.37.2 +version: 0.1.0 commands: - sdd command_help: @@ -15,5 +15,5 @@ publisher: description: Create and validate Spec-Driven Development manifests and mappings. license: Apache-2.0 integrity: - checksum: sha256:13fba474517e8c1d74e4972b0d232b074a0b20a2f1860cb2b935f3cf000a682f - signature: NCuP7ytnX9XbWdlqFCaerqOsRzOiv7atY/w/ibIngAJGpFCelBwLnbf4qGHqMP10oJCAQh2oWV/13mAm6IYpCw== + checksum: sha256:1d5e11925f8e578dc3186baad6cf6e6beed9af2824d21967ae56440d65f36222 + signature: rJtZAsUXpIeBkLx8Oe0LgMPKs4pSof52r0FhHAOCDaR/Y2559XAKOUcNYdBkyO1BwosCbZqvmEEf1gZzZKwLAQ== diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index 46c8636a..c4adc760 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -1,5 +1,5 @@ name: spec -version: 0.37.2 +version: 0.1.0 commands: - spec command_help: @@ -15,5 +15,5 @@ publisher: description: Integrate and run API specification and contract checks. license: Apache-2.0 integrity: - checksum: sha256:9f94350b959ab97fbb309ecdd0aaaf52137ae7cbae16b66078fde7d38406f39c - signature: +UoWzNOY5pHJzrff3Rt85xnXhae/Tbsxhv4e5oH+siIIcdTjp+8qK9ZESAsT44YgqufcjiRoTi/Ld6z8s675CQ== + checksum: sha256:f14970c58bed5647cfcdc76933f4c7af22c186ef89f74d63bb97df3a5e4a09c4 + signature: /V0wm4tU6gKoXZ29AX9FiICdF0loq/N9OVvQyQ5ICtVarJFBQLUPeYZJup5/PJgIrhjZxDr6Ih+wLG6gZBtLAg== diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index cbf90623..5675c012 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -1,5 +1,5 @@ name: sync -version: 0.37.2 +version: 0.1.0 commands: - sync command_help: @@ -18,5 +18,5 @@ publisher: description: Synchronize repository state with connected external systems. license: Apache-2.0 integrity: - checksum: sha256:6fcd7b3d1276c318b6ca94f61df056ebff42ec5d4c6770e5b5c7c396dccdf156 - signature: 6/DQ1LgbzysUep7niPIK9iW/AYd0g4/152yEu0qksuLhPMtC88ghMTgcho1ooBQZjvYuA/T2wOVUcv5Y+UsLBw== + checksum: sha256:05023a72241101dd19a9c402fcb4882e40a925d0958b95b4b13217032ad8e31b + signature: rovvEszsr1+/kq2yy9R1g01fjhlG38R2eIwg/aXZy789SKq9ttBkBqJ6d1U+ysXYzOUBqgc6WwcfCI1X2Il7Dg== diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index 8ed83409..d0708644 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -1,5 +1,5 @@ name: upgrade -version: 0.37.2 +version: 0.1.0 commands: - upgrade command_help: @@ -15,5 +15,5 @@ publisher: description: Check and apply SpecFact CLI version upgrades. license: Apache-2.0 integrity: - checksum: sha256:c4dd29c7289f946a56e00a9dc408a0d56708fc39a89cd8fd5fddaadb1a56dec5 - signature: 4aNspYwRcNWBnwn3p8tJUfG2BA4ZaXvRNczvjDQXTR/U/SChgMQ0e8B72ujYh0XCLFZLWCW+/kj/w2Y+N8vjCw== + checksum: sha256:441c8d1d5bb5b57b809150e58911966cd1b2aec20ff88dba9985114a65a3aead + signature: mr1FGw1rrBbFEH812TGAxoykpSfP+VzyEMwW5Q5UGNzJgqXwXQxa5bOsVYHwTfToIttGGoFv1jDjJ4NE6b+EBg== diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index 09a367d2..3a7fb67b 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -1,5 +1,5 @@ name: validate -version: 0.37.2 +version: 0.1.0 commands: - validate command_help: @@ -15,5 +15,5 @@ publisher: description: Run schema, contract, and workflow validation suites. license: Apache-2.0 integrity: - checksum: sha256:b1f954e5fef7f8b964299aec54f75b3b24ebc50c24b90db8598fc4b3d8532ee1 - signature: +1azi81aZmGaQxiu4i9gcemRTPyBthDiwP+VWEeK9AGTcTPHjz/meuY3GsO8c33SBVsNltZ3PEggQpw7h8kDCw== + checksum: sha256:01252349bfc86e36138b2acb4e82e60bcaaa84b0f60dc1bfcf4ca554a02bad67 + signature: rU1JJUw057QUVp6YaEEM0vcx+/hrciNsh2A3SlD4xhZwbPyzf9O+RvaAh99q/iAns9EzmsqMDW9IYafLXmEYDQ== diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index 2ab003b9..64520d1c 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -5,6 +5,7 @@ import hashlib import os import shutil +import sys import tarfile import tempfile from functools import lru_cache @@ -22,6 +23,7 @@ from specfact_cli.registry.crypto_validator import verify_checksum, verify_signature from specfact_cli.registry.marketplace_client import download_module from specfact_cli.registry.module_security import assert_module_allowed, ensure_publisher_trusted +from specfact_cli.runtime import is_debug_mode USER_MODULES_ROOT = Path.home() / ".specfact" / "modules" @@ -30,6 +32,14 @@ _IGNORED_MODULE_FILE_SUFFIXES = {".pyc", ".pyo"} +@beartype +def _integrity_debug_details_enabled() -> bool: + """Return True when verbose integrity diagnostics should be shown.""" + if is_debug_mode(): + return True + return "--debug" in sys.argv[1:] + + @beartype def _bundled_public_key_path() -> Path: """Resolve default bundled public key path for module signature verification. @@ -336,13 +346,17 @@ def verify_module_artifact( try: stable_payload = _module_artifact_payload_stable(package_dir) verify_checksum(stable_payload, meta.integrity.checksum) - logger.info( - "Module %s: checksum matched with generated-file exclusions (cache/transient files ignored)", - meta.name, - ) + if _integrity_debug_details_enabled(): + logger.debug( + "Module %s: checksum matched with generated-file exclusions (cache/transient files ignored)", + meta.name, + ) verification_payload = stable_payload except ValueError: - logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) + if _integrity_debug_details_enabled(): + logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) + else: + logger.debug("Module %s: Integrity check failed: %s", meta.name, exc) return False if meta.integrity.signature: diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index e69405a1..336ae4f7 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -817,6 +817,11 @@ def register_module_package_commands( skipped.append((meta.name, f"missing dependencies: {', '.join(missing)}")) continue if not verify_module_artifact(package_dir, meta, allow_unsigned=allow_unsigned): + print_warning( + f"Security check: module '{meta.name}' failed integrity verification and was not loaded. " + "This may indicate tampering or an outdated local module copy. " + "Run `specfact module init` to restore trusted bundled modules." + ) skipped.append((meta.name, "integrity/trust check failed")) continue if not _check_schema_compatibility(meta.schema_version, CURRENT_PROJECT_SCHEMA_VERSION): diff --git a/tests/unit/commands/test_backlog_commands.py b/tests/unit/commands/test_backlog_commands.py index c9dc574d..0149dab7 100644 --- a/tests/unit/commands/test_backlog_commands.py +++ b/tests/unit/commands/test_backlog_commands.py @@ -27,6 +27,7 @@ _parse_refinement_output_fields, _resolve_refine_export_comment_window, _resolve_refine_preview_comment_window, + app as backlog_app, ) from specfact_cli.templates.registry import BacklogTemplate, TemplateRegistry @@ -242,8 +243,10 @@ def test_map_fields_requires_token(self) -> None: @patch("questionary.checkbox") @patch("specfact_cli.utils.auth_tokens.get_token") + @patch("requests.post") def test_map_fields_provider_picker_accepts_choice_objects( self, + mock_post: MagicMock, mock_get_token: MagicMock, mock_checkbox: MagicMock, tmp_path, @@ -256,6 +259,12 @@ def __init__(self, value: str) -> None: mock_checkbox.return_value.ask.return_value = [_ChoiceLike("github")] mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + "data": {"repository": {"issueTypes": {"nodes": [{"id": "IT_TASK", "name": "Task"}]}}} + } + mock_post.return_value = mock_response import os @@ -263,9 +272,8 @@ def __init__(self, value: str) -> None: try: os.chdir(tmp_path) result = runner.invoke( - app, + backlog_app, [ - "backlog", "map-fields", "--github-project-id", "nold-ai/specfact-demo-repo", @@ -284,18 +292,35 @@ def __init__(self, value: str) -> None: assert "No providers selected" not in result.stdout @patch("specfact_cli.utils.auth_tokens.get_token") - def test_map_fields_github_provider_persists_backlog_config(self, mock_get_token: MagicMock, tmp_path) -> None: + @patch("requests.post") + def test_map_fields_github_provider_persists_backlog_config( + self, mock_post: MagicMock, mock_get_token: MagicMock, tmp_path + ) -> None: """Test GitHub provider mapping persistence into .specfact/backlog-config.yaml.""" mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + "data": { + "repository": { + "issueTypes": { + "nodes": [ + {"id": "IT_BUG", "name": "Bug"}, + {"id": "IT_TASK", "name": "Task"}, + ] + } + } + } + } + mock_post.return_value = mock_response import os cwd = Path.cwd() try: os.chdir(tmp_path) result = runner.invoke( - app, + backlog_app, [ - "backlog", "map-fields", "--provider", "github", @@ -321,12 +346,53 @@ def test_map_fields_github_provider_persists_backlog_config(self, mock_get_token assert mapping["project_id"] == "PVT_project_id" assert mapping["type_field_id"] == "PVT_type_field" assert mapping["type_option_ids"]["task"] == "OPT_TASK" + assert github_settings["github_issue_types"]["type_ids"]["task"] == "IT_TASK" + assert github_settings["github_issue_types"]["type_ids"]["bug"] == "IT_BUG" assert github_settings["field_mapping_file"] == ".specfact/templates/backlog/field_mappings/github_custom.yaml" github_custom = tmp_path / ".specfact" / "templates" / "backlog" / "field_mappings" / "github_custom.yaml" assert github_custom.exists() github_custom_payload = yaml.safe_load(github_custom.read_text(encoding="utf-8")) assert github_custom_payload["type_mapping"]["task"] == "task" + @patch("specfact_cli.utils.auth_tokens.get_token") + @patch("requests.post") + def test_map_fields_github_provider_fails_when_issue_types_unavailable( + self, mock_post: MagicMock, mock_get_token: MagicMock, tmp_path + ) -> None: + """GitHub map-fields should fail when repository issue type IDs cannot be discovered.""" + mock_get_token.return_value = {"access_token": "gho_test", "token_type": "bearer"} + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = {"data": {"repository": {"issueTypes": {"nodes": []}}}} + mock_post.return_value = mock_response + + import os + + cwd = Path.cwd() + try: + os.chdir(tmp_path) + result = runner.invoke( + backlog_app, + [ + "map-fields", + "--provider", + "github", + "--github-project-id", + "nold-ai/specfact-demo-repo", + "--github-project-v2-id", + "PVT_project_id", + "--github-type-field-id", + "PVT_type_field", + "--github-type-option", + "task=OPT_TASK", + ], + ) + finally: + os.chdir(cwd) + + assert result.exit_code != 0 + assert "repository issue types" in result.stdout.lower() + def test_backlog_init_config_scaffolds_default_file(self, tmp_path) -> None: """Test backlog init-config creates default backlog-config scaffold.""" import os diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py index b05b5a61..81836e98 100644 --- a/tests/unit/registry/test_module_installer.py +++ b/tests/unit/registry/test_module_installer.py @@ -5,6 +5,7 @@ import io import tarfile from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -221,6 +222,92 @@ def test_verify_module_artifact_detects_tamper_in_non_manifest_file(tmp_path: Pa assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is False +def test_verify_module_artifact_checksum_mismatch_hides_raw_details_without_debug( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + payload = module_installer._module_artifact_payload(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + source.write_text("print('tampered')\n", encoding="utf-8") + + mock_logger = MagicMock() + monkeypatch.setattr(module_installer, "get_bridge_logger", lambda _name: mock_logger) + monkeypatch.setattr(module_installer, "is_debug_mode", lambda: False, raising=False) + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is False + mock_logger.warning.assert_not_called() + mock_logger.debug.assert_called() + + +def test_verify_module_artifact_checksum_mismatch_logs_raw_details_in_debug( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + payload = module_installer._module_artifact_payload(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + source.write_text("print('tampered')\n", encoding="utf-8") + + mock_logger = MagicMock() + monkeypatch.setattr(module_installer, "get_bridge_logger", lambda _name: mock_logger) + monkeypatch.setattr(module_installer, "is_debug_mode", lambda: True, raising=False) + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is False + mock_logger.warning.assert_called_once() + + +def test_verify_module_artifact_checksum_mismatch_logs_raw_details_when_debug_flag_in_argv( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + payload = module_installer._module_artifact_payload(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + source.write_text("print('tampered')\n", encoding="utf-8") + + mock_logger = MagicMock() + monkeypatch.setattr(module_installer, "get_bridge_logger", lambda _name: mock_logger) + monkeypatch.setattr(module_installer, "is_debug_mode", lambda: False, raising=False) + monkeypatch.setattr(module_installer.sys, "argv", ["specfact", "--debug", "module", "list"]) + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is False + mock_logger.warning.assert_called_once() + + def test_verify_module_artifact_ignores_runtime_cache_files(tmp_path: Path) -> None: module_dir = tmp_path / "secure" (module_dir / "src").mkdir(parents=True) @@ -245,6 +332,70 @@ def test_verify_module_artifact_ignores_runtime_cache_files(tmp_path: Path) -> N assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is True +def test_verify_module_artifact_fallback_does_not_emit_info_in_normal_mode( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + pycache_file = module_dir / "__pycache__" / "main.cpython-312.pyc" + pycache_file.parent.mkdir(parents=True) + pycache_file.write_bytes(b"\x00\x01\x02") + + stable_payload = module_installer._module_artifact_payload_stable(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(stable_payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + + mock_logger = MagicMock() + monkeypatch.setattr(module_installer, "get_bridge_logger", lambda _name: mock_logger) + monkeypatch.setattr(module_installer, "is_debug_mode", lambda: False, raising=False) + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is True + mock_logger.info.assert_not_called() + mock_logger.debug.assert_not_called() + + +def test_verify_module_artifact_fallback_emits_debug_in_debug_mode( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + module_dir = tmp_path / "secure" + (module_dir / "src").mkdir(parents=True) + manifest = module_dir / "module-package.yaml" + source = module_dir / "src" / "main.py" + manifest.write_text("name: secure\nversion: '0.1.0'\ncommands: [secure]\n", encoding="utf-8") + source.write_text("print('v1')\n", encoding="utf-8") + + pycache_file = module_dir / "__pycache__" / "main.cpython-312.pyc" + pycache_file.parent.mkdir(parents=True) + pycache_file.write_bytes(b"\x00\x01\x02") + + stable_payload = module_installer._module_artifact_payload_stable(module_dir) + checksum = f"sha256:{__import__('hashlib').sha256(stable_payload).hexdigest()}" + metadata = ModulePackageMetadata( + name="secure", + version="0.1.0", + commands=["secure"], + integrity=IntegrityInfo(checksum=checksum), + ) + + mock_logger = MagicMock() + monkeypatch.setattr(module_installer, "get_bridge_logger", lambda _name: mock_logger) + monkeypatch.setattr(module_installer, "is_debug_mode", lambda: True, raising=False) + + assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is True + mock_logger.info.assert_not_called() + mock_logger.debug.assert_called_once() + + def test_verify_module_artifact_falls_back_when_signature_backend_unavailable( monkeypatch, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 9ec27d80..902b3174 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -315,6 +315,24 @@ def verify_may_fail(_package_dir: Path, meta, allow_unsigned: bool = False): assert "bad_cmd" not in names +def test_integrity_failure_shows_user_friendly_risk_warning(monkeypatch, tmp_path: Path) -> None: + """Integrity failure should emit concise risk guidance instead of raw checksum diagnostics.""" + from specfact_cli.registry import module_packages as mp + + shown_messages: list[str] = [] + metadata = [(tmp_path / "bad", ModulePackageMetadata(name="bad", version="0.1.0", commands=["bad_cmd"]))] + monkeypatch.setattr(mp, "discover_all_package_metadata", lambda: metadata) + monkeypatch.setattr(mp, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: False) + monkeypatch.setattr(mp, "read_modules_state", dict) + monkeypatch.setattr(mp, "print_warning", shown_messages.append) + + register_module_package_commands() + + assert any("failed integrity verification and was not loaded" in msg for msg in shown_messages) + assert any("Run `specfact module init`" in msg for msg in shown_messages) + assert not any("Checksum mismatch" in msg for msg in shown_messages) + + def test_module_state_read_write(tmp_path: Path): """read_modules_state / write_modules_state roundtrip.""" os.environ["SPECFACT_REGISTRY_DIR"] = str(tmp_path) diff --git a/tests/unit/specfact_cli/registry/test_signing_artifacts.py b/tests/unit/specfact_cli/registry/test_signing_artifacts.py index 9d3d9301..886fb939 100644 --- a/tests/unit/specfact_cli/registry/test_signing_artifacts.py +++ b/tests/unit/specfact_cli/registry/test_signing_artifacts.py @@ -137,6 +137,126 @@ def test_sign_modules_py_help_mentions_passphrase_sources(): assert "--allow-same-version" in result.stdout +def test_sign_modules_py_help_mentions_changed_module_automation(): + """sign-modules.py help SHALL expose changed-module automation flags.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + import subprocess + + result = subprocess.run( + ["python3", str(SIGN_PYTHON_SCRIPT), "--help"], + capture_output=True, + text=True, + cwd=REPO_ROOT, + timeout=10, + ) + assert result.returncode == 0 + assert "--changed-only" in result.stdout + assert "--base-ref" in result.stdout + assert "--bump-version" in result.stdout + + +def test_sign_modules_py_changed_only_auto_bump_and_sign(tmp_path: Path): + """Changed-only signing SHALL bump changed module version and add checksum metadata.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + + import subprocess + + repo = tmp_path / "repo" + module_dir = repo / "modules" / "sample" + source = module_dir / "src" / "sample" / "main.py" + manifest = module_dir / "module-package.yaml" + + source.parent.mkdir(parents=True) + manifest.write_text( + "name: sample\nversion: 0.1.0\npublisher: nold-ai\ncommands: [sample]\n", + encoding="utf-8", + ) + source.write_text("print('v1')\n", encoding="utf-8") + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True, capture_output=True, text=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True, capture_output=True, text=True) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True, text=True) + + source.write_text("print('v2')\n", encoding="utf-8") + + result = subprocess.run( + [ + "python3", + str(SIGN_PYTHON_SCRIPT), + "--allow-unsigned", + "--changed-only", + "--base-ref", + "HEAD", + "--bump-version", + "patch", + ], + capture_output=True, + text=True, + cwd=repo, + timeout=20, + ) + assert result.returncode == 0, result.stderr + + import yaml + + signed = yaml.safe_load(manifest.read_text(encoding="utf-8")) + assert signed["version"] == "0.1.1" + assert signed.get("integrity", {}).get("checksum", "").startswith("sha256:") + + +def test_sign_modules_py_changed_only_fails_on_invalid_base_ref(tmp_path: Path): + """Changed-only signing SHALL fail fast when --base-ref does not resolve.""" + if not SIGN_PYTHON_SCRIPT.exists(): + pytest.skip("sign-modules.py not present") + + import subprocess + + repo = tmp_path / "repo" + module_dir = repo / "modules" / "sample" + source = module_dir / "src" / "sample" / "main.py" + manifest = module_dir / "module-package.yaml" + + source.parent.mkdir(parents=True) + manifest.write_text( + "name: sample\nversion: 0.1.0\npublisher: nold-ai\ncommands: [sample]\n", + encoding="utf-8", + ) + source.write_text("print('v1')\n", encoding="utf-8") + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, text=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=repo, check=True, capture_output=True, text=True + ) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True, capture_output=True, text=True) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True, text=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True, text=True) + + result = subprocess.run( + [ + "python3", + str(SIGN_PYTHON_SCRIPT), + "--allow-unsigned", + "--changed-only", + "--base-ref", + "not-a-real-ref", + "--bump-version", + "patch", + ], + capture_output=True, + text=True, + cwd=repo, + timeout=20, + ) + assert result.returncode != 0 + assert "--base-ref is invalid" in result.stderr + + def test_sign_modules_py_checksum_changes_when_module_files_change(tmp_path: Path): """Checksum SHALL reflect full module payload, not only manifest metadata.""" if not SIGN_PYTHON_SCRIPT.exists(): diff --git a/tests/unit/specfact_cli/test_module_migration_compatibility.py b/tests/unit/specfact_cli/test_module_migration_compatibility.py index c0297196..39cbcde0 100644 --- a/tests/unit/specfact_cli/test_module_migration_compatibility.py +++ b/tests/unit/specfact_cli/test_module_migration_compatibility.py @@ -158,11 +158,11 @@ def test_module_discovery_registers_commands_from_manifests(tmp_path: Path, monk assert not missing, "Missing commands after registry bootstrap:\n" + "\n".join(f"- {cmd}" for cmd in missing) -def test_builtin_module_manifest_versions_match_cli_version() -> None: - """Built-in module manifests under src/specfact_cli/modules SHALL stay version-synced with CLI.""" - from specfact_cli import __version__ +def test_builtin_module_manifest_versions_follow_module_level_semver() -> None: + """Built-in module manifests SHALL use module-level semver, independent from CLI package version.""" + issues: list[str] = [] + semver_pattern = re.compile(r"^\d+\.\d+\.\d+$") - mismatches: list[str] = [] for module_name in _module_package_names(): manifest = MODULES_ROOT / module_name / "module-package.yaml" if not manifest.exists(): @@ -173,12 +173,12 @@ def test_builtin_module_manifest_versions_match_cli_version() -> None: version_line = line.split(":", 1)[1].strip().strip("\"'") break if version_line is None: - mismatches.append(f"{module_name}: missing version field") + issues.append(f"{module_name}: missing version field") continue - if version_line != __version__: - mismatches.append(f"{module_name}: {version_line} != {__version__}") + if not semver_pattern.match(version_line): + issues.append(f"{module_name}: invalid semver '{version_line}'") - assert not mismatches, "Built-in module version drift detected:\n" + "\n".join(f"- {item}" for item in mismatches) + assert not issues, "Built-in module version metadata issues:\n" + "\n".join(f"- {item}" for item in issues) def test_module_manifest_descriptions_are_meaningful() -> None: