diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 05692f6..ed1cdd3 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -25,7 +25,12 @@ jobs:
- run: pip install -e ".[docs]"
- run: cp CHANGELOG.md docs/changelog.md
- run: mkdocs build --strict
+ # Tries to switch Pages on. The built-in token is not always allowed to; if this step cannot,
+ # enable Settings > Pages > Source: GitHub Actions once, by hand.
- uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0
+ continue-on-error: true
+ with:
+ enablement: true
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: site
diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
new file mode 100644
index 0000000..479e89b
--- /dev/null
+++ b/.github/workflows/prepare-release.yml
@@ -0,0 +1,38 @@
+name: Prepare release
+
+# Run from the Actions tab: bumps the version, rolls the changelog, tags, and starts the release.
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Version to release, for example 1.0.0
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ prepare:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ actions: write
+ env:
+ VERSION: ${{ inputs.version }}
+ GH_TOKEN: ${{ github.token }}
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ fetch-depth: 0
+ - name: Bump the version and the changelog
+ run: python3 scripts/bump_release.py "$VERSION"
+ - name: Commit and tag
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -am "Release $VERSION"
+ git tag "v$VERSION"
+ git push origin HEAD "v$VERSION"
+ # Pushes made with the built-in token do not start other workflows, so start it explicitly.
+ - name: Start the release workflow
+ run: gh workflow run release.yml --ref "v$VERSION"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c395f06..b3018e3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,6 +3,8 @@ name: Release
on:
push:
tags: ["v*"]
+ # Started by the prepare-release workflow, whose tag push cannot trigger this one itself.
+ workflow_dispatch:
permissions:
contents: read
@@ -44,7 +46,10 @@ jobs:
with:
name: dist
path: dist/
+ # With no PYPI_API_TOKEN secret this uses trusted publishing (OIDC).
- uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
+ with:
+ password: ${{ secrets.PYPI_API_TOKEN }}
github-release:
needs: pypi
@@ -98,3 +103,27 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+
+ action-tag:
+ needs: github-release
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.ACTION_REPO_TOKEN }}
+ OWNER: ${{ github.repository_owner }}
+ steps:
+ - name: Move the major tag of the action repository
+ run: |
+ if [ -z "$GH_TOKEN" ]; then
+ echo "::notice::ACTION_REPO_TOKEN is not set, skipping the action tags"
+ exit 0
+ fi
+ version="${GITHUB_REF_NAME#v}"
+ major="v${version%%.*}"
+ gh auth setup-git
+ git clone "https://github.com/$OWNER/devshield-action.git" out
+ cd out
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -f "$GITHUB_REF_NAME"
+ git tag -f "$major"
+ git push -f origin "$GITHUB_REF_NAME" "$major"
diff --git a/.github/workflows/sync-action.yml b/.github/workflows/sync-action.yml
new file mode 100644
index 0000000..d15a4c5
--- /dev/null
+++ b/.github/workflows/sync-action.yml
@@ -0,0 +1,46 @@
+name: Sync action
+
+# Publishes packaging/devshield-action to the ByteGigs-Labs/devshield-action repository.
+# It needs a secret named ACTION_REPO_TOKEN (a personal access token with the repo scope).
+# Without the secret this workflow does nothing, and the action still works from this
+# repository as ByteGigs-Labs/DevShield-CLI/packaging/devshield-action@main.
+on:
+ push:
+ branches: [main]
+ paths: ["packaging/devshield-action/**", ".github/workflows/sync-action.yml"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.ACTION_REPO_TOKEN }}
+ OWNER: ${{ github.repository_owner }}
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - name: Sync
+ run: |
+ if [ -z "$GH_TOKEN" ]; then
+ echo "::notice::ACTION_REPO_TOKEN is not set, skipping the action repository sync"
+ exit 0
+ fi
+ target="$OWNER/devshield-action"
+ gh repo view "$target" > /dev/null 2>&1 \
+ || gh repo create "$target" --public --description "GitHub Action for DevShield"
+ gh auth setup-git
+ git clone "https://github.com/$target.git" out
+ cp packaging/devshield-action/action.yml packaging/devshield-action/README.md out/
+ cd out
+ git checkout -B main
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add -A
+ if git diff --cached --quiet; then
+ echo "Already up to date"
+ else
+ git commit -m "Sync from DevShield-CLI $GITHUB_SHA"
+ git push origin main
+ fi
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a8f2f5..e6e1f56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,8 @@ All notable changes to this project are documented here. The format follows
- Plugin system: rules from third-party packages through the `devshield.rules` entry point,
`devshield plugins list`, failure isolation, `devshield.testing.run_rule` and a plugin guide.
- An example plugin in `examples/devshield-plugin-example`.
-- Documentation site, pre-commit hooks, a Docker image and a GitHub Action template.
+- Documentation site with screenshots, pre-commit hooks, a Docker image, a GitHub Action and
+ release automation (`Prepare release` workflow, PyPI publishing, action repository sync).
### Changed
diff --git a/README.md b/README.md
index 3bd57c4..218e238 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,33 @@ Upcoming:
---
+# Screenshots
+
+### Scan
+
+
+
+### HTML report
+
+A single self-contained file with a risk gauge, search and category filters, and no external requests.
+
+
+
+### Safe auto-fix
+
+
+
+### Dependency vulnerabilities
+
+
+
+### Docker image inspection
+
+
+
+Regenerate them with `python scripts/gen_screenshots.py`.
+
+
# Installation
```bash
diff --git a/RELEASING.md b/RELEASING.md
index 0bd324b..a743a27 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,34 +1,47 @@
# Releasing
-Releases are cut from `main` by pushing a tag. Everything after the tag is automated.
+## The short version
-## Checklist
+Open **Actions, Prepare release, Run workflow** and enter the version (for example `1.0.0`).
+It bumps `__version__`, turns the `[Unreleased]` changelog section into that version, commits,
+tags `vX.Y.Z` and starts the `release` workflow. Make sure CI is green on `main` first.
-1. Make sure CI is green on `main`, and run the performance budget: `pytest -m perf`.
-2. In `CHANGELOG.md`, rename `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` and add a fresh empty
- `[Unreleased]` section above it. Update the link references if you keep any.
-3. Set `__version__` in `devshield/__init__.py` to `X.Y.Z`.
-4. Commit: `git commit -am "Release X.Y.Z"`, then `git tag vX.Y.Z` and `git push --follow-tags`.
+You can also do it by hand: `python scripts/bump_release.py X.Y.Z`, commit, `git tag vX.Y.Z`,
+`git push --follow-tags`.
The `release` workflow then:
- builds the sdist and wheel and checks that the tag matches `__version__`;
-- publishes to PyPI with trusted publishing (no token is stored);
+- publishes to PyPI;
- creates a GitHub release whose notes come from the matching `CHANGELOG.md` section;
-- builds and pushes `ghcr.io/bytegigs-labs/devshield` tagged `X.Y.Z`, `X.Y` and `latest`.
+- builds and pushes `ghcr.io/bytegigs-labs/devshield` tagged `X.Y.Z`, `X.Y` and `latest`;
+- moves the `vX` and `vX.Y.Z` tags of the action repository (when `ACTION_REPO_TOKEN` is set).
The `docs` workflow redeploys the documentation site on every push to `main`.
-## One-time setup
-
-- **PyPI:** create the `devshield-cli` project (or a pending publisher) and add a trusted
- publisher: owner `ByteGigs-Labs`, repository `DevShield-CLI`, workflow `release.yml`,
- environment `pypi`. Create a GitHub environment named `pypi`.
-- **GitHub Pages:** Settings, Pages, Source: GitHub Actions.
-- **Packages:** after the first image push, make the `devshield` package public.
-- **GitHub Action:** copy `packaging/devshield-action/` into a new
- `ByteGigs-Labs/devshield-action` repository, tag it `v1`, and move the `v1` tag with each release.
-- **Secret patterns:** `devshield update` reads `data/secret-patterns.json` from `main`.
+## What configures itself
+
+On the first push these need no manual setup: the `pypi` and `github-pages` environments (GitHub
+creates them when a workflow first uses them), the docs build, the CI jobs and the release
+automation above. The `docs` workflow also tries to switch GitHub Pages on.
+
+## What GitHub or PyPI only allow you to do yourself
+
+A workflow cannot grant itself these, so they are one-time clicks:
+
+- **PyPI publishing.** Either add a trusted publisher (PyPI, Your projects or Publishing, add a
+ pending publisher: owner `ByteGigs-Labs`, repository `DevShield-CLI`, workflow `release.yml`,
+ environment `pypi`), or create an API token and save it as the repository secret
+ `PYPI_API_TOKEN`. The workflow uses the token when it exists and trusted publishing otherwise.
+- **GitHub Pages**, only if the `docs` workflow could not enable it: Settings, Pages, Source:
+ GitHub Actions.
+- **The action repository (optional).** Save a personal access token with the `repo` scope as the
+ secret `ACTION_REPO_TOKEN`. The `sync-action` workflow then creates
+ `ByteGigs-Labs/devshield-action`, keeps it in sync, and `release` moves its `v1` tag. Without it
+ the action works from this repository as
+ `ByteGigs-Labs/DevShield-CLI/packaging/devshield-action@main`.
+- **Image visibility.** After the first image push, open the `devshield` package on GitHub and set
+ its visibility to public.
## Version numbers
diff --git a/docs/images/deps.svg b/docs/images/deps.svg
new file mode 100644
index 0000000..11e2066
--- /dev/null
+++ b/docs/images/deps.svg
@@ -0,0 +1,153 @@
+
diff --git a/docs/images/docker-scan.svg b/docs/images/docker-scan.svg
new file mode 100644
index 0000000..c518994
--- /dev/null
+++ b/docs/images/docker-scan.svg
@@ -0,0 +1,119 @@
+
diff --git a/docs/images/fix.svg b/docs/images/fix.svg
new file mode 100644
index 0000000..eddc99b
--- /dev/null
+++ b/docs/images/fix.svg
@@ -0,0 +1,227 @@
+
diff --git a/docs/images/html-report.png b/docs/images/html-report.png
new file mode 100644
index 0000000..a6083f8
Binary files /dev/null and b/docs/images/html-report.png differ
diff --git a/docs/images/scan.svg b/docs/images/scan.svg
new file mode 100644
index 0000000..da4e4f7
--- /dev/null
+++ b/docs/images/scan.svg
@@ -0,0 +1,286 @@
+
diff --git a/docs/index.md b/docs/index.md
index e419348..1cb31ab 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -15,4 +15,22 @@ devshield scan
- **Baselines** to adopt it on an existing project without a wall of findings
- **Plugins** so you can add your own rules
+## What it looks like
+
+
+
+The HTML report is a single self-contained file with a risk gauge, search and category filters:
+
+
+
+`devshield fix` previews exactly what it would change before it touches anything:
+
+
+
+`devshield deps` and `devshield docker scan` cover dependencies and local images:
+
+
+
+
+
Start with [Getting started](getting-started.md).
diff --git a/scripts/bump_release.py b/scripts/bump_release.py
new file mode 100644
index 0000000..40189a7
--- /dev/null
+++ b/scripts/bump_release.py
@@ -0,0 +1,59 @@
+"""Prepare a release: python scripts/bump_release.py X.Y.Z [--date YYYY-MM-DD].
+
+Sets ``__version__`` and turns the ``[Unreleased]`` changelog section into ``[X.Y.Z]``.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from datetime import date
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+VERSION = re.compile(r"^\d+\.\d+\.\d+$")
+UNRELEASED = "## [Unreleased]"
+
+
+def bump_version(source: str, version: str) -> str:
+ updated, count = re.subn(r'(__version__ = ")[^"]+(")', rf"\g<1>{version}\g<2>", source, count=1)
+ if count != 1:
+ raise ValueError("no __version__ assignment found")
+ return updated
+
+
+def bump_changelog(text: str, version: str, released: str) -> str:
+ if UNRELEASED not in text:
+ raise ValueError("CHANGELOG.md has no [Unreleased] section")
+ if f"## [{version}]" in text:
+ raise ValueError(f"CHANGELOG.md already has a section for {version}")
+ body = text.split(UNRELEASED, 1)[1]
+ if not re.search(r"^\s*- ", body.split("\n## [", 1)[0], re.MULTILINE):
+ raise ValueError("the [Unreleased] section is empty")
+ return text.replace(UNRELEASED, f"{UNRELEASED}\n\n## [{version}] - {released}", 1)
+
+
+def main(argv: list[str] | None = None, root: Path = ROOT) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("version")
+ parser.add_argument("--date", default=date.today().isoformat())
+ args = parser.parse_args(argv)
+ if not VERSION.match(args.version):
+ print(f"{args.version!r} is not a version like 1.2.3", file=sys.stderr)
+ return 2
+ init, log = root / "devshield" / "__init__.py", root / "CHANGELOG.md"
+ try:
+ new_init = bump_version(init.read_text(encoding="utf-8"), args.version)
+ new_log = bump_changelog(log.read_text(encoding="utf-8"), args.version, args.date)
+ except ValueError as exc:
+ print(exc, file=sys.stderr)
+ return 1
+ init.write_text(new_init, encoding="utf-8")
+ log.write_text(new_log, encoding="utf-8")
+ print(f"Prepared {args.version}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/gen_screenshots.py b/scripts/gen_screenshots.py
new file mode 100644
index 0000000..a51fc64
--- /dev/null
+++ b/scripts/gen_screenshots.py
@@ -0,0 +1,136 @@
+"""Regenerate the screenshots in docs/images: python scripts/gen_screenshots.py [IMAGE].
+
+Terminal screenshots are SVG exports of real output (rendered by Rich, so they stay sharp and
+searchable). The HTML report screenshot needs Chrome or Chromium and is skipped without it. The
+dependency screenshot queries OSV.dev; the Docker one needs a local image (default node:latest).
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+from devshield.core.cache import DiskCache
+from devshield.core.config import Config
+from devshield.core.models import Severity
+from devshield.core.registry import default_registry
+from devshield.core.runner import ScanOptions, run_scan
+from devshield.fixers import plan_fixes, render_diff
+from devshield.reports.deps import render_deps_terminal
+from devshield.reports.formats import FORMATS
+from devshield.reports.image import render_image_terminal
+from devshield.reports.terminal import render_terminal
+from devshield.scanners.deps import scan_dependencies
+from devshield.scanners.image import DockerError, scan_image
+from devshield.scanners.osv import OsvClient
+from rich.console import Console
+from rich.syntax import Syntax
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURES = ROOT / "tests" / "fixtures"
+OUT = ROOT / "docs" / "images"
+WIDTH = 100
+
+
+def recorder() -> Console:
+ return Console(record=True, width=WIDTH, force_terminal=True, color_system="truecolor")
+
+
+def save(console: Console, name: str, title: str) -> None:
+ console.save_svg(str(OUT / name), title=title)
+ print(f"wrote docs/images/{name}")
+
+
+def scan_screenshot(registry) -> None: # type: ignore[no-untyped-def]
+ console = recorder()
+ options = ScanOptions(min_severity=Severity.MEDIUM)
+ result = run_scan(FIXTURES / "node-express-insecure", Config(), registry, options)
+ titles = {rule.id: rule.title for rule in registry.all()}
+ console.print("$ devshield scan --min-severity medium", style="bold green")
+ render_terminal(result, console, titles, verbose=False)
+ save(console, "scan.svg", "devshield scan")
+
+
+def fix_screenshot(registry) -> None: # type: ignore[no-untyped-def]
+ console = recorder()
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp) / "project"
+ shutil.copytree(FIXTURES / "node-express-insecure", root)
+ plan = plan_fixes(root, run_scan(root, Config(), registry), Config().auto_fix)
+ console.print("$ devshield fix --dry-run", style="bold green")
+ console.print(Syntax(render_diff(plan.changes), "diff", theme="monokai"))
+ for skipped in plan.skipped:
+ console.print(
+ f"Skipped {skipped.rule_id} ({skipped.file}): {skipped.reason}", style="dim"
+ )
+ console.print("\nDry run: re-run with --yes to apply.", style="dim")
+ save(console, "fix.svg", "devshield fix")
+
+
+def deps_screenshot() -> None:
+ console = recorder()
+ with tempfile.TemporaryDirectory() as tmp:
+ client = OsvClient(DiskCache(Path(tmp)))
+ result = scan_dependencies(FIXTURES / "node-outdated-lodash", client)
+ console.print("$ devshield deps", style="bold green")
+ render_deps_terminal(result, console)
+ save(console, "deps.svg", "devshield deps")
+
+
+def docker_screenshot(image: str) -> None:
+ try:
+ report = scan_image(image)
+ except DockerError as exc:
+ print(f"skipped the Docker screenshot: {exc}")
+ return
+ console = recorder()
+ console.print(f"$ devshield docker scan {image}", style="bold green")
+ render_image_terminal(report, console)
+ save(console, "docker-scan.svg", "devshield docker scan")
+
+
+def html_screenshot(registry) -> None: # type: ignore[no-untyped-def]
+ chrome = next(
+ (c for c in ("google-chrome", "chromium", "chromium-browser") if shutil.which(c)), None
+ )
+ if chrome is None:
+ print("skipped the HTML screenshot: Chrome or Chromium not found")
+ return
+ result = run_scan(FIXTURES / "node-express-insecure", Config(), registry)
+ with tempfile.TemporaryDirectory() as tmp:
+ page = Path(tmp) / "report.html"
+ page.write_text(FORMATS["html"].render(result, registry), encoding="utf-8")
+ target = OUT / "html-report.png"
+ subprocess.run( # noqa: S603 - fixed arguments, a path we just created
+ [
+ chrome,
+ "--headless=new",
+ "--no-sandbox",
+ "--disable-gpu",
+ "--hide-scrollbars",
+ f"--screenshot={target}",
+ "--window-size=1200,1500",
+ page.as_uri(),
+ ],
+ check=True,
+ capture_output=True,
+ timeout=120,
+ )
+ print("wrote docs/images/html-report.png")
+
+
+def main(image: str) -> None:
+ OUT.mkdir(parents=True, exist_ok=True)
+ registry = default_registry()
+ scan_screenshot(registry)
+ fix_screenshot(registry)
+ deps_screenshot()
+ docker_screenshot(image)
+ html_screenshot(registry)
+
+
+if __name__ == "__main__":
+ main(sys.argv[1] if len(sys.argv) > 1 else "node:latest")
diff --git a/tests/test_release_script.py b/tests/test_release_script.py
new file mode 100644
index 0000000..8397425
--- /dev/null
+++ b/tests/test_release_script.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).parents[1] / "scripts" / "bump_release.py"
+spec = importlib.util.spec_from_file_location("bump_release", SCRIPT)
+assert spec and spec.loader
+bump = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(bump)
+
+CHANGELOG = """# Changelog
+
+## [Unreleased]
+
+### Added
+
+- Something new.
+
+## [0.9.0] - 2026-01-01
+
+- Older.
+"""
+
+
+def project(tmp_path: Path, changelog: str = CHANGELOG) -> Path:
+ (tmp_path / "devshield").mkdir(parents=True)
+ (tmp_path / "devshield" / "__init__.py").write_text('__version__ = "0.1.0.dev0"\n')
+ (tmp_path / "CHANGELOG.md").write_text(changelog)
+ return tmp_path
+
+
+def test_bumps_version_and_changelog(tmp_path: Path) -> None:
+ root = project(tmp_path)
+ assert bump.main(["1.0.0", "--date", "2026-11-01"], root) == 0
+ assert (root / "devshield" / "__init__.py").read_text() == '__version__ = "1.0.0"\n'
+ log = (root / "CHANGELOG.md").read_text()
+ assert "## [Unreleased]\n\n## [1.0.0] - 2026-11-01\n\n### Added" in log
+ assert log.index("[1.0.0]") < log.index("[0.9.0]")
+
+
+@pytest.mark.parametrize("version", ["1.0", "v1.0.0", "1.0.0-rc1", ""])
+def test_rejects_bad_versions(tmp_path: Path, version: str) -> None:
+ assert bump.main([version], project(tmp_path)) == 2
+
+
+def test_refuses_empty_or_duplicate_sections_without_touching_files(tmp_path: Path) -> None:
+ empty = project(tmp_path, "# Changelog\n\n## [Unreleased]\n\n## [0.9.0] - 2026-01-01\n- x\n")
+ assert bump.main(["1.0.0"], empty) == 1
+ assert (
+ (empty / "devshield" / "__init__.py").read_text().startswith('__version__ = "0.1.0.dev0"')
+ )
+ assert bump.main(["0.9.0"], project(tmp_path / "b")) == 1
+ assert bump.main(["1.0.0"], project(tmp_path / "c", "# Changelog\n")) == 1