diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index be4e923..8278597 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -12,14 +12,18 @@ permissions: contents: read issues: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' @@ -29,7 +33,7 @@ jobs: pip install -e ".[config]" - name: Restore previous audit history - uses: actions/cache@v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | output/audit-report-*.json @@ -144,7 +148,7 @@ jobs: fi - name: Save audit history - uses: actions/cache/save@v5 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | output/audit-report-*.json @@ -153,7 +157,7 @@ jobs: key: audit-history-${{ github.repository }}-${{ github.run_number }} - name: Upload reports - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: audit-reports-${{ github.run_number }} path: output/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 812db47..0ea2603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ jobs: matrix: python-version: ["3.11"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 39b7ac7..aac3fe2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,15 +30,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: ${{ matrix.language }} queries: security-extended,security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index cf77494..913e488 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -23,13 +23,13 @@ jobs: esac - name: Checkout release tag - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.ref }} fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" @@ -43,7 +43,7 @@ jobs: run: python -m twine check dist/* - name: Upload distributions - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-distributions path: dist/* @@ -60,10 +60,10 @@ jobs: steps: - name: Download distributions - uses: actions/download-artifact@v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: python-distributions path: dist - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdd38b2..4499da6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # full history so pip can detect installed version - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" @@ -45,7 +45,7 @@ jobs: run: ls -lh dist/ - name: Create GitHub Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: files: | dist/*.whl diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 5c971fe..1b2582f 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import subprocess from datetime import datetime, timezone from pathlib import Path @@ -79,6 +80,23 @@ "tests", } ) +# Directory-name substrings (case-insensitive) marking deliberate non-projects. +# A match skips the directory AND its subtree during discovery, so neither the +# container nor anything nested under it reaches the catalog-completeness gate. +# nogoprjs -> operator-flagged "no-go" projects, never pursued +# smoke-export -> generated AuraForge signed-smoke-export bundles (no real repo) +IGNORE_PROJECT_DIR_TOKENS = frozenset({"nogoprjs", "smoke-export"}) +# Transient / generated working directories matched by regex on the dir name — +# e.g. a `-tmp-` clone left behind by a tooling run. +IGNORE_PROJECT_DIR_PATTERNS: tuple[re.Pattern[str], ...] = (re.compile(r"-tmp-\d+$"),) + + +def _is_ignored_project_dir(name: str) -> bool: + """True if a directory name is a transient/non-project artifact to skip.""" + lowered = name.lower() + if any(token in lowered for token in IGNORE_PROJECT_DIR_TOKENS): + return True + return any(pattern.search(name) for pattern in IGNORE_PROJECT_DIR_PATTERNS) def discover_workspace_projects( @@ -93,6 +111,8 @@ def discover_workspace_projects( for child in sorted(workspace_root.iterdir(), key=lambda item: item.name.lower()): if child.name.startswith(".") or not child.is_dir() or child.is_symlink(): continue + if _is_ignored_project_dir(child.name): + continue if _is_project_dir(child): discovered.append( _inspect_project_dir(child, workspace_root, catalog_data=catalog_data, now=now) @@ -162,6 +182,8 @@ def _discover_nested_projects( for child in sorted(root.iterdir(), key=lambda item: item.name.lower()): if child.name.startswith(".") or not child.is_dir() or child.is_symlink(): continue + if _is_ignored_project_dir(child.name): + continue if _is_project_dir(child): discovered.append( _inspect_project_dir(child, workspace_root, catalog_data=catalog_data, now=now) diff --git a/tests/test_distribution_policy.py b/tests/test_distribution_policy.py index ed8c839..e800e94 100644 --- a/tests/test_distribution_policy.py +++ b/tests/test_distribution_policy.py @@ -57,7 +57,9 @@ def test_pypi_workflow_is_manual_trusted_publishing_only() -> None: assert "push:" not in workflow assert "environment: pypi" in workflow assert "id-token: write" in workflow - assert "pypa/gh-action-pypi-publish@release/v1" in workflow + # SHA-pinned to the v1 trusted-publishing release line + assert "pypa/gh-action-pypi-publish@" in workflow + assert "# v1" in workflow assert "actions/upload-artifact" in workflow assert "actions/download-artifact" in workflow @@ -67,8 +69,10 @@ def test_public_repo_has_code_scanning_workflow_documented() -> None: workflows_readme = (ROOT / ".github" / "workflows" / "README.md").read_text() security_model = (ROOT / "docs" / "security-model.md").read_text() - assert "github/codeql-action/init@v4" in workflow - assert "github/codeql-action/analyze@v4" in workflow + # CodeQL actions SHA-pinned to the v4 line + assert "github/codeql-action/init@" in workflow + assert "github/codeql-action/analyze@" in workflow + assert "# v4" in workflow assert "security-events: write" in workflow assert "pull_request:" in workflow assert "schedule:" in workflow diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index 5c0a762..e2f4387 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -8,7 +8,13 @@ from __future__ import annotations -from src.portfolio_truth_sources import _dedupe_checkouts_by_origin +from datetime import datetime, timezone + +from src.portfolio_truth_sources import ( + _dedupe_checkouts_by_origin, + _is_ignored_project_dir, + discover_workspace_projects, +) def _p(name: str, repo_full_name: str = "", path: str | None = None) -> dict: @@ -76,3 +82,49 @@ def test_result_is_sorted_by_name_case_insensitively() -> None: ] result = _dedupe_checkouts_by_origin(discovered) assert [p["name"] for p in result] == ["Alpha", "mike", "zeta"] + + +# --- discovery ignore-list: transient / non-project directories --- +# NoGoPRJs (operator-flagged never-pursued), `*-smoke-export` (generated +# AuraForge bundles), and `*-tmp-` clones are scratch artifacts, not real +# projects. Discovery must skip them (and their subtrees) so they never reach +# the catalog-completeness gate. + + +def test_ignore_predicate_matches_transient_dirs() -> None: + assert _is_ignored_project_dir("Misc:NoGoPRJs") # colon form, as on disk + assert _is_ignored_project_dir("NoGoPRJs") + assert _is_ignored_project_dir("auraforge-signed-smoke-export") + assert _is_ignored_project_dir("resume-evolver-tmp-1776063720") + + +def test_ignore_predicate_keeps_real_projects() -> None: + # guard against over-broad matching: legit names that merely resemble a rule + for name in ( + "GithubRepoAuditor", + "ApplyKit-public", + "cost-tracker", + "resume-evolver", # the real repo, sans -tmp- suffix + "smoke-test-runner", # "smoke" but not "smoke-export" + "tmp-tools", # "tmp" but not the -tmp- clone pattern + ): + assert not _is_ignored_project_dir(name), name + + +def test_discovery_skips_ignored_subtrees(tmp_path) -> None: + def _project(*parts: str) -> None: + d = tmp_path.joinpath(*parts) + d.mkdir(parents=True) + (d / "README.md").write_text("# fixture") + + _project("LegitProject") # real top-level project -> kept + _project("NoGoPRJs", "app") # nested under ignored container -> skipped + _project("auraforge-signed-smoke-export", "foo-plan") # ignored bundle -> skipped + _project("resume-evolver-tmp-1776063720") # top-level tmp clone -> skipped + + result = discover_workspace_projects( + tmp_path, + catalog_data={}, + now=datetime(2026, 6, 2, tzinfo=timezone.utc), + ) + assert {p["name"] for p in result} == {"LegitProject"}