docs: plan semantic version bumper #9
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
| name: quality | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: quality-${{ github.ref }} | |
| cancel-in-progress: true | |
| defaults: | |
| run: | |
| shell: bash | |
| jobs: | |
| repository-sanity: | |
| name: repository sanity checks | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Validate tracked file hygiene | |
| run: | | |
| python - <<'PY' | |
| from pathlib import Path | |
| import subprocess | |
| import sys | |
| tracked = subprocess.check_output( | |
| ["git", "ls-files"], text=True, encoding="utf-8" | |
| ).splitlines() | |
| failures = [] | |
| text_suffixes = { | |
| ".py", ".yml", ".yaml", ".md", ".txt", ".json", ".toml", ".ini", ".cfg" | |
| } | |
| for rel in tracked: | |
| path = Path(rel) | |
| if path.suffix.lower() not in text_suffixes: | |
| continue | |
| data = path.read_bytes() | |
| if not data: | |
| continue | |
| if b"\r\n" in data: | |
| failures.append(f"{rel}: contains CRLF line endings") | |
| if not data.endswith(b"\n"): | |
| failures.append(f"{rel}: missing final newline") | |
| for lineno, line in enumerate(data.splitlines(), start=1): | |
| if line.rstrip(b" \t") != line: | |
| failures.append(f"{rel}:{lineno}: trailing whitespace") | |
| if failures: | |
| print("Tracked file hygiene failures:") | |
| print("\n".join(failures)) | |
| sys.exit(1) | |
| print(f"Validated hygiene for {len(tracked)} tracked files.") | |
| PY | |
| - name: Validate GitHub workflow YAML syntax | |
| run: | | |
| ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "#{f}: yaml ok" }' .github/workflows/*.yml .semgrep.yml | |
| - name: Check for hardcoded credential-like assignments | |
| run: | | |
| python - <<'PY' | |
| from pathlib import Path | |
| import re | |
| import subprocess | |
| import sys | |
| tracked = subprocess.check_output(["git", "ls-files"], text=True).splitlines() | |
| suffixes = {".py", ".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".md"} | |
| # Looks for real-looking credential assignments while intentionally ignoring | |
| # scanner rule definitions and non-secret placeholders used by tests. | |
| pattern = re.compile( | |
| r"(?i)\b(api[_-]?key|secret|password|passwd|private[_-]?token|access[_-]?token)\b" | |
| r"\s*[:=]\s*['\"][A-Za-z0-9_./+=:-]{12,}['\"]" | |
| ) | |
| allowlisted = { | |
| ".semgrep.yml", | |
| ".github/workflows/quality.yml", | |
| "token-rotate/test_gitlab_project_token_rotator.py", | |
| } | |
| failures = [] | |
| for rel in tracked: | |
| if rel in allowlisted or Path(rel).suffix.lower() not in suffixes: | |
| continue | |
| text = Path(rel).read_text(encoding="utf-8", errors="ignore") | |
| for lineno, line in enumerate(text.splitlines(), start=1): | |
| if pattern.search(line): | |
| failures.append(f"{rel}:{lineno}: possible hardcoded credential assignment") | |
| if failures: | |
| print("\n".join(failures)) | |
| sys.exit(1) | |
| print("No hardcoded credential-like assignments found in tracked source files.") | |
| PY | |
| python-quality: | |
| name: python ${{ matrix.python-version }} quality gates | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: ["3.10", "3.11", "3.12", "3.13"] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| - name: Confirm production code uses only standard-library or local imports | |
| run: | | |
| python - <<'PY' | |
| import ast | |
| import pathlib | |
| import sys | |
| sources = [pathlib.Path("token-rotate/gitlab_project_token_rotator.py")] | |
| sources.extend(pathlib.Path("token-rotate/token_rotate").glob("*.py")) | |
| allowed_local = {"__future__", "token_rotate"} | |
| stdlib = set(getattr(sys, "stdlib_module_names", ())) | allowed_local | |
| failures = [] | |
| for source in sources: | |
| tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) | |
| imported = set() | |
| for node in ast.walk(tree): | |
| if isinstance(node, ast.Import): | |
| imported.update(alias.name.split(".")[0] for alias in node.names) | |
| elif isinstance(node, ast.ImportFrom) and node.module: | |
| if node.level: | |
| continue | |
| imported.add(node.module.split(".")[0]) | |
| third_party = sorted(name for name in imported if name not in stdlib) | |
| if third_party: | |
| failures.append(f"{source}: {third_party}") | |
| if failures: | |
| print("Non-standard-library imports found in production code:") | |
| print("\n".join(failures)) | |
| sys.exit(1) | |
| print("Production code imports are standard-library or local package only.") | |
| PY | |
| - name: Compile every tracked Python file | |
| run: | | |
| python - <<'PY' | |
| import py_compile | |
| import subprocess | |
| import sys | |
| files = subprocess.check_output( | |
| ["git", "ls-files", "*.py"], text=True, encoding="utf-8" | |
| ).splitlines() | |
| for filename in files: | |
| py_compile.compile(filename, doraise=True) | |
| print(f"Compiled {len(files)} Python files.") | |
| PY | |
| - name: Run unit tests without coverage first | |
| run: python -m unittest discover -s token-rotate -p 'test_*.py' -v | |
| - name: Install coverage quality tool | |
| run: python -m pip install --upgrade coverage | |
| - name: Run tests with branch coverage gate | |
| run: python token-rotate/quality_gate.py --min-coverage 95 | |
| - name: Write coverage XML artifact | |
| if: matrix.python-version == '3.12' | |
| run: python -m coverage xml -o coverage.xml | |
| - name: Upload coverage XML artifact | |
| if: matrix.python-version == '3.12' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-xml | |
| path: coverage.xml | |
| - name: CLI smoke tests | |
| run: | | |
| python token-rotate/gitlab_project_token_rotator.py --help >/tmp/rotator-help.txt | |
| python token-rotate/quality_gate.py --help >/tmp/quality-gate-help.txt | |
| test -s /tmp/rotator-help.txt | |
| test -s /tmp/quality-gate-help.txt | |
| sonar: | |
| name: sonar scan | |
| runs-on: ubuntu-latest | |
| needs: python-quality | |
| if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Download coverage XML artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: coverage-xml | |
| path: . | |
| - name: Normalize SonarCloud main branch | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| run: | | |
| set +x | |
| curl -fsS -u "${SONAR_TOKEN}:" -X POST "https://sonarcloud.io/api/project_branches/delete" \ | |
| --data-urlencode "project=RandomCodeSpace_glab-utils" \ | |
| --data-urlencode "branch=main" >/dev/null || true | |
| curl -fsS -u "${SONAR_TOKEN}:" -X POST "https://sonarcloud.io/api/project_branches/rename" \ | |
| --data-urlencode "project=RandomCodeSpace_glab-utils" \ | |
| --data-urlencode "name=main" >/dev/null || true | |
| - name: Run Sonar scan | |
| uses: SonarSource/sonarqube-scan-action@v6 | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| semgrep: | |
| name: semgrep security scan | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install Semgrep | |
| run: python -m pip install --upgrade semgrep | |
| - name: Run custom, Python, and secrets Semgrep rules | |
| run: semgrep scan --config .semgrep.yml --config p/python --config p/secrets --error . |