releaseprobe 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,9 @@
1
+ {
2
+ "features": {
3
+ "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
4
+ "version": "1.10.0",
5
+ "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e",
6
+ "integrity": "sha256:c2c2cf829505ead8e4892c88c31b6594ae94a2bbb209e16e1fac456c1a3a624e"
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "releaseprobe",
3
+ "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
4
+ "postCreateCommand": "pip install -e '.[dev]'",
5
+ "customizations": {
6
+ "vscode": {
7
+ "extensions": [
8
+ "ms-python.python",
9
+ "ms-python.vscode-pylance",
10
+ "charliermarsh.ruff",
11
+ "ms-python.mypy-type-checker"
12
+ ],
13
+ "settings": {
14
+ "python.testing.pytestEnabled": true,
15
+ "python.testing.pytestArgs": ["tests"],
16
+ "[python]": {
17
+ "editor.defaultFormatter": "charliermarsh.ruff",
18
+ "editor.formatOnSave": true
19
+ }
20
+ }
21
+ }
22
+ },
23
+ "remoteUser": "vscode"
24
+ }
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ - package-ecosystem: "pip"
8
+ directory: "/"
9
+ schedule:
10
+ interval: "weekly"
@@ -0,0 +1,80 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ paths:
7
+ - "src/releaseprobe/__init__.py"
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ release:
15
+ runs-on: ubuntu-latest
16
+ permissions:
17
+ contents: write
18
+ outputs:
19
+ released: ${{ steps.check.outputs.released }}
20
+ tag: ${{ steps.check.outputs.tag }}
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+
30
+ - name: Determine whether the version was bumped
31
+ id: check
32
+ run: |
33
+ version=$(python -c "import re, pathlib; print(re.search(r'__version__\s*=\s*\"([^\"]+)\"', pathlib.Path('src/releaseprobe/__init__.py').read_text())[1])")
34
+ tag="v${version}"
35
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
36
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
37
+
38
+ if git rev-parse "${tag}" >/dev/null 2>&1; then
39
+ echo "released=false" >> "$GITHUB_OUTPUT"
40
+ echo "Tag ${tag} already exists, skipping release."
41
+ else
42
+ echo "released=true" >> "$GITHUB_OUTPUT"
43
+ echo "Version bump detected, releasing ${tag}."
44
+ fi
45
+
46
+ - name: Create tag and GitHub release
47
+ if: steps.check.outputs.released == 'true'
48
+ env:
49
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50
+ TAG: ${{ steps.check.outputs.tag }}
51
+ run: |
52
+ git tag "${TAG}"
53
+ git push origin "${TAG}"
54
+ gh release create "${TAG}" \
55
+ --title "${TAG}" \
56
+ --generate-notes
57
+
58
+ publish:
59
+ needs: release
60
+ if: needs.release.outputs.released == 'true'
61
+ runs-on: ubuntu-latest
62
+ environment: pypi
63
+ permissions:
64
+ id-token: write
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ with:
68
+ ref: ${{ needs.release.outputs.tag }}
69
+
70
+ - uses: actions/setup-python@v5
71
+ with:
72
+ python-version: "3.12"
73
+
74
+ - name: Build distribution
75
+ run: |
76
+ pip install build
77
+ python -m build
78
+
79
+ - name: Publish to PyPI
80
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,39 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - name: Set up Python ${{ matrix.python-version }}
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: ${{ matrix.python-version }}
27
+ cache: pip
28
+
29
+ - name: Install dependencies
30
+ run: pip install -e ".[dev]"
31
+
32
+ - name: Lint
33
+ run: ruff check .
34
+
35
+ - name: Type check
36
+ run: mypy
37
+
38
+ - name: Test
39
+ run: pytest
@@ -0,0 +1,22 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .env
10
+
11
+ # Testing / coverage
12
+ .coverage
13
+ .coverage.*
14
+ htmlcov/
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+
19
+ # Editors
20
+ .vscode/
21
+ .idea/
22
+ *.swp
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David
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,124 @@
1
+ Metadata-Version: 2.5
2
+ Name: releaseprobe
3
+ Version: 0.1.0
4
+ Summary: Find release notes, URLs and metadata from container labels and tags.
5
+ Project-URL: Homepage, https://github.com/dkarv/releaseprobe
6
+ Project-URL: Repository, https://github.com/dkarv/releaseprobe
7
+ Project-URL: Issues, https://github.com/dkarv/releaseprobe/issues
8
+ Author-email: David Punzet <d.punzet@testbirds.de>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: container,docker,labels,oci,release-notes
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Build Tools
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Provides-Extra: dev
25
+ Requires-Dist: build>=1.2; extra == 'dev'
26
+ Requires-Dist: mypy>=1.11; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # releaseprobe
33
+
34
+ Find release notes, URLs and other release metadata for container images by
35
+ reading their labels. Inspired by [Renovate's docker datasource](https://docs.renovatebot.com/modules/datasource/docker/):
36
+
37
+ - [OCI image labels](https://github.com/opencontainers/image-spec/blob/main/annotations.md)
38
+ (`org.opencontainers.image.*`), preferred.
39
+ - [label-schema.org](http://label-schema.org/rc1/) labels (`org.label-schema.*`),
40
+ as a fallback for older images.
41
+
42
+ releaseprobe does not talk to a registry or the Docker daemon itself - it
43
+ takes the labels you already have (from `docker inspect`, `skopeo inspect`,
44
+ `crane config`, the registry HTTP API, a Kubernetes pod spec, ...) as input
45
+ and tells you what release metadata they contain.
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install releaseprobe
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ Pass labels as a JSON object, e.g. straight from `docker inspect`:
56
+
57
+ ```bash
58
+ docker inspect --format '{{json .Config.Labels}}' ghcr.io/org/app:1.2.3 \
59
+ | releaseprobe
60
+ ```
61
+
62
+ ```
63
+ version: 1.2.3
64
+ source: https://github.com/org/app
65
+ url: -
66
+ documentation: -
67
+ revision: abc123
68
+ release notes: https://github.com/org/app
69
+ ```
70
+
71
+ A path to a JSON file works too (`releaseprobe labels.json`); omit the path or
72
+ pass `-` to read from stdin. Add `--json` for machine-readable output.
73
+
74
+ ## Library usage
75
+
76
+ ```python
77
+ from releaseprobe import probe_labels
78
+
79
+ info = probe_labels({"org.opencontainers.image.source": "https://github.com/org/app"})
80
+ info.release_notes_url() # "https://github.com/org/app"
81
+ ```
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ python -m venv .venv
87
+ source .venv/bin/activate
88
+ pip install -e ".[dev]"
89
+
90
+ pytest
91
+ ruff check .
92
+ mypy
93
+ ```
94
+
95
+ ### Dev container
96
+
97
+ A [dev container](.devcontainer/devcontainer.json) is included so you can try
98
+ releaseprobe in VS Code: open the repo and choose "Reopen in Container"
99
+ (needs the Dev Containers extension). It gives you a Python 3.12 environment
100
+ with the project installed in editable mode.
101
+
102
+ The container itself has no Docker CLI, so to try releaseprobe against a
103
+ real image, inspect it from your host and drop the labels into the workspace
104
+ (they land in the container too, since the workspace folder is mounted in):
105
+
106
+ ```bash
107
+ # on your host
108
+ docker inspect --format '{{json .Config.Labels}}' ghcr.io/grafana/grafana:11.2.0 > labels.json
109
+ ```
110
+
111
+ ```bash
112
+ # inside the dev container
113
+ releaseprobe labels.json
114
+ ```
115
+
116
+ ## Releasing
117
+
118
+ Bump the `__version__` string in `src/releaseprobe/__init__.py` and merge to
119
+ `main`. The CI pipeline detects the version bump, tags the commit, creates a
120
+ GitHub release with auto-generated notes, and publishes the package to PyPI.
121
+
122
+ ## License
123
+
124
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,93 @@
1
+ # releaseprobe
2
+
3
+ Find release notes, URLs and other release metadata for container images by
4
+ reading their labels. Inspired by [Renovate's docker datasource](https://docs.renovatebot.com/modules/datasource/docker/):
5
+
6
+ - [OCI image labels](https://github.com/opencontainers/image-spec/blob/main/annotations.md)
7
+ (`org.opencontainers.image.*`), preferred.
8
+ - [label-schema.org](http://label-schema.org/rc1/) labels (`org.label-schema.*`),
9
+ as a fallback for older images.
10
+
11
+ releaseprobe does not talk to a registry or the Docker daemon itself - it
12
+ takes the labels you already have (from `docker inspect`, `skopeo inspect`,
13
+ `crane config`, the registry HTTP API, a Kubernetes pod spec, ...) as input
14
+ and tells you what release metadata they contain.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install releaseprobe
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Pass labels as a JSON object, e.g. straight from `docker inspect`:
25
+
26
+ ```bash
27
+ docker inspect --format '{{json .Config.Labels}}' ghcr.io/org/app:1.2.3 \
28
+ | releaseprobe
29
+ ```
30
+
31
+ ```
32
+ version: 1.2.3
33
+ source: https://github.com/org/app
34
+ url: -
35
+ documentation: -
36
+ revision: abc123
37
+ release notes: https://github.com/org/app
38
+ ```
39
+
40
+ A path to a JSON file works too (`releaseprobe labels.json`); omit the path or
41
+ pass `-` to read from stdin. Add `--json` for machine-readable output.
42
+
43
+ ## Library usage
44
+
45
+ ```python
46
+ from releaseprobe import probe_labels
47
+
48
+ info = probe_labels({"org.opencontainers.image.source": "https://github.com/org/app"})
49
+ info.release_notes_url() # "https://github.com/org/app"
50
+ ```
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ python -m venv .venv
56
+ source .venv/bin/activate
57
+ pip install -e ".[dev]"
58
+
59
+ pytest
60
+ ruff check .
61
+ mypy
62
+ ```
63
+
64
+ ### Dev container
65
+
66
+ A [dev container](.devcontainer/devcontainer.json) is included so you can try
67
+ releaseprobe in VS Code: open the repo and choose "Reopen in Container"
68
+ (needs the Dev Containers extension). It gives you a Python 3.12 environment
69
+ with the project installed in editable mode.
70
+
71
+ The container itself has no Docker CLI, so to try releaseprobe against a
72
+ real image, inspect it from your host and drop the labels into the workspace
73
+ (they land in the container too, since the workspace folder is mounted in):
74
+
75
+ ```bash
76
+ # on your host
77
+ docker inspect --format '{{json .Config.Labels}}' ghcr.io/grafana/grafana:11.2.0 > labels.json
78
+ ```
79
+
80
+ ```bash
81
+ # inside the dev container
82
+ releaseprobe labels.json
83
+ ```
84
+
85
+ ## Releasing
86
+
87
+ Bump the `__version__` string in `src/releaseprobe/__init__.py` and merge to
88
+ `main`. The CI pipeline detects the version bump, tags the commit, creates a
89
+ GitHub release with auto-generated notes, and publishes the package to PyPI.
90
+
91
+ ## License
92
+
93
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,69 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "releaseprobe"
7
+ dynamic = ["version"]
8
+ description = "Find release notes, URLs and metadata from container labels and tags."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "David Punzet", email = "d.punzet@testbirds.de" }]
13
+ keywords = ["docker", "oci", "container", "release-notes", "labels"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Build Tools",
25
+ "Topic :: Utilities",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=8.0",
32
+ "pytest-cov>=5.0",
33
+ "ruff>=0.6",
34
+ "mypy>=1.11",
35
+ "build>=1.2",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/dkarv/releaseprobe"
40
+ Repository = "https://github.com/dkarv/releaseprobe"
41
+ Issues = "https://github.com/dkarv/releaseprobe/issues"
42
+
43
+ [project.scripts]
44
+ releaseprobe = "releaseprobe.cli:main"
45
+
46
+ [tool.hatch.version]
47
+ path = "src/releaseprobe/__init__.py"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/releaseprobe"]
51
+
52
+ [tool.pytest.ini_options]
53
+ addopts = "--cov=releaseprobe --cov-report=term-missing"
54
+ testpaths = ["tests"]
55
+
56
+ [tool.coverage.run]
57
+ source = ["releaseprobe"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ target-version = "py310"
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "UP", "B"]
65
+
66
+ [tool.mypy]
67
+ python_version = "3.10"
68
+ strict = true
69
+ files = ["src"]
@@ -0,0 +1,7 @@
1
+ """releaseprobe - find release notes, URLs and metadata from container labels and tags."""
2
+
3
+ from releaseprobe.probe import ReleaseInfo, probe_labels
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["ReleaseInfo", "probe_labels", "__version__"]
@@ -0,0 +1,77 @@
1
+ """Command line interface for releaseprobe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import json
8
+ import sys
9
+ from typing import TextIO
10
+
11
+ from releaseprobe import __version__
12
+ from releaseprobe.probe import probe_labels
13
+
14
+
15
+ def build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="releaseprobe",
18
+ description=(
19
+ "Find release notes, URLs and metadata from container image labels. "
20
+ "Labels are read as a JSON object, e.g. the output of "
21
+ "`docker inspect --format '{{json .Config.Labels}}' <image>`."
22
+ ),
23
+ )
24
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
25
+ parser.add_argument(
26
+ "labels_file",
27
+ nargs="?",
28
+ default="-",
29
+ help="Path to a JSON file with the image labels, or '-' to read from stdin (default).",
30
+ )
31
+ parser.add_argument(
32
+ "--json",
33
+ action="store_true",
34
+ help="Print the full result as JSON instead of a human-readable summary.",
35
+ )
36
+ return parser
37
+
38
+
39
+ def _read_labels(path: str, stdin: TextIO) -> dict[str, str]:
40
+ if path == "-":
41
+ raw = stdin.read()
42
+ else:
43
+ with open(path, encoding="utf-8") as f:
44
+ raw = f.read()
45
+ data = json.loads(raw) if raw.strip() else {}
46
+ if not isinstance(data, dict):
47
+ raise ValueError("labels JSON must be an object of label key/value pairs")
48
+ return data
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ parser = build_parser()
53
+ args = parser.parse_args(argv)
54
+
55
+ try:
56
+ labels = _read_labels(args.labels_file, sys.stdin)
57
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
58
+ print(f"error: could not read labels: {exc}", file=sys.stderr)
59
+ return 1
60
+
61
+ info = probe_labels(labels)
62
+
63
+ if args.json:
64
+ print(json.dumps(dataclasses.asdict(info), indent=2))
65
+ return 0
66
+
67
+ print(f"version: {info.version or '-'}")
68
+ print(f"source: {info.source or '-'}")
69
+ print(f"url: {info.url or '-'}")
70
+ print(f"documentation: {info.documentation or '-'}")
71
+ print(f"revision: {info.revision or '-'}")
72
+ print(f"release notes: {info.release_notes_url() or '-'}")
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,35 @@
1
+ """Well-known container image label keys relevant to release metadata.
2
+
3
+ Two competing conventions are in use in the wild, mirroring how Renovate's
4
+ docker datasource reads them (see
5
+ https://docs.renovatebot.com/modules/datasource/docker/):
6
+
7
+ - OCI Image Format Specification (current):
8
+ https://github.com/opencontainers/image-spec/blob/main/annotations.md
9
+ - label-schema.org (deprecated, still common on older images):
10
+ http://label-schema.org/rc1/
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # OCI Image Format Specification annotations.
16
+ OCI_SOURCE = "org.opencontainers.image.source"
17
+ OCI_URL = "org.opencontainers.image.url"
18
+ OCI_DOCUMENTATION = "org.opencontainers.image.documentation"
19
+ OCI_VERSION = "org.opencontainers.image.version"
20
+ OCI_REVISION = "org.opencontainers.image.revision"
21
+ OCI_VENDOR = "org.opencontainers.image.vendor"
22
+ OCI_TITLE = "org.opencontainers.image.title"
23
+ OCI_DESCRIPTION = "org.opencontainers.image.description"
24
+ OCI_CREATED = "org.opencontainers.image.created"
25
+
26
+ # label-schema.org annotations (deprecated), used as a fallback when the OCI
27
+ # label is absent.
28
+ LABEL_SCHEMA_VCS_URL = "org.label-schema.vcs-url"
29
+ LABEL_SCHEMA_VCS_REF = "org.label-schema.vcs-ref"
30
+ LABEL_SCHEMA_VERSION = "org.label-schema.version"
31
+
32
+ # Field -> ordered list of label keys to check, OCI first, label-schema as fallback.
33
+ SOURCE_KEYS = (OCI_SOURCE, LABEL_SCHEMA_VCS_URL)
34
+ REVISION_KEYS = (OCI_REVISION, LABEL_SCHEMA_VCS_REF)
35
+ VERSION_KEYS = (OCI_VERSION, LABEL_SCHEMA_VERSION)
@@ -0,0 +1,54 @@
1
+ """Derive release metadata from a container image's labels.
2
+
3
+ The labels themselves are expected to already have been collected by the
4
+ caller (e.g. via `docker inspect`, `skopeo inspect`, `crane config`, the
5
+ registry HTTP API, or a Kubernetes pod spec) - this module only interprets
6
+ them, following the same label conventions as Renovate's docker datasource:
7
+ https://docs.renovatebot.com/modules/datasource/docker/
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+
15
+ from releaseprobe import labels as label_keys
16
+
17
+
18
+ def _first(labels: Mapping[str, str], keys: tuple[str, ...]) -> str | None:
19
+ for key in keys:
20
+ value = labels.get(key)
21
+ if value:
22
+ return value
23
+ return None
24
+
25
+
26
+ @dataclass
27
+ class ReleaseInfo:
28
+ version: str | None = None
29
+ source: str | None = None
30
+ url: str | None = None
31
+ documentation: str | None = None
32
+ revision: str | None = None
33
+ raw_labels: dict[str, str] = field(default_factory=dict)
34
+
35
+ def release_notes_url(self) -> str | None:
36
+ """Best-effort guess at a release notes / changelog URL.
37
+
38
+ Prefers the VCS source repository (as Renovate does when deriving a
39
+ changelog), then falls back to the generic project URL and finally
40
+ the documentation URL.
41
+ """
42
+ return self.source or self.url or self.documentation
43
+
44
+
45
+ def probe_labels(labels: Mapping[str, str]) -> ReleaseInfo:
46
+ """Extract release metadata from a mapping of container image labels."""
47
+ return ReleaseInfo(
48
+ version=_first(labels, label_keys.VERSION_KEYS),
49
+ source=_first(labels, label_keys.SOURCE_KEYS),
50
+ url=labels.get(label_keys.OCI_URL),
51
+ documentation=labels.get(label_keys.OCI_DOCUMENTATION),
52
+ revision=_first(labels, label_keys.REVISION_KEYS),
53
+ raw_labels=dict(labels),
54
+ )
File without changes
@@ -0,0 +1,26 @@
1
+ {
2
+ "io.hass.base.image": "ghcr.io/home-assistant/base:3.24",
3
+ "io.hass.arch": "amd64",
4
+ "org.opencontainers.image.url": "https://www.home-assistant.io/",
5
+ "io.hass.type": "core",
6
+ "io.hass.base.name": "python",
7
+ "io.hass.version": "2026.9.2",
8
+ "org.opencontainers.image.version": "2026.9.2",
9
+ "org.opencontainers.image.authors": "The Home Assistant Authors",
10
+ "org.opencontainers.image.source": "https://github.com/home-assistant/core",
11
+ "com.docker.compose.container-number": "1",
12
+ "com.docker.compose.replace": "homeassistant",
13
+ "com.docker.compose.service": "homeassistant",
14
+ "org.opencontainers.image.documentation": "https://www.home-assistant.io/docs/",
15
+ "org.opencontainers.image.licenses": "Apache-2.0",
16
+ "com.docker.compose.project.working_dir": "/var/misc/temp/periphery/repos/komodo/homeassistant",
17
+ "org.opencontainers.image.description": "Open-source home automation platform running on Python 3",
18
+ "com.docker.compose.version": "5.3.1",
19
+ "com.docker.compose.oneoff": "False",
20
+ "com.docker.compose.depends_on": "",
21
+ "io.hass.base.version": "2026.06.1",
22
+ "org.opencontainers.image.created": "2026-09-11 19:45:15+00:00",
23
+ "org.opencontainers.image.title": "Home Assistant",
24
+ "com.docker.compose.project": "homeassistant",
25
+ "io.hass.base.arch": "amd64"
26
+ }
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from releaseprobe import __version__
6
+ from releaseprobe.cli import main
7
+
8
+
9
+ def test_version_flag(capsys) -> None:
10
+ exit_code = None
11
+ try:
12
+ main(["--version"])
13
+ except SystemExit as exc:
14
+ exit_code = exc.code
15
+
16
+ assert exit_code == 0
17
+ assert __version__ in capsys.readouterr().out
18
+
19
+
20
+ def test_main_prints_human_summary(tmp_path, capsys) -> None:
21
+ labels_file = tmp_path / "labels.json"
22
+ labels_file.write_text(
23
+ json.dumps(
24
+ {
25
+ "org.opencontainers.image.version": "1.2.3",
26
+ "org.opencontainers.image.source": "https://github.com/org/app",
27
+ }
28
+ )
29
+ )
30
+
31
+ exit_code = main([str(labels_file)])
32
+
33
+ out = capsys.readouterr().out
34
+ assert exit_code == 0
35
+ assert "version: 1.2.3" in out
36
+ assert "release notes: https://github.com/org/app" in out
37
+
38
+
39
+ def test_main_prints_json(tmp_path, capsys) -> None:
40
+ labels_file = tmp_path / "labels.json"
41
+ labels_file.write_text(json.dumps({"org.opencontainers.image.version": "1.2.3"}))
42
+
43
+ exit_code = main([str(labels_file), "--json"])
44
+
45
+ out = capsys.readouterr().out
46
+ assert exit_code == 0
47
+ assert json.loads(out)["version"] == "1.2.3"
48
+
49
+
50
+ def test_main_reads_labels_from_stdin(monkeypatch, capsys) -> None:
51
+ import io
52
+
53
+ monkeypatch.setattr("sys.stdin", io.StringIO('{"org.opencontainers.image.version": "9.9.9"}'))
54
+
55
+ exit_code = main(["-"])
56
+
57
+ out = capsys.readouterr().out
58
+ assert exit_code == 0
59
+ assert "version: 9.9.9" in out
60
+
61
+
62
+ def test_main_reports_invalid_json(tmp_path, capsys) -> None:
63
+ labels_file = tmp_path / "labels.json"
64
+ labels_file.write_text("not json")
65
+
66
+ exit_code = main([str(labels_file)])
67
+
68
+ assert exit_code == 1
69
+ assert "error" in capsys.readouterr().err
70
+
71
+
72
+ def test_main_reports_missing_file(capsys) -> None:
73
+ exit_code = main(["/nonexistent/labels.json"])
74
+
75
+ assert exit_code == 1
76
+ assert "error" in capsys.readouterr().err
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from releaseprobe import labels
4
+ from releaseprobe.probe import probe_labels
5
+
6
+
7
+ def test_probe_labels_reads_oci_annotations() -> None:
8
+ info = probe_labels(
9
+ {
10
+ labels.OCI_VERSION: "1.2.3",
11
+ labels.OCI_SOURCE: "https://github.com/org/app",
12
+ labels.OCI_URL: "https://app.example.com",
13
+ labels.OCI_DOCUMENTATION: "https://docs.example.com",
14
+ labels.OCI_REVISION: "abc123",
15
+ }
16
+ )
17
+
18
+ assert info.version == "1.2.3"
19
+ assert info.source == "https://github.com/org/app"
20
+ assert info.url == "https://app.example.com"
21
+ assert info.documentation == "https://docs.example.com"
22
+ assert info.revision == "abc123"
23
+
24
+
25
+ def test_probe_labels_falls_back_to_label_schema() -> None:
26
+ info = probe_labels(
27
+ {
28
+ labels.LABEL_SCHEMA_VERSION: "1.0.0",
29
+ labels.LABEL_SCHEMA_VCS_URL: "https://github.com/org/legacy",
30
+ labels.LABEL_SCHEMA_VCS_REF: "def456",
31
+ }
32
+ )
33
+
34
+ assert info.version == "1.0.0"
35
+ assert info.source == "https://github.com/org/legacy"
36
+ assert info.revision == "def456"
37
+
38
+
39
+ def test_probe_labels_prefers_oci_over_label_schema() -> None:
40
+ info = probe_labels(
41
+ {
42
+ labels.OCI_SOURCE: "https://github.com/org/app",
43
+ labels.LABEL_SCHEMA_VCS_URL: "https://github.com/org/legacy",
44
+ }
45
+ )
46
+
47
+ assert info.source == "https://github.com/org/app"
48
+
49
+
50
+ def test_probe_labels_keeps_raw_labels() -> None:
51
+ raw = {labels.OCI_VERSION: "1.2.3"}
52
+ info = probe_labels(raw)
53
+
54
+ assert info.raw_labels == raw
55
+
56
+
57
+ def test_release_notes_url_prefers_source_then_url_then_documentation() -> None:
58
+ assert (
59
+ probe_labels(
60
+ {
61
+ labels.OCI_SOURCE: "https://github.com/org/app",
62
+ labels.OCI_URL: "https://app.example.com",
63
+ labels.OCI_DOCUMENTATION: "https://docs.example.com",
64
+ }
65
+ ).release_notes_url()
66
+ == "https://github.com/org/app"
67
+ )
68
+
69
+ assert (
70
+ probe_labels(
71
+ {
72
+ labels.OCI_URL: "https://app.example.com",
73
+ labels.OCI_DOCUMENTATION: "https://docs.example.com",
74
+ }
75
+ ).release_notes_url()
76
+ == "https://app.example.com"
77
+ )
78
+
79
+ assert (
80
+ probe_labels({labels.OCI_DOCUMENTATION: "https://docs.example.com"}).release_notes_url()
81
+ == "https://docs.example.com"
82
+ )
83
+
84
+
85
+ def test_release_notes_url_returns_none_when_absent() -> None:
86
+ assert probe_labels({}).release_notes_url() is None