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.
- shipcheck_cli-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +38 -0
- shipcheck_cli-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +25 -0
- shipcheck_cli-0.1.0/.github/workflows/ci.yml +44 -0
- shipcheck_cli-0.1.0/.github/workflows/release.yml +65 -0
- shipcheck_cli-0.1.0/.github/workflows/tests.yml +24 -0
- shipcheck_cli-0.1.0/.gitignore +14 -0
- shipcheck_cli-0.1.0/.shipcheck.toml +3 -0
- shipcheck_cli-0.1.0/CHANGELOG.md +22 -0
- shipcheck_cli-0.1.0/CONTRIBUTING.md +46 -0
- shipcheck_cli-0.1.0/LICENSE +21 -0
- shipcheck_cli-0.1.0/PKG-INFO +118 -0
- shipcheck_cli-0.1.0/README.md +102 -0
- shipcheck_cli-0.1.0/SECURITY.md +30 -0
- shipcheck_cli-0.1.0/pyproject.toml +38 -0
- shipcheck_cli-0.1.0/src/shipcheck/__init__.py +3 -0
- shipcheck_cli-0.1.0/src/shipcheck/cli.py +311 -0
- shipcheck_cli-0.1.0/tests/test_checks.py +19 -0
- shipcheck_cli-0.1.0/tests/test_cli.py +191 -0
|
@@ -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,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,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
|