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..53ba2a59 --- /dev/null +++ b/.github/workflows/sign-modules-on-approval.yml @@ -0,0 +1,101 @@ +# 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:-}" ] || [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE:-}" ]; then + echo "::error::SPECFACT_MODULE_PRIVATE_SIGN_KEY and SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE must be set" + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + 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" + git push origin "HEAD:${PR_HEAD_REF}" + + - 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/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/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 index 2d89e505..9db2e63d 100644 --- a/openspec/changes/marketplace-06-ci-module-signing/design.md +++ b/openspec/changes/marketplace-06-ci-module-signing/design.md @@ -13,8 +13,10 @@ This repo differs from `specfact-cli` in two important ways that shape the imple `pr-orchestrator.yml` has a `verify-module-signatures` job, and it always runs `--require-signature` regardless of target branch. -3. **No pre-commit signing check**: The modules pre-commit (`scripts/pre-commit-quality-checks.sh`) - does NOT include a module-signature verification step. No pre-commit changes are required. +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 @@ -28,8 +30,8 @@ are already configured via `publish-modules.yml`. - Enable non-interactive development on feature/dev branches without a local private key. **Non-Goals:** -- Adding a pre-commit signature verification step (none exists today; introducing one would - recreate the problem). +- 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. diff --git a/openspec/changes/marketplace-06-ci-module-signing/proposal.md b/openspec/changes/marketplace-06-ci-module-signing/proposal.md index d6e0c311..bf00dd79 100644 --- a/openspec/changes/marketplace-06-ci-module-signing/proposal.md +++ b/openspec/changes/marketplace-06-ci-module-signing/proposal.md @@ -16,8 +16,9 @@ policy set by `specfact-cli/marketplace-06-ci-module-signing`. 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. -- **NO CHANGE**: `scripts/pre-commit-quality-checks.sh` — the modules pre-commit does not include - a module-signature verification step; no pre-commit changes are needed. +- **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; @@ -36,17 +37,21 @@ policy set by `specfact-cli/marketplace-06-ci-module-signing`. - `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) -- **No Python source changes**: all modifications are to YAML workflows only +- **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**: 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 index 27df93de..fdb24f22 100644 --- 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 @@ -34,3 +34,20 @@ for pull requests or pushes targeting `dev`; it SHALL enforce `--require-signatu - **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 index 555b9ade..74ec562f 100644 --- 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 @@ -13,7 +13,8 @@ manifests back to the PR branch. - **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 relative to `origin/dev` + 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 @@ -22,7 +23,8 @@ manifests back to the PR branch. - **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 `origin/main` +- **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 @@ -38,6 +40,13 @@ manifests back to the PR branch. - **THEN** the workflow SHALL fail with a clear error naming the missing secret - **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 diff --git a/openspec/changes/marketplace-06-ci-module-signing/tasks.md b/openspec/changes/marketplace-06-ci-module-signing/tasks.md index 584d3385..1aef110c 100644 --- a/openspec/changes/marketplace-06-ci-module-signing/tasks.md +++ b/openspec/changes/marketplace-06-ci-module-signing/tasks.md @@ -1,67 +1,75 @@ ## 1. Branch, coordination, and issue sync -- [ ] 1.1 Create `feature/marketplace-06-ci-module-signing` in a dedicated worktree from `origin/dev`; +- [x] 1.1 Create `feature/marketplace-06-ci-module-signing` in a dedicated worktree from `origin/dev`; run pre-flight status checks. -- [ ] 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)* +- [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) -- [ ] 2.1 Write tests for the updated `pr-orchestrator.yml` `verify-module-signatures` logic in +- [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`. -- [ ] 2.2 Write workflow-structure tests for `sign-modules-on-approval.yml` in +- [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 -- [ ] 3.1 In `.github/workflows/pr-orchestrator.yml`, in the `verify-module-signatures` job, +- [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). -- [ ] 3.2 For events targeting `dev`: run `verify-modules-signature.py` without `--require-signature` +- [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`). -- [ ] 3.3 For events targeting `main`: retain `--require-signature --payload-from-filesystem +- [x] 3.3 For events targeting `main`: retain `--require-signature --payload-from-filesystem --enforce-version-bump` with `--version-check-base`. -- [ ] 3.4 Run actionlint on the modified workflow and fix any findings. +- [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 -- [ ] 4.1 Create `.github/workflows/sign-modules-on-approval.yml` with trigger: +- [x] 4.1 Create `.github/workflows/sign-modules-on-approval.yml` with trigger: `pull_request_review: types: [submitted]`. -- [ ] 4.2 Add job condition: - `if: github.event.review.state == 'approved' && (github.event.pull_request.base.ref == 'dev' || github.event.pull_request.base.ref == 'main')`. -- [ ] 4.3 Add steps: checkout PR head, set up Python 3.12, install signing deps +- [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`). -- [ ] 4.4 Add manifest discovery step: - `mapfile -t MANIFESTS < <(find packages -name 'module-package.yaml' -type f | sort)`. -- [ ] 4.5 Add signing step: compute `BASE_REF="origin/${{ github.event.pull_request.base.ref }}"`; - for each changed manifest (compare against BASE_REF), run - `python scripts/sign-modules.py --allow-same-version --payload-from-filesystem ""`; - or use `--changed-only --base-ref "$BASE_REF"` if the script supports `packages/` discovery. - Fail immediately if `SPECFACT_MODULE_PRIVATE_SIGN_KEY` or `SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE` - is empty. -- [ ] 4.6 Add write-back step: configure git user (github-actions bot), `git add` changed manifests, +- [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`. -- [ ] 4.7 Add job summary step showing which manifests were signed or "no changes detected". -- [ ] 4.8 Run actionlint on the new workflow. Fix any findings. +- [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 -- [ ] 5.1 Run the new tests from 2.1–2.2 and confirm they pass after the workflow changes. -- [ ] 5.2 Run `hatch run lint` (or equivalent) to confirm no regressions. -- [ ] 5.3 Run `hatch run yaml-lint` to validate all modified and new YAML workflow files. -- [ ] 5.4 Run `specfact code review run --json --out .specfact/code-review.json`; resolve every - finding at warning or error severity. -- [ ] 5.5 Record final passing test runs in `TDD_EVIDENCE.md`. +- [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 -- [ ] 6.1 Push the branch and open a PR targeting `dev`; verify CI passes (dev PRs no longer - require `--require-signature`). -- [ ] 6.2 Link the PR to the GitHub issue created in 1.2 and to the paired specfact-cli PR. +- [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..c0fe9ff4 100755 --- a/scripts/pre_commit_code_review.py +++ b/scripts/pre_commit_code_review.py @@ -53,6 +53,9 @@ def _is_review_gate_path(path: str) -> bool: normalized = path.replace("\\", "/").strip() if not normalized or normalized.endswith(("/TDD_EVIDENCE.md", "TDD_EVIDENCE.md")): return False + # OpenSpec change docs are Markdown; the review CLI treats them as Python and emits noise. + if normalized.startswith("openspec/changes/") and normalized.lower().endswith(".md"): + return False prefixes = ( "packages/", "registry/", diff --git a/tests/unit/scripts/test_pre_commit_code_review.py b/tests/unit/scripts/test_pre_commit_code_review.py index 3ce4a69e..e9600c18 100644 --- a/tests/unit/scripts/test_pre_commit_code_review.py +++ b/tests/unit/scripts/test_pre_commit_code_review.py @@ -44,10 +44,11 @@ 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"] def test_build_review_command_writes_json_report() -> 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..327c5ffb --- /dev/null +++ b/tests/unit/workflows/test_sign_modules_on_approval.py @@ -0,0 +1,76 @@ +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.ref" 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_sign_and_secrets() -> None: + workflow = _workflow_text() + assert "Guard signing secrets" in workflow + assert '[ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY:-}" ]' in workflow + assert "exit 1" in workflow + 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 + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in workflow + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" 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 "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