Make specfact upgrade install-method-aware (uv/uvx support, pipx/pip detection)
#271
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 | |
| # Auto-sign changed bundled modules on push to dev/main, then strict-verify; manifest commits | |
| # open an auto/sign-* PR (protected branches — no direct push). PRs / workflow_dispatch use the | |
| # same relaxed verify bundle as pre-commit omit (see scripts/module-verify-policy.sh). | |
| # | |
| # Push runs for every actor (including github-actions[bot]) so merge commits that | |
| # land manifest or payload changes still refresh integrity.checksum before strict | |
| # verify; otherwise verify fails with checksum mismatch when signing is skipped. | |
| name: Module Signature Hardening | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| base_branch: | |
| description: Remote branch to compare for --changed-only (fetches origin/<branch>) | |
| type: choice | |
| options: | |
| - dev | |
| - main | |
| default: dev | |
| version_bump: | |
| description: Auto-bump when module version is still unchanged from the base ref | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| default: patch | |
| resign_all_manifests: | |
| description: Sign every bundled module-package.yaml (not only --changed-only vs base). Use when manifests match the base but lack signatures. | |
| type: boolean | |
| default: false | |
| push: | |
| branches: [dev, main] | |
| paths: | |
| - "src/specfact_cli/modules/**" | |
| - "modules/**" | |
| - "resources/keys/**" | |
| - "scripts/sign-modules.py" | |
| - "scripts/verify-modules-signature.py" | |
| - "scripts/module-verify-policy.sh" | |
| - ".github/workflows/sign-modules.yml" | |
| - ".github/workflows/sign-modules-on-approval.yml" | |
| pull_request: | |
| branches: [dev, main] | |
| paths: | |
| - "src/specfact_cli/modules/**" | |
| - "modules/**" | |
| - "resources/keys/**" | |
| - "scripts/sign-modules.py" | |
| - "scripts/verify-modules-signature.py" | |
| - "scripts/module-verify-policy.sh" | |
| - ".github/workflows/sign-modules.yml" | |
| - ".github/workflows/sign-modules-on-approval.yml" | |
| jobs: | |
| verify: | |
| name: Verify Module Signatures | |
| runs-on: ubuntu-latest | |
| outputs: | |
| signing_pr_created: ${{ steps.open_auto_sign_pr.outputs.created == 'true' && 'true' || 'false' }} | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Fetch workflow_dispatch comparison base | |
| if: github.event_name == 'workflow_dispatch' | |
| 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: Auto-sign changed bundled modules (push to dev/main) | |
| if: >- | |
| github.event_name == 'push' && | |
| (github.ref_name == 'dev' || github.ref_name == 'main') | |
| 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 \ | |
| --repair-stale-integrity \ | |
| --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 | |
| # shellcheck disable=SC1091 | |
| source scripts/module-verify-policy.sh | |
| BEFORE="${{ github.event.before }}" | |
| if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then | |
| BEFORE="HEAD~1" | |
| fi | |
| python scripts/verify-modules-signature.py "${VERIFY_MODULES_STRICT[@]}" --version-check-base "$BEFORE" | |
| - name: PR or dispatch verify (relaxed checksum; version bump vs base) | |
| if: github.event_name != 'push' | |
| run: | | |
| set -euo pipefail | |
| # shellcheck disable=SC1091 | |
| source scripts/module-verify-policy.sh | |
| VERIFY_ARGS=("${VERIFY_MODULES_PR[@]}") | |
| BASE_REF="" | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE_REF="origin/${{ github.event.pull_request.base.ref }}" | |
| elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| BASE_REF="origin/${{ github.event.inputs.base_branch }}" | |
| fi | |
| if [ -z "$BASE_REF" ]; then | |
| echo "::error::Missing comparison base for module verification." | |
| exit 1 | |
| fi | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.resign_all_manifests }}" = "true" ]; then | |
| RESIGN_ARGS=() | |
| skip_next=0 | |
| for arg in "${VERIFY_ARGS[@]}"; do | |
| if [ "${skip_next}" -eq 1 ]; then | |
| skip_next=0 | |
| continue | |
| fi | |
| if [ "${arg}" = "--enforce-version-bump" ]; then | |
| continue | |
| fi | |
| if [ "${arg}" = "--version-check-base" ]; then | |
| skip_next=1 | |
| continue | |
| fi | |
| RESIGN_ARGS+=("${arg}") | |
| done | |
| python scripts/verify-modules-signature.py "${RESIGN_ARGS[@]}" | |
| else | |
| python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" --version-check-base "$BASE_REF" | |
| fi | |
| - id: open_auto_sign_pr | |
| name: Open PR with auto-signed manifests (dev/main; no direct push) | |
| if: >- | |
| github.event_name == 'push' && | |
| (github.ref_name == 'dev' || github.ref_name == 'main') | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| SIGNING_PR_CREATED=false | |
| 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." | |
| echo "created=${SIGNING_PR_CREATED}" >> "${GITHUB_OUTPUT}" | |
| exit 0 | |
| fi | |
| BRANCH="auto/sign-${GITHUB_REF_NAME}-${{ github.run_id }}" | |
| git checkout -b "${BRANCH}" | |
| git commit -m "chore(modules): auto-sign bundled manifests [skip ci]" | |
| git push -u origin "${BRANCH}" | |
| gh pr create \ | |
| --repo "${{ github.repository }}" \ | |
| --base "${GITHUB_REF_NAME}" \ | |
| --head "${BRANCH}" \ | |
| --title "chore(modules): auto-sign bundled manifests (${GITHUB_REF_NAME})" \ | |
| --body "Automated integrity refresh after push to \`${GITHUB_REF_NAME}\`. Merge so strict verify and reproducibility run against the signed tip (protected branch — no direct push)." | |
| SIGNING_PR_CREATED=true | |
| echo "created=${SIGNING_PR_CREATED}" >> "${GITHUB_OUTPUT}" | |
| reproducibility: | |
| name: Assert signing reproducibility | |
| if: >- | |
| github.event_name == 'push' && | |
| github.ref_name == 'main' && | |
| needs.verify.outputs.signing_pr_created != 'true' | |
| runs-on: ubuntu-latest | |
| needs: [verify] | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Sync to remote branch tip (after verify; skip when a signing PR was opened instead) | |
| 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: | |
| 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: Re-sign manifests and assert no diff | |
| 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: | | |
| if [ -z "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ]; then | |
| echo "::notice::Skipping reproducibility check because SPECFACT_MODULE_PRIVATE_SIGN_KEY is not configured." | |
| exit 0 | |
| fi | |
| mapfile -t MANIFESTS < <(find src/specfact_cli/modules modules -name 'module-package.yaml' -type f 2>/dev/null | sort) | |
| if [ "${#MANIFESTS[@]}" -eq 0 ]; then | |
| echo "No module manifests found" | |
| exit 0 | |
| fi | |
| python scripts/sign-modules.py --payload-from-filesystem "${MANIFESTS[@]}" | |
| if ! git diff --exit-code -- src/specfact_cli/modules modules; then | |
| echo "::error::Module signatures are stale for the configured signing key. Re-sign and commit manifest updates." | |
| git --no-pager diff --name-only -- src/specfact_cli/modules modules | |
| exit 1 | |
| fi | |
| sign-and-push: | |
| name: Sign changed modules (manual dispatch) | |
| if: github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| needs: [verify] | |
| 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 branch | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.ref }} | |
| persist-credentials: true | |
| - name: Fetch comparison base | |
| 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 module manifests | |
| 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 }}" | |
| if [ "${{ github.event.inputs.resign_all_manifests }}" = "true" ]; then | |
| mapfile -t MANIFESTS < <(find src/specfact_cli/modules modules -name 'module-package.yaml' -type f 2>/dev/null | sort) | |
| if [ "${#MANIFESTS[@]}" -eq 0 ]; then | |
| echo "No module manifests found" | |
| exit 0 | |
| fi | |
| python scripts/sign-modules.py --payload-from-filesystem "${MANIFESTS[@]}" | |
| else | |
| python scripts/sign-modules.py \ | |
| --changed-only \ | |
| --base-ref "$MERGE_BASE" \ | |
| --bump-version "${BUMP}" \ | |
| --payload-from-filesystem | |
| fi | |
| - name: Commit and push signed manifests | |
| 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 workflow_dispatch sign changed modules [skip ci]" | |
| git push origin "HEAD:${GITHUB_REF_NAME}" | |
| echo "## Signed manifests pushed" >> "${GITHUB_STEP_SUMMARY}" | |
| echo "Branch: \`${GITHUB_REF_NAME}\` (base: \`origin/${{ github.event.inputs.base_branch }}\`, bump: \`${{ github.event.inputs.version_bump }}\`, resign_all: \`${{ github.event.inputs.resign_all_manifests }}\`)." >> "${GITHUB_STEP_SUMMARY}" |