Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .coveragerc-app
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Coverage config for the informational app measurement in the `build-speed`
# CI job, which runs `pytest tests/app`.
#
# The library gate (.coveragerc-lib) omits PyMemoryEditor/app: most of it is
# widget wiring the suite does not drive, so counting it there would make the
# library's floor measure the UI. That left the app unmeasured altogether.
# This config measures it on its own, with no floor, under its own Codecov
# flag, so a gate can be chosen from an observed trend rather than a guess.
[run]
# A plain value, not a TOML list: this is an INI file (see .coveragerc-lib).
source = PyMemoryEditor/app

[report]
show_missing = True
skip_empty = True
49 changes: 49 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: CodeQL

# Static analysis of the Python sources (the library, the MCP server and the
# app). Findings land in the repository's Security tab as code-scanning
# alerts. This is the advanced-setup workflow: the repository's CodeQL
# "default setup" must stay off, since GitHub refuses to run both.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Weekly, so new queries shipped with a CodeQL release reach code that has
# not changed. Offset from python-package.yml's midnight run.
schedule:
- cron: '17 3 * * 1'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
analyze:
name: Analyze (python)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write # upload the SARIF results to code scanning
actions: read # read the workflow run's metadata for the upload
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: python
# Python needs no build. `security-extended` widens the default suite
# with lower-precision security queries but leaves out the
# maintainability ones, which flake8 and mypy already cover here.
build-mode: none
queries: security-extended

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: /language:python
78 changes: 75 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@ on:
release:
types: [published]

# Least privilege by default; each job below asks for exactly what it needs.
permissions:
contents: read

jobs:
publish:
# Everything that can be checked before an irrevocable upload, in a job that
# holds no OIDC token: the tag against the version, the release commit
# against CI, and the distributions against PyPI's metadata rules. The
# distributions it builds are handed to `publish` as an artifact, so the
# files that were checked are the files that are uploaded.
build:
runs-on: ubuntu-latest
environment: pypi
timeout-minutes: 60
permissions:
id-token: write # Required for trusted publishing (OIDC)
contents: read
actions: read # the CI gate below lists this commit's workflow runs
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand All @@ -34,22 +44,84 @@ jobs:
exit 1
fi

- name: Check the release commit passed CI
# A release is a commit that python-package.yml already ran green on --
# normally the push run on main after the version-bump PR merged. This
# asks for that run instead of re-running the whole matrix: a run still
# in progress is waited for (up to ~45 minutes), and no green run at all
# stops the release before anything is built.
#
# If main's run was cancelled by a newer push (the workflow cancels
# superseded runs), start one on the tag and re-run this workflow once
# it is green:
# gh workflow run python-package.yml --ref <tag>
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ github.sha }}
run: |
runs="repos/$GITHUB_REPOSITORY/actions/workflows/python-package.yml/runs?head_sha=$SHA&per_page=100"
for _ in $(seq 1 90); do
# Pull-request runs don't count: the audit job is advisory there.
green=$(gh api "$runs" --jq '[.workflow_runs[] | select(.event != "pull_request" and .conclusion == "success")] | length')
pending=$(gh api "$runs" --jq '[.workflow_runs[] | select(.event != "pull_request" and .status != "completed")] | length')
if [ "$green" -gt 0 ]; then
echo "python-package.yml passed on $SHA"
exit 0
fi
if [ "$pending" -eq 0 ]; then
break
fi
echo "python-package.yml still running on $SHA; waiting"
sleep 30
done
echo "::error::no successful python-package.yml run for $SHA. Run 'gh workflow run python-package.yml --ref $GITHUB_REF_NAME' and re-run this workflow once it is green."
exit 1

- name: Build package
run: python -m build

