Declare plan in specfact-project manifest, update docs, and add regression test
#87
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 || github.event.number }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: write | |
| pull-requests: read | |
| jobs: | |
| sign-modules: | |
| 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 || github.base_ref }} | |
| PR_HEAD_REF: ${{ github.event.pull_request.head.ref || github.head_ref }} | |
| steps: | |
| - name: Eligibility gate (required status check) | |
| id: gate | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| review_state="${{ github.event.review.state || '' }}" | |
| author_association="${{ github.event.review.user.author_association || '' }}" | |
| base_ref="${{ github.event.pull_request.base.ref }}" | |
| if [ "$base_ref" != "dev" ] && [ "$base_ref" != "main" ]; then | |
| echo "sign=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Skipping module signing: base branch is not dev or main." | |
| exit 0 | |
| fi | |
| head_repo="${{ github.event.pull_request.head.repo.full_name }}" | |
| this_repo="${{ github.repository }}" | |
| if [ "$head_repo" != "$this_repo" ]; then | |
| echo "sign=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Skipping module signing: fork PR (head repo differs from target repo)." | |
| exit 0 | |
| fi | |
| if [ "$review_state" != "approved" ]; then | |
| echo "sign=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Skipping module signing: review state is not approved." | |
| exit 0 | |
| fi | |
| case "$author_association" in | |
| COLLABORATOR|MEMBER|OWNER) | |
| ;; | |
| *) | |
| echo "sign=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Skipping module signing: reviewer association '${author_association}' is not trusted for signing." | |
| exit 0 | |
| ;; | |
| esac | |
| echo "sign=true" >> "$GITHUB_OUTPUT" | |
| echo "Eligible for module signing (same-repo PR to dev or main with trusted approval state)." | |
| - name: Guard signing secrets | |
| if: steps.gate.outputs.sign == 'true' | |
| 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 | |
| if: steps.gate.outputs.sign == 'true' | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| fetch-depth: 0 | |
| - name: Set up Python 3.12 | |
| if: steps.gate.outputs.sign == 'true' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install signing dependencies | |
| if: steps.gate.outputs.sign == 'true' | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install pyyaml beartype icontract cryptography cffi | |
| - name: Discover module manifests | |
| if: steps.gate.outputs.sign == 'true' | |
| 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 | |
| if: steps.gate.outputs.sign == 'true' | |
| 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 | |
| if: steps.gate.outputs.sign == 'true' | |
| 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" | |
| 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: | |
| GATE_SIGN: ${{ steps.gate.outputs.sign }} | |
| COMMIT_CHANGED: ${{ steps.commit.outputs.changed || '' }} | |
| MANIFESTS_COUNT: ${{ steps.discover.outputs.manifests_count || '' }} | |
| run: | | |
| { | |
| echo "### Module signing (CI approval)" | |
| if [ "${GATE_SIGN}" != "true" ]; then | |
| echo "Signing skipped (eligibility gate: no trusted approval, wrong base branch, or fork PR)." | |
| else | |
| 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 | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" |