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
126 changes: 115 additions & 11 deletions .github/workflows/pr-orchestrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ jobs:
outputs:
code_changed: ${{ steps.out.outputs.code_changed }}
workflow_changed: ${{ steps.out.outputs.workflow_changed }}
pyproject_changed: ${{ steps.out.outputs.pyproject_changed }}
license_inputs_changed: ${{ steps.out.outputs.license_inputs_changed }}
version_sources_changed: ${{ steps.out.outputs.version_sources_changed }}
skip_tests_dev_to_main: ${{ steps.out.outputs.skip_tests_dev_to_main }}
steps:
- uses: actions/checkout@v4
Expand All @@ -41,6 +44,20 @@ jobs:
- '!**/*.mdc'
- '!docs/**'
- '!.github/workflows/**'
pyproject:
- 'pyproject.toml'
Comment thread
djm81 marked this conversation as resolved.
license_inputs:
- 'pyproject.toml'
- 'modules/**/module-package.yaml'
- 'src/specfact_cli/modules/**/module-package.yaml'
- 'scripts/check_license_compliance.py'
- 'scripts/license_allowlist.yaml'
- 'scripts/module_pip_dependencies_licenses.yaml'
version_sources:
- 'pyproject.toml'
- 'setup.py'
- 'src/__init__.py'
- 'src/specfact_cli/__init__.py'
workflow:
- '.github/workflows/**'
- 'scripts/run_actionlint.sh'
Expand All @@ -58,11 +75,21 @@ jobs:
PR_BASE_SHA="${PR_BASE_SHA:-}"
PR_HEAD_SHA="${PR_HEAD_SHA:-}"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "code_changed=true" >> "$GITHUB_OUTPUT"
echo "workflow_changed=true" >> "$GITHUB_OUTPUT"
{
echo "code_changed=true"
echo "workflow_changed=true"
echo "pyproject_changed=true"
echo "license_inputs_changed=true"
echo "version_sources_changed=true"
} >> "$GITHUB_OUTPUT"
else
echo "code_changed=${{ steps.filter.outputs.code }}" >> "$GITHUB_OUTPUT"
echo "workflow_changed=${{ steps.filter.outputs.workflow }}" >> "$GITHUB_OUTPUT"
{
echo "code_changed=${{ steps.filter.outputs.code }}"
echo "workflow_changed=${{ steps.filter.outputs.workflow }}"
echo "pyproject_changed=${{ steps.filter.outputs.pyproject }}"
echo "license_inputs_changed=${{ steps.filter.outputs.license_inputs }}"
echo "version_sources_changed=${{ steps.filter.outputs.version_sources }}"
} >> "$GITHUB_OUTPUT"
fi
SKIP_TESTS=false
if [ "$EVENT_NAME" = "pull_request" ] && [ "$PR_BASE_REF" = "main" ] && [ "$PR_HEAD_REF" = "dev" ]; then
Expand Down Expand Up @@ -110,19 +137,20 @@ jobs:
python -m pip install --upgrade pip
python -m pip install pyyaml beartype icontract cryptography cffi

- name: Verify bundled module checksums (signatures enforced on push via sign-modules workflow)
- name: Verify bundled module manifests (PR = relaxed checksum; push = payload checksum + version)
run: |
set -euo pipefail
VERIFY_ARGS=(--payload-from-filesystem --enforce-version-bump)
# shellcheck disable=SC1091
source scripts/module-verify-policy.sh
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_REF="origin/${{ github.event.pull_request.base.ref }}"
python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" --version-check-base "$BASE_REF"
python scripts/verify-modules-signature.py "${VERIFY_MODULES_PR[@]}" --version-check-base "$BASE_REF"
else
BEFORE="${{ github.event.before }}"
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
BEFORE="HEAD~1"
fi
python scripts/verify-modules-signature.py "${VERIFY_ARGS[@]}" --version-check-base "$BEFORE"
python scripts/verify-modules-signature.py "${VERIFY_MODULES_PUSH_ORCHESTRATOR[@]}" --version-check-base "$BEFORE"
fi

workflow-lint:
Expand Down Expand Up @@ -220,10 +248,28 @@ jobs:
run: python scripts/check_version_sources.py

