qso-graph-sim 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,37 @@
1
+ # Dependabot: security updates only, grouped into one pull request.
2
+ #
3
+ # WHY THIS FILE EXISTS (2026-10-25). There was no dependabot.yml in any of the 22
4
+ # servers. Without one Dependabot runs *security* updates only -- those need no
5
+ # config -- and they are ungrouped by default: one advisory x one package x one
6
+ # repository = one pull request. Four upgrades produced **57 open pull requests**
7
+ # across the fleet, and KI7MT's objection was the right one: nobody could say
8
+ # what 57 pull requests did. Measured, they were four dependency upgrades closing
9
+ # 130 alerts, one of them CRITICAL. The volume was not noise, it was signal
10
+ # nobody could read.
11
+ #
12
+ # `groups` with `applies-to: security-updates` collapses that to one pull request
13
+ # per repository.
14
+ #
15
+ # open-pull-requests-limit: 0 disables *version* updates, which we do not want --
16
+ # ci.yml audits the locked dependencies on every pull request and weekly, so a
17
+ # stale lock already fails CI and does not need a bot to notice. Security updates
18
+ # are unaffected by this limit: they have their own fixed limit of 10, which
19
+ # cannot be configured. Checked against the Dependabot options reference rather
20
+ # than assumed, because setting this wrong would silently switch off the thing
21
+ # that found those 130 alerts.
22
+ #
23
+ # `uv` is the ecosystem for a uv.lock project, also checked against that
24
+ # reference and not inferred from the file name.
25
+ version: 2
26
+ updates:
27
+ - package-ecosystem: "uv"
28
+ directory: "/"
29
+ schedule:
30
+ interval: "weekly"
31
+ day: "monday"
32
+ open-pull-requests-limit: 0
33
+ groups:
34
+ security:
35
+ applies-to: security-updates
36
+ patterns:
37
+ - "*"
@@ -0,0 +1,136 @@
1
+ name: CI
2
+
3
+ # A pull request is tested whatever it targets, and deliberately has no branch
4
+ # filter. `main` is the one long-lived branch now, so `push` names it; but a filter
5
+ # here would mean a pull request into any other branch ran no ruff, no mypy and no
6
+ # pytest while still reporting green.
7
+ on:
8
+ pull_request:
9
+ push:
10
+ branches: [main]
11
+ # Weekly, because an advisory is published against a lockfile that has not
12
+ # changed, and no pull-request-or-push trigger fires for that.
13
+ schedule:
14
+ - cron: "17 6 * * 1"
15
+
16
+ concurrency:
17
+ group: ci-${{ github.workflow }}-${{ github.ref }}
18
+ cancel-in-progress: true
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ # Lint and types, once rather than per Python: both read the source, and
25
+ # neither answer changes with the interpreter running them (qso-graph-devel#66).
26
+ lint:
27
+ name: ruff and mypy
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v5
31
+
32
+ - uses: astral-sh/setup-uv@v7
33
+ with:
34
+ python-version: "3.11.18"
35
+ enable-cache: true
36
+
37
+ - name: Install (locked)
38
+ run: uv sync --group dev --frozen
39
+
40
+ - name: Lint (ruff)
41
+ run: uv run ruff check .
42
+
43
+ - name: Type-check (mypy)
44
+ run: uv run mypy src
45
+
46
+ test:
47
+ name: pytest (${{ matrix.python-version }})
48
+ runs-on: ubuntu-latest
49
+ strategy:
50
+ fail-fast: false
51
+ matrix:
52
+ # Patch versions, not minor ones. Asked for "3.12", uv takes whatever
53
+ # copy the runner already has -- Ubuntu 24.04's 3.12.3, twelve patches
54
+ # behind -- and nothing says so. These are the releases that reject the
55
+ # compromised eMudhra intermediate CAs (2026-10).
56
+ python-version: ["3.11.18", "3.12.16", "3.13.18"]
57
+ steps:
58
+ - uses: actions/checkout@v5
59
+
60
+ - uses: astral-sh/setup-uv@v7
61
+ with:
62
+ python-version: ${{ matrix.python-version }}
63
+ enable-cache: true
64
+
65
+ - name: Install (locked)
66
+ run: uv sync --group dev --frozen
67
+
68
+ - name: Unit tests
69
+ run: uv run pytest tests --ignore=tests/test_security.py -v
70
+
71
+ - name: Security tests
72
+ run: uv run pytest tests/test_security.py -v
73
+
74
+ # Audits the LOCKED environment, which is a different question from the one the
75
+ # release gate asks. publish.yml runs `pip-audit --strict --desc on .` -- the
76
+ # project's *declared* dependencies, resolved fresh -- which protects the people
77
+ # who `pip install` this package and cannot see uv.lock at all.
78
+ #
79
+ # This job syncs `--frozen` and audits what that produced, so a stale lock cannot
80
+ # sit unnoticed. On 2026-10-25 five MCP servers carried a CRITICAL certifi
81
+ # advisory in their lockfiles that Dependabot never opened a pull request for.
82
+ audit:
83
+ name: audit (locked dependencies)
84
+ runs-on: ubuntu-latest
85
+ steps:
86
+ - uses: actions/checkout@v5
87
+
88
+ - uses: astral-sh/setup-uv@v7
89
+ with:
90
+ python-version: "3.12.16"
91
+ enable-cache: true
92
+
93
+ - name: Install (locked)
94
+ run: uv sync --group dev --frozen
95
+
96
+ - name: No known vulnerability in the locked dependencies
97
+ # No --strict, deliberately. --strict fails on any distribution it
98
+ # could not audit, and `uv sync` installs the project itself -- so the
99
+ # moment a release bumps the version to one not yet on PyPI, pip-audit
100
+ # cannot look the project up and --strict turns that into a failure.
101
+ # It blocked every version bump in all 23 repositories, which is how it
102
+ # was found: the first real release, dxe-mcp 0.0.3, went red on it with
103
+ # "dxe-mcp: Dependency not found on PyPI and could not be audited"
104
+ # while the lockfile was perfectly clean.
105
+ #
106
+ # Without --strict, pip-audit still exits 1 when it FINDS a
107
+ # vulnerability, which is the whole job. An unauditable local project is
108
+ # reported as skipped, with its reason, and does not fail the run.
109
+ #
110
+ # Verified in both directions against a lock holding certifi 2026.7.22
111
+ # with the version bumped past PyPI: it reports PYSEC-2026-4320 and
112
+ # exits 1; with the lock current it reports no vulnerabilities, names the
113
+ # project as skipped, and exits 0.
114
+ run: uv run --with pip-audit pip-audit
115
+
116
+ ci-all-green:
117
+ name: ci-all-green
118
+ needs: [lint, test, audit]
119
+ runs-on: ubuntu-latest
120
+ if: always()
121
+ steps:
122
+ - name: Verify required jobs all succeeded
123
+ run: |
124
+ if [[ "${{ needs.lint.result }}" != "success" ]]; then
125
+ echo "FAIL: ruff or mypy did not pass (result=${{ needs.lint.result }})"
126
+ exit 1
127
+ fi
128
+ if [[ "${{ needs.test.result }}" != "success" ]]; then
129
+ echo "FAIL: test matrix did not all succeed (result=${{ needs.test.result }})"
130
+ exit 1
131
+ fi
132
+ if [[ "${{ needs.audit.result }}" != "success" ]]; then
133
+ echo "FAIL: the locked dependencies have a known vulnerability (result=${{ needs.audit.result }})"
134
+ exit 1
135
+ fi
136
+ echo "All required CI jobs passed."
@@ -0,0 +1,306 @@
1
+ name: Publish to PyPI
2
+
3
+ # A release happens on purpose, not as a side effect of merging (KI7MT, 2026-10-25).
4
+ # qso-graph-sim is a command-line tool: PyPI only, no MCP Registry entry, no server.json.
5
+ # This file is qso-graph-auth's, the org's other PyPI-only package, so the two release
6
+ # the same way.
7
+ #
8
+ # `main` is the one working branch. Merging into it builds and tests and publishes
9
+ # nothing; a release is Actions -> Publish to PyPI -> Run workflow, with the version typed
10
+ # in. Typed, because it is a decision: it must match what the tree already says.
11
+ on:
12
+ workflow_dispatch:
13
+ inputs:
14
+ version:
15
+ description: "The version to publish, e.g. 0.1.7 — must match pyproject.toml"
16
+ required: true
17
+ type: string
18
+
19
+ concurrency:
20
+ group: publish
21
+ cancel-in-progress: false
22
+
23
+ permissions:
24
+ contents: read
25
+
26
+ jobs:
27
+ security:
28
+ name: Security gates
29
+ runs-on: ubuntu-latest
30
+ permissions:
31
+ contents: read
32
+ checks: read
33
+ outputs:
34
+ version: ${{ steps.version.outputs.version }}
35
+ package: ${{ steps.version.outputs.package }}
36
+ # "true" when this version is on PyPI with no tag: an interrupted
37
+ # release being finished, not a new one being made.
38
+ resume: ${{ steps.version.outputs.resume }}
39
+ steps:
40
+ - uses: actions/checkout@v5
41
+
42
+ # workflow_dispatch offers a branch picker, so unlike the old push trigger this
43
+ # workflow can be started from anywhere. The lock is the pypi Environment's
44
+ # deployment branch rule (main only); this is the same rule stated where a failed
45
+ # run can show it, because an Environment rejection does not say what was wrong.
46
+ - name: This runs from main
47
+ run: |
48
+ if [ "$GITHUB_REF" != "refs/heads/main" ]; then
49
+ echo "FAIL: a release is published from main only (this is $GITHUB_REF)"
50
+ exit 1
51
+ fi
52
+
53
+ # Merging no longer publishes, so nothing else guarantees that what is about to be
54
+ # released is the code CI passed on. Under the old trigger the release PR had to be
55
+ # green before the merge that published it; this restores that property.
56
+ - name: CI passed on the commit being published
57
+ env:
58
+ GH_TOKEN: ${{ github.token }}
59
+ run: |
60
+ PASSED="$(gh api \
61
+ "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?check_name=ci-all-green" \
62
+ --jq '[.check_runs[] | select(.conclusion == "success")] | length')"
63
+ if [ "${PASSED:-0}" -lt 1 ]; then
64
+ echo "FAIL: ci-all-green has not passed on ${GITHUB_SHA}"
65
+ exit 1
66
+ fi
67
+ echo "ci-all-green passed on ${GITHUB_SHA}"
68
+
69
+ # A release publishes a version that is not out yet. Computed once; every later job
70
+ # uses it. The gate fails closed: anything it cannot confirm stops the release.
71
+ - name: The version is the one asked for, and new
72
+ id: version
73
+ env:
74
+ # Through the environment rather than interpolated into the script: a ${{ }}
75
+ # expansion inside run: is substituted before the shell sees it, so the input
76
+ # would be script rather than data. Only a writer can dispatch this, and the
77
+ # rule is still input validation on every supplied string.
78
+ ASKED: ${{ inputs.version }}
79
+ run: |
80
+ VERSION="$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)"
81
+ PACKAGE="$(grep -m1 '^name' pyproject.toml | cut -d'"' -f2)"
82
+ if ! printf '%s' "$ASKED" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([a-z0-9.-]*)$'; then
83
+ echo "FAIL: '$ASKED' is not a version"
84
+ exit 1
85
+ fi
86
+ # Typed by the person releasing, so a slip names a version that is not here
87
+ # rather than shipping whatever happens to be on main.
88
+ if [ "$ASKED" != "$VERSION" ]; then
89
+ echo "FAIL: asked to publish ${ASKED}, but this tree is ${VERSION}"
90
+ exit 1
91
+ fi
92
+ echo "${PACKAGE} ${VERSION}"
93
+ # Only a 404 means "not published yet"; unreachable or 5xx is not a yes.
94
+ CODE="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json")"
95
+ # Read with the tag below, not alone. This refused on PyPI 200 full
96
+ # stop, so a failure in verify or tag -- both of which run after the
97
+ # upload -- left the version on PyPI, immutable and untagged, with no
98
+ # recovery but abandoning it. Three releases went that way across the
99
+ # fleet before 2026-10-27.
100
+ case "$CODE" in
101
+ 404|200) : ;;
102
+ *) echo "FAIL: PyPI returned ${CODE}; cannot tell whether ${VERSION} is published"; exit 1 ;;
103
+ esac
104
+ # git ls-remote --exit-code: 2 means no such tag; anything else (0, 128) stops.
105
+ set +e
106
+ git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" > /dev/null
107
+ RC=$?
108
+ set -e
109
+ case "$RC" in
110
+ 0) TAGGED=yes ;;
111
+ 2) TAGGED=no ;;
112
+ *) echo "FAIL: could not check tags (git ls-remote exited ${RC})"; exit 1 ;;
113
+ esac
114
+
115
+ RESUME=false
116
+ if [ "$CODE" = 404 ] && [ "$TAGGED" = no ]; then
117
+ echo "PyPI: ${PACKAGE} ${VERSION} is new, tag v${VERSION} is free"
118
+ elif [ "$CODE" = 200 ] && [ "$TAGGED" = no ]; then
119
+ RESUME=true
120
+ echo "NOTE: ${PACKAGE} ${VERSION} is already on PyPI with no tag."
121
+ echo " A previous run uploaded it and did not finish. Resuming:"
122
+ echo " the upload is skipped, verify and tag complete the release."
123
+ elif [ "$CODE" = 200 ] && [ "$TAGGED" = yes ]; then
124
+ echo "FAIL: ${PACKAGE} ${VERSION} is on PyPI and tagged — already released."
125
+ exit 1
126
+ else
127
+ echo "FAIL: tag v${VERSION} exists but PyPI has no ${VERSION}."
128
+ echo " Not a state this workflow creates; stopping rather than guessing."
129
+ exit 1
130
+ fi
131
+ echo "resume=${RESUME}" >> "$GITHUB_OUTPUT"
132
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
133
+ echo "package=${PACKAGE}" >> "$GITHUB_OUTPUT"
134
+
135
+ - uses: actions/setup-python@v6
136
+ with:
137
+ python-version: "3.12.16"
138
+
139
+ # pip loads its own vendored certifi as well as the system store, with no
140
+ # way to turn that off, so a current certifi in uv.lock does nothing for
141
+ # pip itself. 26.2.2 is the release past the eMudhra advisory.
142
+ - name: Current pip
143
+ run: |
144
+ python -m pip install --upgrade "pip>=26.2.2"
145
+ python -m pip --version
146
+
147
+ - name: Install test dependencies
148
+ run: pip install pytest pip-audit
149
+
150
+ - name: Run security test suite
151
+ run: python -m pytest tests/test_security.py -v
152
+
153
+ - name: Audit dependencies for vulnerabilities
154
+ # Audits this package's declared dependencies (it has none today), and can fail: a known CVE stops the release.
155
+ # A finding with no fix yet is excepted only explicitly: --ignore-vuln ID, with a reason and date.
156
+ run: pip-audit --strict --desc on .
157
+
158
+ - name: Grep check — no subprocess/shell
159
+ run: |
160
+ if grep -rn 'subprocess\.\|os\.system\|shell=True' src/; then
161
+ echo "FAIL: subprocess/shell found in source"
162
+ exit 1
163
+ fi
164
+
165
+ - name: Grep check — no plaintext http://
166
+ run: |
167
+ # Tests the output rather than the pipeline's exit status, because the output is
168
+ # what this gate is actually asking about: are there any plaintext URLs. The old
169
+ # form here printed nothing when it failed, and answered through whichever grep
170
+ # was on PATH.
171
+ #
172
+ # This fix landed on `develop` as #16 and never reached `main`, which is where the
173
+ # release gate runs -- so every release since went out through the old check. That
174
+ # is the "fixed on develop is not fixed" case in .claude/rules/security.md, and
175
+ # retiring develop is what finally carries it across.
176
+ hits="$(grep -rn 'http://' src/ | grep -v 'localhost\|127\.0\.0\.1\|::1' || true)"
177
+ if [ -n "$hits" ]; then
178
+ echo "$hits"
179
+ echo "FAIL: non-HTTPS URL found in source"
180
+ exit 1
181
+ fi
182
+
183
+ - name: Grep check — no credential logging
184
+ run: |
185
+ if grep -rn -i 'print.*password\|print.*api_key\|logging.*password\|logging.*api_key' src/; then
186
+ echo "FAIL: credential logging found in source"
187
+ exit 1
188
+ fi
189
+
190
+ publish:
191
+ name: Build and publish to PyPI
192
+ needs: security
193
+ runs-on: ubuntu-latest
194
+ environment: pypi
195
+ permissions:
196
+ id-token: write # Required for trusted publishing (OIDC)
197
+ steps:
198
+ - uses: actions/checkout@v5
199
+
200
+ - uses: actions/setup-python@v6
201
+ with:
202
+ python-version: "3.12.16"
203
+
204
+ - name: Install build dependencies
205
+ if: needs.security.outputs.resume != 'true'
206
+ run: |
207
+ python -m pip install --upgrade "pip>=26.2.2"
208
+ python -m pip --version
209
+ pip install build
210
+
211
+ - name: Build package
212
+ if: needs.security.outputs.resume != 'true'
213
+ run: python -m build
214
+
215
+ - name: Publish to PyPI
216
+ if: needs.security.outputs.resume != 'true'
217
+ uses: pypa/gh-action-pypi-publish@release/v1
218
+
219
+ - name: Already on PyPI — resuming
220
+ if: needs.security.outputs.resume == 'true'
221
+ run: |
222
+ echo "skipped the upload: this version is already on PyPI"
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Done when PyPI serves the version (verify); then tag the released commit. A workflow's
226
+ # own tag push triggers nothing. If tagging fails after PyPI serves it, the
227
+ # release IS published: re-running the job is safe.
228
+ # ---------------------------------------------------------------------------
229
+ verify:
230
+ name: Verify PyPI
231
+ needs: [security, publish]
232
+ runs-on: ubuntu-latest
233
+ steps:
234
+ - name: PyPI serves the released version
235
+ run: |
236
+ VERSION="${{ needs.security.outputs.version }}"
237
+ PACKAGE="${{ needs.security.outputs.package }}"
238
+ for i in $(seq 1 40); do
239
+ if [ "$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json")" = "200" ]; then
240
+ echo "PyPI serves ${PACKAGE} ${VERSION}"
241
+ exit 0
242
+ fi
243
+ sleep 15
244
+ done
245
+ echo "FAIL: PyPI never served ${PACKAGE} ${VERSION}"
246
+ exit 1
247
+
248
+ tag:
249
+ name: Tag the release
250
+ needs: [security, verify]
251
+ runs-on: ubuntu-latest
252
+ permissions:
253
+ contents: write
254
+ steps:
255
+ - uses: actions/checkout@v5
256
+
257
+ - name: Create and push vX.Y.Z
258
+ run: |
259
+ VERSION="${{ needs.security.outputs.version }}"
260
+ git config user.name "github-actions[bot]"
261
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
262
+ git tag -a "v${VERSION}" -m "${GITHUB_REPOSITORY#*/} ${VERSION}" "$GITHUB_SHA"
263
+ git push origin "v${VERSION}"
264
+
265
+ # The tag alone left every Releases page in this org empty. The body is
266
+ # this version's own CHANGELOG section, extracted by heading rather than
267
+ # by line count, because the fleet has three heading styles:
268
+ # ## [0.4.0] — date ## [2.0.0] - date ## 0.2.0 (date)
269
+ # It stops at the next "## " heading, and returns nothing for a version
270
+ # it cannot find -- so a missing section falls back to GitHub's own
271
+ # generated notes rather than publishing the whole changelog.
272
+ #
273
+ # After `verify` by construction: a Release only ever describes a version
274
+ # already confirmed on PyPI.
275
+ - name: Create the GitHub Release
276
+ env:
277
+ GH_TOKEN: ${{ github.token }}
278
+ run: |
279
+ VERSION="${{ needs.security.outputs.version }}"
280
+ python3 - "$VERSION" CHANGELOG.md > /tmp/notes.md <<'EXTRACT'
281
+ import re, sys, pathlib
282
+ ver, path = sys.argv[1], sys.argv[2]
283
+ text = pathlib.Path(path).read_text()
284
+ start = re.search(r'(?m)^##\s+\[?%s\]?\b.*$' % re.escape(ver), text)
285
+ if not start:
286
+ raise SystemExit(0)
287
+ rest = text[start.end():]
288
+ nxt = re.search(r'(?m)^##\s', rest)
289
+ print((rest[:nxt.start()] if nxt else rest).strip())
290
+ EXTRACT
291
+
292
+ # A version with a suffix (0.2.1rc1) is a pre-release, not Latest.
293
+ PRE=""
294
+ case "$VERSION" in
295
+ *[!0-9.]*) PRE="--prerelease" ;;
296
+ esac
297
+
298
+ if [ -s /tmp/notes.md ]; then
299
+ echo "release notes: this version's CHANGELOG section ($(wc -l < /tmp/notes.md) lines)"
300
+ gh release create "v${VERSION}" --verify-tag --title "v${VERSION}" \
301
+ --notes-file /tmp/notes.md $PRE
302
+ else
303
+ echo "release notes: no CHANGELOG section for ${VERSION}; using generated notes"
304
+ gh release create "v${VERSION}" --verify-tag --title "v${VERSION}" \
305
+ --generate-notes $PRE
306
+ fi
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to `qso-graph-sim` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-29
11
+
12
+ ### Security
13
+
14
+ - **CI and publishing run patched Pythons and pip** (eMudhra CA advisory, 2026-10). Workflows
15
+ ask for exact patch releases (3.11.18, 3.12.16, 3.13.18) instead of "3.12", which accepted
16
+ the runner's unpatched 3.12.3, and `publish.yml` upgrades pip to 26.2.2 or later before
17
+ using it. A test fails any workflow that asks for a minor version or an older patch.
18
+
19
+ ### Added
20
+
21
+ - **`qso-graph-sim --wsjtx`**: plays a WSJT-X `ALL.TXT` as WSJT-X's UDP messages
22
+ (Heartbeat, Status, Decode, Clear, Close), one cycle every 15 seconds, and answers Replay.
23
+ - A sample log of 120 decodes from 20 m FT8, played when no `--file` is given.
24
+ - An encoder and decoder for WSJT-X's protocol, schema 3, written to `NetworkMessage.hpp`.
25
+ - A test that the command is the package name, and any other command is prefixed with it.
26
+
27
+ ### Fixed
28
+
29
+ - The README matched to what the tool does: the sample is 120 decodes in 7 cycles, Clear and Close are sent on every exit,
30
+ and the link to `NetworkMessage.hpp` points at WSJT-X's `master` branch. `--help` names the
31
+ `--id` and `--speed` values `NAME` and `N`, as the README does.
32
+