Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 105 additions & 6 deletions .github/workflows/sign-modules-on-approval.yml
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not depend on workflow_dispatch before default-branch rollout

This change is intended to let maintainers run the signer from dev before the workflow exists on main, but workflow_dispatch does not fire unless the workflow file is already present on the default branch, so the new manual path cannot be used in the rollout window it is meant to cover. In that state, approval-time signing is still blocked and the operational guidance added in this commit fails.

Useful? React with 👍 / 👎.

inputs:
base_branch:
description: Branch whose tip supplies trusted scripts; merge-base for --changed-only uses origin/<branch>
type: choice
options:
- dev
- main
default: dev
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent no-op defaults for manual signing on dev

The new dispatch input defaults base_branch to dev, but when operators run this workflow on branch dev (the documented bootstrap flow), git merge-base HEAD origin/dev resolves to HEAD, so --changed-only --base-ref "$MERGE_BASE" selects no module changes and silently skips signing. This makes the default path ineffective unless users override the input each time.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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}"
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base_branch>`.
- **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
Expand Down
8 changes: 6 additions & 2 deletions docs/reference/module-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base_branch>` 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/<branch>`). The **verify** step passes
`--version-check-base origin/<branch>` so `workflow_dispatch` is not stuck on `HEAD~1` before the
Expand Down
74 changes: 59 additions & 15 deletions tests/unit/workflows/test_sign_modules_on_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading