From 9bfe5f3bb3b87469bb21df719a0ffb0dba1b371f Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Wed, 15 Apr 2026 00:19:14 +0200 Subject: [PATCH] feat(ci): workflow_dispatch for sign-modules-on-approval - Add sign-on-dispatch job with base_branch/version_bump inputs and merge-base signing - Rename approval job to sign-on-approval; fix concurrency for manual runs - Document default-branch vs Run workflow on dev; update tests and CHANGELOG - Refactor workflow tests to satisfy code-review complexity gate Made-with: Cursor --- .../workflows/sign-modules-on-approval.yml | 111 +++++++++++++++++- CHANGELOG.md | 3 + docs/reference/module-security.md | 8 +- .../test_sign_modules_on_approval.py | 74 +++++++++--- 4 files changed, 173 insertions(+), 23 deletions(-) diff --git a/.github/workflows/sign-modules-on-approval.yml b/.github/workflows/sign-modules-on-approval.yml index c5ae611e..d616e020 100644 --- a/.github/workflows/sign-modules-on-approval.yml +++ b/.github/workflows/sign-modules-on-approval.yml @@ -1,21 +1,40 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# Sign changed bundled module manifests after a PR is approved (same-repo PRs only). -# Runs scripts/sign-modules.py from pull_request.base.sha (trusted) while operating on the PR head tree -# so branch content cannot replace the signer before secrets are injected. +# Sign changed bundled module manifests after a PR is approved (same-repo PRs only), or manually via +# workflow_dispatch (uses workflow file from the branch you run — run from dev before default branch has this file). +# Runs scripts/sign-modules.py from a trusted revision while operating on the target tree so branch content +# cannot replace the signer before secrets are injected. name: Sign modules on PR approval on: pull_request_review: types: [submitted] + workflow_dispatch: + inputs: + base_branch: + description: Branch whose tip supplies trusted scripts; merge-base for --changed-only uses origin/ + type: choice + options: + - dev + - main + default: dev + version_bump: + description: Semver bump when module version is unchanged from the merge-base ref + type: choice + options: + - patch + - minor + - major + default: patch concurrency: - group: sign-modules-on-approval-${{ github.event.pull_request.number }} + group: sign-modules-on-approval-${{ github.event.pull_request.number || github.ref_name }}-${{ github.event_name }} cancel-in-progress: true jobs: - sign: - name: CI sign changed modules + sign-on-approval: + name: CI sign changed modules (on approval) if: | + github.event_name == 'pull_request_review' && 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 @@ -95,3 +114,83 @@ jobs: git push origin "HEAD:${HEAD_REF}" echo "## Signed manifests pushed" >> "${GITHUB_STEP_SUMMARY}" echo "Updated \`module-package.yaml\` files were committed to \`${HEAD_REF}\`." >> "${GITHUB_STEP_SUMMARY}" + + sign-on-dispatch: + name: CI sign changed modules (manual) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Require module signing key secret + env: + SPECFACT_MODULE_PRIVATE_SIGN_KEY: ${{ secrets.SPECFACT_MODULE_PRIVATE_SIGN_KEY }} + run: | + if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then + echo "::error::Missing or empty repository secret SPECFACT_MODULE_PRIVATE_SIGN_KEY." + exit 1 + fi + + - name: Checkout trusted signing scripts (integration branch tip) + uses: actions/checkout@v4 + with: + fetch-depth: 1 + ref: ${{ github.event.inputs.base_branch }} + path: _trusted_scripts + + - name: Checkout branch to sign + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.ref }} + path: _pr_workspace + persist-credentials: true + + - name: Fetch integration branch for merge-base + working-directory: _pr_workspace + run: git fetch --no-tags origin "${{ github.event.inputs.base_branch }}" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install signer dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pyyaml beartype icontract cryptography cffi + + - name: Sign changed module manifests + working-directory: _pr_workspace + 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 + MERGE_BASE="$(git merge-base HEAD "origin/${{ github.event.inputs.base_branch }}")" + BUMP="${{ github.event.inputs.version_bump }}" + python "${GITHUB_WORKSPACE}/_trusted_scripts/scripts/sign-modules.py" \ + --changed-only \ + --base-ref "${MERGE_BASE}" \ + --bump-version "${BUMP}" \ + --payload-from-filesystem + + - name: Commit and push signed manifests + working-directory: _pr_workspace + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git diff --quiet; then + echo "No manifest changes to commit." + echo "## No signing changes" >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + git add -u -- src/specfact_cli/modules modules + if git diff --cached --quiet; then + echo "No staged module manifest updates." + exit 0 + fi + git commit -m "chore(modules): manual approval-workflow sign changed modules [skip ci]" + git push origin "HEAD:${GITHUB_REF_NAME}" + echo "## Signed manifests pushed" >> "${GITHUB_STEP_SUMMARY}" + echo "Branch: \`${GITHUB_REF_NAME}\` (merge-base vs \`origin/${{ github.event.inputs.base_branch }}\`, bump: \`${{ github.event.inputs.version_bump }}\`)." >> "${GITHUB_STEP_SUMMARY}" diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbac36c..585c042d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ All notable changes to this project will be documented in this file. ### Added +- **CI / modules**: `sign-modules-on-approval.yml` **`workflow_dispatch`** (**`sign-on-dispatch`**) — run from + **Actions** on **`dev`** before this workflow exists on the default branch; trusted scripts from + **`base_branch`** tip, `--changed-only` vs **`git merge-base`** to `origin/`. - **CI / modules**: `.github/workflows/sign-modules-on-approval.yml` — after an **approved** review on same-repo PRs to `dev`/`main`, signs changed bundled modules with `scripts/sign-modules.py --changed-only` and commits manifests to the PR branch (repository secrets diff --git a/docs/reference/module-security.md b/docs/reference/module-security.md index 7c56cb06..4e18bf98 100644 --- a/docs/reference/module-security.md +++ b/docs/reference/module-security.md @@ -58,8 +58,12 @@ Module packages carry **publisher** and **integrity** metadata so installation, targeting **`dev` or `main`**, CI runs `pull_request.base.sha`’s **`scripts/sign-modules.py`** (trusted revision) against the **PR head** working tree, then pushes updated `module-package.yaml` files to the PR branch — branch content cannot replace the signer before secrets are used. Fork PRs - are skipped (no push permission). If the workflow or secrets are unavailable, sign bundled manifests - before merging into `main` or the post-merge push verify job will still fail. + are skipped (no push permission). **`pull_request_review` uses the workflow definition from the repo’s + default branch** (often `main`); until this file exists on `main`, use **Actions → Sign modules on PR + approval → Run workflow**, select **`dev`** (or your branch), and pick **`base_branch`** / **`version_bump`** + — that run uses the workflow file from the branch you choose and signs with **`MERGE_BASE`** vs + `origin/` like the manual path above. If the workflow or secrets are unavailable, sign + bundled manifests before merging into `main` or the post-merge push verify job will still fail. - **Manual signing** (`sign-modules.yml` → **Run workflow**): choose the branch to update, then pick **base branch** (`dev` or `main` — the workflow fetches `origin/`). The **verify** step passes `--version-check-base origin/` so `workflow_dispatch` is not stuck on `HEAD~1` before the diff --git a/tests/unit/workflows/test_sign_modules_on_approval.py b/tests/unit/workflows/test_sign_modules_on_approval.py index f8b6e81e..34281f5f 100644 --- a/tests/unit/workflows/test_sign_modules_on_approval.py +++ b/tests/unit/workflows/test_sign_modules_on_approval.py @@ -26,46 +26,90 @@ def _workflow_on_block(workflow: dict[str, Any]) -> dict[str, Any]: return cast(dict[str, Any], on_block) -def test_sign_modules_on_approval_workflow_exists() -> None: - assert WORKFLOW.is_file(), "sign-modules-on-approval.yml must exist" - - -def test_sign_modules_on_approval_trigger_and_guards() -> None: - workflow = _load_yaml(WORKFLOW) - on_block = _workflow_on_block(workflow) +def _assert_pr_review_and_dispatch_triggers(on_block: dict[str, Any]) -> None: review = on_block.get("pull_request_review") assert isinstance(review, dict), "Expected pull_request_review trigger mapping" assert review.get("types") == ["submitted"] + dispatch = on_block.get("workflow_dispatch") + assert isinstance(dispatch, dict), "Expected workflow_dispatch for manual runs" + dispatch_inputs = dispatch.get("inputs") + assert isinstance(dispatch_inputs, dict) + assert "base_branch" in dispatch_inputs + assert "version_bump" in dispatch_inputs - jobs = workflow.get("jobs") - assert isinstance(jobs, dict) - sign_job = jobs.get("sign") + +def _assert_sign_on_approval_job_guards(jobs: dict[str, Any]) -> None: + sign_job = jobs.get("sign-on-approval") assert isinstance(sign_job, dict) job_if = sign_job.get("if") assert isinstance(job_if, str) + assert "github.event_name == 'pull_request_review'" in job_if assert "github.event.review.state == 'approved'" in job_if assert "github.event.pull_request.base.ref == 'dev'" in job_if assert "github.event.pull_request.base.ref == 'main'" in job_if assert "github.event.pull_request.head.repo.full_name == github.repository" in job_if - perms = sign_job.get("permissions") assert isinstance(perms, dict) assert perms.get("contents") == "write" -def test_sign_modules_on_approval_runs_signer_with_changed_only_mode() -> None: - raw = WORKFLOW.read_text(encoding="utf-8") +def _assert_sign_on_dispatch_job_guards(jobs: dict[str, Any]) -> None: + manual = jobs.get("sign-on-dispatch") + assert isinstance(manual, dict) + assert manual.get("if") == "github.event_name == 'workflow_dispatch'" + manual_perms = manual.get("permissions") + assert isinstance(manual_perms, dict) + assert manual_perms.get("contents") == "write" + + +def _assert_trusted_dual_checkout_snippets(raw: str) -> None: assert "github.event.pull_request.base.sha" in raw assert "path: _trusted_scripts" in raw assert "path: _pr_workspace" in raw assert "working-directory: _pr_workspace" in raw assert "${GITHUB_WORKSPACE}/_trusted_scripts/scripts/sign-modules.py" in raw + + +def _assert_approval_sign_shell_snippets(raw: str) -> None: assert "--changed-only" in raw assert "--bump-version patch" in raw assert "--payload-from-filesystem" in raw assert '--base-ref "${BASE_REF}"' in raw assert "origin/${{ github.event.pull_request.base.ref }}" in raw - assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in raw - assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in raw assert "chore(modules): ci sign changed modules [skip ci]" in raw assert 'git push origin "HEAD:${HEAD_REF}"' in raw + + +def _assert_dispatch_sign_shell_snippets(raw: str) -> None: + assert "workflow_dispatch:" in raw + assert "git merge-base" in raw + assert '--base-ref "${MERGE_BASE}"' in raw + assert "chore(modules): manual approval-workflow sign changed modules" in raw + assert 'git push origin "HEAD:${GITHUB_REF_NAME}"' in raw + + +def _assert_signing_secrets_referenced(raw: str) -> None: + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY" in raw + assert "SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE" in raw + + +def test_sign_modules_on_approval_workflow_exists() -> None: + assert WORKFLOW.is_file(), "sign-modules-on-approval.yml must exist" + + +def test_sign_modules_on_approval_trigger_and_guards() -> None: + workflow = _load_yaml(WORKFLOW) + on_block = _workflow_on_block(workflow) + _assert_pr_review_and_dispatch_triggers(on_block) + jobs = workflow.get("jobs") + assert isinstance(jobs, dict) + _assert_sign_on_approval_job_guards(jobs) + _assert_sign_on_dispatch_job_guards(jobs) + + +def test_sign_modules_on_approval_runs_signer_with_changed_only_mode() -> None: + raw = WORKFLOW.read_text(encoding="utf-8") + _assert_trusted_dual_checkout_snippets(raw) + _assert_approval_sign_shell_snippets(raw) + _assert_dispatch_sign_shell_snippets(raw) + _assert_signing_secrets_referenced(raw)