diff --git a/.github/ISSUE_TEMPLATE/augmentation_metadata.md b/.github/ISSUE_TEMPLATE/augmentation_metadata.md index 991c62c5..f2c119e6 100644 --- a/.github/ISSUE_TEMPLATE/augmentation_metadata.md +++ b/.github/ISSUE_TEMPLATE/augmentation_metadata.md @@ -32,7 +32,8 @@ Example validation commands: ```bash hatch run check-bundle-imports -hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump +hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump +# add --require-signature when validating main-branch policy ``` ## Alternative Solutions diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e48292cb..52543152 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -22,7 +22,9 @@ hatch run Example: ```bash -hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump +hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump +# For main-equivalent failures, also try: +# hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump ``` ## Expected Behavior @@ -55,7 +57,7 @@ Typical commands: - `hatch run lint` - `hatch run yaml-lint` - `hatch run check-bundle-imports` -- `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` +- `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` (and `--require-signature` if reproducing on **`main`**) ## Additional Context diff --git a/.github/ISSUE_TEMPLATE/change_proposal.md b/.github/ISSUE_TEMPLATE/change_proposal.md index 876e6337..fba6bb6a 100644 --- a/.github/ISSUE_TEMPLATE/change_proposal.md +++ b/.github/ISSUE_TEMPLATE/change_proposal.md @@ -20,7 +20,7 @@ High-level implementation summary. Include affected bundles and workflow/config - [ ] Bundle changes are scoped and intentional (`packages/*`) - [ ] `module-package.yaml` versions are bumped where contents changed - [ ] Manifests are re-signed -- [ ] `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` passes +- [ ] `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` passes for **`dev`**-targeting work; add `--require-signature` when the change must satisfy **`main`** policy - [ ] Import boundaries respected (`hatch run check-bundle-imports`) - [ ] Required quality gates pass in PR orchestrator diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3968b95c..d3e7030f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -40,14 +40,16 @@ Paste command output snippets or link workflow runs. ### Signature + version integrity (required) -- [ ] `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` -- [ ] Changed bundle versions were bumped before signing -- [ ] Manifests re-signed after bundle content changes +- [ ] `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` passes (matches PRs targeting **`dev`**) +- [ ] If this PR targets **`main`**, also confirmed: `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` (and/or approval triggered **`sign-modules-on-approval`** for same-repo PRs) +- [ ] Changed bundle versions were bumped when module payloads changed +- [ ] Manifests signed when required by your target branch (CI may sign on **approval** for `dev`/`main` same-repo PRs) ## CI and Branch Protection - [ ] PR orchestrator jobs expected: - `verify-module-signatures` + - `sign-modules-on-approval` (on approval, same-repo PRs to `dev`/`main` only) - `quality (3.11)` - `quality (3.12)` - `quality (3.13)` diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index 7d18a8fb..a8638d9b 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -79,18 +79,31 @@ jobs: python -m pip install pyyaml cryptography cffi - name: Verify bundled module signatures and version bumps run: | + set -euo pipefail + TARGET_BRANCH="" + if [ "${{ github.event_name }}" = "pull_request" ]; then + TARGET_BRANCH="${{ github.event.pull_request.base.ref }}" + else + TARGET_BRANCH="${GITHUB_REF#refs/heads/}" + fi + BASE_REF="" if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_REF="origin/${{ github.event.pull_request.base.ref }}" fi + if [ -z "${SPECFACT_MODULE_PUBLIC_SIGN_KEY:-}" ] && [ -z "${SPECFACT_MODULE_SIGNING_PUBLIC_KEY_PEM:-}" ]; then echo "warning: no public signing key secret set; verifier must resolve key from repo/default path" fi + + VERIFY_CMD=(python scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump) + if [ "$TARGET_BRANCH" = "main" ]; then + VERIFY_CMD+=(--require-signature) + fi if [ -n "$BASE_REF" ]; then - python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump --version-check-base "$BASE_REF" - else - python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump + VERIFY_CMD+=(--version-check-base "$BASE_REF") fi + "${VERIFY_CMD[@]}" quality: name: quality (${{ matrix.python-version }}) diff --git a/.github/workflows/sign-modules-on-approval.yml b/.github/workflows/sign-modules-on-approval.yml new file mode 100644 index 00000000..c6d71bb7 --- /dev/null +++ b/.github/workflows/sign-modules-on-approval.yml @@ -0,0 +1,108 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +name: sign-modules-on-approval + +on: + pull_request_review: + types: [submitted] + +concurrency: + group: sign-modules-on-approval-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + sign-modules: + if: >- + github.event.review.state == 'approved' && + (github.event.pull_request.base.ref == 'dev' || github.event.pull_request.base.ref == 'main') && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + env: + SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} + SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + steps: + - name: Guard signing secrets + run: | + set -euo pipefail + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY:-}" ]; then + echo "::error::Missing secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY" + exit 1 + fi + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE:-}" ]; then + echo "::error::Missing secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install signing dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pyyaml beartype icontract cryptography cffi + + - name: Discover module manifests + id: discover + run: | + set -euo pipefail + mapfile -t MANIFESTS < <(find packages -name 'module-package.yaml' -type f | sort) + echo "manifests_count=${#MANIFESTS[@]}" >> "$GITHUB_OUTPUT" + echo "Discovered ${#MANIFESTS[@]} module-package.yaml file(s) under packages/" + + - name: Sign changed module manifests + id: sign + run: | + set -euo pipefail + git fetch origin "${PR_BASE_REF}" --no-tags + MERGE_BASE="$(git merge-base HEAD "origin/${PR_BASE_REF}")" + python scripts/sign-modules.py \ + --changed-only \ + --base-ref "$MERGE_BASE" \ + --bump-version patch \ + --payload-from-filesystem + + - name: Commit and push signed manifests + id: commit + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if [ -z "$(git status --porcelain -- packages/)" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No manifest changes to commit." + exit 0 + fi + git add -u -- packages/ + git commit -m "chore(modules): ci sign changed modules [skip ci]" + echo "changed=true" >> "$GITHUB_OUTPUT" + if ! git push origin "HEAD:${PR_HEAD_REF}"; then + echo "::error::Push to ${PR_HEAD_REF} failed (branch may have advanced after the approved commit). Update the PR branch and re-approve if signing is still required." + exit 1 + fi + + - name: Write job summary + if: always() + env: + COMMIT_CHANGED: ${{ steps.commit.outputs.changed }} + MANIFESTS_COUNT: ${{ steps.discover.outputs.manifests_count }} + run: | + { + echo "### Module signing (CI approval)" + echo "Manifests discovered under \`packages/\`: ${MANIFESTS_COUNT:-unknown}" + if [ "${COMMIT_CHANGED}" = "true" ]; then + echo "Committed signed manifest updates to ${PR_HEAD_REF}." + else + echo "No changes detected (manifests already signed or no module changes on this PR vs merge-base)." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 259bb6dc..92810078 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: hooks: - id: verify-module-signatures name: Verify module signatures and version bumps - entry: hatch run ./scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump + entry: ./scripts/pre-commit-verify-modules-signature.sh language: system pass_filenames: false always_run: true diff --git a/README.md b/README.md index 42bcd6b5..7e456b26 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,17 @@ hatch run type-check hatch run lint hatch run yaml-lint hatch run check-bundle-imports -hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump +hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump hatch run contract-test hatch run smart-test hatch run test hatch run specfact code review run --json --out .specfact/code-review.json ``` +**Module signatures:** `pr-orchestrator` enforces `--require-signature` only for events targeting **`main`**; for **`dev`** (and feature branches) CI checks checksums and version bumps without requiring a cryptographic signature yet. Add `--require-signature` to the `verify-modules-signature` command when you want the same bar as **`main`** (for example before merging to `main`). Pre-commit runs `scripts/pre-commit-verify-modules-signature.sh`, which mirrors that policy (signatures required on branch `main`, or when `GITHUB_BASE_REF=main` in Actions). + +**CI signing:** Approved PRs to `dev` or `main` from **this repository** (not forks) run `.github/workflows/sign-modules-on-approval.yml`, which can commit signed manifests using repository secrets. See [Module signing](/authoring/module-signing/). + To mirror CI locally with git hooks, enable pre-commit: ```bash @@ -55,7 +59,7 @@ pre-commit install pre-commit run --all-files ``` -**Code review gate (matches specfact-cli core):** runs in **Block 2** after module signatures and Block 1 quality hooks (`pre-commit-quality-checks.sh block2`, which calls `scripts/pre_commit_code_review.py`). Staged paths under `packages/`, `registry/`, `scripts/`, `tools/`, `tests/`, and `openspec/changes/` (excluding `TDD_EVIDENCE.md`) are forwarded to the helper, which runs `specfact code review run --json --out .specfact/code-review.json` with that path list. The helper prints only a short findings summary and copy-paste prompts on stderr (not the nested CLI’s full tool output). Block 1 is split into separate pre-commit hooks so output appears between stages instead of buffering until the end. Requires a local **specfact-cli** install (`hatch run dev-deps` resolves sibling `../specfact-cli` or `SPECFACT_CLI_REPO`). +**Code review gate (matches specfact-cli core):** runs in **Block 2** after the module verify hook and Block 1 quality hooks (`pre-commit-quality-checks.sh block2`, which calls `scripts/pre_commit_code_review.py`). Staged paths under `packages/`, `registry/`, `scripts/`, `tools/`, `tests/`, and `openspec/changes/` are eligible; `openspec/changes/**/TDD_EVIDENCE.md` is excluded from the gate. OpenSpec Markdown other than evidence files is not passed to SpecFact (the review CLI treats paths as Python). The helper runs `specfact code review run --json --out .specfact/code-review.json` on the remaining paths and prints only a short findings summary and copy-paste prompts on stderr. Block 1 is split into separate pre-commit hooks so output appears between stages instead of buffering until the end. Requires a local **specfact-cli** install (`hatch run dev-deps` resolves sibling `../specfact-cli` or `SPECFACT_CLI_REPO`). Scope notes: - Pre-commit runs `hatch run lint` in the **Block 1 — lint** hook when any staged path matches `*.py` / `*.pyi`, matching the CI quality job (Ruff alone does not run pylint). diff --git a/docs/agent-rules/20-repository-context.md b/docs/agent-rules/20-repository-context.md index ebfc092e..d771d9ee 100644 --- a/docs/agent-rules/20-repository-context.md +++ b/docs/agent-rules/20-repository-context.md @@ -45,7 +45,8 @@ hatch run format hatch run type-check hatch run lint hatch run yaml-lint -hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump +hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump +# add --require-signature for main-equivalent checks (see agent-rules/50-quality-gates-and-review.md) hatch run contract-test hatch run smart-test hatch run test diff --git a/docs/agent-rules/50-quality-gates-and-review.md b/docs/agent-rules/50-quality-gates-and-review.md index 2ffe6b90..a2b49f1f 100644 --- a/docs/agent-rules/50-quality-gates-and-review.md +++ b/docs/agent-rules/50-quality-gates-and-review.md @@ -43,7 +43,7 @@ depends_on: 3. `hatch run lint` 4. `hatch run yaml-lint` 5. `hatch run check-bundle-imports` -6. `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` +6. `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` (add `--require-signature` when checking as for `main`; matches CI and `scripts/pre-commit-verify-modules-signature.sh`) 7. `hatch run contract-test` 8. `hatch run smart-test` 9. `hatch run test` @@ -51,7 +51,7 @@ depends_on: ## Pre-commit order -1. Module signature verification (`.pre-commit-config.yaml`, `fail_fast: true` so a failing earlier hook never runs later stages). +1. Module signature verification via `scripts/pre-commit-verify-modules-signature.sh` (`.pre-commit-config.yaml`; `fail_fast: true` so a failing earlier hook never runs later stages). The hook adds `--require-signature` on branch `main`, or when `GITHUB_BASE_REF` is `main` (PR target in Actions). 2. **Block 1** — four separate hooks (each flushes pre-commit output when it exits, so you see progress between stages): `pre-commit-quality-checks.sh block1-format` (always), `block1-yaml` when staged `*.yaml` / `*.yml`, `block1-bundle` (always), `block1-lint` when staged `*.py` / `*.pyi`. 3. **Block 2** — `pre-commit-quality-checks.sh block2` (skipped for “safe-only” staged paths): `hatch run python scripts/pre_commit_code_review.py …` on **staged paths under `packages/`, `registry/`, `scripts/`, `tools/`, `tests/`, and `openspec/changes/`** (excluding `TDD_EVIDENCE.md`), then `contract-test-status` / `hatch run contract-test`. diff --git a/docs/authoring/module-signing.md b/docs/authoring/module-signing.md index eeeaa273..aec84e0e 100644 --- a/docs/authoring/module-signing.md +++ b/docs/authoring/module-signing.md @@ -89,7 +89,8 @@ hatch run python scripts/sign-modules.py \ --base-ref origin/dev \ --bump-version patch -# Verify after signing (must match sign payload mode) +# Verify after signing (must match sign payload mode). For a dev-targeting branch, CI omits +# --require-signature; add it when checking as for main: hatch run python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump --version-check-base origin/dev ``` @@ -110,26 +111,35 @@ python scripts/sign-modules.py --allow-unsigned --payload-from-filesystem packag ## Verify Signatures Locally -Strict verification (checksum + signature required): +Checksum + version enforcement (matches **`dev`** / feature CI and pre-commit when not on `main`): ```bash -python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem +python scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump +``` + +Strict verification (checksum + **signature** required, matches **`main`** CI): + +```bash +python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump ``` With explicit public key file: ```bash -python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --public-key-file resources/keys/module-signing-public.pem +python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump --public-key-file resources/keys/module-signing-public.pem ``` ## CI Enforcement -`pr-orchestrator.yml` contains a strict gate: +`pr-orchestrator.yml` job **`verify-module-signatures`** always runs with `--payload-from-filesystem --enforce-version-bump`. It adds **`--require-signature` only when the pull request or push targets `main`**. For **`dev`** and feature work, the job still enforces checksums and version bumps so unsigned manifests can land on `dev`; signatures are expected by the time changes reach **`main`**. + +### Signing on approval (same-repo PRs) + +Workflow **`sign-modules-on-approval.yml`** runs when a review is **submitted** and **approved** on a PR whose base is **`dev`** or **`main`**, and only when the PR head is in **this** repository (`head.repo` equals the base repo). It checks out **`github.event.pull_request.head.sha`** (the commit that was approved, not the moving branch tip), uses `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` (each validated with a named error if missing), discovers changes against the **merge-base** with the base branch (not the moving base tip alone), runs `scripts/sign-modules.py --changed-only --bump-version patch --payload-from-filesystem`, and commits results with `[skip ci]`. If `git push` is rejected because the PR branch advanced after approval, the job fails with guidance to update the branch and re-approve. **Fork PRs** are skipped (the default `GITHUB_TOKEN` cannot push to a contributor fork). -- Job: `verify-module-signatures` -- Command: `python scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump` +### Pre-commit -This runs on PR/push for `dev` and `main` and fails the pipeline if module signatures/checksums are missing or stale. +The first pre-commit hook runs **`scripts/pre-commit-verify-modules-signature.sh`**, which mirrors CI: **`--require-signature` on branch `main`**, or when **`GITHUB_BASE_REF=main`** in Actions pull-request contexts; otherwise checksum + version enforcement only. ## Rotation Procedure diff --git a/docs/authoring/publishing-modules.md b/docs/authoring/publishing-modules.md index 8499ebd4..f8166bfe 100644 --- a/docs/authoring/publishing-modules.md +++ b/docs/authoring/publishing-modules.md @@ -82,7 +82,7 @@ Repository workflow `.github/workflows/publish-modules.yml`: - Bump module `version` in `module-package.yaml` whenever payload or manifest content changes; keep versions immutable for published artifacts. - Use `namespace/name` for any module you publish to a registry. -- Run `scripts/verify-modules-signature.py --require-signature --payload-from-filesystem` (or your registry’s policy) before releasing. +- Before releasing from **`main`**, run `scripts/verify-modules-signature.py --require-signature --payload-from-filesystem` (or your registry’s policy). On **`dev`**, CI may accept checksum-only manifests until promotion; align with your registry’s requirements. - Prefer `--download-base-url` and `--index-fragment` when integrating with a custom registry index. ## See also diff --git a/docs/guides/ci-cd-pipeline.md b/docs/guides/ci-cd-pipeline.md index da96c402..355c414f 100644 --- a/docs/guides/ci-cd-pipeline.md +++ b/docs/guides/ci-cd-pipeline.md @@ -35,23 +35,30 @@ hatch run format hatch run type-check hatch run lint hatch run yaml-lint -hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump +hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump hatch run contract-test hatch run smart-test hatch run test ``` +Add `--require-signature` to the verify step when checking the same policy as **`main`** (for example before promoting work to `main`). On feature branches and for PRs targeting **`dev`**, CI does not require signatures yet; pre-commit matches that via `scripts/pre-commit-verify-modules-signature.sh`. + Use the same order locally before pushing changes that affect docs, bundles, or registry metadata. ## 2.1 CI/CD stage mapping Map the local commands to the pipeline stages this repository enforces: -- Pre-commit stage: `pre-commit run --all-files` +- Pre-commit stage: `pre-commit run --all-files` (first hook: branch-aware module verify, then Block 1 / Block 2) - Quality gates stage: `hatch run format` -> `hatch run type-check` -> `hatch run lint` -> `hatch run yaml-lint` -- Release-readiness stage: `hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` +- Module integrity stage: `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` (add `--require-signature` for **main**-equivalent checks) - Validation stage: `hatch run contract-test` -> `hatch run smart-test` -> `hatch run test` +GitHub Actions also runs: + +- **`pr-orchestrator`**: `verify-module-signatures` uses `--require-signature` only when the event targets **`main`**. +- **`sign-modules-on-approval`**: after an **approved** review on a PR to **`dev`** or **`main`**, signs changed `packages/*/module-package.yaml` using repository secrets and pushes a commit back to the PR branch (**same-repo PRs only**; fork heads cannot be updated with the default token). + ## 3. Add scoped workflow checks while developing ```bash diff --git a/docs/guides/command-chains.md b/docs/guides/command-chains.md index 61454771..a354dbfa 100644 --- a/docs/guides/command-chains.md +++ b/docs/guides/command-chains.md @@ -78,12 +78,14 @@ hatch run format hatch run type-check hatch run lint hatch run yaml-lint -hatch run verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump +hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump hatch run contract-test hatch run smart-test hatch run test ``` +Append `--require-signature` to the verify line when you need the **`main`** policy (signatures mandatory). Otherwise this order matches **`dev`** / feature CI for `verify-module-signatures` plus checksum and version enforcement. + Use this chain as the full required pre-push gate order so the local run matches the repository CI quality gates. Related: [CI/CD pipeline](/guides/ci-cd-pipeline/) diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md index fc5466f2..5edfb5e0 100644 --- a/docs/reference/module-security.md +++ b/docs/reference/module-security.md @@ -46,8 +46,10 @@ Module packages carry **publisher** and **integrity** metadata so installation, - `SPECFACT_MODULE_PRIVATE_SIGN_KEY` - `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` - **Verification command**: - - `scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump` - - `--version-check-base ` can be used in CI PR comparisons. + - Default strict local / **main** check: `scripts/verify-modules-signature.py --require-signature --payload-from-filesystem --enforce-version-bump` + - **Dev / feature parity with CI** (checksum + version bump, signature optional): omit `--require-signature` (see `pr-orchestrator` and `scripts/pre-commit-verify-modules-signature.sh`). + - `--version-check-base ` is used for PR comparisons in CI. +- **CI signing**: Approved same-repo PRs to `dev` or `main` may receive automated signing commits via `sign-modules-on-approval.yml` (repository secrets; merge-base scoped `--changed-only`). ## Public key and key rotation diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 5220103a..673e74b5 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -79,6 +79,12 @@ These changes are the modules-side runtime companions to split core governance a | governance | 04 | governance-04-deterministic-agent-governance-loading | [#181](https://github.com/nold-ai/specfact-cli-modules/issues/181) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); paired core [specfact-cli#494](https://github.com/nold-ai/specfact-cli/issues/494); baseline [#178](https://github.com/nold-ai/specfact-cli-modules/issues/178) (implements archived `governance-03-github-hierarchy-cache`, paired core [specfact-cli#491](https://github.com/nold-ai/specfact-cli/issues/491)) | | validation | 02 | validation-02-full-chain-engine | [#171](https://github.com/nold-ai/specfact-cli-modules/issues/171) | Parent Feature: [#163](https://github.com/nold-ai/specfact-cli-modules/issues/163); core counterpart `specfact-cli#241`; runtime inputs from `#164` and `#165`; policy semantics from `#158` | +### Module trust chain and CI security + +| Module | Order | Change folder | GitHub # | Blocked by | +|--------|-------|---------------|----------|------------| +| marketplace | 06 | marketplace-06-ci-module-signing | [#185](https://github.com/nold-ai/specfact-cli-modules/issues/185) | Parent Feature: [#187](https://github.com/nold-ai/specfact-cli-modules/issues/187); Parent Epic: [#186](https://github.com/nold-ai/specfact-cli-modules/issues/186); paired core [specfact-cli#500](https://github.com/nold-ai/specfact-cli/issues/500) | + ### Documentation restructure | Module | Order | Change folder | GitHub # | Blocked by | diff --git a/openspec/changes/marketplace-06-ci-module-signing/.openspec.yaml b/openspec/changes/marketplace-06-ci-module-signing/.openspec.yaml new file mode 100644 index 00000000..e14f3223 --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-13 diff --git a/openspec/changes/marketplace-06-ci-module-signing/TDD_EVIDENCE.md b/openspec/changes/marketplace-06-ci-module-signing/TDD_EVIDENCE.md new file mode 100644 index 00000000..feede7b7 --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/TDD_EVIDENCE.md @@ -0,0 +1,87 @@ +# TDD evidence: marketplace-06-ci-module-signing + +Chain: delta specs (`ci-integration`, `ci-module-signing-on-approval`) → workflow tests → **Red** evidence → +implement `.github/workflows/sign-modules-on-approval.yml` + `pr-orchestrator.yml` → **Green** evidence. + +## Red (workflow tests before `sign-modules-on-approval.yml` existed) + +Artifacts: `tests/unit/workflows/test_sign_modules_on_approval.py` (expects +`.github/workflows/sign-modules-on-approval.yml`), and companion tests for +`pr-orchestrator.yml` / pre-commit parity added in the same change. + +Captured **2026-04-14** by temporarily moving the workflow file aside and running one failing test: + +```bash +python -m pytest \ + tests/unit/workflows/test_sign_modules_on_approval.py::test_sign_modules_on_approval_trigger_and_job_filter \ + -q +``` + +Excerpt (failure: missing `sign-modules-on-approval.yml`): + +```text +tests/unit/workflows/test_sign_modules_on_approval.py F [100%] + +=================================== FAILURES =================================== +_____________ test_sign_modules_on_approval_trigger_and_job_filter _____________ + + def test_sign_modules_on_approval_trigger_and_job_filter() -> None: +> workflow = _workflow_text() +... +E FileNotFoundError: [Errno 2] No such file or directory: '.../.github/workflows/sign-modules-on-approval.yml' + +=========================== short test summary info ============================ +FAILED tests/unit/workflows/test_sign_modules_on_approval.py::test_sign_modules_on_approval_trigger_and_job_filter +============================== 1 failed in 0.22s =============================== +``` + +## Green (verification) + +Run from worktree `feature/marketplace-06-ci-module-signing` (repo root of this change): + +```bash +python -m pytest tests/unit/workflows/ tests/unit/test_pre_commit_quality_parity.py \ + tests/unit/test_pre_commit_verify_modules_signature_script.py -q +# workflow + pre-commit parity tests (2026-04-14) + +hatch run contract-test +# 555 passed (2026-04-14, after sign-modules-on-approval hardening) + +hatch run lint +# All checks passed + +hatch run yaml-lint +# Validated manifests / registry + +actionlint .github/workflows/pr-orchestrator.yml .github/workflows/sign-modules-on-approval.yml +# exit 0 +``` + +### PR review follow-up (sign-modules-on-approval) + +- Same-repo PRs only: job `if` adds `github.event.pull_request.head.repo.full_name == github.repository` so fork PRs are not pushed to the wrong remote. +- Checkout uses `head.ref` (branch tip) to align with `git push origin "HEAD:${PR_HEAD_REF}"`. +- Signing uses `MERGE_BASE="$(git merge-base HEAD "origin/${PR_BASE_REF}")"` after `git fetch origin "${PR_BASE_REF}" --no-tags` so `--changed-only` is PR-scoped vs the merge-base, not the moving base-branch tip. + +SpecFact code review (use Hatch so `semgrep` resolves to the project venv; system `specfact` may +invoke a broken `semgrep` shim): + +```bash +hatch run specfact code review run --json --out .specfact/code-review.json --include-tests \ + tests/unit/workflows/test_pr_orchestrator_signing.py \ + tests/unit/workflows/test_sign_modules_on_approval.py +# exit 0 (2026-04-14) +``` + +## Pre-commit note + +`scripts/pre_commit_code_review.py` now skips `openspec/changes/**/*.md` (including `tasks.md`) so +the SpecFact review gate does not parse Markdown task lists as Python (false positives). + +## PR + +- Opened: [specfact-cli-modules#188](https://github.com/nold-ai/specfact-cli-modules/pull/188) (target `dev`). + +## Cleanup (post-merge, human) + +Tasks 6.3–6.4 (remove worktree / branch after merge, record cleanup): pending merge. diff --git a/openspec/changes/marketplace-06-ci-module-signing/design.md b/openspec/changes/marketplace-06-ci-module-signing/design.md new file mode 100644 index 00000000..74fcb829 --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/design.md @@ -0,0 +1,100 @@ +# Design: CI-Driven Module Signing On PR Approval (specfact-cli-modules) + +## Context + +This repo differs from `specfact-cli` in two important ways that shape the implementation: + +1. **Module manifest location**: Manifests live under `packages/*/module-package.yaml`, not + `src/specfact_cli/modules/` or `modules/`. The signing script (`scripts/sign-modules.py`) must + be invoked with the correct root discovery path, or with explicit manifest paths. + +2. **No existing signing workflow**: `specfact-cli` has a `sign-modules.yml` hardening workflow + that already scopes verification to dev/main; this repo has no equivalent. Only + `pr-orchestrator.yml` has a `verify-module-signatures` job, and it always runs + `--require-signature` regardless of target branch. + +3. **Pre-commit verify hook**: `.pre-commit-config.yaml` already runs `verify-modules-signature.py` on + every commit. That entry is replaced with a thin wrapper that mirrors `pr-orchestrator` policy + (`--require-signature` when the current branch is `main`, or `GITHUB_BASE_REF` is `main` in Actions). + Block 1 quality stages in `pre-commit-quality-checks.sh` are unchanged. + +Both repos share the same `scripts/sign-modules.py` logic (or a copy of it) and the same GitHub +secrets (`SPECFACT_MODULE_PRIVATE_SIGN_KEY`, `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE`), which +are already configured via `publish-modules.yml`. + +## Goals / Non-Goals + +**Goals:** +- Add automated CI signing on PR approval, covering `packages/*/module-package.yaml` manifests. +- Relax `verify-module-signatures` in `pr-orchestrator.yml` for dev-targeting events. +- Enable non-interactive development on feature/dev branches without a local private key. + +**Non-Goals:** +- Adding *new* mandatory local signing with a private key (the wrapper relaxes `--require-signature` + off `main` instead). +- Adding a `sign-modules.yml` hardening workflow (out of scope; this repo's release flow differs + from specfact-cli). +- Changing `publish-modules.yml` or the registry index logic. +- Changing install-time signature verification for end users. + +## Decisions + +### Decision 1: Reuse `scripts/sign-modules.py` with `--changed-only` and merge-base + +**Chosen**: In CI, `git fetch origin `, compute `MERGE_BASE="$(git merge-base HEAD "origin/")"`, +then run `sign-modules.py --changed-only --base-ref "$MERGE_BASE" --bump-version patch +--payload-from-filesystem` with **no explicit manifest arguments** so the script selects changed +modules itself. `_iter_manifests()` discovers `packages/*/module-package.yaml` (this repo layout). + +**Approach** (workflow sign step): + +```bash +git fetch origin "${PR_BASE_REF}" --no-tags +MERGE_BASE="$(git merge-base HEAD "origin/${PR_BASE_REF}")" +python scripts/sign-modules.py \ + --changed-only \ + --base-ref "$MERGE_BASE" \ + --bump-version patch \ + --payload-from-filesystem +``` + +`--allow-same-version` is not used; `--bump-version patch` auto-bumps when the version is unchanged +from the comparison ref (same policy as specfact-cli). + +A separate workflow step may still **count** manifests under `packages/` for job summary only; it +does not feed paths into `sign-modules.py`. + +### Decision 2: Same trigger as specfact-cli (`pull_request_review`, approved) + +All design decisions from `specfact-cli/marketplace-06-ci-module-signing` Design § Decisions 1–5 +apply identically. See that document for rationale. This repo’s `sign-modules.py` discovers manifests +under `packages/`; merge-base scoping for `--changed-only` matches PR-local changes. + +### Decision 3: pr-orchestrator split mirrors specfact-cli exactly + +The `verify-module-signatures` job split (dev: no `--require-signature`; main: +`--require-signature`) is identical in logic to the specfact-cli change. The only structural +difference is that this workflow uses `SPECFACT_MODULE_PUBLIC_SIGN_KEY` for verification (the +public key secret); the signing job uses the private key secret. + +## Risks / Trade-offs + +- **Risk: `_iter_manifests()` in sign-modules.py doesn't discover `packages/`** — mitigated by + explicit manifest discovery in the workflow step (see Decision 1). +- **Risk: Version bump race condition** — if two PRs to dev are approved simultaneously and both + change the same module, auto-bump produces the same patch version. Mitigation: signing is + idempotent; the second approval re-signs but the version bump check compares against + `origin/dev`, which may already include the first bump. In practice this scenario is unlikely + given the single-maintainer cadence; a future change can add mutex locking if needed. +- All other risks from the paired `specfact-cli` design apply equally here. + +## Migration Plan + +Identical to `specfact-cli/marketplace-06-ci-module-signing` Migration Plan. Merge on `dev` first +(no `--require-signature` on dev PRs), then the signing workflow activates automatically for the +next main-targeting PR. + +## Open Questions + +Same open questions as the paired specfact-cli change (GPG-signed bot commits, dev registry +endpoint). No modules-specific open questions. diff --git a/openspec/changes/marketplace-06-ci-module-signing/proposal.md b/openspec/changes/marketplace-06-ci-module-signing/proposal.md new file mode 100644 index 00000000..bf00dd79 --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/proposal.md @@ -0,0 +1,60 @@ +# Change: CI-Driven Module Signing On PR Approval (specfact-cli-modules) + +## Why + +`specfact-cli-modules` currently verifies module signatures on every PR (including feature→dev +branches) but has no automated signing step in CI at all. The only path to a signed manifest is +local signing with the private key — which blocks non-interactive development (AI agents, Cursor, +headless CI). This is the modules-repo half of the paired change: it adds the missing CI signing +job (triggered by PR approval) and relaxes the verify gate on dev-targeting PRs, matching the +policy set by `specfact-cli/marketplace-06-ci-module-signing`. + +## What Changes + +- **NEW**: `sign-modules-on-approval.yml` GitHub Actions workflow — triggers on + `pull_request_review` (state: `approved`), signs changed module manifests under `packages/` + via CI secrets, and commits signed manifests back to the PR branch. +- **MODIFY**: `.github/workflows/pr-orchestrator.yml` `verify-module-signatures` job — drop + `--require-signature` for PRs and pushes targeting `dev`; keep it for `main`-targeting events. +- **MODIFY**: `.pre-commit-config.yaml` verify hook — run `scripts/pre-commit-verify-modules-signature.sh` + so local verification matches CI branch policy (`--require-signature` only on `main`; checksum + + version enforcement elsewhere). `scripts/pre-commit-quality-checks.sh` itself is unchanged. +- **NO CHANGE**: `publish-modules.yml` — handles signing at publish/registry-update time; + unchanged. +- **NO CHANGE**: Module install-time verification (end users always install from the main registry; + install-time verification always requires signatures). + +## Capabilities + +### New Capabilities + +- `ci-module-signing-on-approval`: Automated CI workflow that signs changed `packages/*/ + module-package.yaml` manifests using repository secrets when a PR targeting `dev` or `main` + is approved, and commits the signed manifests back to the PR branch. + +### Modified Capabilities + +- `ci-integration`: The `verify-module-signatures` job in `pr-orchestrator.yml` applies a + branch-aware policy — checksum-only for dev-targeting events, full `--require-signature` for + main-targeting events. +- `modules-pre-commit-quality-parity`: The always-run pre-commit verify step matches that policy + (`main` only: `--require-signature`; other branches: checksum + version bump only). + +## Impact + +- **Affected workflows**: `.github/workflows/pr-orchestrator.yml` +- **New workflow**: `.github/workflows/sign-modules-on-approval.yml` +- **Pre-commit**: `.pre-commit-config.yaml`, new `scripts/pre-commit-verify-modules-signature.sh` +- **GitHub secrets used** (already configured via publish-modules.yml): + `SPECFACT_MODULE_PRIVATE_SIGN_KEY`, `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- **Permissions added**: `contents: write` on the new signing workflow +- **Module manifest paths**: `packages/*/module-package.yaml` (found under `packages/` in this + repo, not `src/` or `modules/` as in specfact-cli) +- **Supporting changes**: shell wrapper + unit tests for pre-commit parity; main spec + `openspec/specs/modules-pre-commit-quality-parity/spec.md` updated to match behavior +- **Paired change**: `specfact-cli/marketplace-06-ci-module-signing` — covers the pre-commit hook, + `sign-modules.yml`, and pr-orchestrator changes in the core CLI repo +- **Source Tracking**: + - GitHub Issue: [specfact-cli-modules#185](https://github.com/nold-ai/specfact-cli-modules/issues/185) + - Paired Core Change: [specfact-cli#500](https://github.com/nold-ai/specfact-cli/issues/500) + - Parent Feature (core side): [#353 Marketplace Module Distribution](https://github.com/nold-ai/specfact-cli/issues/353) diff --git a/openspec/changes/marketplace-06-ci-module-signing/specs/ci-integration/spec.md b/openspec/changes/marketplace-06-ci-module-signing/specs/ci-integration/spec.md new file mode 100644 index 00000000..fdb24f22 --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/specs/ci-integration/spec.md @@ -0,0 +1,53 @@ +# ci-integration Delta Specification (specfact-cli-modules) + +## ADDED Requirements + +### Requirement: pr-orchestrator skips signature requirement for dev-targeting events + +The `verify-module-signatures` job in `pr-orchestrator.yml` SHALL NOT enforce `--require-signature` +for pull requests or pushes targeting `dev`; it SHALL enforce `--require-signature` only for +`main`-targeting events. + +#### Scenario: Feature-to-dev PR with unsigned package manifests + +- **WHEN** a pull request targets `dev` +- **AND** the PR contains package manifest changes with checksum-only integrity blocks +- **THEN** the `verify-module-signatures` job SHALL pass without `--require-signature` +- **AND** all downstream jobs (`quality`, `contract-tests`, etc.) SHALL not be blocked + +#### Scenario: Dev-to-main PR with unsigned manifests (before approval) + +- **WHEN** a pull request targets `main` +- **AND** one or more `packages/*/module-package.yaml` files lack a valid signature +- **THEN** the `verify-module-signatures` job SHALL fail with `--require-signature` +- **AND** the PR SHALL be blocked from merging + +#### Scenario: Dev-to-main PR after CI signing commit + +- **WHEN** a pull request targets `main` +- **AND** the CI `sign-modules-on-approval` workflow has committed signed manifests to the PR branch +- **THEN** the `verify-module-signatures` job SHALL pass with `--require-signature` +- **AND** the PR SHALL be unblocked (subject to other required checks) + +#### Scenario: Push to main post-merge + +- **WHEN** a commit is pushed to `main` (post-merge) +- **THEN** the `verify-module-signatures` job SHALL run with `--require-signature` +- **AND** fail if any `packages/*/module-package.yaml` lacks a valid signature + +### Requirement: pre-commit verify mirrors pr-orchestrator signature policy + +The repository pre-commit hook that runs `verify-modules-signature.py` SHALL apply the same branch rule as the `verify-module-signatures` CI job: omit `--require-signature` unless the integration target is `main` (local branch `main`, or `GITHUB_BASE_REF=main` on pull request events in Actions). + +#### Scenario: Commit on a feature branch without a signing key + +- **WHEN** a developer commits on a branch other than `main` +- **AND** manifests satisfy checksum and version-bump policy but lack a valid signature +- **THEN** the pre-commit signature hook SHALL pass (no `--require-signature`) +- **AND** the developer SHALL remain unblocked until a `main`-targeting flow enforces signatures in CI + +#### Scenario: Commit on main requires signatures + +- **WHEN** a developer commits on branch `main` +- **AND** any `packages/*/module-package.yaml` lacks a valid signature under `--require-signature` +- **THEN** the pre-commit signature hook SHALL fail diff --git a/openspec/changes/marketplace-06-ci-module-signing/specs/ci-module-signing-on-approval/spec.md b/openspec/changes/marketplace-06-ci-module-signing/specs/ci-module-signing-on-approval/spec.md new file mode 100644 index 00000000..bd4596bf --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/specs/ci-module-signing-on-approval/spec.md @@ -0,0 +1,66 @@ +# ci-module-signing-on-approval Specification (specfact-cli-modules) + +## ADDED Requirements + +### Requirement: Sign packages manifests on PR approval + +The system SHALL automatically sign changed `packages/*/module-package.yaml` manifests using CI +secrets when a pull request targeting `dev` or `main` is approved, and SHALL commit the signed +manifests back to the PR branch. + +#### Scenario: PR to dev approved with package module changes + +- **WHEN** a pull request targeting `dev` is approved by a reviewer +- **AND** the PR contains changes to one or more files under `packages/` +- **THEN** the CI signing workflow SHALL discover all `packages/*/module-package.yaml` manifests + whose payload changed on the PR branch since the merge-base with `origin/dev` (not merely + divergent from the moving `origin/dev` tip) +- **AND** SHALL sign them using `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and + `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` +- **AND** SHALL commit the updated manifests back to the PR branch + +#### Scenario: PR to main approved with package module changes + +- **WHEN** a pull request targeting `main` is approved +- **AND** the PR contains changes to one or more files under `packages/` +- **THEN** the CI signing workflow SHALL sign all changed manifests relative to the merge-base + between the PR head and `origin/main` +- **AND** SHALL commit the signed manifests back to the PR branch before merge + +#### Scenario: PR approved with no package changes + +- **WHEN** a pull request is approved +- **AND** no files under `packages/` have changed relative to the base branch +- **THEN** the CI signing workflow SHALL exit cleanly with no commit + +#### Scenario: Missing signing secret + +- **WHEN** the signing workflow triggers on approval +- **AND** `SPECFACT_MODULE_PRIVATE_SIGN_KEY` is empty or unset, or `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` is empty or unset +- **THEN** the workflow SHALL fail before checkout/signing with a clear error naming which secret(s) are missing +- **AND** SHALL NOT commit partial changes + +#### Scenario: Fork PR is out of scope for automated signing + +- **WHEN** a pull request targets `dev` or `main` but the head branch lives in a fork + (`head.repo` differs from the base repository) +- **THEN** the signing workflow SHALL NOT run (the default `GITHUB_TOKEN` cannot push to the + contributor fork; maintainers sign or merge via same-repo branches instead) + +### Requirement: Manifest discovery covers packages directory + +The signing workflow SHALL discover module manifests from the `packages/` directory tree, not only +from `src/specfact_cli/modules/` or `modules/` (which do not exist in this repository). + +#### Scenario: Sign only changed packages manifests + +- **WHEN** the signing workflow runs with changes across multiple packages +- **AND** only a subset of packages have payload changes +- **THEN** only the changed `packages/*/module-package.yaml` files SHALL be signed and committed +- **AND** unchanged package manifests SHALL NOT be modified + +#### Scenario: Sign workflow produces idempotent output + +- **WHEN** the signing workflow runs twice on the same package payload +- **THEN** the resulting `integrity:` block SHALL be byte-for-byte identical +- **AND** the second run SHALL produce no git diff and SHALL skip the commit diff --git a/openspec/changes/marketplace-06-ci-module-signing/tasks.md b/openspec/changes/marketplace-06-ci-module-signing/tasks.md new file mode 100644 index 00000000..5a7fff30 --- /dev/null +++ b/openspec/changes/marketplace-06-ci-module-signing/tasks.md @@ -0,0 +1,77 @@ +# Tasks: marketplace-06-ci-module-signing + +## 1. Branch, coordination, and issue sync + +- [x] 1.1 Create `feature/marketplace-06-ci-module-signing` in a dedicated worktree from `origin/dev`; + run pre-flight status checks. +- [x] 1.2 ~~Create a GitHub User Story issue~~ Issue created: [specfact-cli-modules#185](https://github.com/nold-ai/specfact-cli-modules/issues/185); `proposal.md` Source Tracking updated. Paired core issue: [specfact-cli#500](https://github.com/nold-ai/specfact-cli/issues/500). *(done)* +- [ ] 1.3 Confirm `SPECFACT_MODULE_PRIVATE_SIGN_KEY` and `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` + are set as repository secrets in `specfact-cli-modules` (should already be present via + publish-modules.yml). *(human)* + +## 2. Specs and TDD evidence (failing tests first) + +- [x] 2.1 Write tests for the updated `pr-orchestrator.yml` `verify-module-signatures` logic in + `tests/unit/workflows/test_pr_orchestrator_signing.py` — confirm branch split: dev PRs pass + without `--require-signature`, main PRs enforce it. Capture failing output in `TDD_EVIDENCE.md`. +- [x] 2.2 Write workflow-structure tests for `sign-modules-on-approval.yml` in + `tests/unit/workflows/test_sign_modules_on_approval.py` — validate trigger config, manifest + discovery from `packages/`, and commit-back step. Capture failing output in `TDD_EVIDENCE.md`. + +## 3. pr-orchestrator.yml — split verify by target branch + +- [x] 3.1 In `.github/workflows/pr-orchestrator.yml`, in the `verify-module-signatures` job, + extract the target branch from `github.event.pull_request.base.ref` (PR events) and `github.ref` + (push events). +- [x] 3.2 For events targeting `dev`: run `verify-modules-signature.py` without `--require-signature` + (keep `--payload-from-filesystem --enforce-version-bump` and any `--version-check-base`). +- [x] 3.3 For events targeting `main`: retain `--require-signature --payload-from-filesystem + --enforce-version-bump` with `--version-check-base`. +- [x] 3.4 Run actionlint on the modified workflow and fix any findings. +- [x] 3.5 Align `.pre-commit-config.yaml` module verify hook with CI: add + `scripts/pre-commit-verify-modules-signature.sh` (`--require-signature` when branch is `main` or + `GITHUB_BASE_REF=main` in Actions PR contexts) + only); update `modules-pre-commit-quality-parity` spec and tests. + +## 4. New workflow — sign-modules-on-approval.yml + +- [x] 4.1 Create `.github/workflows/sign-modules-on-approval.yml` with trigger: + `pull_request_review: types: [submitted]`. +- [x] 4.2 Add job condition: approved review, base `dev` or `main`, and + `github.event.pull_request.head.repo.full_name == github.repository` (same-repo only; fork PRs + cannot be pushed with the default token). +- [x] 4.3 Add steps: checkout PR head, set up Python 3.12, install signing deps + (`pyyaml beartype icontract cryptography cffi`). +- [x] 4.4 Add manifest discovery step (count for job summary): + `mapfile -t MANIFESTS < <(find packages -name 'module-package.yaml' -type f | sort)`; + write `manifests_count` to `GITHUB_OUTPUT`. +- [x] 4.5 Add signing step: `git fetch origin --no-tags` then set `MERGE_BASE="$(git merge-base HEAD + "origin/")"` so `--changed-only` reflects **PR-scoped** deltas (not the base-branch tip vs a + stale branch); run `python scripts/sign-modules.py --changed-only --base-ref "$MERGE_BASE" + --bump-version patch --payload-from-filesystem` (version auto-bump when unchanged since merge-base; + no `--allow-same-version`). Fail immediately if `SPECFACT_MODULE_PRIVATE_SIGN_KEY` or + `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` is empty. +- [x] 4.6 Add write-back step: configure git user (github-actions bot), `git add` changed manifests, + commit `chore(modules): ci sign changed modules [skip ci]` (skip if no changes), push using + `GITHUB_TOKEN` with `permissions: contents: write`. +- [x] 4.7 Add job summary step showing which manifests were signed or "no changes detected". +- [x] 4.8 Run actionlint on the new workflow. Fix any findings. + +## 5. Testing and quality gates + +- [x] 5.1 Run the new tests from 2.1–2.2 and confirm they pass after the workflow changes. +- [x] 5.2 Run `hatch run lint` (or equivalent) to confirm no regressions. +- [x] 5.3 Run `hatch run yaml-lint` to validate all modified and new YAML workflow files. +- [x] 5.4 Run `specfact code review run --json --out .specfact/code-review.json`; resolve every + finding at warning or error severity. *(Used `hatch run specfact …` so Semgrep resolves from the + project venv.)* +- [x] 5.5 Record final passing test runs in `TDD_EVIDENCE.md`. + +## 6. PR and cleanup + +- [x] 6.1 Push the branch and open a PR targeting `dev`; verify CI passes (dev PRs no longer + require `--require-signature`). PR: [specfact-cli-modules#188](https://github.com/nold-ai/specfact-cli-modules/pull/188). +- [x] 6.2 Link the PR to the GitHub issue created in 1.2 and to the paired specfact-cli PR. + *(Closes #185 in PR body; link specfact-cli PR manually when it exists.)* +- [ ] 6.3 After merge: remove the worktree, delete the local branch, run `git worktree prune`. +- [ ] 6.4 Record cleanup completion in `TDD_EVIDENCE.md`. diff --git a/openspec/config.yaml b/openspec/config.yaml index 89c2397c..066eb77d 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -29,8 +29,10 @@ context: | Quality & CI (typical order): `format` → `type-check` → `lint` → `yaml-lint` → module **signature verification** (`verify-modules-signature`, enforce version bump when manifests change) → `contract-test` → `smart-test` → - `test`. Pre-commit: signatures → split `pre-commit-quality-checks.sh` hooks (format, yaml-if-staged, bundle, - lint-if-staged, then block2: `pre_commit_code_review.py` + contract tests; JSON report under `.specfact/`). + `test`. Pre-commit: `pre-commit-verify-modules-signature.sh` (branch-aware `--require-signature` when target is + `main`: local branch `main` or `GITHUB_BASE_REF=main` on PR events), + then split `pre-commit-quality-checks.sh` hooks (format, yaml-if-staged, bundle, lint-if-staged, then block2: + `pre_commit_code_review.py` + contract tests; JSON report under `.specfact/`). Hatch default env sets `SPECFACT_MODULES_REPO={root}`; `apply_specfact_workspace_env` also sets `SPECFACT_REPO_ROOT` when the sibling core checkout resolves (parity with specfact-cli test/CI module discovery). CI: `.github/workflows/pr-orchestrator.yml` (matrix Python, gates above). diff --git a/openspec/specs/modules-pre-commit-quality-parity/spec.md b/openspec/specs/modules-pre-commit-quality-parity/spec.md index dc2dfc7a..d03c0991 100644 --- a/openspec/specs/modules-pre-commit-quality-parity/spec.md +++ b/openspec/specs/modules-pre-commit-quality-parity/spec.md @@ -5,12 +5,15 @@ TBD - created by archiving change modules-pre-commit-quality-parity. Update Purp ## Requirements ### Requirement: Modules Repo Pre-Commit Must Verify Bundle Signatures -The modules repo pre-commit configuration SHALL fail a commit when bundle signatures or required version bumps are stale. +The modules repo pre-commit configuration SHALL fail a commit when module payload integrity or required version bumps are stale, and SHALL mirror CI branch policy for cryptographic signatures. #### Scenario: Signature verification hook is configured + - **WHEN** a developer installs and runs the repository pre-commit hooks - **THEN** the hook set includes an always-run signature verification command -- **AND** that command enforces both required signatures and version-bump policy. +- **AND** that command always enforces filesystem payload checksums and version-bump policy (`--payload-from-filesystem --enforce-version-bump`) +- **AND** when the active Git branch is `main`, or GitHub Actions sets `GITHUB_BASE_REF` to `main` (PR target branch), that command also enforces `--require-signature` +- **AND** on any other branch (for example `dev` or a feature branch), that command SHALL NOT pass `--require-signature`, matching `pr-orchestrator` behavior for non-`main` targets ### Requirement: Modules Repo Pre-Commit Must Catch Formatting And Quality Drift Early diff --git a/scripts/pre-commit-verify-modules-signature.sh b/scripts/pre-commit-verify-modules-signature.sh new file mode 100755 index 00000000..2fcd3b97 --- /dev/null +++ b/scripts/pre-commit-verify-modules-signature.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Mirror pr-orchestrator verify-module-signatures policy: require cryptographic signatures only when +# the integration target is `main`. Locally that is branch `main`; in GitHub Actions pull_request* +# contexts use GITHUB_BASE_REF (PR base / target), not GITHUB_REF_NAME (head). +set -euo pipefail +_repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$_repo_root" + +_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +_require_signature=false +if [[ -n "${GITHUB_BASE_REF:-}" ]]; then + if [[ "${GITHUB_BASE_REF}" == "main" ]]; then + _require_signature=true + fi +elif [[ "$_branch" == "main" ]]; then + _require_signature=true +fi + +_base=(hatch run ./scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump) +if [[ "$_require_signature" == true ]]; then + exec "${_base[@]}" --require-signature +else + exec "${_base[@]}" +fi diff --git a/scripts/pre_commit_code_review.py b/scripts/pre_commit_code_review.py index 63480274..62569677 100755 --- a/scripts/pre_commit_code_review.py +++ b/scripts/pre_commit_code_review.py @@ -51,7 +51,9 @@ def _load_dev_bootstrap() -> Any: def _is_review_gate_path(path: str) -> bool: """Return whether a repo-relative path should participate in the pre-commit review gate.""" normalized = path.replace("\\", "/").strip() - if not normalized or normalized.endswith(("/TDD_EVIDENCE.md", "TDD_EVIDENCE.md")): + if not normalized: + return False + if normalized.startswith("openspec/changes/") and Path(normalized).name.casefold() == "tdd_evidence.md": return False prefixes = ( "packages/", @@ -80,6 +82,17 @@ def filter_review_gate_paths(paths: Sequence[str]) -> list[str]: return filtered +def _specfact_review_paths(paths: Sequence[str]) -> list[str]: + """Paths to pass to SpecFact ``code review run`` (it treats inputs as Python; skip OpenSpec Markdown).""" + result: list[str] = [] + for raw in paths: + normalized = raw.replace("\\", "/").strip() + if normalized.startswith("openspec/changes/") and normalized.lower().endswith(".md"): + continue + result.append(raw) + return result + + @require(lambda files: files is not None) @ensure(lambda result: result[:5] == [sys.executable, "-m", "specfact_cli.cli", "code", "review"]) @ensure(lambda result: "--json" in result and "--out" in result) @@ -271,15 +284,23 @@ def main(argv: Sequence[str] | None = None) -> int: ) return 0 + specfact_files = _specfact_review_paths(files) + if len(specfact_files) == 0: + sys.stdout.write( + "Staged review paths are only OpenSpec Markdown under openspec/changes/; " + "skipping SpecFact code review (those files are not Python review targets).\n" + ) + return 0 + available, guidance = ensure_runtime_available() if available is False: sys.stdout.write(f"Unable to run the code review gate. {guidance}\n") return 1 repo_root = _repo_root() - cmd = build_review_command(files) + cmd = build_review_command(specfact_files) report_path = _prepare_report_path(repo_root) - result = _run_review_subprocess(cmd, repo_root, files) + result = _run_review_subprocess(cmd, repo_root, specfact_files) if result is None: return 1 if not report_path.is_file(): diff --git a/tests/unit/scripts/test_pre_commit_code_review.py b/tests/unit/scripts/test_pre_commit_code_review.py index 3ce4a69e..dc433625 100644 --- a/tests/unit/scripts/test_pre_commit_code_review.py +++ b/tests/unit/scripts/test_pre_commit_code_review.py @@ -34,6 +34,13 @@ def _load_script_module() -> Any: } +def test_specfact_review_paths_skips_openspec_markdown() -> None: + module = _load_script_module() + assert module._specfact_review_paths( + ["tests/test_app.py", "openspec/changes/foo/tasks.md", "openspec/changes/foo/proposal.md"] + ) == ["tests/test_app.py"] + + def test_filter_review_gate_paths_keeps_contract_relevant_trees() -> None: """Review gate should include staged paths under tooling and contract trees.""" module = _load_script_module() @@ -44,10 +51,15 @@ def test_filter_review_gate_paths_keeps_contract_relevant_trees() -> None: "README.md", "tests/test_app.py", "openspec/changes/foo/tasks.md", + "openspec/changes/foo/proposal.md", "openspec/changes/foo/TDD_EVIDENCE.md", "notes.txt", ] - ) == ["tests/test_app.py", "openspec/changes/foo/tasks.md"] + ) == [ + "tests/test_app.py", + "openspec/changes/foo/tasks.md", + "openspec/changes/foo/proposal.md", + ] def test_build_review_command_writes_json_report() -> None: @@ -74,6 +86,17 @@ def test_main_skips_when_no_relevant_files(capsys: pytest.CaptureFixture[str]) - assert "skipping code review gate" in out +def test_main_skips_specfact_when_only_openspec_markdown(capsys: pytest.CaptureFixture[str]) -> None: + """OpenSpec Markdown is gate-relevant but must not be passed to SpecFact as Python paths.""" + module = _load_script_module() + + exit_code = module.main(["openspec/changes/foo/tasks.md", "openspec/changes/foo/proposal.md"]) + + assert exit_code == 0 + out = capsys.readouterr().out + assert "skipping SpecFact code review" in out + + def test_main_propagates_review_gate_exit_code( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/unit/test_pre_commit_quality_parity.py b/tests/unit/test_pre_commit_quality_parity.py index fe27dd26..e234c14d 100644 --- a/tests/unit/test_pre_commit_quality_parity.py +++ b/tests/unit/test_pre_commit_quality_parity.py @@ -85,6 +85,23 @@ def test_pre_commit_config_has_signature_and_modules_quality_hooks() -> None: _assert_pairwise_hook_order(ordered_hook_ids, _EXPECTED_HOOK_ORDER) +def test_pre_commit_verify_modules_signature_uses_branch_aware_wrapper() -> None: + config = _load_pre_commit_config() + repos = config.get("repos") + assert isinstance(repos, list) + for repo in repos: + if not isinstance(repo, dict): + continue + for hook in repo.get("hooks", []): + if not isinstance(hook, dict): + continue + if hook.get("id") != "verify-module-signatures": + continue + assert hook.get("entry") == "./scripts/pre-commit-verify-modules-signature.sh" + return + raise AssertionError("verify-module-signatures hook not found") + + def test_modules_pre_commit_script_enforces_required_quality_commands() -> None: script_path = REPO_ROOT / "scripts" / "pre-commit-quality-checks.sh" assert script_path.exists() diff --git a/tests/unit/test_pre_commit_verify_modules_signature_script.py b/tests/unit/test_pre_commit_verify_modules_signature_script.py new file mode 100644 index 00000000..a2a31d7c --- /dev/null +++ b/tests/unit/test_pre_commit_verify_modules_signature_script.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_pre_commit_verify_modules_signature_script_matches_ci_branch_policy() -> None: + text = (REPO_ROOT / "scripts" / "pre-commit-verify-modules-signature.sh").read_text(encoding="utf-8") + assert "git rev-parse --abbrev-ref HEAD" in text + assert "GITHUB_BASE_REF" in text + assert "_branch" in text + assert "_require_signature" in text + assert '== "main"' in text + assert "--payload-from-filesystem" in text + assert "--enforce-version-bump" in text + assert "verify-modules-signature.py" in text + + marker = 'if [[ "$_require_signature" == true ]]; then' + assert marker in text + _head, tail = text.split(marker, 1) + assert "--require-signature" not in _head + true_part, from_else = tail.split("else", 1) + false_part = from_else.split("fi", 1)[0] + assert "--require-signature" in true_part + assert "--require-signature" not in false_part diff --git a/tests/unit/workflows/test_pr_orchestrator_signing.py b/tests/unit/workflows/test_pr_orchestrator_signing.py new file mode 100644 index 00000000..fd810937 --- /dev/null +++ b/tests/unit/workflows/test_pr_orchestrator_signing.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def test_pr_orchestrator_verify_splits_signature_requirement_by_target_branch() -> None: + workflow = (REPO_ROOT / ".github" / "workflows" / "pr-orchestrator.yml").read_text(encoding="utf-8") + + assert "verify-module-signatures" in workflow + assert "scripts/verify-modules-signature.py" in workflow + assert "--payload-from-filesystem" in workflow + assert "--enforce-version-bump" in workflow + assert "github.event.pull_request.base.ref" in workflow + assert "TARGET_BRANCH" in workflow + assert "GITHUB_REF#refs/heads/" in workflow or "${GITHUB_REF#refs/heads/}" in workflow + branch_guard = 'if [ "$TARGET_BRANCH" = "main" ]; then' + require_append = "VERIFY_CMD+=(--require-signature)" + assert branch_guard in workflow + assert require_append in workflow + assert workflow.index(branch_guard) < workflow.index(require_append) + assert '[ "$TARGET_BRANCH" = "main" ]' in workflow + assert "--require-signature" in workflow + assert "VERIFY_CMD" in workflow diff --git a/tests/unit/workflows/test_sign_modules_on_approval.py b/tests/unit/workflows/test_sign_modules_on_approval.py new file mode 100644 index 00000000..fa2993b6 --- /dev/null +++ b/tests/unit/workflows/test_sign_modules_on_approval.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _workflow_text() -> str: + path = REPO_ROOT / ".github" / "workflows" / "sign-modules-on-approval.yml" + return path.read_text(encoding="utf-8") + + +def test_sign_modules_on_approval_trigger_and_job_filter() -> None: + workflow = _workflow_text() + assert "pull_request_review:" in workflow + assert "types:" in workflow + assert "submitted" in workflow + assert "github.event.review.state == 'approved'" in workflow + assert "github.event.pull_request.base.ref == 'dev'" in workflow + assert "github.event.pull_request.base.ref == 'main'" in workflow + assert "github.event.pull_request.head.repo.full_name == github.repository" in workflow + assert "concurrency:" in workflow + assert "cancel-in-progress: true" in workflow + assert "permissions:" in workflow + assert "contents: write" in workflow + + +def test_sign_modules_on_approval_checkout_and_python() -> None: + workflow = _workflow_text() + assert "actions/checkout@v4" in workflow + assert "github.event.pull_request.head.sha" in workflow + assert "PR_HEAD_REF:" in workflow + assert "PR_BASE_REF:" in workflow + assert 'python-version: "3.12"' in workflow or "python-version: '3.12'" in workflow + + +def test_sign_modules_on_approval_dependencies_and_discover() -> None: + workflow = _workflow_text() + assert "beartype" in workflow and "icontract" in workflow + assert "cryptography" in workflow and "cffi" in workflow + assert "mapfile -t MANIFESTS" in workflow + assert "find packages -name 'module-package.yaml'" in workflow + assert "manifests_count=" in workflow + assert "GITHUB_OUTPUT" in workflow + assert "id: discover" in workflow + + +def test_sign_modules_on_approval_secrets_guard() -> None: + workflow = _workflow_text() + assert "Guard signing secrets" in workflow + assert '[ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY:-}" ]' in workflow + assert '[ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE:-}" ]' in workflow + assert "Missing secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY" in workflow + assert "Missing secret: SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in workflow + assert "exit 1" in workflow + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in workflow + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in workflow + + +def test_sign_modules_on_approval_sign_step_merge_base() -> None: + workflow = _workflow_text() + assert "MERGE_BASE=" in workflow + assert "git merge-base HEAD" in workflow + assert 'git fetch origin "${PR_BASE_REF}"' in workflow + assert "--no-tags" in workflow + assert "scripts/sign-modules.py" in workflow + assert "--changed-only" in workflow + assert "--base-ref" in workflow + assert '"$MERGE_BASE"' in workflow + assert "--bump-version patch" in workflow + assert "--payload-from-filesystem" in workflow + + +def test_sign_modules_on_approval_commit_push_and_summary() -> None: + workflow = _workflow_text() + assert "github-actions[bot]" in workflow + assert "chore(modules): ci sign changed modules [skip ci]" in workflow + assert "git push origin" in workflow + assert "Push to ${PR_HEAD_REF} failed" in workflow + assert "HEAD:${PR_HEAD_REF}" in workflow + assert "GITHUB_STEP_SUMMARY" in workflow + assert "COMMIT_CHANGED:" in workflow + assert "MANIFESTS_COUNT:" in workflow + assert "steps.discover.outputs.manifests_count" in workflow