systemd-search 1.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.
Files changed (38) hide show
  1. systemd_search-1.1.0/.github/workflows/pr.yml +24 -0
  2. systemd_search-1.1.0/.github/workflows/release.yml +170 -0
  3. systemd_search-1.1.0/.github/workflows/tests.yml +68 -0
  4. systemd_search-1.1.0/.gitignore +41 -0
  5. systemd_search-1.1.0/ARCHITECTURE.md +227 -0
  6. systemd_search-1.1.0/LICENSE +184 -0
  7. systemd_search-1.1.0/PKG-INFO +550 -0
  8. systemd_search-1.1.0/Pipfile +17 -0
  9. systemd_search-1.1.0/Pipfile.lock +1003 -0
  10. systemd_search-1.1.0/README.md +532 -0
  11. systemd_search-1.1.0/molecule/common/converge.yml +75 -0
  12. systemd_search-1.1.0/molecule/common/files/fixture-disabled.service +14 -0
  13. systemd_search-1.1.0/molecule/common/files/fixture-failing.service +13 -0
  14. systemd_search-1.1.0/molecule/common/files/fixture-multi-a.service +15 -0
  15. systemd_search-1.1.0/molecule/common/files/fixture-multi.path +14 -0
  16. systemd_search-1.1.0/molecule/common/files/fixture-multi.timer +14 -0
  17. systemd_search-1.1.0/molecule/common/files/fixture-single.service +13 -0
  18. systemd_search-1.1.0/molecule/common/prepare.yml +22 -0
  19. systemd_search-1.1.0/molecule/common/verify.yml +458 -0
  20. systemd_search-1.1.0/molecule/debian/molecule.yml +35 -0
  21. systemd_search-1.1.0/molecule/rocky/molecule.yml +35 -0
  22. systemd_search-1.1.0/pyproject.toml +38 -0
  23. systemd_search-1.1.0/scripts/build-deb.sh +36 -0
  24. systemd_search-1.1.0/scripts/build-rpm.sh +48 -0
  25. systemd_search-1.1.0/scripts/build-standalone.sh +24 -0
  26. systemd_search-1.1.0/scripts/release-notes.md +19 -0
  27. systemd_search-1.1.0/setup.cfg +4 -0
  28. systemd_search-1.1.0/src/systemd_search/__init__.py +275 -0
  29. systemd_search-1.1.0/src/systemd_search.egg-info/PKG-INFO +550 -0
  30. systemd_search-1.1.0/src/systemd_search.egg-info/SOURCES.txt +36 -0
  31. systemd_search-1.1.0/src/systemd_search.egg-info/dependency_links.txt +1 -0
  32. systemd_search-1.1.0/src/systemd_search.egg-info/entry_points.txt +2 -0
  33. systemd_search-1.1.0/src/systemd_search.egg-info/scm_file_list.json +33 -0
  34. systemd_search-1.1.0/src/systemd_search.egg-info/scm_version.json +8 -0
  35. systemd_search-1.1.0/src/systemd_search.egg-info/top_level.txt +1 -0
  36. systemd_search-1.1.0/systemd-search +3 -0
  37. systemd_search-1.1.0/tests/conftest.py +13 -0
  38. systemd_search-1.1.0/tests/test_unit.py +297 -0
