Skip to content

Commit ebc0125

Browse files
committed
Fix CI usage error tests, add the coverage badge and README header
- Typer styles and wraps usage errors in a panel when GITHUB_ACTIONS is set, so four CLI tests now compare the message's plain text (usage_error) - CI writes the coverage total as a shields.io endpoint file, and badges.yml commits it to the badges branch after a passing run on main - The README opens with the Libre DevOps logo and centred badges, as the organisation profile does, with a rule between sections and a footer
1 parent 901e319 commit ebc0125

9 files changed

Lines changed: 194 additions & 11 deletions

File tree

‎.github/workflows/badges.yml‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Badges
2+
3+
# Publishes the README's coverage badge. After 'Lint and Test' passes on a push to main,
4+
# the coverage total it wrote (a shields.io endpoint file) is committed to the 'badges'
5+
# branch, which holds nothing else; the README reads it through img.shields.io/endpoint.
6+
# No third-party service is involved. This is a workflow of its own because the release
7+
# calls ci.yml with read-only permissions, and a job there cannot ask for more.
8+
on:
9+
workflow_run:
10+
workflows: ["Lint and Test"]
11+
types: [completed]
12+
branches: [main]
13+
14+
permissions:
15+
contents: read
16+
17+
concurrency:
18+
group: badges
19+
cancel-in-progress: false
20+
21+
jobs:
22+
coverage:
23+
name: Coverage badge
24+
# Only a passing run from a push to this repository's main: never a pull request.
25+
if: >-
26+
github.event.workflow_run.conclusion == 'success' &&
27+
github.event.workflow_run.event == 'push' &&
28+
github.event.workflow_run.head_branch == 'main' &&
29+
github.event.workflow_run.head_repository.full_name == github.repository
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 5
32+
permissions:
33+
# Pushing to the badges branch, and reading the triggering run's artifact.
34+
contents: write
35+
actions: read
36+
37+
steps:
38+
- name: Fetch the badge
39+
uses: actions/download-artifact@v8
40+
with:
41+
name: coverage-badge
42+
run-id: ${{ github.event.workflow_run.id }}
43+
github-token: ${{ github.token }}
44+
45+
# The file is published as it is, so check it is exactly the shape expected.
46+
- name: Check the badge
47+
run: |
48+
python3 - <<'PY'
49+
import json
50+
import re
51+
52+
with open("coverage-badge.json", encoding="utf-8") as handle:
53+
badge = json.load(handle)
54+
assert set(badge) == {"schemaVersion", "label", "message", "color"}, badge
55+
assert badge["schemaVersion"] == 1 and badge["label"] == "coverage", badge
56+
assert re.fullmatch(r"\d{1,3}\.\d%", badge["message"]), badge
57+
assert badge["color"] in {"brightgreen", "green", "yellow", "red"}, badge
58+
PY
59+
60+
- name: Publish to the badges branch
61+
env:
62+
GH_TOKEN: ${{ github.token }}
63+
SHA: ${{ github.event.workflow_run.head_sha }}
64+
run: |
65+
set -euo pipefail
66+
remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
67+
if git ls-remote --exit-code --heads "$remote" badges > /dev/null; then
68+
git clone --quiet --depth 1 --branch badges "$remote" "$RUNNER_TEMP/badges"
69+
else
70+
git init --quiet --initial-branch badges "$RUNNER_TEMP/badges"
71+
git -C "$RUNNER_TEMP/badges" remote add origin "$remote"
72+
fi
73+
cp coverage-badge.json "$RUNNER_TEMP/badges/coverage.json"
74+
cd "$RUNNER_TEMP/badges"
75+
git config user.name "github-actions[bot]"
76+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
77+
git add coverage.json
78+
if git diff --cached --quiet; then
79+
echo "coverage unchanged"
80+
exit 0
81+
fi
82+
git commit --quiet --message "Coverage $(python3 -c 'import json; print(json.load(open("coverage.json"))["message"])') at ${SHA:0:7}"
83+
git push --quiet origin badges

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,30 @@ jobs:
176176
if-no-files-found: ignore
177177
retention-days: 14
178178

179+
# The total as a shields.io endpoint; badges.yml publishes it for the README badge
180+
# after a push to main.
181+
- name: Write the coverage badge
182+
shell: bash
183+
run: |
184+
total="$(uv run coverage report --format=total --precision=1 --fail-under=0)"
185+
python3 - "$total" > coverage-badge.json <<'PY'
186+
import json
187+
import sys
188+
189+
total = float(sys.argv[1])
190+
colour = next(c for floor, c in ((90, "brightgreen"), (80, "green"), (70, "yellow"), (0, "red")) if total >= floor)
191+
print(json.dumps({"schemaVersion": 1, "label": "coverage", "message": f"{total:.1f}%", "color": colour}))
192+
PY
193+
cat coverage-badge.json
194+
195+
- name: Keep the badge
196+
uses: actions/upload-artifact@v7
197+
with:
198+
name: coverage-badge
199+
path: coverage-badge.json
200+
if-no-files-found: error
201+
retention-days: 14
202+
179203
build:
180204
name: Build
181205
runs-on: ubuntu-latest

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ All notable changes to libre-devops-helpers are recorded here. The project follo
2626
Azure CLI's lock file current.
2727
- `just image`, `image-slim`, `image-run` and `image-scan`, and `just coverage`.
2828
- Line and branch coverage in CI, failing below a floor set in `pyproject.toml`.
29+
The README's coverage badge comes from CI too: `badges.yml` publishes the total to a
30+
`badges` branch after each passing run on main, with no third-party service.
2931
- Signing in again when a sign-in lapses. When the Azure CLI's refresh token has run out
3032
(a sign-in frequency policy, 90 days unused, a password change, a revoked session, new
3133
MFA), the error now names the cause (`ReauthRequired`, with the Entra ID code
@@ -88,10 +90,14 @@ All notable changes to libre-devops-helpers are recorded here. The project follo
8890
concern; a test keeps the two trees in step. Many more CLI paths are tested.
8991
- A release now publishes the container images before the GitHub release, so a release
9092
never exists without them.
93+
- The README opens with the Libre DevOps logo and centred badges, as the organisation's
94+
profile does, with a rule between sections.
9195

9296
### Fixed
9397

9498
- `just use` and `just token` ran commands that had moved under `az` and `entra`.
99+
- Four CLI tests failed in GitHub Actions, where Typer styles and wraps usage errors; they
100+
now compare the message's plain text.
95101

96102
### Security
97103

‎README.md‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
1+
<div align="center">
2+
3+
<a href="https://libredevops.org">
4+
<picture>
5+
<source media="(prefers-color-scheme: dark)" srcset="https://libredevops.org/assets/libre-devops-white.png">
6+
<img alt="Libre DevOps" src="https://libredevops.org/assets/libre-devops-black.png" width="320">
7+
</picture>
8+
</a>
9+
110
# Libre DevOps Helpers
211

12+
`ldo`: importable Python helpers and a fast CLI for day-to-day DevOps and security work.
13+
314
[![Lint and Test](https://github.com/libre-devops/python-helpers/actions/workflows/ci.yml/badge.svg)](https://github.com/libre-devops/python-helpers/actions/workflows/ci.yml)
15+
[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/libre-devops/python-helpers/badges/coverage.json)](https://github.com/libre-devops/python-helpers/actions/workflows/ci.yml)
416
[![CodeQL](https://github.com/libre-devops/python-helpers/actions/workflows/codeql.yml/badge.svg)](https://github.com/libre-devops/python-helpers/actions/workflows/codeql.yml)
517
[![Container](https://github.com/libre-devops/python-helpers/actions/workflows/container.yml/badge.svg)](https://github.com/libre-devops/python-helpers/actions/workflows/container.yml)
18+
19+
[![Release](https://img.shields.io/github/v/release/libre-devops/python-helpers?label=release&color=1793D1)](https://github.com/libre-devops/python-helpers/releases)
20+
[![Container images](https://img.shields.io/badge/ghcr.io-python--helpers-2496ED?logo=docker&logoColor=white)](https://github.com/libre-devops/python-helpers/pkgs/container/python-helpers)
21+
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776AB?logo=python&logoColor=white)](pyproject.toml)
622
[![Licence: MIT](https://img.shields.io/badge/licence-MIT-blue.svg)](LICENSE)
723

8-
`ldo`: importable Python helpers and a fast CLI for day-to-day DevOps and security work.
24+
</div>
25+
26+
---
27+
928
The Python sibling of the [LibreDevOpsHelpers](https://github.com/libre-devops/powershell-helpers)
1029
PowerShell module.
1130

@@ -38,6 +57,8 @@ Azure CLI's active account.
3857
The whole project can be renamed for your organisation with one command; see
3958
[Rebranding](#rebranding).
4059

60+
---
61+
4162
## Requirements
4263

4364
- Python 3.11 or later, and [uv](https://docs.astral.sh/uv/)
@@ -47,6 +68,8 @@ The whole project can be renamed for your organisation with one command; see
4768
- [just](https://just.systems/) is optional: `uv sync` installs it into the project
4869
environment (the `rust-just` package), so `uv run just ...` always works
4970

71+
---
72+
5073
## Install
5174

5275
```bash
@@ -70,6 +93,8 @@ Run `ldo` on its own, or `ldo welcome`, for a greeting and the next step. The ba
7093
appears on a terminal, so scripts and CI never see it; set `LDO_NO_BANNER=1` to turn it off
7194
there too, or `NO_COLOR=1` to keep it without colour.
7295

96+
---
97+
7398
## Container images
7499

75100
Each release is published to GitHub Container Registry in two variants, for `linux/amd64`
@@ -153,6 +178,8 @@ identity, managed identity, and browser or device code sign-in without `az`. The
153178
is only there for the default sign-in, which reuses the session you already have, so it
154179
stays an outside program: on your `PATH`, or inside the default image.
155180

181+
---
182+
156183
## Configuration
157184

158185
`config init` writes `~/.config/ldo/config.toml` (override with `--config` or `LDO_CONFIG`;
@@ -360,6 +387,8 @@ client_id = "<the app id printed above>"
360387

361388
Graph insists on a ReadWrite scope even to list PIM requests; `ldo` still only ever reads.
362389

390+
---
391+
363392
## Commands
364393

365394
```bash
@@ -651,6 +680,8 @@ type-checks the whole of it and creates nothing: the authority the offline check
651680

652681
Code 3 lets a scheduled job alert on findings while still failing loudly on errors.
653682

683+
---
684+
654685
## ServiceNow
655686

656687
The `snow` commands sign in to a ServiceNow instance as you, and read from it:
@@ -748,6 +779,8 @@ If it is not offered there, sign in to the instance as admin and install it from
748779
System Applications > All Available Applications, with its demo data if you want records
749780
to work with. Then `ldo snow instance` shows it installed.
750781

782+
---
783+
751784
## Permissions
752785

753786
Commands run with the permissions of the profile's credential. The Azure CLI's delegated
@@ -790,6 +823,8 @@ The signature is **not** verified: this answers "is this the token I meant to ge
790823
this token genuine?". The token value is never printed unless you pass `--raw`, and never
791824
logged.
792825

826+
---
827+
793828
## Rebranding
794829

795830
To run this inside a company under the company's own name, rename it with one command.
@@ -818,7 +853,10 @@ the running tool shows come from one module (`core/brand.py`). After rewriting,
818853
refreshes the lock file and runs every check. `LICENSE` is never changed: the MIT licence
819854
requires its copyright and permission notice to stay with the code. A test rebrands a copy of
820855
the repository and runs the copy's whole test suite, so the rename keeps working as the code
821-
grows.
856+
grows. The README's logo, badges and footer point at Libre DevOps and this repository's
857+
GitHub pages, so replace them with your own by hand.
858+
859+
---
822860

823861
## Using it as a library
824862

@@ -865,6 +903,8 @@ with EntraClient.for_profile(profile, tokens) as entra, XdrClient.for_profile(pr
865903
Library code raises `LdoError` subclasses and never exits; only the CLI turns errors into
866904
messages and exit codes.
867905

906+
---
907+
868908
## CI/CD
869909

870910
Every pull request and push to `main` runs `.github/workflows/ci.yml`:
@@ -875,7 +915,7 @@ Every pull request and push to `main` runs `.github/workflows/ci.yml`:
875915
| Lint | `ruff check` and `ruff format --check`, after `uv sync --locked` |
876916
| Dependency audit | `pip-audit` over the locked, hashed dependency tree |
877917
| Test | pytest on Python 3.11 to 3.14 on Linux, and 3.13 on Windows and macOS, including the rebrand test |
878-
| Coverage | line and branch coverage, failing below the floor in `pyproject.toml` (`fail_under`), with the report in the job summary |
918+
| Coverage | line and branch coverage, failing below the floor in `pyproject.toml` (`fail_under`), with the report in the job summary and the total written for the badge |
879919
| Build | builds the sdist and wheel once, installs the wheel in a clean environment, and keeps both as the run's artifact |
880920

881921
`.github/workflows/container.yml` builds both images on every pull request and push, runs
@@ -886,6 +926,10 @@ fixed critical one and reports the rest, since the Azure CLI inside it pins its
886926
dependencies. Findings
887927
go to the Security tab.
888928

929+
After a passing run on `main`, `.github/workflows/badges.yml` commits the coverage total
930+
to the `badges` branch as a shields.io endpoint file, which is where the README's coverage
931+
badge reads it; nothing else lives on that branch.
932+
889933
CodeQL scans the Python code and the workflows on every change and weekly; Dependency
890934
Review comments on pull requests that change dependencies. Third-party actions are pinned
891935
to a commit.
@@ -920,6 +964,8 @@ both container images for every platform, with attestations, and only then publi
920964
files the gate built (not a rebuild) as a GitHub release with `SHA256SUMS`, after checking
921965
the tag matches the version. A release therefore never exists without its images.
922966

967+
---
968+
923969
## Development
924970

925971
```bash
@@ -943,6 +989,17 @@ so an hour-long watch runs in microseconds. Runtime dependencies are kept to `re
943989
See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request, and
944990
[SECURITY.md](SECURITY.md) to report a vulnerability.
945991

992+
---
993+
946994
## Licence
947995

948996
[MIT](LICENSE)
997+
998+
---
999+
1000+
<div align="center">
1001+
<sub>
1002+
Part of <a href="https://libredevops.org">Libre DevOps</a>. Everything we publish is open and
1003+
provided as-is; review and test it against your own requirements before production use.
1004+
</sub>
1005+
</div>

‎tests/cli/commands/test_azure.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fakes.http import routes
44
from fakes.ids import OTHER_SUBSCRIPTION, SUBSCRIPTION, TENANT
5-
from fakes.tenant import invoke, run
5+
from fakes.tenant import invoke, run, usage_error
66

77
SECURITY = f"/subscriptions/{SUBSCRIPTION}/providers/Microsoft.Security"
88
SUBSCRIPTIONS = (
@@ -96,7 +96,7 @@ def test_recommendations_filter_by_severity_across_subscriptions(config_file):
9696
def test_recommendations_reject_an_unknown_severity(config_file):
9797
result = run(config_file, routes({}), ["azure", "recommendations", "--severity", "urgent"])
9898
assert result.exit_code == 2
99-
assert "--severity must be high, medium or low" in result.output
99+
assert "--severity must be high, medium or low" in usage_error(result)
100100

101101

102102
def test_defender_plans_show_which_are_on(config_file):

‎tests/cli/commands/test_entra.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22

33
from fakes.http import routes
4-
from fakes.tenant import invoke, run, runner, runtime
4+
from fakes.tenant import invoke, run, runner, runtime, usage_error
55
from libre_devops_helpers.cli import app
66

77
GROUP_ID = "55555555-5555-5555-5555-555555555555"
@@ -112,7 +112,7 @@ def test_group_members_of_one_kind(config_file):
112112
def test_group_members_rejects_an_unknown_kind(config_file):
113113
result = run(config_file, routes({}), ["entra", "group-members", "Ring 1", "--kind", "robot"])
114114
assert result.exit_code == 2
115-
assert "--kind must be one of" in result.output
115+
assert "--kind must be one of" in usage_error(result)
116116

117117

118118
def test_user_groups_for_a_upn(config_file):

‎tests/cli/commands/test_keyvault.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22

33
from fakes.http import routes
4-
from fakes.tenant import invoke, run
4+
from fakes.tenant import invoke, run, usage_error
55

66

77
def test_keyvault_expiry_finds_vaults_through_resource_graph(config_file, tenant):
@@ -41,4 +41,4 @@ def test_expiry_needs_a_vault_and_a_known_kind(config_file):
4141
assert "no vaults given" in str(result.exception)
4242
result = run(config_file, routes({}), ["keyvault", "expiry", "kv-app", "--kind", "passwords"])
4343
assert result.exit_code == 2
44-
assert "--kind must be one of" in result.output
44+
assert "--kind must be one of" in usage_error(result)

‎tests/cli/commands/test_xdr.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from urllib.parse import unquote
44

55
from fakes.http import json_body, routes
6-
from fakes.tenant import invoke, run, runner, runtime
6+
from fakes.tenant import invoke, run, runner, runtime, usage_error
77
from libre_devops_helpers.cli import app
88

99
MACHINE_ID = "a" * 40
@@ -179,4 +179,4 @@ def test_a_timespan_needs_graph(config_file):
179179
args = ["xdr", "hunt", "DeviceInfo", "--endpoint", "--timespan", "7d"]
180180
result = run(config_file, routes({}), args)
181181
assert result.exit_code == 2
182-
assert "--timespan is not available with --endpoint" in result.output
182+
assert "--timespan is not available with --endpoint" in usage_error(result)

0 commit comments

Comments
 (0)