- name: Check the distributions
run: python -m twine check --strict dist/*

- name: Upload the distributions
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dist
path: dist/
if-no-files-found: error
retention-days: 7

# The only job that can mint the OIDC token PyPI's trusted publishing
# accepts. It builds nothing and runs no project code: it uploads the
# artifact `build` checked, so nothing that ran before the checks passed
# ever held the credential.
publish:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
environment: pypi
permissions:
id-token: write # Required for trusted publishing (OIDC)
steps:
- name: Download the distributions
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dist
path: dist/

- name: Publish to PyPI
# Pinned to a release tag's commit rather than the moving `release/v1`
# ref; Dependabot bumps the SHA and the tag comment together.
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

# The MCP Registry checks the package on PyPI (the README's mcp-name marker,
# the pinned version), so this runs only once the upload above succeeded.
# It reads server.json from the checkout and the package from PyPI itself,
# so it needs no artifact from `build`.
mcp-registry:
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
id-token: write # mcp-publisher logs in with the workflow's OIDC token
contents: read
Expand Down
100 changes: 86 additions & 14 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Least privilege for every job's GITHUB_TOKEN. Nothing here writes to the
# repository: checkout only reads it, and the Codecov uploads authenticate
# with CODECOV_TOKEN, not with the workflow token.
permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -59,6 +65,16 @@ jobs:
- name: Run mypy
run: |
mypy PyMemoryEditor
# The shared layer branches on sys.platform in places (process/util.py,
# the backend dispatch in __init__.py), and mypy only follows the branch
# for the platform it is told about. Checking as Windows and macOS too
# covers the branches this Linux runner would otherwise skip, without
# paying for a Windows or macOS runner. The backends themselves stay
# under ignore_errors (see pyproject.toml).
- name: Run mypy as Windows and macOS
run: |
mypy --platform win32 PyMemoryEditor
mypy --platform darwin PyMemoryEditor

build:
needs: lint
Expand Down Expand Up @@ -160,10 +176,18 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

# macOS gets a single cell instead of a row in the matrix above. Its only
# macOS gets its own short row instead of joining the matrix above. Its
# purpose is to measure the `macos` backend so the merged Codecov report
# covers all three platforms; the Python-version sweep stays on Linux and
# Windows, where it costs a fraction of the wall clock.
# covers all three platforms; the full Python-version sweep stays on Linux
# and Windows, where it costs a fraction of the wall clock.
#
# Three versions -- the floor (3.10), the reference (3.12) and the newest
# stable (3.14) -- rather than one: the backend is ctypes over Mach structs
# and a ctypes or struct-layout change between CPython versions would only
# show up here. A cell took 4m06s on 2026-10-11, under the slowest Windows
# cell, and the cells run in parallel, so the extra two cost runner minutes
# (free for a public repository) but no wall clock. With build-mcp's macOS
# cell that is four concurrent macOS jobs, inside the free plan's five.
#
# Unlike the matrix, this job installs `.[dev,speed]` (NumPy). That is not a
# preference — it is what makes a macOS cell viable at all. A macOS process
Expand All @@ -179,12 +203,16 @@ jobs:
runs-on: macos-latest
# Hard ceiling so a pathological scan can never sit at the 6h default.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.12', '3.14']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
python-version: ${{ matrix.python-version }}
# No Qt system-library step here: the PySide6 macOS wheel bundles its own
# Qt frameworks, unlike the Linux one.
- name: Install dependencies (with NumPy speed extra)
Expand All @@ -204,7 +232,7 @@ jobs:
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage.xml
flags: macOS-py3.12
flags: macOS-py${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

Expand Down Expand Up @@ -266,6 +294,29 @@ jobs:
flags: speed-Linux-py3.12
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
# The Qt app is omitted from the library gate (.coveragerc-lib), so on its
# own it was never measured at all. This measures it -- informational, no
# floor -- under its own config and Codecov flag, so the number exists and
# its trend is visible before anyone picks a gate for it. Only tests/app
# runs here (~30s), and only in this one cell: the app is the same Python
# on every OS.
- name: Measure app coverage (informational)
# The same tests already gate in the step above; a failure here must
# not fail the job a second time.
if: always()
continue-on-error: true
env:
QT_QPA_PLATFORM: offscreen
run: |
pytest tests/app -q --cov=PyMemoryEditor/app --cov-config=.coveragerc-app --cov-report=term --cov-report=xml:coverage-app.xml
- name: Upload app coverage to Codecov
if: always()
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage-app.xml
flags: app-Linux-py3.12
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

# The MCP server gets its own job rather than a row in the matrix above.
#
Expand Down Expand Up @@ -398,26 +449,47 @@ jobs:

# Dependency audit. The core library has no dependencies; this checks the
# extras (Qt app, MCP SDK, NumPy) and their transitive closure against the
# PyPI advisory database. Advisory, not a gate: `continue-on-error` keeps a
# new upstream CVE from blocking an unrelated PR, and the weekly `schedule`
# run surfaces it on main regardless. Dependabot security PRs remain the
# PyPI advisory database.
#
# Advisory on pull requests, a gate everywhere else. On a PR,
# `continue-on-error` keeps a new upstream CVE from blocking an unrelated
# change. On main (push, the weekly `schedule`, a manual dispatch) a finding
# fails the run -- and publish.yml only releases a commit whose run here is
# green, so a release cannot ship with an unanswered advisory in the extras'
# ranges. The answer is a pin bump, or `--ignore-vuln <ID>` below with a
# comment saying why it does not apply. Dependabot security PRs remain the
# fix path; this job is the visibility the disabled version-update PRs
# don't provide (see .github/dependabot.yml).
# don't provide (see .github/dependabot.yml). It was green on the runs that
# led up to v3.2.0, which is what made it safe to turn into a gate.
audit:
needs: lint
runs-on: ubuntu-latest
timeout-minutes: 10
continue-on-error: true
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
# The extras go into their own venv and only their frozen closure is
# audited: auditing the job's environment would also audit pip-audit's own
# dependencies, and a CVE in one of those would block a release of a
# package that never ships it.
- name: Install the package with every runtime extra
run: |
python -m pip install --upgrade pip
pip install ".[app,mcp,speed]" pip-audit
python -m pip install --upgrade pip pip-audit
python -m venv "$RUNNER_TEMP/target"
"$RUNNER_TEMP/target/bin/pip" install ".[app,mcp,speed]"
"$RUNNER_TEMP/target/bin/pip" freeze --exclude PyMemoryEditor > "$RUNNER_TEMP/requirements.txt"
# Retried because the advisory service is a network dependency: a
# transient API failure must not turn a release gate red. A real finding
# fails all three attempts the same way.
- name: pip-audit
run: |
pip-audit --progress-spinner off
for attempt in 1 2 3; do
pip-audit --progress-spinner off --no-deps --disable-pip \
-r "$RUNNER_TEMP/requirements.txt" && exit 0
[ "$attempt" -lt 3 ] && sleep 30
done
exit 1
Loading
Loading