@@ -0,0 +1,24 @@
1
+ ---
2
+ # Runs on every pull request and on direct pushes to master/main.
3
+ # Branch protection must require all jobs from the called tests workflow to pass.
4
+ #
5
+ # Configure under: Settings → Branches → Branch protection rules → master
6
+ # Required status checks:
7
+ # - Tests / Unit tests
8
+ # - Tests / Molecule — rocky
9
+ # - Tests / Molecule — debian
10
+
11
+ name: PR
12
+
13
+ on:
14
+ push:
15
+ branches: [master]
16
+ pull_request:
17
+
18
+ concurrency:
19
+ group: pr-${{ github.ref }}
20
+ cancel-in-progress: true
21
+
22
+ jobs:
23
+ tests:
24
+ uses: ./.github/workflows/tests.yml
@@ -0,0 +1,170 @@
1
+ ---
2
+ # Runs when a tag is pushed. Validates the tag is strict semver, runs the
3
+ # full test suite, then builds and publishes a GitHub Release.
4
+ #
5
+ # Tag format: MAJOR.MINOR.PATCH (no v prefix), with optional pre-release
6
+ # and build metadata as defined by semver.org. Examples: 1.0.0 1.2.0-beta.1
7
+
8
+ name: Release
9
+
10
+ on:
11
+ push:
12
+ tags: ["*"]
13
+
14
+ jobs:
15
+ # ── Shared test suite ────────────────────────────────────────────────────────
16
+ tests:
17
+ uses: ./.github/workflows/tests.yml
18
+
19
+ # ── Semver gate ──────────────────────────────────────────────────────────────
20
+ # Validates the tag against the official semver.org regex before any build
21
+ # work starts. Runs in parallel with tests so a bad tag fails fast.
22
+ # https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
23
+ validate-tag:
24
+ name: Validate semver tag
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - name: Check tag matches semver
28
+ run: |
29
+ TAG="${GITHUB_REF#refs/tags/}"
30
+ SEMVER='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(\+([0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*))?$'
31
+ if ! echo "$TAG" | grep -qE "$SEMVER"; then
32
+ echo "::error::Tag '$TAG' is not valid semver. Release aborted."
33
+ exit 1
34
+ fi
35
+ echo "Tag '$TAG' is valid semver."
36
+
37
+ # ── Package builds ───────────────────────────────────────────────────────────
38
+ # Both tests and validate-tag must pass before any package is built.
39
+ package:
40
+ name: Package — ${{ matrix.target }}
41
+ runs-on: ubuntu-latest
42
+ needs: [tests, validate-tag]
43
+ strategy:
44
+ fail-fast: false
45
+ matrix:
46
+ include:
47
+ - target: rocky9
48
+ image: rockylinux/rockylinux:9
49
+ script: build-rpm.sh
50
+ - target: rocky10
51
+ image: rockylinux/rockylinux:10
52
+ script: build-rpm.sh
53
+ - target: debian12
54
+ image: debian:12
55
+ script: build-deb.sh
56
+ - target: debian13
57
+ image: debian:13
58
+ script: build-deb.sh
59
+
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+
63
+ - name: Build package in ${{ matrix.target }} container
64
+ run: |
65
+ VERSION=${GITHUB_REF#refs/tags/}
66
+ docker run --rm \
67
+ -v "${{ github.workspace }}:/workspace" \
68
+ ${{ matrix.image }} \
69
+ bash /workspace/scripts/${{ matrix.script }} ${{ matrix.target }} "$VERSION"
70
+
71
+ - name: Compute SHA-256 checksum
72
+ run: |
73
+ pkg=$(ls systemd-search-*-${{ matrix.target }}.*)
74
+ sha256sum "$pkg" > "${pkg}.sha256"
75
+ cat "${pkg}.sha256"
76
+
77
+ - name: Upload package artifact
78
+ uses: actions/upload-artifact@v4
79
+ with:
80
+ name: pkg-${{ matrix.target }}
81
+ path: systemd-search-*-${{ matrix.target }}.*
82
+ if-no-files-found: error
83
+
84
+ # ── Standalone zipapp executable ──────────────────────────────────────────────
85
+ executable:
86
+ name: Standalone executable
87
+ runs-on: ubuntu-latest
88
+ needs: [tests, validate-tag]
89
+ steps:
90
+ - uses: actions/checkout@v4
91
+ with:
92
+ fetch-depth: 0 # setuptools-scm needs full history to resolve the tag
93
+
94
+ - uses: actions/setup-python@v5
95
+ with:
96
+ python-version: "3.9"
97
+
98
+ - name: Build standalone zipapp
99
+ run: |
100
+ VERSION=${GITHUB_REF#refs/tags/}
101
+ pip install . --quiet
102
+ bash scripts/build-standalone.sh "$VERSION"
103
+ sha256sum "systemd-search-${VERSION}" > "systemd-search-${VERSION}.sha256"
104
+
105
+ - name: Upload executable artifact
106
+ uses: actions/upload-artifact@v4
107
+ with:
108
+ name: pkg-executable
109
+ path: systemd-search-*
110
+ if-no-files-found: error
111
+
112
+ # ── PyPI publish ──────────────────────────────────────────────────────────────
113
+ # Requires a Trusted Publisher configured on PyPI for this repo.
114
+ # See: https://docs.pypi.org/trusted-publishers/
115
+ pypi:
116
+ name: Publish to PyPI
117
+ runs-on: ubuntu-latest
118
+ needs: [tests, validate-tag]
119
+ environment: pypi
120
+ permissions:
121
+ id-token: write # required for OIDC Trusted Publisher
122
+ steps:
123
+ - uses: actions/checkout@v4
124
+ with:
125
+ fetch-depth: 0 # setuptools-scm needs full history to resolve the tag
126
+
127
+ - uses: actions/setup-python@v5
128
+ with:
129
+ python-version: "3.9"
130
+
131
+ - name: Build wheel and sdist
132
+ run: |
133
+ pip install build --quiet
134
+ python -m build
135
+
136
+ - name: Publish to PyPI
137
+ uses: pypa/gh-action-pypi-publish@release/v1
138
+ with:
139
+ skip-existing: true
140
+
141
+ # ── GitHub Release ────────────────────────────────────────────────────────────
142
+ release:
143
+ name: GitHub Release
144
+ runs-on: ubuntu-latest
145
+ needs: [package, executable, pypi]
146
+ permissions:
147
+ contents: write
148
+ steps:
149
+ - uses: actions/checkout@v4
150
+
151
+ - name: Download all package artifacts
152
+ uses: actions/download-artifact@v4
153
+ with:
154
+ pattern: pkg-*
155
+ merge-multiple: true
156
+ path: dist/
157
+
158
+ - name: List release assets
159
+ run: ls -lh dist/
160
+
161
+ - name: Create GitHub Release
162
+ env:
163
+ GH_TOKEN: ${{ github.token }}
164
+ run: |
165
+ TAG=${GITHUB_REF#refs/tags/}
166
+ sed "s/{{VERSION}}/$TAG/g" scripts/release-notes.md > /tmp/release-notes.md
167
+ gh release create "$TAG" \
168
+ --title "systemd-search ${TAG}" \
169
+ --notes-file /tmp/release-notes.md \
170
+ dist/*
@@ -0,0 +1,68 @@
1
+ ---
2
+ # Reusable workflow — unit tests + molecule integration tests.
3
+ # Called by pr.yml and release.yml. Never triggered directly.
4
+
5
+ name: Tests
6
+
7
+ on:
8
+ workflow_call:
9
+
10
+ jobs:
11
+ unit-tests:
12
+ name: Unit tests
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.9"
20
+
21
+ - name: Cache pipenv virtualenv
22
+ uses: actions/cache@v4
23
+ with:
24
+ path: ~/.local/share/virtualenvs
25
+ key: pipenv-${{ runner.os }}-${{ hashFiles('Pipfile') }}
26
+
27
+ - name: Install pipenv
28
+ run: pip install pipenv
29
+
30
+ - name: Install dependencies
31
+ run: pipenv install --dev
32
+
33
+ - name: Run unit tests
34
+ run: pipenv run pytest tests/ -v
35
+
36
+ molecule:
37
+ name: Molecule — ${{ matrix.scenario }}
38
+ runs-on: ubuntu-latest
39
+ needs: unit-tests
40
+ strategy:
41
+ fail-fast: false
42
+ matrix:
43
+ scenario: [rocky, debian]
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+
47
+ - uses: actions/setup-python@v5
48
+ with:
49
+ python-version: "3.9"
50
+
51
+ - name: Cache pipenv virtualenv
52
+ uses: actions/cache@v4
53
+ with:
54
+ path: ~/.local/share/virtualenvs
55
+ key: pipenv-${{ runner.os }}-${{ hashFiles('Pipfile') }}
56
+
57
+ - name: Install pipenv
58
+ run: pip install pipenv
59
+
60
+ - name: Install dependencies
61
+ run: pipenv install --dev
62
+
63
+ - name: Run molecule scenario
64
+ run: pipenv run molecule test -s ${{ matrix.scenario }}
65
+ env:
66
+ MOLECULE_PROJECT_DIRECTORY: ${{ github.workspace }}
67
+ PY_COLORS: "1"
68
+ ANSIBLE_FORCE_COLOR: "1"
@@ -0,0 +1,41 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+
6
+ # Virtual environments
7
+ # Pipenv creates .venv when PIPENV_VENV_IN_PROJECT=1 is set
8
+ .venv/
9
+ venv/
10
+ env/
11
+
12
+ # Pipfile.lock is committed — do not add it here
13
+
14
+ # Molecule ephemeral state
15
+ molecule/**/molecule.yml.bak
16
+ .molecule/
17
+
18
+ # Built packages (produced by CI, not committed)
19
+ *.rpm
20
+ *.deb
21
+ *.sha256
22
+ dist/
23
+ *.egg-info/
24
+ *.whl
25
+
26
+ # Standalone zipapp versioned executables
27
+ systemd-search-[0-9]*
28
+
29
+ # Staged binary copied by molecule prepare playbook
30
+ molecule/common/files/systemd-search
31
+
32
+ # macOS
33
+ .DS_Store
34
+
35
+ # Editors
36
+ .idea/
37
+ .vscode/
38
+ *.swp
39
+ *.swo
40
+ *~
41
+ .claude/settings.local.json
@@ -0,0 +1,227 @@
1
+ # Architecture & Design Decisions
2
+
3
+ This document explains how `systemd-search` is built, why each decision was made, and what trade-offs were accepted. It is written for anyone who needs to extend, debug, or maintain the tool in the future.
4
+
5
+ ---
6
+
7
+ ## Problem statement
8
+
9
+ systemd has no native concept of user-defined tags on unit files. Finding all units that belong to a project means either maintaining a separate registry, grepping file contents, or knowing the naming convention ahead of time. None of those scale and all of them break when drop-in override files are involved.
10
+
11
+ `systemd-search` uses a property of the systemd unit file format to solve this without patching systemd or maintaining any external state.
12
+
13
+ ---
14
+
15
+ ## The `X-` section trick
16
+
17
+ The `systemd.unit(5)` man page specifies that any section whose name begins with `X-` is completely ignored by systemd. The unit loads and runs normally. Third-party applications are explicitly invited to use these sections for their own metadata.
18
+
19
+ This means an `[X-Labels]` block (or any `[X-*]` block) can be added to an existing unit file at any time with zero risk of affecting systemd behaviour. `systemd-search` reads those sections to provide a tagging layer on top of systemd with no daemon, no database, and no infrastructure.
20
+
21
+ Reference: <https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html>
22
+
23
+ ---
24
+
25
+ ## How the tool works
26
+
27
+ ### Step 1 — enumerate unit files
28
+
29
+ `systemctl list-unit-files --type=<type>` returns all installed units of the requested type and their enabled state (`enabled`, `disabled`, `static`, etc.). This is cheap and does not require reading any file on disk.
30
+
31
+ ### Step 2 — read the effective configuration
32
+
33
+ For every unit that passes the enabled/disabled filter, `systemctl cat <unit>` is called. This command resolves the full configuration: the base unit file plus every drop-in file under `<unit>.d/` directories, concatenated in the correct override order. Later definitions of the same key win, which matches systemd's own merge semantics.
34
+
35
+ Parsing the raw file on disk would be wrong — it would miss drop-ins that change or add label keys.
36
+
37
+ ### Step 3 — parse
38
+
39
+ The output of `systemctl cat` is INI-format text. It is parsed with Python's `configparser.RawConfigParser` rather than hand-rolled string splitting. Key decisions:
40
+
41
+ - `strict=False` — allows duplicate sections (base file and drop-in both define `[X-Labels]`) and duplicate keys. Later definitions overwrite earlier ones, which is exactly what systemd does when merging drop-ins.
42
+ - `delimiters=('=',)` — systemd never uses `:` as a key-value separator. Preventing configparser from treating it as one avoids misreading values like `ExecStart=/bin/sh -c 'key:value'`.
43
+ - `optionxform = str` — configparser lowercases keys by default. Label keys are case-sensitive (users define `Project`, not `project`), so this must be disabled.
44
+ - `RawConfigParser` rather than `ConfigParser` — prevents interpolation of `%`-style variables that may appear in systemd values.
45
+
46
+ ### Step 4 — section gate
47
+
48
+ Every unit is checked for the presence of the target section **unconditionally**, regardless of what flags were passed. If the section is absent the unit is silently skipped. This is a hard invariant: `systemd-search` only surfaces units that carry the section. Running `systemd-search` with no flags does not list every service on the system — it lists only the services the operator has labelled.
49
+
50
+ This was a deliberate late decision. An early design allowed unlabelled units to appear when no label filter was set (to allow browsing). It was rejected because `systemd-search` is a tool for labelled infrastructure, not a general unit browser, and mixing unlabelled system services into the output defeats the purpose.
51
+
52
+ ### Step 5 — apply label and exclude filters
53
+
54
+ `--label KEY` — the key must be present in the section.
55
+ `--label KEY=VALUE` — the key must be present and equal the given value.
56
+ `--exclude KEY` — the unit is dropped if the key is present, regardless of value.
57
+ `--exclude KEY=VALUE` — the unit is dropped only if the key is present *and* equals the value.
58
+
59
+ Multiple `--label` filters are ANDed: all must match.
60
+ Multiple `--exclude` filters are ORed: any hit drops the unit.
61
+
62
+ `--label` and `--exclude` compose freely with each other and with state filters.
63
+
64
+ ### Step 6 — active state
65
+
66
+ `systemctl is-active <unit>` is called only when needed:
67
+
68
+ - `--active` or `--dead` is passed (state filter)
69
+ - `--json` is passed (the output always includes `is-active`)
70
+
71
+ In all other output modes the active-state subprocess call is skipped for performance. On a system with many labelled units this matters.
72
+
73
+ ### Step 7 — output
74
+
75
+ Three output modes are mutually exclusive:
76
+
77
+ | Mode | Flag | Format |
78
+ |---|---|---|
79
+ | Plain | (default) | One unit name per line |
80
+ | Verbose | `--verbose` / `-v` | `unit-name\tkey=value …` |
81
+ | JSON | `--json` | JSON array, one object per unit |
82
+
83
+ `--verbose` shows only the keys that were queried with `--label`. If no `--label` filter is set it shows every key in the section.
84
+
85
+ `--json` always shows all section keys under `labels`, plus `enabled` (bool) and `is-active` (bool). The `enabled` field is derived from the enabled-state string returned by `systemctl list-unit-files`: the states `enabled`, `enabled-runtime`, `static`, `alias`, `generated`, and `transient` are treated as enabled; `disabled`, `masked`, `masked-runtime`, and `indirect` are treated as disabled.
86
+
87
+ ### Exit code
88
+
89
+ Exit code `0` — at least one result was returned.
90
+ Exit code `1` — no results matched. This allows shell scripts and monitoring agents to detect the empty case without parsing output.
91
+
92
+ In JSON mode an empty result produces `[]` on stdout with exit code `1`, so JSON consumers always receive valid JSON regardless of whether results were found.
93
+
94
+ ---
95
+
96
+ ## Python version target
97
+
98
+ The tool targets **Python 3.9** — the system Python on Rocky Linux 9. Rocky Linux 9 ships 3.9 as its default and that version will not change for the lifetime of the distribution. The tool must work without installing any additional Python version or any third-party packages. All dependencies (`argparse`, `configparser`, `json`, `subprocess`, `sys`) are standard library modules present in 3.9.
99
+
100
+ Unit tests are run against Python 3.9 in CI for the same reason.
101
+
102
+ ---
103
+
104
+ ## Test strategy
105
+
106
+ ### Unit tests (`tests/`)
107
+
108
+ Pure function tests with no I/O. They cover every testable function in the script:
109
+
110
+ - `parse_cat_output` — INI parsing, drop-in merge semantics, edge cases
111
+ - `parse_label_filters` — `KEY` and `KEY=VALUE` parsing, whitespace, embedded `=`
112
+ - `section_matches` — positive filter logic
113
+ - `section_excluded` — negative filter logic
114
+ - `format_json_entry` — JSON shape, `enabled` bool derivation, label copy
115
+ - `format_verbose` — tab-separated output, key selection
116
+
117
+ `main()` is not unit-tested directly because it depends on live `systemctl` calls. Integration coverage is provided by molecule.
118
+
119
+ The script is named `systemd-search` (no `.py` extension) to be installable as a plain executable. Importing a hyphen-named file requires `importlib.machinery.SourceFileLoader` rather than the standard import machinery. `tests/conftest.py` handles this once so test files can do a normal `import systemd_search`.
120
+
121
+ ### Molecule integration tests (`molecule/`)
122
+
123
+ Two scenarios — `rocky` and `debian` — each define two platforms so a single `molecule test` run covers both versions of the distribution simultaneously. `rocky` tests Rocky Linux 9 and 10; `debian` tests Debian 12 and 13.
124
+
125
+ The fixture units are designed to exercise every code path:
126
+
127
+ | Unit | Labels | State |
128
+ |---|---|---|
129
+ | `fixture-single.service` | `Project=single-app` | enabled, not started |
130
+ | `fixture-multi-a.service` | `Project=myapp`, `Domain=foo`, `Env=prod` | enabled, active |
131
+ | `fixture-multi.timer` | `Project=myapp`, `Domain=foo`, `Env=prod` | enabled, active |
132
+ | `fixture-multi.path` | `Project=myapp`, `Domain=foo`, `Env=prod` | enabled, active |
133
+ | `fixture-disabled.service` | `Project=myapp`, `Visibility=disabled-only` | installed, not enabled |
134
+ | `fixture-failing.service` | `Project=myapp`, `Status=failing` | enabled, failed |
135
+
136
+ `fixture-single` deliberately uses `Project=single-app` (different value from the others) to verify that `--verbose` shows per-unit values and that `--exclude Project=myapp` correctly returns only this unit.
137
+
138
+ `fixture-failing` uses `ExecStart=/bin/false` to produce a genuine failed state. Molecule starts it with `ignore_errors: true` because systemd correctly refuses to report success when the service fails.
139
+
140
+ Molecule scenarios share converge and verify playbooks via `molecule/common/`. Only `molecule.yml` (the platform definition) differs between scenarios. The binary is staged from the project root into `molecule/common/files/systemd-search` by a `prepare.yml` playbook that runs on the Ansible controller before the container is provisioned, so the tool does not need to be built or packaged for testing.
141
+
142
+ Every molecule command that is expected to produce empty output has `failed_when: false` to prevent Ansible from treating exit code `1` as a task failure. Assertions on the registered result then check both `rc == 1` and empty stdout/`[]`.
143
+
144
+ ---
145
+
146
+ ## Packaging
147
+
148
+ ### RPM (`scripts/build-rpm.sh`)
149
+
150
+ Runs inside the target Rocky Linux container (`rockylinux:9` or `rockylinux:10`). Installs `rpm-build`, generates a spec file with the version from the `VERSION` file, and calls `rpmbuild -bb`. The resulting `.noarch.rpm` is copied back to the host workspace. `BuildArch: noarch` is correct because the tool is a Python script with no compiled components.
151
+
152
+ ### DEB (`scripts/build-deb.sh`)
153
+
154
+ Runs inside the target Debian container (`debian:12` or `debian:13`). Builds a package directory tree under `/build/DEBIAN/` with a `control` file, then calls `dpkg-deb --build`. `Architecture: all` is the Debian equivalent of `noarch`.
155
+
156
+ ### Version
157
+
158
+ The version is the git tag itself. Both packaging scripts accept it as a second positional argument (`build-rpm.sh <target> <version>`, `build-deb.sh <target> <version>`). In CI the tag is extracted from `GITHUB_REF` and passed through to the container. There is no separate `VERSION` file — the tag is the single source of truth.
159
+
160
+ ---
161
+
162
+ ## CI/CD pipeline
163
+
164
+ ### Pipeline files
165
+
166
+ The CI/CD configuration is split across three files, following the DRY principle via GitHub Actions reusable workflows:
167
+
168
+ | File | Trigger | Responsibility |
169
+ |---|---|---|
170
+ | `tests.yml` | `workflow_call` (never directly) | Unit tests + molecule — the shared task definition |
171
+ | `pr.yml` | `pull_request`, push to `master`/`main` | Calls `tests.yml`; nothing else |
172
+ | `release.yml` | Push of any tag | Calls `tests.yml`, validates semver, builds packages, publishes release |
173
+
174
+ `tests.yml` is a reusable workflow (`on: workflow_call`). It is never triggered directly — only called by `pr.yml` and `release.yml`. This is the single definition of what "passing tests" means. Any change to the test suite is made once and is immediately in effect for both contexts.
175
+
176
+ ### Semver validation
177
+
178
+ GitHub Actions `on.push.tags` only supports glob patterns, not regex. `release.yml` uses `tags: ["*"]` to catch all tag pushes, then a dedicated `validate-tag` job applies the official semver.org ERE regex via `grep -E`. The regex validates:
179
+
180
+ - `MAJOR.MINOR.PATCH` with no leading zeroes in any component
181
+ - Optional pre-release identifier after `-` (e.g. `1.0.0-beta.1`)
182
+ - Optional build metadata after `+` (e.g. `1.0.0+20250625`)
183
+
184
+ `validate-tag` runs in parallel with the `tests` job so a bad tag is rejected immediately without waiting for the full test suite. `package` and `executable` both `need: [tests, validate-tag]` — both gates must be green before a single package is built.
185
+
186
+ Tags carry no `v` prefix. The tag `1.2.0` produces the release `systemd-search 1.2.0`.
187
+
188
+ ### Release
189
+
190
+ The `release` job downloads all artifacts uploaded by `package` and `executable`, then calls `gh release create` with the tag name. Each release includes:
191
+
192
+ - `systemd-search` — plain Python executable, runs on any distro with Python 3.9+
193
+ - `systemd-search-<version>-rocky9.noarch.rpm` + `.sha256`
194
+ - `systemd-search-<version>-rocky10.noarch.rpm` + `.sha256`
195
+ - `systemd-search-<version>-debian12.all.deb` + `.sha256`
196
+ - `systemd-search-<version>-debian13.all.deb` + `.sha256`
197
+
198
+ ### Branch protection
199
+
200
+ Branch protection rules for `master` must require the following status checks (reported by `pr.yml`) to pass before merging:
201
+
202
+ - `Tests / Unit tests`
203
+ - `Tests / Molecule — rocky`
204
+ - `Tests / Molecule — debian`
205
+
206
+ This is configured in GitHub under **Settings → Branches → Branch protection rules** and is not encoded in any workflow file.
207
+
208
+ ---
209
+
210
+ ## Local development
211
+
212
+ Dependencies are managed with Pipenv. The `Pipfile` pins Python 3.9 to match the production target. CI also uses Pipenv — there is no separate `requirements-test.txt`.
213
+
214
+ ```bash
215
+ pipenv install --dev # create venv and install all dev deps
216
+ pipenv shell # activate
217
+
218
+ pytest tests/ -v # unit tests
219
+ molecule test -s rocky # integration tests (Docker required)
220
+ molecule test -s debian
221
+ ```
222
+
223
+ To update dependencies:
224
+
225
+ ```bash
226
+ pipenv install --dev some-new-package
227
+ ```