diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d8f3842 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.pytest_cache +**/__pycache__ +**/*.pyc +site/node_modules +site/dist +site/.astro +work diff --git a/.github/workflows/spec-kit.yml b/.github/workflows/spec-kit.yml index 3f95a3a..02e8358 100644 --- a/.github/workflows/spec-kit.yml +++ b/.github/workflows/spec-kit.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + types: [opened, synchronize, reopened, edited, ready_for_review] permissions: contents: read @@ -14,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Check required files exist run: | @@ -39,3 +40,130 @@ jobs: - name: Summary run: echo "Spec-Kit validation complete." + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Compile Python sources + run: python -m compileall -q scripts + - name: Run unit tests + run: python -m unittest discover -s tests -p "test_*.py" + + container-smoke: + name: Container smoke test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Build site image + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + docker build --secret id=github_token,env=GITHUB_TOKEN --file infra/Dockerfile --tag fortipath:${{ github.sha }} . + - name: Run site smoke check + run: | + docker run --detach --name fortipath-smoke --publish 127.0.0.1:8080:8080 \ + fortipath:${{ github.sha }} + for _ in {1..20}; do + if curl --fail --silent http://127.0.0.1:8080/ >/dev/null; then + exit 0 + fi + sleep 1 + done + docker logs fortipath-smoke + exit 1 + - name: Verify FortiPath route and emitted asset + run: | + curl --fail --silent --show-error http://127.0.0.1:8080/FortiPath/development-board/ >/dev/null + asset_path=$(python - <<'PY' + import re + import sys + import urllib.request + + html = urllib.request.urlopen('http://127.0.0.1:8080/FortiPath/', timeout=10).read().decode() + match = re.search(r'["\'](?P/FortiPath/_astro/[^"\']+\.(?:js|css))["\']', html) + if not match: + raise SystemExit('no emitted /FortiPath/_astro asset found in page HTML') + sys.stdout.write(match.group('asset')) + PY + ) + curl --fail --silent --show-error "http://127.0.0.1:8080${asset_path}" >/dev/null + - name: Clean up smoke container + if: always() + run: docker rm --force fortipath-smoke || true + + independent-review: + name: Independent review evidence + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + PR_BODY: ${{ github.event.pull_request.body }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + IS_DRAFT: ${{ github.event.pull_request.draft }} + steps: + - name: Validate merge path and exact-head review evidence + shell: python + run: | + import os + import re + + body = os.environ.get("PR_BODY", "") + head_ref = os.environ.get("HEAD_REF", "") + head_sha = os.environ.get("HEAD_SHA", "") + errors = [] + + if os.environ.get("IS_DRAFT", "").lower() == "true": + errors.append("pull request must be ready for review") + if not head_ref.startswith("codex/"): + errors.append("pull requests to main must originate from codex/*") + if "## Agent quality evidence" not in body: + errors.append("missing Agent quality evidence section") + + reviewer = re.search( + r"(?im)^\|\s*Independent reviewer\s*\|\s*([^|]+)\|\s*([^|]+)\|", + body, + ) + if reviewer is None: + errors.append("missing Independent reviewer evidence row") + else: + identity, verdict = reviewer.groups() + evidence = f"{identity} {verdict}" + if re.search(r"\b(pending|tbd|todo|placeholder|awaiting)\b", evidence, re.I): + errors.append("independent reviewer evidence is still pending") + if not re.search(r"\b(pass(?:ed)?|approved|no blockers)\b", verdict, re.I): + errors.append("independent reviewer must record a passing verdict") + if head_sha not in verdict: + errors.append("review evidence must reference the exact PR head SHA") + + if errors: + raise SystemExit("\n".join(f"- {error}" for error in errors)) + print("PR policy and exact-head independent review evidence passed") + + required-ci: + name: Required CI + if: always() + needs: [validate, unit-tests, container-smoke, independent-review] + runs-on: ubuntu-latest + steps: + - name: Require every applicable gate + env: + EVENT: ${{ github.event_name }} + SPEC: ${{ needs.validate.result }} + UNIT: ${{ needs.unit-tests.result }} + CONTAINER: ${{ needs.container-smoke.result }} + REVIEW: ${{ needs.independent-review.result }} + run: | + test "$SPEC" = success + test "$UNIT" = success + test "$CONTAINER" = success + if [ "$EVENT" = pull_request ]; then + test "$REVIEW" = success + fi diff --git a/.gitignore b/.gitignore index 9329f01..e7b21e0 100644 --- a/.gitignore +++ b/.gitignore @@ -91,9 +91,6 @@ markmap/ *.dll *.exe -# Docker -.dockerignore - # Coverage reports /coverage/ *.coverage diff --git a/infra/Dockerfile b/infra/Dockerfile index 851af76..ded5e24 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -1,17 +1,25 @@ -# Use a base image with the required runtime for FortiPath -FROM ubuntu:20.04 +FROM node:22-alpine AS build -# Set working directory -WORKDIR /app +WORKDIR /app/site -# Copy the FortiPath application to the container -COPY . /app +COPY site/package.json site/package-lock.json ./ +RUN npm ci -# Install any necessary dependencies -# TODO: Add installation commands for FortiPath dependencies +COPY site/ ./ +COPY docs /app/docs +COPY mermaid /app/mermaid +RUN --mount=type=secret,id=github_token,required=true \ + export GITHUB_TOKEN="$(cat /run/secrets/github_token)" \ + && node --experimental-strip-types scripts/fetch_repo_data.ts \ + && node --experimental-strip-types scripts/fetch_discussions.ts \ + && node --experimental-strip-types scripts/fetch_projects.ts \ + && npm run build -# Expose the port FortiPath runs on -EXPOSE 8080 +FROM nginxinc/nginx-unprivileged:1.27-alpine + +COPY infra/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/site/dist /usr/share/nginx/html -# Command to run the application -CMD ["./fortipath"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --spider http://127.0.0.1:8080/ || exit 1 +EXPOSE 8080 diff --git a/infra/nginx.conf b/infra/nginx.conf new file mode 100644 index 0000000..3c833aa --- /dev/null +++ b/infra/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 8080; + listen [::]:8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ $uri.html =404; + } + + location = /FortiPath { + return 301 /FortiPath/; + } + + location ^~ /FortiPath/ { + rewrite ^/FortiPath/?(.*)$ /$1 break; + try_files $uri $uri/ $uri.html =404; + } + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options "SAMEORIGIN" always; + + access_log /dev/stdout; + error_log /dev/stderr warn; +} diff --git a/tests/test_professional_emails.py b/tests/test_professional_emails.py new file mode 100644 index 0000000..8301aec --- /dev/null +++ b/tests/test_professional_emails.py @@ -0,0 +1,50 @@ +import datetime +import importlib.util +from pathlib import Path +import unittest +from unittest import mock + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "report-writing" + / "Professional_Emails.py" +) +SPEC = importlib.util.spec_from_file_location("professional_emails", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load {MODULE_PATH}") +professional_emails = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(professional_emails) + + +class FixedDatetime(datetime.datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 7, 20, tzinfo=tz) + + +class GenerateEmailDraftTests(unittest.TestCase): + def test_generates_expected_professional_fields(self): + with mock.patch.object( + professional_emails.datetime, + "datetime", + FixedDatetime, + ): + draft = professional_emails.generate_email_draft( + "Alex Rivera", + "alex@example.com", + "Protective operations update", + "The advance is complete.", + ) + + self.assertIn("Date: 2026-07-20", draft) + self.assertIn("To: Alex Rivera ", draft) + self.assertIn("Subject: Protective operations update", draft) + self.assertIn("Dear Alex Rivera,", draft) + self.assertIn("The advance is complete.", draft) + self.assertIn("FortiPath Security Team", draft) + + +if __name__ == "__main__": + unittest.main()