- name: Verify local version is ahead of PyPI
if: needs.changes.outputs.skip_tests_dev_to_main != 'true'
if: >-
needs.changes.outputs.skip_tests_dev_to_main != 'true' &&
needs.changes.outputs.version_sources_changed == 'true'
env:
SPECFACT_PYPI_VERSION_CHECK_LENIENT_NETWORK: "1"
run: python scripts/check_local_version_ahead_of_pypi.py
shell: bash
run: |
set -euo pipefail
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ "${{ github.event_name }}" = "push" ]; then
BEFORE="${{ github.event.before }}"
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then
BASE="$BEFORE"
fi
fi
if [ -n "$BASE" ]; then
python scripts/check_local_version_ahead_of_pypi.py --skip-when-version-unchanged-vs "$BASE"
else
python scripts/check_local_version_ahead_of_pypi.py
fi

- name: Cache hatch environments
if: needs.changes.outputs.skip_tests_dev_to_main != 'true'
Expand Down Expand Up @@ -570,10 +616,68 @@ jobs:
path: logs/lint/
if-no-files-found: ignore

license-check:
name: License Compliance Gate
runs-on: ubuntu-latest
needs: [changes, verify-module-signatures]
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.license_inputs_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
pyproject.toml

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run license compliance gate
run: |
echo "πŸ” Running license compliance gate..."
python scripts/check_license_compliance.py
Comment thread
coderabbitai[bot] marked this conversation as resolved.

security-audit:
name: Security Audit (pip-audit)
runs-on: ubuntu-latest
needs: [changes, verify-module-signatures]
if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true'
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
pyproject.toml

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run CVE security audit
run: |
echo "πŸ” Running CVE security audit..."
python scripts/security_audit_gate.py
Comment thread
djm81 marked this conversation as resolved.

package-validation:
name: Package Validation (uvx/pip)
runs-on: ubuntu-latest
needs: [tests, compat-py311, contract-first-ci, cli-validation, type-checking, linting]
needs: [tests, compat-py311, contract-first-ci, cli-validation, type-checking, linting, license-check, security-audit]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: read
Expand Down
194 changes: 191 additions & 3 deletions .github/workflows/publish-modules.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Publish module tarball and checksum when a release tag is pushed.
# Tag format: {module-name}-v{version} (e.g. module-registry-v0.1.3, backlog-v0.29.0)
# Publish module tarball and checksum.
#
# Triggers:
# 1. push tag (`*-v*`) β€” manual release for a single module by tag
# 2. workflow_dispatch β€” manual one-shot for a single module path
# 3. workflow_run (sign-modules.yml) β€” automatic publish of every module whose
# manifest changed in the auto-sign commit
# on dev/main. Not blocked by [skip ci]
# on the auto-sign commit (workflow_run is
# scheduled by the run that completed,
# not by the commit message).
#
# Optional signing: set repository secrets SPECFACT_MODULE_PRIVATE_SIGN_KEY (PEM string)
# and SPECFACT_MODULE_PRIVATE_SIGN_KEY_PASSPHRASE to sign the module manifest before packaging.
Expand All @@ -14,10 +23,15 @@ on:
push:
tags:
- "*-v*"
workflow_run:
workflows: ["Module Signature Hardening"]
types: [completed]
branches: [dev, main]

