diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index cb3208d9..ee383b52 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -110,22 +110,19 @@ jobs: python -m pip install --upgrade pip python -m pip install pyyaml beartype icontract cryptography cffi - - name: Verify bundled module checksums and signatures + - name: Verify bundled module checksums (signatures enforced on push via sign-modules workflow) run: | - BASE_REF="" + set -euo pipefail VERIFY_ARGS=(--payload-from-filesystem --enforce-version-bump) if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_REF="origin/${{ github.event.pull_request.base.ref }}" - if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then - VERIFY_ARGS=(--require-signature "${VERIFY_ARGS[@]}") - fi - elif [ "${{ github.ref_name }}" = "main" ]; then - VERIFY_ARGS=(--require-signature "${VERIFY_ARGS[@]}") - fi - if [ -n "$BASE_REF" ]; then python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" --version-check-base "$BASE_REF" else - python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" + BEFORE="${{ github.event.before }}" + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + BEFORE="HEAD~1" + fi + python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" --version-check-base "$BEFORE" fi workflow-lint: diff --git a/.github/workflows/sign-modules.yml b/.github/workflows/sign-modules.yml index 6eacfdea..72413454 100644 --- a/.github/workflows/sign-modules.yml +++ b/.github/workflows/sign-modules.yml @@ -1,5 +1,6 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# Harden module signing by enforcing strict verification and deterministic signing output checks. +# Auto-sign changed bundled modules on push to dev/main, then strict-verify. PRs use checksum-only +# verification so feature branches are not blocked by missing signatures before CI signs. name: Module Signature Hardening on: @@ -50,7 +51,7 @@ jobs: name: Verify Module Signatures runs-on: ubuntu-latest permissions: - contents: read + contents: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -71,25 +72,83 @@ jobs: python -m pip install --upgrade pip python -m pip install pyyaml beartype icontract cryptography cffi - - name: Verify bundled module signatures + - name: Auto-sign changed bundled modules (push to dev/main, non-bot actors) + if: >- + github.event_name == 'push' && + (github.ref_name == 'dev' || github.ref_name == 'main') && + github.actor != 'github-actions[bot]' + 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 }} + run: | + set -euo pipefail + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then + echo "::error::Missing SPECFACT_MODULE_PRIVATE_SIGN_KEY. Configure the secret so pushes to ${GITHUB_REF_NAME} can auto-sign bundled modules." + exit 1 + fi + BEFORE="${{ github.event.before }}" + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + BEFORE="$(git rev-parse HEAD~1 2>/dev/null || true)" + fi + if [ -z "$BEFORE" ]; then + echo "::error::Unable to resolve parent commit for --changed-only signing." + exit 1 + fi + python scripts/sign-modules.py \ + --changed-only \ + --base-ref "$BEFORE" \ + --bump-version patch \ + --payload-from-filesystem + + - name: Strict verify bundled modules (push to dev/main) + if: github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'main') + run: | + set -euo pipefail + BEFORE="${{ github.event.before }}" + if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then + BEFORE="HEAD~1" + fi + python scripts/verify-modules-signature.py \ + --require-signature \ + --payload-from-filesystem \ + --enforce-version-bump \ + --version-check-base "$BEFORE" + + - name: PR or dispatch verify (checksum-only, no signature required on head) + if: github.event_name != 'push' run: | + set -euo pipefail BASE_REF="" - VERIFY_ARGS=(--payload-from-filesystem --enforce-version-bump) if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_REF="origin/${{ github.event.pull_request.base.ref }}" - if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]; then - VERIFY_ARGS=(--require-signature "${VERIFY_ARGS[@]}") - fi elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then BASE_REF="origin/${{ github.event.inputs.base_branch }}" - elif [ "${{ github.ref_name }}" = "main" ] && [ "${{ github.event_name }}" != "workflow_dispatch" ]; then - VERIFY_ARGS=(--require-signature "${VERIFY_ARGS[@]}") fi - if [ -n "$BASE_REF" ]; then - python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" --version-check-base "$BASE_REF" - else - python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" + if [ -z "$BASE_REF" ]; then + echo "::error::Missing comparison base for module verification." + exit 1 fi + python scripts/verify-modules-signature.py \ + --payload-from-filesystem \ + --enforce-version-bump \ + --version-check-base "$BASE_REF" + + - name: Commit auto-signed manifests (push to dev/main, non-bot actors) + if: >- + github.event_name == 'push' && + (github.ref_name == 'dev' || github.ref_name == 'main') && + github.actor != 'github-actions[bot]' + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u -- src/specfact_cli/modules modules + if git diff --cached --quiet; then + echo "No manifest signing changes to commit." + exit 0 + fi + git commit -m "chore(modules): auto-sign bundled manifests [skip ci]" + git push origin "HEAD:${GITHUB_REF_NAME}" reproducibility: name: Assert signing reproducibility @@ -104,6 +163,12 @@ jobs: with: fetch-depth: 0 + - name: Sync to remote branch tip (after verify job may have pushed auto-sign commit) + run: | + set -euo pipefail + git fetch origin "${GITHUB_REF_NAME}" + git reset --hard "origin/${GITHUB_REF_NAME}" + - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b88a91..c07852ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,33 @@ All notable changes to this project will be documented in this file. --- +## [0.46.2] - 2026-04-15 + +### Fixed + +- **Modules**: `init` **0.1.29** — patch bump so **`dev` → `main`** PRs satisfy **`--enforce-version-bump`** + against **`origin/main`** when **`main`** already had **0.1.28** (adding **`integrity.signature`** alone is not + enough; the module **version** must increase when the manifest is in the diff). + +### Changed + +- **CI / modules**: **`pr-orchestrator.yml`** verifies bundled modules **without** **`--require-signature`** + (checksum + **`--enforce-version-bump`** + **`--payload-from-filesystem`**), so PR heads and non-**`main`** + contexts are not blocked by missing signatures during implementation. **`sign-modules.yml`** **auto-signs** + **`--changed-only`** manifests on **push** to **`dev`** or **`main`** for non-bot actors (requires + **`SPECFACT_MODULE_PRIVATE_SIGN_KEY`**), runs **strict** **`--require-signature`** verification in the same job, + then commits and pushes **`chore(modules): auto-sign bundled manifests [skip ci]`** when needed; pushes from + **`github-actions[bot]`** skip signing and only run strict verify. **Reproducibility** resets to + **`origin/`** after verify so it targets the post-auto-sign tip. + ## [0.46.1] - 2026-04-14 ### Security - **CI / modules**: `sign-modules-on-approval.yml` checks out **`pull_request.base.sha`** for `scripts/sign-modules.py` and runs it from **`GITHUB_WORKSPACE`** against the PR head checkout (secrets - never execute branch-supplied signer code). **Fork PRs to `main`** regain **`--require-signature`** in - `pr-orchestrator.yml` and `sign-modules.yml` (approval signer cannot fix fork heads). + never execute branch-supplied signer code). Pull requests **into `main`** use **`--require-signature`** in + `pr-orchestrator.yml` and `sign-modules.yml` (approval-time signing cannot fix unsigned **fork** heads). ### Added diff --git a/pyproject.toml b/pyproject.toml index 31b2343b..18bd9439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.46.1" +version = "0.46.2" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 92957309..368d5a25 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.46.1", + version="0.46.2", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index e02e583c..ebf9070d 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.46.1" +__version__ = "0.46.2" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index e22a7f8b..caedc68b 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -45,6 +45,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.46.1" +__version__ = "0.46.2" __all__ = ["__version__"] diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 268044d3..6f82d2e4 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.28 +version: 0.1.30 commands: - init category: core @@ -17,4 +17,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:6b86d171d0ddef42965919d85ad658ef221eb6521095c0798594b4cc647ef219 + checksum: sha256:9e03421972d3254082307834b474e7673957de8c11ffacc563f2da3f35e7cf05 + signature: ikUYhUJ8AFU9RkB+ZBnp0lKrDLT7ZIqkauRYUMRY/swrIjRCTRzHIpNcvCmujK6h1w6EtT/+wGG1v5bZtZB8CQ== diff --git a/tests/unit/specfact_cli/registry/test_signing_artifacts.py b/tests/unit/specfact_cli/registry/test_signing_artifacts.py index 07038b08..2b5cbe45 100644 --- a/tests/unit/specfact_cli/registry/test_signing_artifacts.py +++ b/tests/unit/specfact_cli/registry/test_signing_artifacts.py @@ -574,14 +574,14 @@ def test_verify_script_reports_version_bump_failure_even_when_checksum_fails(tmp def test_pr_orchestrator_contains_verify_module_signatures_job(): - """PR orchestrator SHALL include module signature verification gate.""" + """PR orchestrator SHALL include bundled module verification (checksum + version policy; not strict signatures).""" if not PR_ORCHESTRATOR_WORKFLOW.exists(): pytest.skip("pr-orchestrator workflow not present") content = PR_ORCHESTRATOR_WORKFLOW.read_text(encoding="utf-8") assert "verify-module-signatures" in content assert "verify-modules-signature.py" in content - assert "--require-signature" in content assert "--enforce-version-bump" in content + assert "--require-signature" not in content assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in content assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in content assert re.search( @@ -591,13 +591,13 @@ def test_pr_orchestrator_contains_verify_module_signatures_job(): ) -def test_pr_orchestrator_requires_signatures_for_fork_prs_to_main() -> None: - """Fork PRs cannot use approval-time signing; verify SHALL require signatures when base is main.""" +def test_pr_orchestrator_does_not_require_signatures_on_pr_heads() -> None: + """PR orchestrator SHALL NOT pass --require-signature; CI auto-signs on dev/main after merge.""" if not PR_ORCHESTRATOR_WORKFLOW.exists(): pytest.skip("pr-orchestrator workflow not present") content = PR_ORCHESTRATOR_WORKFLOW.read_text(encoding="utf-8") - assert "github.event.pull_request.head.repo.full_name" in content - assert '!= "${{ github.repository }}" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]' in content + assert "verify-modules-signature.py" in content + assert "--require-signature" not in content def test_sign_modules_workflow_uses_private_key_and_passphrase_secrets(): @@ -608,7 +608,8 @@ def test_sign_modules_workflow_uses_private_key_and_passphrase_secrets(): assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in content assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in content assert "--enforce-version-bump" in content - assert '!= "${{ github.repository }}" ] && [ "${{ github.event.pull_request.base.ref }}" = "main" ]' in content + assert "Auto-sign changed bundled modules" in content + assert "--require-signature" in content def test_pr_orchestrator_pins_virtualenv_below_21_for_hatch_jobs():