shipcheck-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ name: Bug report
2
+ description: Report a reproducible problem with ShipCheck.
3
+ title: "bug: "
4
+ labels:
5
+ - bug
6
+ body:
7
+ - type: markdown
8
+ attributes:
9
+ value: |
10
+ Thanks for reporting a bug. Please remove any secrets or sensitive project data before submitting.
11
+ - type: input
12
+ id: version
13
+ attributes:
14
+ label: ShipCheck version
15
+ placeholder: "0.1.0"
16
+ validations:
17
+ required: true
18
+ - type: textarea
19
+ id: description
20
+ attributes:
21
+ label: What happened?
22
+ description: Describe the problem and the expected behavior.
23
+ validations:
24
+ required: true
25
+ - type: textarea
26
+ id: reproduce
27
+ attributes:
28
+ label: How can we reproduce it?
29
+ description: Include the command and a minimal project/configuration example.
30
+ validations:
31
+ required: true
32
+ - type: textarea
33
+ id: environment
34
+ attributes:
35
+ label: Environment
36
+ description: Include Python version and operating system.
37
+ validations:
38
+ required: true
@@ -0,0 +1,25 @@
1
+ name: Feature request
2
+ description: Suggest an improvement or new capability for ShipCheck.
3
+ title: "feat: "
4
+ labels:
5
+ - enhancement
6
+ body:
7
+ - type: textarea
8
+ id: problem
9
+ attributes:
10
+ label: Problem or use case
11
+ description: What problem would this feature solve?
12
+ validations:
13
+ required: true
14
+ - type: textarea
15
+ id: proposal
16
+ attributes:
17
+ label: Proposed solution
18
+ description: Describe the behavior or interface you would like.
19
+ validations:
20
+ required: true
21
+ - type: textarea
22
+ id: alternatives
23
+ attributes:
24
+ label: Alternatives considered
25
+ description: Describe any alternative approaches you considered.
@@ -0,0 +1,44 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13"]
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ cache: pip
24
+
25
+ - name: Upgrade packaging tools
26
+ run: python -m pip install --upgrade pip build
27
+
28
+ - name: Install project
29
+ run: python -m pip install -e ".[dev]"
30
+
31
+ - name: Lint
32
+ run: ruff check .
33
+
34
+ - name: Test
35
+ run: pytest -q
36
+
37
+ - name: Build package
38
+ run: python -m build
39
+
40
+ - name: Validate wheel metadata
41
+ run: python -m pip install dist/*.whl --force-reinstall
42
+
43
+ - name: CLI smoke test
44
+ run: shipcheck . --json
@@ -0,0 +1,65 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+
8
+ permissions:
9
+ contents: write
10
+ id-token: write
11
+
12
+ jobs:
13
+ release:
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.11"
24
+
25
+ - name: Validate tag version
26
+ env:
27
+ TAG_NAME: ${{ github.ref_name }}
28
+ run: |
29
+ python - <<'PY'
30
+ import os
31
+ import tomllib
32
+
33
+ with open("pyproject.toml", "rb") as fh:
34
+ version = tomllib.load(fh)["project"]["version"]
35
+
36
+ tag_version = os.environ["TAG_NAME"].removeprefix("v")
37
+ if version != tag_version:
38
+ raise SystemExit(
39
+ f"Tag version {tag_version!r} does not match pyproject.toml version {version!r}"
40
+ )
41
+ PY
42
+
43
+ - name: Install build tooling
44
+ run: python -m pip install --upgrade pip build twine
45
+
46
+ - name: Build package
47
+ run: python -m build
48
+
49
+ - name: Validate distributions
50
+ run: twine check dist/*
51
+
52
+ - name: Replace existing GitHub Release
53
+ env:
54
+ GH_TOKEN: ${{ github.token }}
55
+ TAG_NAME: ${{ github.ref_name }}
56
+ run: gh release delete "$TAG_NAME" --yes || true
57
+
58
+ - name: Create GitHub Release
59
+ env:
60
+ GH_TOKEN: ${{ github.token }}
61
+ TAG_NAME: ${{ github.ref_name }}
62
+ run: gh release create "$TAG_NAME" dist/* --generate-notes --verify-tag
63
+
64
+ - name: Publish to PyPI
65
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,24 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python-version: ["3.11", "3.12", "3.13"]
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install project
20
+ run: python -m pip install --upgrade pip && pip install -e ".[dev]"
21
+ - name: Run tests
22
+ run: pytest -q
23
+ - name: Lint
24
+ run: ruff check .
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ .env
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
10
+ dist/
11
+ build/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
@@ -0,0 +1,3 @@
1
+ # Optional ShipCheck project policy.
2
+ # The CLI flag --threshold overrides this value.
3
+ threshold = 80
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to ShipCheck are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow Semantic Versioning.
6
+
7
+ ## [0.1.0] - 2026-09-09
8
+
9
+ ### Added
10
+
11
+ - Project, Git, framework, README, `.gitignore`, and environment checks.
12
+ - Dependency manifest and deployment configuration detection.
13
+ - Provider-aware validation for Vercel, Docker, and GitHub Actions.
14
+ - Secret scanning for common credential formats.
15
+ - Weighted deployment-readiness scoring.
16
+ - Configurable readiness thresholds through `.shipcheck.toml` and `--threshold`.
17
+ - Deployment gate mode with a non-zero exit code when readiness fails.
18
+ - Rich terminal reports and JSON output.
19
+ - Automated test coverage and GitHub Actions CI across Python 3.11–3.13.
20
+ - Python package build and CLI smoke-test validation in CI.
21
+
22
+ [0.1.0]: https://github.com/farukislamyt/ShipCheck/releases/tag/v0.1.0
@@ -0,0 +1,46 @@
1
+ # Contributing to ShipCheck
2
+
3
+ Thanks for contributing.
4
+
5
+ ## Development setup
6
+
7
+ ShipCheck requires Python 3.11 or newer.
8
+
9
+ ```bash
10
+ git clone https://github.com/farukislamyt/ShipCheck.git
11
+ cd ShipCheck
12
+ python -m pip install -e ".[dev]"
13
+ ```
14
+
15
+ ## Before opening a pull request
16
+
17
+ Run the same core checks used by CI:
18
+
19
+ ```bash
20
+ ruff check .
21
+ pytest -q
22
+ python -m build
23
+ ```
24
+
25
+ Keep changes focused and add or update tests for behavior changes.
26
+
27
+ ## Pull requests
28
+
29
+ Please include:
30
+
31
+ - A concise description of the problem and solution.
32
+ - Tests covering new or changed behavior.
33
+ - Documentation updates when the CLI, configuration, or user-facing behavior changes.
34
+ - Any compatibility or migration considerations.
35
+
36
+ Do not commit credentials, API keys, private keys, `.env` files containing secrets, build artifacts, or local virtual environments.
37
+
38
+ ## Commit and code style
39
+
40
+ Use clear, imperative commit messages such as `feat: add provider validation` or `fix: handle malformed workflow YAML`.
41
+
42
+ Follow the existing Python style and Ruff configuration in `pyproject.toml`.
43
+
44
+ ## Security issues
45
+
46
+ Do not disclose security vulnerabilities in public issues. Follow the process in [SECURITY.md](SECURITY.md).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faruk Islam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: shipcheck-cli
3
+ Version: 0.1.0
4
+ Summary: Pre-deployment health checks for software projects.
5
+ Author: Faruk Islam
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: rich>=13.7
11
+ Requires-Dist: typer>=0.12
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff>=0.6; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ShipCheck
18
+
19
+ > Pre-deployment health checks for modern software projects.
20
+
21
+ ShipCheck is a developer-first CLI that scans a project before deployment and highlights configuration problems, missing files, exposed secrets, dependency issues, and other deployment risks.
22
+
23
+ ## Status
24
+
25
+ 🚀 v0.1.0 — first public release.
26
+
27
+ ## What it checks
28
+
29
+ - Git repository and working-tree status
30
+ - Framework detection
31
+ - `.gitignore` and environment configuration
32
+ - Secret detection
33
+ - Dependency manifests
34
+ - Deployment configuration
35
+ - Provider-specific deployment configuration
36
+ - Tests and CI/CD signals
37
+ - Project documentation
38
+
39
+ ## Installation
40
+
41
+ ### From PyPI
42
+
43
+ ```bash
44
+ python -m pip install shipcheck-cli
45
+ ```
46
+
47
+ ### From source
48
+
49
+ ```bash
50
+ git clone https://github.com/farukislamyt/ShipCheck.git
51
+ cd ShipCheck
52
+ python -m pip install -e ".[dev]"
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ Run a readiness report:
58
+
59
+ ```bash
60
+ shipcheck .
61
+ ```
62
+
63
+ Machine-readable JSON output:
64
+
65
+ ```bash
66
+ shipcheck . --json
67
+ ```
68
+
69
+ Use the deployment gate in CI/CD. It exits with status `1` when a check fails or the readiness score is below the configured threshold:
70
+
71
+ ```bash
72
+ shipcheck . --gate
73
+ ```
74
+
75
+ Override the default readiness threshold of 80:
76
+
77
+ ```bash
78
+ shipcheck . --gate --threshold 90
79
+ ```
80
+
81
+ ## Configuration
82
+
83
+ Create `.shipcheck.toml` in the project root to persist the readiness threshold:
84
+
85
+ ```toml
86
+ [shipcheck]
87
+ threshold = 80
88
+ ```
89
+
90
+ The CLI `--threshold` option takes precedence over the configuration file.
91
+
92
+ ## Release process
93
+
94
+ Releases are tag-driven. The GitHub Actions release workflow validates that the tag version matches `pyproject.toml`, builds the package, runs `twine check`, creates a GitHub Release, and publishes distributions to PyPI using trusted publishing.
95
+
96
+ For example:
97
+
98
+ ```bash
99
+ git tag v0.1.0
100
+ git push origin v0.1.0
101
+ ```
102
+
103
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
104
+
105
+ ## Development
106
+
107
+ Install development dependencies and run the checks locally:
108
+
109
+ ```bash
110
+ python -m pip install -e ".[dev]"
111
+ ruff check .
112
+ pytest -q
113
+ python -m build
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT License.
@@ -0,0 +1,102 @@
1
+ # ShipCheck
2
+
3
+ > Pre-deployment health checks for modern software projects.
4
+
5
+ ShipCheck is a developer-first CLI that scans a project before deployment and highlights configuration problems, missing files, exposed secrets, dependency issues, and other deployment risks.
6
+
7
+ ## Status
8
+
9
+ 🚀 v0.1.0 — first public release.
10
+
11
+ ## What it checks
12
+
13
+ - Git repository and working-tree status
14
+ - Framework detection
15
+ - `.gitignore` and environment configuration
16
+ - Secret detection
17
+ - Dependency manifests
18
+ - Deployment configuration
19
+ - Provider-specific deployment configuration
20
+ - Tests and CI/CD signals
21
+ - Project documentation
22
+
23
+ ## Installation
24
+
25
+ ### From PyPI
26
+
27
+ ```bash
28
+ python -m pip install shipcheck-cli
29
+ ```
30
+
31
+ ### From source
32
+
33
+ ```bash
34
+ git clone https://github.com/farukislamyt/ShipCheck.git
35
+ cd ShipCheck
36
+ python -m pip install -e ".[dev]"
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ Run a readiness report:
42
+
43
+ ```bash
44
+ shipcheck .
45
+ ```
46
+
47
+ Machine-readable JSON output:
48
+
49
+ ```bash
50
+ shipcheck . --json
51
+ ```
52
+
53
+ Use the deployment gate in CI/CD. It exits with status `1` when a check fails or the readiness score is below the configured threshold:
54
+
55
+ ```bash
56
+ shipcheck . --gate
57
+ ```
58
+
59
+ Override the default readiness threshold of 80:
60
+
61
+ ```bash
62
+ shipcheck . --gate --threshold 90
63
+ ```
64
+
65
+ ## Configuration
66
+
67
+ Create `.shipcheck.toml` in the project root to persist the readiness threshold:
68
+
69
+ ```toml
70
+ [shipcheck]
71
+ threshold = 80
72
+ ```
73
+
74
+ The CLI `--threshold` option takes precedence over the configuration file.
75
+
76
+ ## Release process
77
+
78
+ Releases are tag-driven. The GitHub Actions release workflow validates that the tag version matches `pyproject.toml`, builds the package, runs `twine check`, creates a GitHub Release, and publishes distributions to PyPI using trusted publishing.
79
+
80
+ For example:
81
+
82
+ ```bash
83
+ git tag v0.1.0
84
+ git push origin v0.1.0
85
+ ```
86
+
87
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
88
+
89
+ ## Development
90
+
91
+ Install development dependencies and run the checks locally:
92
+
93
+ ```bash
94
+ python -m pip install -e ".[dev]"
95
+ ruff check .
96
+ pytest -q
97
+ python -m build
98
+ ```
99
+
100
+ ## License
101
+
102
+ MIT License.
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are provided for the latest released version of ShipCheck. Development versions on `main` may change without notice.
6
+
7
+ | Version | Supported |
8
+ | --- | --- |
9
+ | 0.1.x | Yes |
10
+ | < 0.1.0 | No |
11
+
12
+ ## Reporting a vulnerability
13
+
14
+ Please do **not** report security vulnerabilities through public GitHub issues.
15
+
16
+ Instead, use GitHub's private vulnerability reporting feature for this repository when available. Include:
17
+
18
+ - A clear description of the vulnerability.
19
+ - Steps to reproduce or a minimal proof of concept.
20
+ - The affected version or commit.
21
+ - Any relevant logs, stack traces, or configuration details.
22
+ - The potential security impact.
23
+
24
+ Please avoid including real credentials, API keys, private keys, personal data, or other sensitive information in the report.
25
+
26
+ We will acknowledge valid reports and coordinate a fix and disclosure timeline with the reporter.
27
+
28
+ ## Secret-scanning note
29
+
30
+ ShipCheck is designed to identify common credential patterns before deployment. Its scanner is heuristic and should not be treated as a replacement for dedicated secret-management or secret-scanning systems.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shipcheck-cli"
7
+ version = "0.1.0"
8
+ description = "Pre-deployment health checks for software projects."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Faruk Islam" }]
13
+ dependencies = [
14
+ "typer>=0.12",
15
+ "rich>=13.7",
16
+ "PyYAML>=6.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "ruff>=0.6",
23
+ ]
24
+
25
+ [project.scripts]
26
+ shipcheck = "shipcheck.cli:app"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/shipcheck"]
30
+
31
+ [tool.ruff]
32
+ line-length = 120
33
+
34
+ target-version = "py311"
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ addopts = "-ra"
@@ -0,0 +1,3 @@
1
+ """ShipCheck package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+
13
+ app = typer.Typer(help="Pre-deployment health checks for software projects.")
14
+ console = Console()
15
+ PATH_ARGUMENT = typer.Argument(None, exists=True, file_okay=False, dir_okay=True)
16
+
17
+ SECRET_PATTERNS = {
18
+ "AWS access key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
19
+ "GitHub token": re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
20
+ "Private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
21
+ "Google API key": re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
22
+ "Slack token": re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b"),
23
+ "Stripe live key": re.compile(r"\bsk_live_[0-9A-Za-z]{16,}\b"),
24
+ "Generic API key": re.compile(r"(?i)(api[_-]?key|secret[_-]?key|access[_-]?token)\s*[:=]\s*[\"']([^\"']{16,})[\"']"),
25
+ }
26
+
27
+ IGNORED_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", "dist", "build", ".mypy_cache", ".ruff_cache"}
28
+ SCANNABLE_SUFFIXES = {".py", ".js", ".jsx", ".ts", ".tsx", ".json", ".yaml", ".yml", ".toml", ".ini", ".env", ".txt", ".cfg", ".conf"}
29
+ PLACEHOLDER_VALUES = {"example-placeholder", "changeme", "change-me", "your-api-key", "your-secret-key", "replace-me"}
30
+
31
+ CHECK_WEIGHTS = {
32
+ "Project directory": 5,
33
+ "Git repository": 10,
34
+ "Git working tree": 10,
35
+ "Framework detection": 5,
36
+ "README": 5,
37
+ ".gitignore": 10,
38
+ "Environment configuration": 10,
39
+ "Dependency manifest": 10,
40
+ "Deployment config": 10,
41
+ "Provider validation": 5,
42
+ "Tests": 10,
43
+ "Secrets scan": 10,
44
+ }
45
+ DEFAULT_THRESHOLD = 80
46
+
47
+
48
+ def _git_clean(path: Path) -> str:
49
+ try:
50
+ result = subprocess.run(["git", "-C", str(path), "status", "--porcelain"], capture_output=True, text=True, timeout=5, check=False)
51
+ except (OSError, subprocess.SubprocessError):
52
+ return "WARN"
53
+ if result.returncode != 0:
54
+ return "WARN"
55
+ return "PASS" if not result.stdout.strip() else "WARN"
56
+
57
+
58
+ def _has_dependency_manifest(path: Path) -> str:
59
+ manifests = ("pyproject.toml", "requirements.txt", "poetry.lock", "uv.lock", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "go.mod", "go.sum", "Cargo.toml", "Cargo.lock")
60
+ return "PASS" if any((path / name).exists() for name in manifests) else "WARN"
61
+
62
+
63
+ def _has_tests(path: Path) -> str:
64
+ if any((path / name).is_dir() for name in ("tests", "test", "spec")):
65
+ return "PASS"
66
+ return "PASS" if any(p.name.startswith(("test_", "spec_")) for p in path.rglob("*") if p.is_file()) else "WARN"
67
+
68
+
69
+ def _framework(path: Path) -> str:
70
+ if (path / "manage.py").exists():
71
+ return "Django"
72
+ if (path / "pyproject.toml").exists() or (path / "requirements.txt").exists():
73
+ try:
74
+ files = [p for p in (path / "pyproject.toml", path / "requirements.txt") if p.exists()]
75
+ text = "\n".join(p.read_text(encoding="utf-8", errors="ignore") for p in files).lower()
76
+ for name, label in (("fastapi", "FastAPI"), ("flask", "Flask"), ("django", "Django")):
77
+ if name in text:
78
+ return label
79
+ except OSError:
80
+ pass
81
+ return "Python"
82
+ package = path / "package.json"
83
+ if package.exists():
84
+ try:
85
+ data = json.loads(package.read_text(encoding="utf-8", errors="ignore"))
86
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
87
+ for name, label in (("next", "Next.js"), ("react", "React"), ("vue", "Vue"), ("express", "Express")):
88
+ if name in deps:
89
+ return label
90
+ except (OSError, json.JSONDecodeError):
91
+ pass
92
+ return "Node.js"
93
+ return "Unknown"
94
+
95
+
96
+ def _env_status(path: Path) -> str:
97
+ env = path / ".env"
98
+ template = next((path / name for name in (".env.example", ".env.template") if (path / name).exists()), None)
99
+ if not env.exists() and template is None:
100
+ return "WARN"
101
+ if not env.exists():
102
+ return "WARN"
103
+ if template is None:
104
+ return "PASS"
105
+ try:
106
+ env_keys = {line.split("=", 1)[0].strip() for line in env.read_text(errors="ignore").splitlines() if "=" in line and line.strip() and not line.lstrip().startswith("#")}
107
+ template_keys = {line.split("=", 1)[0].strip() for line in template.read_text(errors="ignore").splitlines() if "=" in line and line.strip() and not line.lstrip().startswith("#")}
108
+ return "PASS" if template_keys <= env_keys else "WARN"
109
+ except OSError:
110
+ return "WARN"
111
+
112
+
113
+ def _deployment_files(path: Path) -> str:
114
+ return "PASS" if _deployment_provider(path) != "Unknown" else "WARN"
115
+
116
+
117
+ def _deployment_provider(path: Path) -> str:
118
+ if (path / "vercel.json").exists() or (path / ".vercel").is_dir():
119
+ return "Vercel"
120
+ if any((path / name).exists() for name in ("Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")):
121
+ return "Docker"
122
+ if (path / "Procfile").exists():
123
+ return "Procfile-compatible"
124
+ workflows = path / ".github" / "workflows"
125
+ if workflows.is_dir() and any(p.suffix in {".yml", ".yaml"} for p in workflows.iterdir() if p.is_file()):
126
+ return "GitHub Actions"
127
+ return "Unknown"
128
+
129
+
130
+ def _read_text(path: Path) -> str | None:
131
+ try:
132
+ return path.read_text(encoding="utf-8", errors="ignore")
133
+ except OSError:
134
+ return None
135
+
136
+
137
+ def _load_yaml_mapping(path: Path) -> dict | None:
138
+ text = _read_text(path)
139
+ if text is None:
140
+ return None
141
+ try:
142
+ data = yaml.safe_load(text)
143
+ except yaml.YAMLError:
144
+ return None
145
+ return data if isinstance(data, dict) else None
146
+
147
+
148
+ def _validate_vercel(path: Path) -> str:
149
+ config = path / "vercel.json"
150
+ if not config.exists():
151
+ return "WARN"
152
+ try:
153
+ data = json.loads(config.read_text(encoding="utf-8"))
154
+ except (OSError, json.JSONDecodeError):
155
+ return "FAIL"
156
+ return "PASS" if isinstance(data, dict) else "FAIL"
157
+
158
+
159
+ def _validate_docker(path: Path) -> str:
160
+ dockerfile = path / "Dockerfile"
161
+ if dockerfile.exists():
162
+ text = _read_text(dockerfile)
163
+ if text is None:
164
+ return "WARN"
165
+ return "PASS" if re.search(r"(?m)^\s*FROM\s+\S+", text) else "FAIL"
166
+ compose = next((path / name for name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml") if (path / name).exists()), None)
167
+ if compose is None:
168
+ return "WARN"
169
+ data = _load_yaml_mapping(compose)
170
+ return "PASS" if data is not None and isinstance(data.get("services"), dict) and data["services"] else "FAIL"
171
+
172
+
173
+ def _validate_github_actions(path: Path) -> str:
174
+ workflows = path / ".github" / "workflows"
175
+ files = [p for p in workflows.iterdir() if p.is_file() and p.suffix in {".yml", ".yaml"}] if workflows.is_dir() else []
176
+ if not files:
177
+ return "WARN"
178
+ for workflow in files:
179
+ data = _load_yaml_mapping(workflow)
180
+ if data is None:
181
+ return "FAIL"
182
+ if not isinstance(data.get("name"), str) or not data.get("name", "").strip():
183
+ return "FAIL"
184
+ if "on" not in data and True not in data:
185
+ return "FAIL"
186
+ jobs = data.get("jobs")
187
+ if not isinstance(jobs, dict) or not jobs:
188
+ return "FAIL"
189
+ return "PASS"
190
+
191
+
192
+ def _provider_validation(path: Path, provider: str) -> str:
193
+ if provider == "Vercel":
194
+ return _validate_vercel(path)
195
+ if provider == "Docker":
196
+ return _validate_docker(path)
197
+ if provider == "GitHub Actions":
198
+ return _validate_github_actions(path)
199
+ if provider == "Procfile-compatible":
200
+ procfile = path / "Procfile"
201
+ text = _read_text(procfile)
202
+ return "PASS" if text and any(re.match(r"^\s*[A-Za-z][A-Za-z0-9_-]*\s*:", line) for line in text.splitlines()) else "FAIL"
203
+ return "WARN"
204
+
205
+
206
+ def _secret_findings(path: Path) -> list[str]:
207
+ findings: list[str] = []
208
+ seen: set[tuple[str, str]] = set()
209
+ for file in path.rglob("*"):
210
+ if not file.is_file() or any(part in IGNORED_DIRS for part in file.parts) or file.suffix.lower() not in SCANNABLE_SUFFIXES:
211
+ continue
212
+ try:
213
+ text = file.read_text(encoding="utf-8", errors="ignore")
214
+ except OSError:
215
+ continue
216
+ for label, pattern in SECRET_PATTERNS.items():
217
+ match = pattern.search(text)
218
+ if match and not (label == "Generic API key" and match.group(2).strip().lower() in PLACEHOLDER_VALUES):
219
+ finding = (str(file.relative_to(path)), label)
220
+ if finding not in seen:
221
+ findings.append(f"{finding[0]}: {finding[1]}")
222
+ seen.add(finding)
223
+ return findings
224
+
225
+
226
+ def check_project(path: Path) -> list[tuple[str, str]]:
227
+ provider = _deployment_provider(path)
228
+ return [
229
+ ("Project directory", "PASS" if path.is_dir() else "FAIL"),
230
+ ("Git repository", "PASS" if (path / ".git").exists() else "WARN"),
231
+ ("Git working tree", _git_clean(path) if (path / ".git").exists() else "WARN"),
232
+ ("Framework detection", "PASS" if _framework(path) != "Unknown" else "WARN"),
233
+ ("README", "PASS" if any((path / name).exists() for name in ("README.md", "README.rst", "README")) else "WARN"),
234
+ (".gitignore", "PASS" if (path / ".gitignore").exists() else "WARN"),
235
+ ("Environment configuration", _env_status(path)),
236
+ ("Dependency manifest", _has_dependency_manifest(path)),
237
+ ("Deployment config", _deployment_files(path)),
238
+ ("Provider validation", _provider_validation(path, provider)),
239
+ ("Tests", _has_tests(path)),
240
+ ("Secrets scan", "FAIL" if _secret_findings(path) else "PASS"),
241
+ ]
242
+
243
+
244
+ def calculate_score(checks: list[tuple[str, str]]) -> int:
245
+ total = sum(CHECK_WEIGHTS.get(name, 0) for name, _ in checks)
246
+ earned = sum(CHECK_WEIGHTS.get(name, 0) for name, status in checks if status == "PASS")
247
+ return round((earned / total) * 100) if total else 0
248
+
249
+
250
+ def is_deployable(checks: list[tuple[str, str]], score: int | None = None, threshold: int = DEFAULT_THRESHOLD) -> bool:
251
+ if any(status == "FAIL" for _, status in checks):
252
+ return False
253
+ return (calculate_score(checks) if score is None else score) >= threshold
254
+
255
+
256
+ def _configured_threshold(path: Path) -> int:
257
+ config = path / ".shipcheck.toml"
258
+ if not config.exists():
259
+ return DEFAULT_THRESHOLD
260
+ try:
261
+ for line in config.read_text(encoding="utf-8", errors="ignore").splitlines():
262
+ if line.strip().startswith("threshold") and "=" in line:
263
+ value = int(line.split("=", 1)[1].strip())
264
+ if 0 <= value <= 100:
265
+ return value
266
+ except (OSError, ValueError):
267
+ pass
268
+ return DEFAULT_THRESHOLD
269
+
270
+
271
+ @app.command()
272
+ def scan(
273
+ path: Path | None = PATH_ARGUMENT,
274
+ json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
275
+ gate: bool = typer.Option(False, "--gate", help="Exit with code 1 when the deployment gate fails."),
276
+ threshold: int | None = typer.Option(None, min=0, max=100, help="Minimum readiness score required by --gate."),
277
+ ) -> None:
278
+ """Scan PATH and report deployment readiness checks."""
279
+ path = (path or Path(".")).resolve()
280
+ checks = check_project(path)
281
+ secrets = _secret_findings(path)
282
+ score = calculate_score(checks)
283
+ configured_threshold = _configured_threshold(path)
284
+ effective_threshold = configured_threshold if threshold is None else threshold
285
+ provider = _deployment_provider(path)
286
+ deployable = is_deployable(checks, score, effective_threshold)
287
+ payload = {"project": path.name, "framework": _framework(path), "deployment_provider": provider, "score": score, "threshold": effective_threshold, "deployable": deployable, "checks": [{"name": n, "status": s} for n, s in checks], "secret_findings": secrets}
288
+
289
+ if json_output:
290
+ typer.echo(json.dumps(payload, indent=2))
291
+ else:
292
+ console.print(Panel.fit("[bold]ShipCheck[/bold]\nPre-deployment health check"))
293
+ console.print(f"\n[bold]Project:[/bold] {path.name}")
294
+ console.print(f"[bold]Framework:[/bold] {_framework(path)}")
295
+ console.print(f"[bold]Deployment target:[/bold] {provider}\n")
296
+ for name, status in checks:
297
+ icon = {"PASS": "[green]✓[/green]", "WARN": "[yellow]⚠[/yellow]", "FAIL": "[red]✗[/red]"}[status]
298
+ console.print(f" {icon} {name}")
299
+ if secrets:
300
+ console.print("\n[bold red]Potential secrets:[/bold red]")
301
+ for finding in secrets[:10]:
302
+ console.print(f" [red]•[/red] {finding}")
303
+ verdict = "READY TO DEPLOY" if deployable else "BLOCKED"
304
+ console.print(f"\n[bold]Deployment Readiness:[/bold] {score}% / {effective_threshold}% — {verdict}")
305
+
306
+ if gate and not deployable:
307
+ raise typer.Exit(code=1)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ app()
@@ -0,0 +1,19 @@
1
+ from pathlib import Path
2
+
3
+ from shipcheck.cli import _secret_findings, check_project
4
+
5
+
6
+ def test_secret_detection(tmp_path: Path) -> None:
7
+ (tmp_path / "config.py").write_text('API_KEY = "super-secret-value-12345"')
8
+ findings = _secret_findings(tmp_path)
9
+ assert findings
10
+ assert "Generic API key" in findings[0]
11
+
12
+
13
+ def test_dependency_and_tests_checks(tmp_path: Path) -> None:
14
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n")
15
+ (tmp_path / "tests").mkdir()
16
+ results = dict(check_project(tmp_path))
17
+ assert results["Dependency manifest"] == "PASS"
18
+ assert results["Tests"] == "PASS"
19
+ assert results["Secrets scan"] == "PASS"
@@ -0,0 +1,191 @@
1
+ from pathlib import Path
2
+
3
+ from typer.testing import CliRunner
4
+
5
+ from shipcheck.cli import (
6
+ _deployment_provider,
7
+ _env_status,
8
+ _framework,
9
+ _provider_validation,
10
+ _secret_findings,
11
+ app,
12
+ calculate_score,
13
+ check_project,
14
+ is_deployable,
15
+ )
16
+
17
+ runner = CliRunner()
18
+
19
+
20
+ def test_project_checks(tmp_path: Path) -> None:
21
+ (tmp_path / ".git").mkdir()
22
+ (tmp_path / "README.md").write_text("# Demo", encoding="utf-8")
23
+ (tmp_path / ".gitignore").write_text("__pycache__/", encoding="utf-8")
24
+ (tmp_path / "pyproject.toml").write_text('[project]\nname = "demo"\ndependencies = ["typer"]\n', encoding="utf-8")
25
+ (tmp_path / "tests").mkdir()
26
+
27
+ results = dict(check_project(tmp_path))
28
+
29
+ assert results["Project directory"] == "PASS"
30
+ assert results["Git repository"] == "PASS"
31
+ assert results["README"] == "PASS"
32
+ assert results[".gitignore"] == "PASS"
33
+ assert results["Dependency manifest"] == "PASS"
34
+ assert results["Tests"] == "PASS"
35
+
36
+
37
+ def test_framework_detection(tmp_path: Path) -> None:
38
+ (tmp_path / "requirements.txt").write_text("fastapi>=0.1", encoding="utf-8")
39
+ assert _framework(tmp_path) == "FastAPI"
40
+
41
+
42
+ def test_environment_template_requires_matching_keys(tmp_path: Path) -> None:
43
+ (tmp_path / ".env.example").write_text("DATABASE_URL=\nAPI_KEY=\n", encoding="utf-8")
44
+ (tmp_path / ".env").write_text("DATABASE_URL=test\n", encoding="utf-8")
45
+ assert _env_status(tmp_path) == "WARN"
46
+
47
+ (tmp_path / ".env").write_text("DATABASE_URL=test\nAPI_KEY=test\n", encoding="utf-8")
48
+ assert _env_status(tmp_path) == "PASS"
49
+
50
+
51
+ def test_secret_scanner_detects_common_secret(tmp_path: Path) -> None:
52
+ source = tmp_path / "config.py"
53
+ aws_key = "AKIA" + "1234567890ABCDEF"
54
+ source.write_text(f'AWS_ACCESS_KEY = "{aws_key}"\n', encoding="utf-8")
55
+ findings = _secret_findings(tmp_path)
56
+ assert any("AWS access key" in finding for finding in findings)
57
+
58
+
59
+ def test_secret_scanner_detects_provider_credentials(tmp_path: Path) -> None:
60
+ source = tmp_path / "config.py"
61
+ google_key = "AIza" + "SyA12345678901234567890123456789012"
62
+ slack_token = "xoxb-" + "1234567890-abcdefghijk"
63
+ stripe_key = "sk_live_" + "1234567890abcdef"
64
+ source.write_text(
65
+ f'GOOGLE = "{google_key}"\n'
66
+ f'SLACK = "{slack_token}"\n'
67
+ f'STRIPE = "{stripe_key}"\n',
68
+ encoding="utf-8",
69
+ )
70
+ findings = _secret_findings(tmp_path)
71
+ assert any("Google API key" in finding for finding in findings)
72
+ assert any("Slack token" in finding for finding in findings)
73
+ assert any("Stripe live key" in finding for finding in findings)
74
+
75
+
76
+ def test_secret_scanner_ignores_venv_and_build_artifacts(tmp_path: Path) -> None:
77
+ github_token = "ghp_" + "123456789012345678901234567890"
78
+ for dirname in (".venv", "node_modules", "dist", "build", ".git"):
79
+ ignored = tmp_path / dirname / "secrets.py"
80
+ ignored.parent.mkdir()
81
+ ignored.write_text(f'TOKEN = "{github_token}"', encoding="utf-8")
82
+ assert _secret_findings(tmp_path) == []
83
+
84
+
85
+ def test_secret_scanner_deduplicates_findings(tmp_path: Path) -> None:
86
+ source = tmp_path / "config.py"
87
+ github_token = "ghp_" + "123456789012345678901234567890"
88
+ source.write_text(f'TOKEN = "{github_token}"\n', encoding="utf-8")
89
+ assert _secret_findings(tmp_path) == ["config.py: GitHub token"]
90
+
91
+
92
+ def test_secret_scanner_ignores_short_generic_values(tmp_path: Path) -> None:
93
+ source = tmp_path / "config.py"
94
+ source.write_text('API_KEY = "example-placeholder"\n', encoding="utf-8")
95
+ assert _secret_findings(tmp_path) == []
96
+
97
+
98
+ def test_weighted_score_uses_check_weights() -> None:
99
+ checks = [("Tests", "PASS"), ("README", "WARN")]
100
+ assert calculate_score(checks) == 67
101
+
102
+
103
+ def test_secret_failure_blocks_deployment() -> None:
104
+ checks = [("Secrets scan", "FAIL"), ("Tests", "PASS")]
105
+ assert is_deployable(checks) is False
106
+
107
+
108
+ def test_threshold_blocks_low_score() -> None:
109
+ checks = [("Tests", "PASS"), ("README", "WARN")]
110
+ assert is_deployable(checks, score=75, threshold=80) is False
111
+ assert is_deployable(checks, score=75, threshold=70) is True
112
+
113
+
114
+ def test_deployment_provider_detection(tmp_path: Path) -> None:
115
+ (tmp_path / "vercel.json").write_text("{}", encoding="utf-8")
116
+ assert _deployment_provider(tmp_path) == "Vercel"
117
+
118
+ (tmp_path / "vercel.json").unlink()
119
+ (tmp_path / "Dockerfile").write_text("FROM python:3.11", encoding="utf-8")
120
+ assert _deployment_provider(tmp_path) == "Docker"
121
+
122
+ (tmp_path / "Dockerfile").unlink()
123
+ workflows = tmp_path / ".github" / "workflows"
124
+ workflows.mkdir(parents=True)
125
+ (workflows / "ci.yml").write_text("name: CI", encoding="utf-8")
126
+ assert _deployment_provider(tmp_path) == "GitHub Actions"
127
+
128
+
129
+ def test_vercel_config_validation(tmp_path: Path) -> None:
130
+ (tmp_path / "vercel.json").write_text('{"buildCommand": "npm run build"}', encoding="utf-8")
131
+ assert _provider_validation(tmp_path, "Vercel") == "PASS"
132
+ (tmp_path / "vercel.json").write_text('{invalid', encoding="utf-8")
133
+ assert _provider_validation(tmp_path, "Vercel") == "FAIL"
134
+
135
+
136
+ def test_dockerfile_validation(tmp_path: Path) -> None:
137
+ (tmp_path / "Dockerfile").write_text("FROM python:3.11\nCMD [\"python\", \"app.py\"]", encoding="utf-8")
138
+ assert _provider_validation(tmp_path, "Docker") == "PASS"
139
+ (tmp_path / "Dockerfile").write_text("CMD [\"python\", \"app.py\"]", encoding="utf-8")
140
+ assert _provider_validation(tmp_path, "Docker") == "FAIL"
141
+
142
+
143
+ def test_compose_yaml_validation(tmp_path: Path) -> None:
144
+ (tmp_path / "compose.yml").write_text("services:\n app:\n image: python:3.11\n", encoding="utf-8")
145
+ assert _provider_validation(tmp_path, "Docker") == "PASS"
146
+ (tmp_path / "compose.yml").write_text("services:\n app: [\n", encoding="utf-8")
147
+ assert _provider_validation(tmp_path, "Docker") == "FAIL"
148
+
149
+
150
+ def test_github_actions_validation(tmp_path: Path) -> None:
151
+ workflows = tmp_path / ".github" / "workflows"
152
+ workflows.mkdir(parents=True)
153
+ workflow = workflows / "deploy.yml"
154
+ workflow.write_text("name: Deploy\non: push\njobs:\n deploy:\n runs-on: ubuntu-latest\n", encoding="utf-8")
155
+ assert _provider_validation(tmp_path, "GitHub Actions") == "PASS"
156
+ workflow.write_text("name: Deploy\non: [push\njobs:\n deploy:\n runs-on: ubuntu-latest\n", encoding="utf-8")
157
+ assert _provider_validation(tmp_path, "GitHub Actions") == "FAIL"
158
+ workflow.write_text("name: Deploy\njobs:\n deploy:\n runs-on: ubuntu-latest\n", encoding="utf-8")
159
+ assert _provider_validation(tmp_path, "GitHub Actions") == "FAIL"
160
+
161
+
162
+ def test_provider_validation_is_reported_and_weighted(tmp_path: Path) -> None:
163
+ (tmp_path / "Dockerfile").write_text("CMD [\"python\", \"app.py\"]", encoding="utf-8")
164
+ results = dict(check_project(tmp_path))
165
+ assert results["Provider validation"] == "FAIL"
166
+ assert calculate_score([("Provider validation", "PASS")]) == 100
167
+
168
+
169
+ def test_gate_exits_nonzero_when_blocked(tmp_path: Path) -> None:
170
+ result = runner.invoke(app, [str(tmp_path), "--gate", "--json"])
171
+ assert result.exit_code == 1
172
+
173
+
174
+ def test_cli_threshold_override(tmp_path: Path) -> None:
175
+ result = runner.invoke(app, [str(tmp_path), "--threshold", "0", "--json"])
176
+ assert result.exit_code == 0
177
+ assert '"threshold": 0' in result.stdout
178
+ assert '"deployable": true' in result.stdout
179
+
180
+
181
+ def test_json_output_is_blocked_for_empty_project(tmp_path: Path) -> None:
182
+ result = runner.invoke(app, [str(tmp_path), "--json"])
183
+ assert result.exit_code == 0
184
+ assert '"deployable": false' in result.stdout
185
+
186
+
187
+ def test_json_output_includes_provider(tmp_path: Path) -> None:
188
+ (tmp_path / "vercel.json").write_text("{}", encoding="utf-8")
189
+ result = runner.invoke(app, [str(tmp_path), "--json"])
190
+ assert result.exit_code == 0
191
+ assert '"deployment_provider": "Vercel"' in result.stdout