jobs:
publish:
name: Validate and package module
name: Validate and package module (single)
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -164,3 +178,177 @@ jobs:
dist/*.tar.gz
dist/*.sha256
dist/registry-entry.yaml

auto-publish:
name: Auto-publish version-bumped modules (after sign-modules)
# Trigger only when sign-modules.yml completed successfully on dev/main.
# Uses workflow_run so it is NOT suppressed by the `[skip ci]` marker on
# the bot's auto-sign commit (push events would be).
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
(github.event.workflow_run.head_branch == 'dev' || github.event.workflow_run.head_branch == 'main')
runs-on: ubuntu-latest
permissions:
contents: read
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 }}
SPECFACT_MODULES_REPO_TOKEN: ${{ secrets.SPECFACT_MODULES_REPO_TOKEN }}
REGISTRY_REPO: nold-ai/specfact-cli-modules
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
steps:
- name: Checkout repository at workflow_run head
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pyyaml beartype icontract cryptography cffi packaging

- name: Validate registry repo token
run: |
if [ -z "${SPECFACT_MODULES_REPO_TOKEN}" ]; then
echo "::error::Missing secret SPECFACT_MODULES_REPO_TOKEN."
exit 1
fi

- name: Checkout registry repository
uses: actions/checkout@v4
with:
repository: ${{ env.REGISTRY_REPO }}
token: ${{ env.SPECFACT_MODULES_REPO_TOKEN }}
path: specfact-cli-modules

- name: Detect modules whose version is ahead of the registry
id: detect
run: |
set -euo pipefail
# Compare each bundled module's manifest version against the value
# currently recorded in registry/index.json. This is robust to all
# version-bump origins (manual user bump, sign-modules.yml auto-bump,
# multiple consecutive merges) β€” we publish exactly the modules whose
# declared version is strictly greater than the registered one, which
# also matches what publish-module.py would actually accept.
python scripts/_detect_modules_to_publish.py \
--registry-index specfact-cli-modules/registry/index.json \
--modules-root src/specfact_cli/modules \
--modules-root modules \
--output-list /tmp/modules_to_publish.txt

if [ ! -s /tmp/modules_to_publish.txt ]; then
echo "No modules to publish (all manifest versions are <= registry)."
echo "modules=" >> "$GITHUB_OUTPUT"
exit 0
fi

{
echo "modules<<EOF"
cat /tmp/modules_to_publish.txt
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Package, sign, and stage registry entries
id: stage
if: steps.detect.outputs.modules != ''
run: |
set -euo pipefail
mkdir -p dist
PY_ENTRY_SUMMARY='import sys, yaml; from pathlib import Path; data = yaml.safe_load(Path(sys.argv[1]).read_text(encoding="utf-8")); print(f"{data[\"id\"]}@{data[\"latest_version\"]}")'
PUBLISHED=()
while IFS= read -r MODULE_DIR; do
[ -n "${MODULE_DIR}" ] || continue
SLUG="$(basename "${MODULE_DIR}")"
FRAGMENT="dist/${SLUG}-registry-entry.yaml"

# Re-sign defensively; sign-modules.yml already signed on push,
# but signing is idempotent and protects against partial signer state.
if [ -n "${SPECFACT_MODULE_PRIVATE_SIGN_KEY}" ] && [ -f "${MODULE_DIR}/module-package.yaml" ]; then
python scripts/sign-modules.py --payload-from-filesystem "${MODULE_DIR}/module-package.yaml"
fi

python scripts/publish-module.py "${MODULE_DIR}" -o dist --index-fragment "${FRAGMENT}"

python scripts/update-registry-index.py \
--index-path specfact-cli-modules/registry/index.json \
--entry-fragment "${FRAGMENT}" \
--changed-flag /tmp/index_changed.txt
CHANGED=$(tr -d '\n' < /tmp/index_changed.txt)
if [ "${CHANGED}" = "true" ]; then
ENTRY="$(python -c "${PY_ENTRY_SUMMARY}" "${FRAGMENT}")"
PUBLISHED+=("${ENTRY}")
fi
done <<< "${{ steps.detect.outputs.modules }}"

if [ "${#PUBLISHED[@]}" -eq 0 ]; then
echo "Registry index unchanged for all detected modules; nothing to publish."
echo "any_changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi

{
echo "any_changed=true"
echo "published<<EOF"
printf '%s\n' "${PUBLISHED[@]}"
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Create combined registry PR
if: steps.stage.outputs.any_changed == 'true'
env:
GH_TOKEN: ${{ env.SPECFACT_MODULES_REPO_TOKEN }}
run: |
set -euo pipefail
BRANCH="auto/publish-batch-${HEAD_BRANCH}-${{ github.event.workflow_run.id }}"
TITLE="chore(registry): auto-publish modules from ${HEAD_BRANCH}@${HEAD_SHA::7}"
{
echo "Automated registry update triggered after Module Signature Hardening on \`${HEAD_BRANCH}\`."
echo
echo "Source repo: ${{ github.server_url }}/${{ github.repository }}"
echo "Source commit: \`${HEAD_SHA}\`"
echo "Source run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
echo
echo "Modules published:"
while IFS= read -r line; do
[ -n "${line}" ] || continue
echo "- \`${line}\`"
done <<< "${{ steps.stage.outputs.published }}"
} > /tmp/pr_body.md

cd specfact-cli-modules
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "${BRANCH}"
git add registry/index.json
if git diff --cached --quiet; then
echo "Registry index has no staged changes; skipping PR creation."
exit 0
fi
git commit -m "${TITLE}"
git push origin "${BRANCH}"

gh pr create \
--repo "${REGISTRY_REPO}" \
--base main \
--head "${BRANCH}" \
--title "${TITLE}" \
--body-file /tmp/pr_body.md

- name: Upload module artifacts
if: steps.stage.outputs.any_changed == 'true'
uses: actions/upload-artifact@v4
with:
name: module-package-batch
path: |
dist/*.tar.gz
dist/*.sha256
dist/*-registry-entry.yaml
Loading
Loading