diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8e2dac9..fd6fbeb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,8 +7,19 @@ name: CodeQL on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly — catches newly added queries against unchanged code. + - cron: "0 6 * * 1" workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: analyze: name: Analyze (Python) @@ -23,12 +34,12 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@8272c299f21ca24af15dfe9ac0971ba969e5e0d5 # v3.36.2 with: languages: python queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@8272c299f21ca24af15dfe9ac0971ba969e5e0d5 # v3.36.2 with: category: "/language:python" diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 0000000..fb153ad --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,112 @@ +## +## SPDX-FileCopyrightText: 2026 AOT Technologies +## SPDX-License-Identifier: Apache-2.0 +## + +name: Create Release Tag + +# Step 1 of the release flow. Run this first, then run "GitHub Release". +# Go to Actions → "Create Release Tag" → Run workflow with the target version. +on: + workflow_dispatch: + inputs: + version: + description: "Semver version to tag, without leading v (for example, 1.0.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + create-tag: + name: Validate and tag main + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: main + fetch-depth: 0 + + - name: Validate version format + shell: python + run: | + import re + import sys + + version = "${{ inputs.version }}".strip() + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + print(f"ERROR: {version!r} is not a MAJOR.MINOR.PATCH version", file=sys.stderr) + sys.exit(1) + + print(f"Version {version} is valid semver") + + - name: Check tag does not already exist + run: | + TAG="v${{ inputs.version }}" + if git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "ERROR: tag ${TAG} already exists" >&2 + exit 1 + fi + echo "Tag ${TAG} is available" + + - name: Validate package versions match + shell: python + run: | + import pathlib + import sys + import tomllib + + version = "${{ inputs.version }}".strip() + root = pathlib.Path(".") + pyprojects = [root / "pyproject.toml", *sorted(root.glob("packages/**/pyproject.toml"))] + + mismatches = [] + for path in pyprojects: + data = tomllib.loads(path.read_text(encoding="utf-8")) + actual = data.get("project", {}).get("version") + if actual != version: + mismatches.append(f"{path}: expected {version}, found {actual}") + + if mismatches: + print("ERROR: package versions do not match the requested tag", file=sys.stderr) + for mismatch in mismatches: + print(f" {mismatch}", file=sys.stderr) + sys.exit(1) + + print(f"PASS: all packages are at version {version}") + + - name: Validate CHANGELOG entry exists + shell: python + run: | + import pathlib + import re + import sys + + version = "${{ inputs.version }}".strip() + changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8") + + heading = re.compile( + rf"^## \[{re.escape(version)}\] - \d{{4}}-\d{{2}}-\d{{2}}\s*$", + re.MULTILINE, + ) + if not heading.search(changelog): + print(f"ERROR: CHANGELOG.md is missing a dated [{version}] section", file=sys.stderr) + sys.exit(1) + + link_pattern = rf"^\[{re.escape(version)}\]: .+/releases/tag/v{re.escape(version)}\s*$" + if not re.search(link_pattern, changelog, re.MULTILINE): + print(f"ERROR: CHANGELOG.md is missing the [{version}] release link", file=sys.stderr) + sys.exit(1) + + print(f"PASS: CHANGELOG.md has a valid [{version}] entry") + + - name: Create and push tag + run: | + TAG="v${{ inputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${TAG}" -m "Release ${TAG}" + git push origin "${TAG}" + echo "Tagged $(git rev-parse HEAD) as ${TAG}" diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index 1817fbe..35ce389 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -24,7 +24,7 @@ jobs: github.actor != 'renovate[bot]' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 diff --git a/.github/workflows/docker-policy.yml b/.github/workflows/docker-policy.yml index 4a0f53e..31b532f 100644 --- a/.github/workflows/docker-policy.yml +++ b/.github/workflows/docker-policy.yml @@ -8,7 +8,7 @@ name: Docker policy on: push: - branches: [main, master] + branches: [main] paths: - "Dockerfile" - "docker/**" @@ -17,6 +17,13 @@ on: - "Dockerfile" - "docker/**" +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: check: runs-on: ubuntu-latest diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 3b093bb..c58fb53 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -34,12 +34,14 @@ jobs: - name: Resolve release version shell: python + env: + INPUT_VERSION: ${{ inputs.version }} run: | import os import re import sys - version = "${{ inputs.version }}".strip() + version = os.environ["INPUT_VERSION"].strip() tag = f"v{version}" if not re.fullmatch(r"\d+\.\d+\.\d+", version): @@ -56,12 +58,12 @@ jobs: run: git rev-parse --verify "refs/tags/${RELEASE_TAG}" - name: Set up Python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a9008ef..501d692 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,19 +8,26 @@ name: Lint and Type Check on: pull_request: - branches: [ "main" ] + branches: [main] push: - branches: [main, master] + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: lockfile-check: name: Lockfile freshness runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | @@ -34,15 +41,15 @@ jobs: name: Ruff Linters runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | @@ -63,15 +70,15 @@ jobs: name: REUSE compliance runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | @@ -88,15 +95,15 @@ jobs: name: Mypy Type Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 39000f8..ac27a0a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,12 +56,14 @@ jobs: - name: Resolve release version from tag id: resolve shell: python + env: + INPUT_TAG: ${{ inputs.tag }} run: | import os import re import sys - tag = "${{ inputs.tag }}".strip() + tag = os.environ["INPUT_TAG"].strip() version = tag[1:] if tag.startswith("v") else tag if not re.fullmatch(r"\d+\.\d+\.\d+", version): @@ -77,11 +79,13 @@ jobs: - name: Validate package path (allowlist) id: validate shell: python + env: + INPUT_PACKAGE_PATH: ${{ inputs.package_path }} run: | import os import sys - raw = "${{ inputs.package_path }}".strip().replace("\\", "/") + raw = os.environ["INPUT_PACKAGE_PATH"].strip().replace("\\", "/") norm = os.path.normpath(raw).replace("\\", "/") if norm.startswith("..") or os.path.isabs(raw): print("ERROR: invalid package_path", file=sys.stderr) @@ -178,7 +182,7 @@ jobs: ref: refs/tags/${{ needs.prerequisites.outputs.release_tag }} - name: Set up Python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.11" @@ -266,12 +270,12 @@ jobs: ref: refs/tags/${{ needs.prerequisites.outputs.release_tag }} - name: Set up Python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.11" - name: Download all wheel artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: wheels-*-${{ needs.prerequisites.outputs.release_tag }}-${{ needs.prerequisites.outputs.artifact_slug }} path: dist-all @@ -315,8 +319,11 @@ jobs: print(f"PASS: {len(wheels)} wheel(s) match version {expected!r}") + # dist/ holds wheels for all platforms; only the linux cp311 wheel is + # installable on this runner (Python 3.11 / ubuntu). The dependency tree + # audited by pip-audit is identical across platform wheels. - name: Install built wheels for CVE scan - run: pip install dist/*.whl + run: pip install dist/*cp311*manylinux*.whl - name: Vulnerability scan (CVE gate — blocks publish on HIGH or higher) run: | diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 994847b..d0606b9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -8,11 +8,18 @@ name: CI – Pytest on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: name: Run pytest (${{ matrix.os }}, Python ${{ matrix.python-version }}) @@ -29,12 +36,12 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: ${{ matrix.python-version }} - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | @@ -66,12 +73,12 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python 3.11 - uses: actions/setup-python@v5 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 970e855..7a4590b 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -9,9 +9,16 @@ name: Quality gates on: pull_request: push: - branches: [main, master] + branches: [main] -# This workflow enforces Bandit SAST on every PR and push to main/master. +# This workflow enforces Bandit SAST on every PR and push to main. + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: bandit: @@ -20,11 +27,11 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.11" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true cache-dependency-glob: | diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index c70e025..e786df4 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -8,9 +8,9 @@ name: Secret scan on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] schedule: # Weekly — catches newly disclosed patterns against full history. - cron: "0 7 * * 1" @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: gitleaks: name: Gitleaks secret scan @@ -31,17 +35,22 @@ jobs: - name: Cache Gitleaks binary id: cache-gitleaks - uses: actions/cache@v4 + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: ./gitleaks key: gitleaks-v8.27.2-linux-x64 - name: Download Gitleaks if: steps.cache-gitleaks.outputs.cache-hit != 'true' + env: + GITLEAKS_SHA256: "141c3b2dede46d8b3a53b47116da756bd223decc0374797559a6b50ecba5590c" run: | - curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.27.2/gitleaks_8.27.2_linux_x64.tar.gz \ - | tar -xz gitleaks + curl -sSfL -o gitleaks.tar.gz \ + https://github.com/gitleaks/gitleaks/releases/download/v8.27.2/gitleaks_8.27.2_linux_x64.tar.gz + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks chmod +x gitleaks + rm gitleaks.tar.gz - name: Run Gitleaks run: | diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index 040571d..6613689 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -16,7 +16,7 @@ on: - "packages/**" - "src/**" push: - branches: [main, master] + branches: [main] paths: - ".github/workflows/security-pr.yml" - ".github/workflows/publish.yml" @@ -27,6 +27,13 @@ on: schedule: - cron: "17 3 * * *" +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: PIP_AUDIT_VERSION: "2.7.3" @@ -54,7 +61,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.11" diff --git a/README.md b/README.md index 4f59115..34e7196 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 [![CI](https://github.com/AOT-Technologies/node-wire/actions/workflows/pytest.yml/badge.svg)](https://github.com/AOT-Technologies/node-wire/actions/workflows/pytest.yml) [![CodeQL](https://github.com/AOT-Technologies/node-wire/actions/workflows/codeql.yml/badge.svg)](https://github.com/AOT-Technologies/node-wire/actions/workflows/codeql.yml) [![PyPI](https://img.shields.io/pypi/v/node-wire.svg)](https://pypi.org/project/node-wire/) -[![Python](https://img.shields.io/pypi/pyversions/node-wire.svg)](https://pypi.org/project/node-wire/) +[![GitHub Release](https://img.shields.io/github/v/release/AOT-Technologies/node-wire)](https://github.com/AOT-Technologies/node-wire/releases/latest) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) Node Wire is a three-layer Python platform that runs connector adapters (Google Drive, SMTP, Stripe, FHIR, etc.) and exposes them over REST, gRPC, or MCP. It provides a consistent execution contract with built-in validation, resilience, and telemetry. diff --git a/docs/quality-security-gates.md b/docs/quality-security-gates.md index 7fbd628..8bb0dea 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -12,7 +12,7 @@ This repository enforces security gates at both PR time and publish time. Workflow: `.github/workflows/quality-gates.yml` -Runs on every pull request and on pushes to `main`/`master`. +Runs on every pull request and on pushes to `main`. Required jobs: @@ -20,12 +20,12 @@ Required jobs: Workflow: `.github/workflows/codeql.yml` -Runs GitHub CodeQL static analysis for Python on pull requests, pushes to `main`/`master`, and weekly (Mondays). No repository secrets are required. +Runs GitHub CodeQL static analysis for Python on pull requests, pushes to `main`, and weekly (Mondays). No repository secrets are required. Workflow: `.github/workflows/pytest.yml` Runs the full test suite on **Linux, macOS, and Windows** (Python 3.11 and 3.12 -matrix) with coverage on every pull request and push to `main`/`master`. +matrix) with coverage on every pull request and push to `main`. Playground integration tests remain manual (`workflow_dispatch`) on Ubuntu only. Workflow: `.github/workflows/lint.yml` also runs `lockfile-check` (`uv lock --check`) to fail PRs when `pyproject.toml` changes without an updated `uv.lock`. @@ -33,7 +33,7 @@ Workflow: `.github/workflows/lint.yml` also runs `lockfile-check` (`uv lock --ch Workflow: `.github/workflows/secret-scan.yml` Runs [Gitleaks](https://github.com/gitleaks/gitleaks) on pull requests, pushes to -`main`/`master`, weekly (Mondays), and on manual dispatch. The workflow checks +`main`, weekly (Mondays), and on manual dispatch. The workflow checks out **full git history** (`fetch-depth: 0`) so secrets in past commits are scanned, not only the working tree. @@ -76,7 +76,7 @@ Workflow: `.github/workflows/secret-scan.yml` (Gitleaks). Policy: -- Scan on every PR and push to `main`/`master`, plus a weekly scheduled run. +- Scan on every PR and push to `main`, plus a weekly scheduled run. - Full repository history is included (`fetch-depth: 0`). - Findings fail the workflow; remediate by rotating exposed credentials and removing secrets from the codebase (never commit live secrets). diff --git a/pyproject.toml b/pyproject.toml index d43584a..d04a89e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,9 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", + "Topic :: System :: Networking", + "Framework :: FastAPI", ] dependencies = [