pse-edge-mcp 0.16.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.
- pse_edge_mcp-0.16.0/.dockerignore +18 -0
- pse_edge_mcp-0.16.0/.env.example +106 -0
- pse_edge_mcp-0.16.0/.github/workflows/ci.yml +71 -0
- pse_edge_mcp-0.16.0/.github/workflows/release.yml +308 -0
- pse_edge_mcp-0.16.0/.gitignore +18 -0
- pse_edge_mcp-0.16.0/CHANGELOG.md +407 -0
- pse_edge_mcp-0.16.0/CLAUDE.md +242 -0
- pse_edge_mcp-0.16.0/Caddyfile +34 -0
- pse_edge_mcp-0.16.0/Dockerfile +54 -0
- pse_edge_mcp-0.16.0/LICENSE +21 -0
- pse_edge_mcp-0.16.0/PKG-INFO +385 -0
- pse_edge_mcp-0.16.0/README.md +358 -0
- pse_edge_mcp-0.16.0/alembic.ini +40 -0
- pse_edge_mcp-0.16.0/compose.nas.yaml +289 -0
- pse_edge_mcp-0.16.0/compose.prod.yaml +250 -0
- pse_edge_mcp-0.16.0/compose.tunnel.yaml +60 -0
- pse_edge_mcp-0.16.0/compose.yaml +78 -0
- pse_edge_mcp-0.16.0/docs/deploy.md +511 -0
- pse_edge_mcp-0.16.0/docs/endpoints.md +246 -0
- pse_edge_mcp-0.16.0/docs/plan.md +267 -0
- pse_edge_mcp-0.16.0/docs/reference-card.html +735 -0
- pse_edge_mcp-0.16.0/docs/walkthrough.md +1071 -0
- pse_edge_mcp-0.16.0/docs/walkthrough.pdf +0 -0
- pse_edge_mcp-0.16.0/examples/langgraph_client.py +209 -0
- pse_edge_mcp-0.16.0/migrations/env.py +62 -0
- pse_edge_mcp-0.16.0/migrations/script.py.mako +25 -0
- pse_edge_mcp-0.16.0/migrations/versions/0001_initial_schema.py +79 -0
- pse_edge_mcp-0.16.0/migrations/versions/0002_auth_users_tokens.py +58 -0
- pse_edge_mcp-0.16.0/migrations/versions/0003_oauth_passkeys.py +100 -0
- pse_edge_mcp-0.16.0/migrations/versions/0004_usage_events.py +39 -0
- pse_edge_mcp-0.16.0/migrations/versions/0005_machine_clients.py +40 -0
- pse_edge_mcp-0.16.0/pyproject.toml +79 -0
- pse_edge_mcp-0.16.0/scripts/check_image.py +207 -0
- pse_edge_mcp-0.16.0/scripts/render_doc_pdf.py +67 -0
- pse_edge_mcp-0.16.0/server.json +27 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/__init__.py +3 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/__main__.py +113 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/accounts.py +196 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/admin.py +485 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/archive.py +49 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/archive_postgres.py +91 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/asgi.py +253 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth.py +190 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth_app.py +1228 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth_middleware.py +138 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/auth_store.py +69 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/cache.py +41 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/canary.py +316 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/client.py +284 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/config.py +132 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/db.py +255 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/email.py +89 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/errors.py +94 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/health.py +89 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/logging_config.py +145 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/market_calendar.py +91 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/memo.py +70 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/models.py +360 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/notifications.py +141 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/oauth.py +758 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/parsers.py +752 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/passkeys.py +424 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/ratelimit.py +137 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/repositories.py +623 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/server.py +646 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/service.py +208 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/sources.py +79 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/storage_postgres.py +77 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/usage.py +139 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/usage_postgres.py +56 -0
- pse_edge_mcp-0.16.0/src/pse_edge_mcp/validation.py +97 -0
- pse_edge_mcp-0.16.0/tests/conftest.py +178 -0
- pse_edge_mcp-0.16.0/tests/fixtures/announcements_empty.html +56 -0
- pse_edge_mcp-0.16.0/tests/fixtures/announcements_search.html +418 -0
- pse_edge_mcp-0.16.0/tests/fixtures/announcements_short_page.html +311 -0
- pse_edge_mcp-0.16.0/tests/fixtures/autocomplete.json +1 -0
- pse_edge_mcp-0.16.0/tests/fixtures/chart.json +1 -0
- pse_edge_mcp-0.16.0/tests/fixtures/company_disclosures_last_page.html +361 -0
- pse_edge_mcp-0.16.0/tests/fixtures/company_disclosures_search.html +410 -0
- pse_edge_mcp-0.16.0/tests/fixtures/company_profile.html +300 -0
- pse_edge_mcp-0.16.0/tests/fixtures/disclosure_viewer.html +115 -0
- pse_edge_mcp-0.16.0/tests/fixtures/dividends.html +72 -0
- pse_edge_mcp-0.16.0/tests/fixtures/financial_reports.html +461 -0
- pse_edge_mcp-0.16.0/tests/fixtures/homepage.html +831 -0
- pse_edge_mcp-0.16.0/tests/fixtures/keyword_search.html +168 -0
- pse_edge_mcp-0.16.0/tests/fixtures/rights_empty.html +62 -0
- pse_edge_mcp-0.16.0/tests/fixtures/stock_data.html +30 -0
- pse_edge_mcp-0.16.0/tests/test_admin_postgres.py +129 -0
- pse_edge_mcp-0.16.0/tests/test_auth.py +251 -0
- pse_edge_mcp-0.16.0/tests/test_auth_journey.py +1226 -0
- pse_edge_mcp-0.16.0/tests/test_auth_middleware.py +126 -0
- pse_edge_mcp-0.16.0/tests/test_calendar.py +56 -0
- pse_edge_mcp-0.16.0/tests/test_canary.py +209 -0
- pse_edge_mcp-0.16.0/tests/test_cli.py +171 -0
- pse_edge_mcp-0.16.0/tests/test_client.py +97 -0
- pse_edge_mcp-0.16.0/tests/test_company_market.py +343 -0
- pse_edge_mcp-0.16.0/tests/test_deploy.py +446 -0
- pse_edge_mcp-0.16.0/tests/test_disclosures.py +310 -0
- pse_edge_mcp-0.16.0/tests/test_end_to_end.py +296 -0
- pse_edge_mcp-0.16.0/tests/test_notifications.py +169 -0
- pse_edge_mcp-0.16.0/tests/test_oauth.py +466 -0
- pse_edge_mcp-0.16.0/tests/test_packaging.py +29 -0
- pse_edge_mcp-0.16.0/tests/test_parsers.py +54 -0
- pse_edge_mcp-0.16.0/tests/test_privacy.py +298 -0
- pse_edge_mcp-0.16.0/tests/test_repositories.py +505 -0
- pse_edge_mcp-0.16.0/tests/test_scaling.py +308 -0
- pse_edge_mcp-0.16.0/tests/test_server_disclosures.py +409 -0
- pse_edge_mcp-0.16.0/tests/test_service.py +242 -0
- pse_edge_mcp-0.16.0/tests/test_storage_postgres.py +358 -0
- pse_edge_mcp-0.16.0/tests/test_validation.py +103 -0
- pse_edge_mcp-0.16.0/uv.lock +1347 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Keep the build context minimal and secret-free.
|
|
2
|
+
.git
|
|
3
|
+
.github
|
|
4
|
+
.env*
|
|
5
|
+
!.env.example
|
|
6
|
+
tests
|
|
7
|
+
docs
|
|
8
|
+
**/__pycache__
|
|
9
|
+
**/*.pyc
|
|
10
|
+
.venv
|
|
11
|
+
.pytest_cache
|
|
12
|
+
.mypy_cache
|
|
13
|
+
.ruff_cache
|
|
14
|
+
compose*.yaml
|
|
15
|
+
Dockerfile
|
|
16
|
+
CHANGELOG.md
|
|
17
|
+
*.md
|
|
18
|
+
!README.md
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copy to .env for docker compose. NEVER commit .env.
|
|
2
|
+
POSTGRES_PASSWORD=change-me
|
|
3
|
+
# DATABASE_URL is set automatically inside compose; for bare-metal runs:
|
|
4
|
+
# DATABASE_URL=postgresql+asyncpg://pse:change-me@localhost:5432/pse_edge
|
|
5
|
+
|
|
6
|
+
# Market session overrides (defaults shown)
|
|
7
|
+
# PSE_MARKET_OPEN=09:30
|
|
8
|
+
# PSE_MARKET_CLOSE=15:00
|
|
9
|
+
# PSE_THROTTLE_RPS=1.0
|
|
10
|
+
|
|
11
|
+
# Auth — opt-in, requires DATABASE_URL. Users self-serve via OAuth 2.1 + passkeys; the CLI
|
|
12
|
+
# is for clients that do not speak OAuth, and is the only route on a plain-http deployment.
|
|
13
|
+
# Provision accounts with: pse-edge-admin create-user EMAIL && pse-edge-admin issue-token EMAIL
|
|
14
|
+
# PSE_AUTH_REQUIRED=1
|
|
15
|
+
# PSE_TOKEN_CACHE_TTL=60 # seconds; this is the revocation-latency budget, nothing else
|
|
16
|
+
# PSE_QUOTA_PER_MIN=60
|
|
17
|
+
# PSE_QUOTA_PER_DAY=2000
|
|
18
|
+
|
|
19
|
+
# DB connection pool (auth adds a potential lookup per request; tune under load)
|
|
20
|
+
# PSE_DB_POOL_SIZE=5
|
|
21
|
+
# PSE_DB_MAX_OVERFLOW=10
|
|
22
|
+
|
|
23
|
+
# Email (signup verification). The ZeptoMail key arrives at runtime only, never committed.
|
|
24
|
+
# ZEPTOMAIL_API_KEY=
|
|
25
|
+
#
|
|
26
|
+
# PSE_EMAIL_FROM must be an address on a domain ZeptoMail has VERIFIED, and it is the most
|
|
27
|
+
# common thing to get wrong. ZeptoMail verifies EXACT domains: a verified example.com does
|
|
28
|
+
# NOT cover mcp.example.com. Both compose files default this to no-reply@$PSE_DOMAIN, which
|
|
29
|
+
# is therefore wrong whenever your hostname is a subdomain. An unverified sender is refused
|
|
30
|
+
# with a bare HTTP 500 and an empty body, so signup fails in a way that looks like the
|
|
31
|
+
# server is broken rather than misconfigured. Test it before trusting it:
|
|
32
|
+
# curl -sS -X POST https://api.zeptomail.com/v1.1/email \
|
|
33
|
+
# -H "Authorization: Zoho-enczapikey $ZEPTOMAIL_API_KEY" \
|
|
34
|
+
# -H 'Content-Type: application/json' \
|
|
35
|
+
# -d '{"from":{"address":"noreply@example.com"},
|
|
36
|
+
# "to":[{"email_address":{"address":"you@example.com"}}],
|
|
37
|
+
# "subject":"test","htmlbody":"<p>test</p>"}'
|
|
38
|
+
# PSE_EMAIL_FROM=noreply@example.com
|
|
39
|
+
#
|
|
40
|
+
# Where the nightly schema canary reports failures. It emails ONLY on failure — a nightly
|
|
41
|
+
# "all fine" message is filtered within a week, and a filtered alert feels like coverage
|
|
42
|
+
# while providing none. Unset, the canary still runs and still logs, but nobody is told.
|
|
43
|
+
# PSE_OPERATOR_EMAIL=ops@example.com
|
|
44
|
+
#
|
|
45
|
+
# Accounts allowed to provision machine clients (headless/agent credentials) from the
|
|
46
|
+
# /account web page — the way to grant agent access on a NAS with no shell. Comma-separated,
|
|
47
|
+
# matched against the signed-in account's email. This is the SAME authority as the
|
|
48
|
+
# pse-edge-admin CLI: keep it to yourself. Empty = web provisioning off, CLI only.
|
|
49
|
+
# PSE_ADMIN_EMAILS=you@example.com
|
|
50
|
+
#
|
|
51
|
+
# PSE_PUBLIC_URL=https://mcp.example.com # drives WebAuthn rp_id, email links, OAuth issuer
|
|
52
|
+
# PSE_USAGE_RETENTION_DAYS=90 # the privacy page states 90 — changing this
|
|
53
|
+
# # changes what users were told
|
|
54
|
+
|
|
55
|
+
# --- VPS or host reachable on 80/443 (compose.yaml + compose.prod.yaml) — docs/deploy.md ---
|
|
56
|
+
# Caddy publishes high by default (8280/8243) to stay clear of a NAS's own web UI. Only the
|
|
57
|
+
# published side moves — Caddy still listens on 80/443 inside the container. YOUR ROUTER MUST
|
|
58
|
+
# FORWARD 80 -> 8280 AND 443 -> 8243, because certificate authorities always validate on 80
|
|
59
|
+
# (HTTP-01) or 443 (TLS-ALPN-01) and no setting changes that. If you cannot forward, use the
|
|
60
|
+
# NAS + tunnel path below, which needs no inbound ports at all.
|
|
61
|
+
# PSE_HTTP_PORT=8280
|
|
62
|
+
# PSE_HTTPS_PORT=8243
|
|
63
|
+
# PSE_DOMAIN must be the real public hostname: it becomes PSE_PUBLIC_URL, which drives the
|
|
64
|
+
# WebAuthn rp_id, the email links and the OAuth issuer. Wrong value = passkeys silently
|
|
65
|
+
# break, because credentials are bound to the origin they were enrolled under.
|
|
66
|
+
# PSE_DOMAIN=mcp.example.com
|
|
67
|
+
# PSE_ACME_EMAIL=ops@example.com
|
|
68
|
+
# PSE_IMAGE_TAG=0.11.0 # PIN THIS — production pulls, it does not build.
|
|
69
|
+
# BACKUP_RETENTION_DAYS=14
|
|
70
|
+
# PSE_LOG_JSON=1
|
|
71
|
+
# PSE_LOG_LEVEL=INFO
|
|
72
|
+
|
|
73
|
+
# --- NAS stage 1: LAN only (compose.nas.yaml) — see docs/deploy.md ---
|
|
74
|
+
# Needs nothing from Cloudflare. Reachable at http://<nas-ip>:8200; auth is on, and passkeys
|
|
75
|
+
# cannot work over plain http, so mint a token with `pse-edge-admin issue-token`.
|
|
76
|
+
# PSE_IMAGE_TAG=0.11.0 # PIN THIS. :latest may predate a flag this file uses.
|
|
77
|
+
# PSE_LAN_PORT=8200 # host side only; the app always listens on 8000 inside
|
|
78
|
+
# PSE_WORKERS=1 # each worker multiplies the in-memory quota ceiling
|
|
79
|
+
# APP_MEMORY_LIMIT=768m
|
|
80
|
+
# DB_MEMORY_LIMIT=1g
|
|
81
|
+
#
|
|
82
|
+
# Put the database and its dumps on NAS directories you can see and snapshot. Create both
|
|
83
|
+
# first — Docker will not, and the mount error does not name the missing path. Keep backups
|
|
84
|
+
# OUTSIDE the database directory: a backup stored inside the thing it protects is not one.
|
|
85
|
+
#
|
|
86
|
+
# No chown needed. Postgres runs as uid 999 and a bind mount would otherwise arrive
|
|
87
|
+
# root-owned, crash-looping on "mkdir: cannot create directory '/var/lib/postgresql':
|
|
88
|
+
# Permission denied" — so a `pgdata-owner` container fixes the ownership before the
|
|
89
|
+
# database starts. That matters on a NAS UI, which may give you no shell at all.
|
|
90
|
+
#
|
|
91
|
+
# PGDATA must be the NAS's OWN internal volume, never SMB/NFS/USB: Postgres needs POSIX
|
|
92
|
+
# locking and honest fsync. Dumps are just files, so a network share is fine for backups.
|
|
93
|
+
# SETTING PSE_PGDATA_PATH ON AN EXISTING DEPLOYMENT STARTS AN EMPTY DATABASE — dump first,
|
|
94
|
+
# see docs/deploy.md "Moving the database onto a NAS share".
|
|
95
|
+
# PSE_PGDATA_PATH=/volume1/docker/pse-mcp/pgdata
|
|
96
|
+
# PSE_BACKUP_PATH=/volume1/docker/pse-mcp/backups
|
|
97
|
+
|
|
98
|
+
# --- NAS stage 2: public hostname (+ compose.tunnel.yaml) — see docs/deploy.md ---
|
|
99
|
+
# cloudflared dials out; Cloudflare terminates TLS, so no ACME here. Set PSE_LAN_BIND to
|
|
100
|
+
# close the stage 1 LAN port — an overlay cannot remove a port, because Compose merges
|
|
101
|
+
# `ports` additively, so binding it to loopback is how it stops being reachable.
|
|
102
|
+
# PSE_LAN_BIND=127.0.0.1
|
|
103
|
+
# PSE_DOMAIN=mcp.example.com
|
|
104
|
+
# Set PSE_EMAIL_FROM in the Email section above — signup goes live at this stage, and the
|
|
105
|
+
# default derived from PSE_DOMAIN is wrong if this hostname is a subdomain.
|
|
106
|
+
# CLOUDFLARE_TUNNEL_TOKEN= # Zero Trust -> Networks -> Tunnels
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
# develop is the integration branch; main is covered by release.yml, which reruns
|
|
5
|
+
# these same gates before it publishes anything.
|
|
6
|
+
push:
|
|
7
|
+
branches: [develop]
|
|
8
|
+
pull_request:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v7
|
|
15
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.14"
|
|
18
|
+
- run: uv sync --all-extras
|
|
19
|
+
- run: uv run ruff check .
|
|
20
|
+
- run: uv run pytest -q
|
|
21
|
+
|
|
22
|
+
# Both published architectures are gated here, on native runners (free for public
|
|
23
|
+
# repos), so arm64 breakage is caught at review time rather than at publish time —
|
|
24
|
+
# otherwise a green PR could merge and then leave main with no image at all.
|
|
25
|
+
# `name:` is pinned so the required-check contexts stay exactly "image (amd64)" and
|
|
26
|
+
# "image (arm64)"; without it GitHub would name them after every matrix value.
|
|
27
|
+
image:
|
|
28
|
+
name: image (${{ matrix.arch }})
|
|
29
|
+
strategy:
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
include:
|
|
33
|
+
- arch: amd64
|
|
34
|
+
runner: ubuntu-latest
|
|
35
|
+
- arch: arm64
|
|
36
|
+
runner: ubuntu-24.04-arm
|
|
37
|
+
runs-on: ${{ matrix.runner }}
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v7
|
|
40
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
41
|
+
with:
|
|
42
|
+
python-version: "3.14"
|
|
43
|
+
# Each runner is native for its own arch, so a plain build needs no buildx or QEMU.
|
|
44
|
+
- name: Build image
|
|
45
|
+
run: docker build -t pse-edge-mcp:ci .
|
|
46
|
+
# Enforces necessity, not a megabyte number: installed distributions must equal
|
|
47
|
+
# the resolved runtime closure, with no toolchain, package manager, dev deps,
|
|
48
|
+
# caches, or stray source tree. Size is reported for information only.
|
|
49
|
+
- name: Check image contains only what the app needs
|
|
50
|
+
run: |
|
|
51
|
+
# pipefail matters: without it the pipeline returns tee's status and a failed
|
|
52
|
+
# check would pass silently.
|
|
53
|
+
set -eo pipefail
|
|
54
|
+
uv sync --all-extras --quiet
|
|
55
|
+
uv run python scripts/check_image.py pse-edge-mcp:ci --extra postgres --extra auth | tee -a "$GITHUB_STEP_SUMMARY"
|
|
56
|
+
- name: Scan image for secrets & vulns
|
|
57
|
+
uses: aquasecurity/trivy-action@v0.36.0
|
|
58
|
+
with:
|
|
59
|
+
image-ref: pse-edge-mcp:ci
|
|
60
|
+
scanners: secret
|
|
61
|
+
exit-code: "1"
|
|
62
|
+
- name: Smoke-test the image
|
|
63
|
+
run: |
|
|
64
|
+
ARCH=$(docker image inspect pse-edge-mcp:ci --format '{{.Architecture}}')
|
|
65
|
+
test "$ARCH" = "${{ matrix.arch }}"
|
|
66
|
+
TOOLS=$(docker run --rm --entrypoint python pse-edge-mcp:ci -c '
|
|
67
|
+
import asyncio
|
|
68
|
+
from pse_edge_mcp.server import build_server
|
|
69
|
+
print(len(asyncio.run(build_server().list_tools())))')
|
|
70
|
+
echo "Tools registered on $ARCH: $TOOLS"
|
|
71
|
+
test "$TOOLS" -ge 6
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Merges to main publish a multi-arch container image to GHCR:
|
|
4
|
+
# docker pull ghcr.io/phdwight/pse-edge-mcp:latest # linux/amd64 + linux/arm64
|
|
5
|
+
#
|
|
6
|
+
# A merge that bumps `version` in pyproject.toml also cuts a GitHub Release and a
|
|
7
|
+
# matching immutable image tag; merges that don't bump only move :latest and :sha-*.
|
|
8
|
+
#
|
|
9
|
+
# Each architecture is built on its OWN NATIVE RUNNER (free for public repos) rather
|
|
10
|
+
# than emulated under QEMU, which keeps builds fast and — more importantly — lets the
|
|
11
|
+
# gates run natively per arch. Invariant #5 (thin, secret-free images) is enforced on
|
|
12
|
+
# every architecture BEFORE anything reaches the registry, so a bloated or broken
|
|
13
|
+
# arm64 image can't ride along behind a passing amd64 one.
|
|
14
|
+
#
|
|
15
|
+
# Publishing is two-phase: each arch pushes an untagged blob addressed by digest, then
|
|
16
|
+
# the merge job stitches those digests into one manifest list under the real tags. That
|
|
17
|
+
# way a tag never exists in a half-published state.
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
branches: [main]
|
|
22
|
+
workflow_dispatch: # lets you re-publish the current main on demand
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: write # create the release + tag
|
|
26
|
+
packages: write # push to GHCR
|
|
27
|
+
|
|
28
|
+
# Never let two main merges publish concurrently, and never cancel a half-done
|
|
29
|
+
# publish: an interrupted push can leave :latest pointing at nothing useful.
|
|
30
|
+
concurrency:
|
|
31
|
+
group: release-main
|
|
32
|
+
cancel-in-progress: false
|
|
33
|
+
|
|
34
|
+
env:
|
|
35
|
+
IMAGE: ghcr.io/${{ github.repository }}
|
|
36
|
+
|
|
37
|
+
jobs:
|
|
38
|
+
version:
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
outputs:
|
|
41
|
+
version: ${{ steps.version.outputs.version }}
|
|
42
|
+
bumped: ${{ steps.version.outputs.bumped }}
|
|
43
|
+
tags: ${{ steps.version.outputs.tags }}
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
with:
|
|
47
|
+
fetch-depth: 2 # need HEAD~1 to tell whether the version changed
|
|
48
|
+
|
|
49
|
+
- name: Resolve version and detect a bump
|
|
50
|
+
id: version
|
|
51
|
+
run: |
|
|
52
|
+
read_version() { grep -m1 '^version = ' "$1" | cut -d'"' -f2; }
|
|
53
|
+
VERSION=$(read_version pyproject.toml)
|
|
54
|
+
if [ -z "$VERSION" ]; then
|
|
55
|
+
echo "::error::could not read version from pyproject.toml"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
git show HEAD~1:pyproject.toml > /tmp/prev-pyproject.toml 2>/dev/null || : > /tmp/prev-pyproject.toml
|
|
59
|
+
PREVIOUS=$(read_version /tmp/prev-pyproject.toml || true)
|
|
60
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
61
|
+
if [ "$VERSION" != "$PREVIOUS" ]; then
|
|
62
|
+
BUMPED=true
|
|
63
|
+
echo "Version bump: ${PREVIOUS:-none} -> $VERSION (will cut a release)"
|
|
64
|
+
else
|
|
65
|
+
BUMPED=false
|
|
66
|
+
echo "Version unchanged at $VERSION (no release; :latest still moves)"
|
|
67
|
+
fi
|
|
68
|
+
echo "bumped=$BUMPED" >> "$GITHUB_OUTPUT"
|
|
69
|
+
# Assembled here, not with an inline conditional at the point of use, so a
|
|
70
|
+
# no-bump build can never emit an empty tag.
|
|
71
|
+
TAGS="${IMAGE}:latest ${IMAGE}:sha-${GITHUB_SHA}"
|
|
72
|
+
if [ "$BUMPED" = "true" ]; then TAGS="$TAGS ${IMAGE}:${VERSION}"; fi
|
|
73
|
+
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
|
74
|
+
echo "Tags: $TAGS"
|
|
75
|
+
|
|
76
|
+
- name: Fail if this release tag already exists
|
|
77
|
+
if: steps.version.outputs.bumped == 'true'
|
|
78
|
+
env:
|
|
79
|
+
GH_TOKEN: ${{ github.token }}
|
|
80
|
+
run: |
|
|
81
|
+
TAG="v${{ steps.version.outputs.version }}"
|
|
82
|
+
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
83
|
+
echo "::error::release $TAG already exists — bump the version in pyproject.toml"
|
|
84
|
+
exit 1
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
test:
|
|
88
|
+
runs-on: ubuntu-latest
|
|
89
|
+
steps:
|
|
90
|
+
- uses: actions/checkout@v7
|
|
91
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
92
|
+
with:
|
|
93
|
+
python-version: "3.14"
|
|
94
|
+
- run: uv sync --all-extras
|
|
95
|
+
- run: uv run ruff check .
|
|
96
|
+
- run: uv run pytest -q
|
|
97
|
+
|
|
98
|
+
build:
|
|
99
|
+
needs: [test, version]
|
|
100
|
+
strategy:
|
|
101
|
+
fail-fast: false # report both arches' problems in one run, not just the first
|
|
102
|
+
matrix:
|
|
103
|
+
include:
|
|
104
|
+
- arch: amd64
|
|
105
|
+
platform: linux/amd64
|
|
106
|
+
runner: ubuntu-latest
|
|
107
|
+
- arch: arm64
|
|
108
|
+
platform: linux/arm64
|
|
109
|
+
runner: ubuntu-24.04-arm
|
|
110
|
+
runs-on: ${{ matrix.runner }}
|
|
111
|
+
steps:
|
|
112
|
+
- uses: actions/checkout@v7
|
|
113
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
114
|
+
with:
|
|
115
|
+
python-version: "3.14"
|
|
116
|
+
- uses: docker/setup-buildx-action@v4
|
|
117
|
+
|
|
118
|
+
# Load into the local daemon first so the gates below run on the exact bits that
|
|
119
|
+
# get pushed. Native runner => no QEMU, and `docker run` works for the smoke test.
|
|
120
|
+
- name: Build ${{ matrix.platform }}
|
|
121
|
+
uses: docker/build-push-action@v7
|
|
122
|
+
with:
|
|
123
|
+
context: .
|
|
124
|
+
platforms: ${{ matrix.platform }}
|
|
125
|
+
load: true
|
|
126
|
+
tags: pse-edge-mcp:candidate
|
|
127
|
+
cache-from: type=gha,scope=${{ matrix.arch }}
|
|
128
|
+
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
|
129
|
+
|
|
130
|
+
# Enforces necessity, not a megabyte number — see scripts/check_image.py.
|
|
131
|
+
- name: Check image contains only what the app needs
|
|
132
|
+
run: |
|
|
133
|
+
# pipefail matters: without it the pipeline returns tee's status and a failed
|
|
134
|
+
# check would pass silently.
|
|
135
|
+
set -eo pipefail
|
|
136
|
+
uv sync --all-extras --quiet
|
|
137
|
+
{
|
|
138
|
+
echo "### \`${{ matrix.arch }}\` image"
|
|
139
|
+
echo '```'
|
|
140
|
+
uv run python scripts/check_image.py pse-edge-mcp:candidate --extra postgres --extra auth
|
|
141
|
+
echo '```'
|
|
142
|
+
} | tee -a "$GITHUB_STEP_SUMMARY"
|
|
143
|
+
|
|
144
|
+
- name: Scan image for secrets
|
|
145
|
+
uses: aquasecurity/trivy-action@v0.36.0
|
|
146
|
+
with:
|
|
147
|
+
image-ref: pse-edge-mcp:candidate
|
|
148
|
+
scanners: secret
|
|
149
|
+
exit-code: "1"
|
|
150
|
+
|
|
151
|
+
- name: Smoke-test the image
|
|
152
|
+
run: |
|
|
153
|
+
# A published image that cannot build the server is worse than no publish.
|
|
154
|
+
ARCH=$(docker image inspect pse-edge-mcp:candidate --format '{{.Architecture}}')
|
|
155
|
+
echo "Image architecture: $ARCH (expected ${{ matrix.arch }})"
|
|
156
|
+
test "$ARCH" = "${{ matrix.arch }}"
|
|
157
|
+
TOOLS=$(docker run --rm --entrypoint python pse-edge-mcp:candidate -c '
|
|
158
|
+
import asyncio
|
|
159
|
+
from pse_edge_mcp.server import build_server
|
|
160
|
+
print(len(asyncio.run(build_server().list_tools())))')
|
|
161
|
+
echo "Tools registered: $TOOLS"
|
|
162
|
+
test "$TOOLS" -ge 6
|
|
163
|
+
|
|
164
|
+
- name: Log in to GHCR
|
|
165
|
+
uses: docker/login-action@v4
|
|
166
|
+
with:
|
|
167
|
+
registry: ghcr.io
|
|
168
|
+
username: ${{ github.actor }}
|
|
169
|
+
password: ${{ github.token }}
|
|
170
|
+
|
|
171
|
+
# Push untagged, addressed only by digest. The merge job gives these real tags.
|
|
172
|
+
# provenance/sbom off so the manifest list holds exactly one entry per arch.
|
|
173
|
+
- name: Push ${{ matrix.platform }} by digest
|
|
174
|
+
id: push
|
|
175
|
+
uses: docker/build-push-action@v7
|
|
176
|
+
with:
|
|
177
|
+
context: .
|
|
178
|
+
platforms: ${{ matrix.platform }}
|
|
179
|
+
provenance: false
|
|
180
|
+
sbom: false
|
|
181
|
+
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
|
182
|
+
cache-from: type=gha,scope=${{ matrix.arch }}
|
|
183
|
+
|
|
184
|
+
- name: Export digest
|
|
185
|
+
run: |
|
|
186
|
+
DIGEST="${{ steps.push.outputs.digest }}"
|
|
187
|
+
if [ -z "$DIGEST" ]; then
|
|
188
|
+
echo "::error::push produced no digest"
|
|
189
|
+
exit 1
|
|
190
|
+
fi
|
|
191
|
+
echo "Pushed ${{ matrix.arch }} as $DIGEST"
|
|
192
|
+
mkdir -p /tmp/digests
|
|
193
|
+
touch "/tmp/digests/${DIGEST#sha256:}"
|
|
194
|
+
|
|
195
|
+
- uses: actions/upload-artifact@v7
|
|
196
|
+
with:
|
|
197
|
+
name: digest-${{ matrix.arch }}
|
|
198
|
+
path: /tmp/digests/*
|
|
199
|
+
if-no-files-found: error
|
|
200
|
+
retention-days: 1
|
|
201
|
+
|
|
202
|
+
pypi:
|
|
203
|
+
# Publishes the sdist+wheel on every version bump via PyPI Trusted Publishing
|
|
204
|
+
# (OIDC — no long-lived secret in this repo). One-time setup on pypi.org: add a
|
|
205
|
+
# GitHub publisher for project pse-edge-mcp (owner phdwight, repo pse-edge-mcp,
|
|
206
|
+
# workflow release.yml, environment pypi). Until that exists this job fails at
|
|
207
|
+
# the upload step — visibly, without blocking the image release (nothing needs it).
|
|
208
|
+
needs: [test, version]
|
|
209
|
+
if: needs.version.outputs.bumped == 'true'
|
|
210
|
+
runs-on: ubuntu-latest
|
|
211
|
+
environment: pypi
|
|
212
|
+
permissions:
|
|
213
|
+
contents: read
|
|
214
|
+
id-token: write # OIDC token for Trusted Publishing
|
|
215
|
+
steps:
|
|
216
|
+
- uses: actions/checkout@v7
|
|
217
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
218
|
+
- run: uv build
|
|
219
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
220
|
+
|
|
221
|
+
merge:
|
|
222
|
+
needs: [build, version]
|
|
223
|
+
runs-on: ubuntu-latest
|
|
224
|
+
steps:
|
|
225
|
+
- uses: actions/download-artifact@v8
|
|
226
|
+
with:
|
|
227
|
+
path: /tmp/digests
|
|
228
|
+
pattern: digest-*
|
|
229
|
+
merge-multiple: true
|
|
230
|
+
|
|
231
|
+
- uses: docker/setup-buildx-action@v4
|
|
232
|
+
- name: Log in to GHCR
|
|
233
|
+
uses: docker/login-action@v4
|
|
234
|
+
with:
|
|
235
|
+
registry: ghcr.io
|
|
236
|
+
username: ${{ github.actor }}
|
|
237
|
+
password: ${{ github.token }}
|
|
238
|
+
|
|
239
|
+
- name: Create the multi-arch manifest list
|
|
240
|
+
env:
|
|
241
|
+
TAGS: ${{ needs.version.outputs.tags }}
|
|
242
|
+
run: |
|
|
243
|
+
cd /tmp/digests
|
|
244
|
+
COUNT=$(ls -1 | wc -l)
|
|
245
|
+
echo "Digests to combine: $COUNT"
|
|
246
|
+
test "$COUNT" -eq 2 # one per architecture; fewer means an arch silently dropped
|
|
247
|
+
TAG_ARGS=""
|
|
248
|
+
for t in $TAGS; do TAG_ARGS="$TAG_ARGS -t $t"; done
|
|
249
|
+
# shellcheck disable=SC2086
|
|
250
|
+
docker buildx imagetools create $TAG_ARGS \
|
|
251
|
+
$(printf "${IMAGE}@sha256:%s " *)
|
|
252
|
+
|
|
253
|
+
- name: Verify both architectures are published
|
|
254
|
+
run: |
|
|
255
|
+
PLATFORMS=$(docker buildx imagetools inspect "${IMAGE}:latest" --format '{{json .Manifest}}' \
|
|
256
|
+
| jq -r '.manifests[]? | select(.platform.architecture != "unknown")
|
|
257
|
+
| .platform.os + "/" + .platform.architecture' | sort -u)
|
|
258
|
+
echo "Published platforms:"; echo "$PLATFORMS"
|
|
259
|
+
echo "$PLATFORMS" | grep -qx "linux/amd64" || { echo "::error::linux/amd64 missing"; exit 1; }
|
|
260
|
+
echo "$PLATFORMS" | grep -qx "linux/arm64" || { echo "::error::linux/arm64 missing"; exit 1; }
|
|
261
|
+
|
|
262
|
+
- name: Create release
|
|
263
|
+
if: needs.version.outputs.bumped == 'true'
|
|
264
|
+
env:
|
|
265
|
+
GH_TOKEN: ${{ github.token }}
|
|
266
|
+
VERSION: ${{ needs.version.outputs.version }}
|
|
267
|
+
run: |
|
|
268
|
+
gh release create "v${VERSION}" \
|
|
269
|
+
--repo "${{ github.repository }}" \
|
|
270
|
+
--target "${{ github.sha }}" \
|
|
271
|
+
--title "v${VERSION}" \
|
|
272
|
+
--notes "$(cat <<EOF
|
|
273
|
+
Multi-arch container image (\`linux/amd64\`, \`linux/arm64\`) for v${VERSION}:
|
|
274
|
+
|
|
275
|
+
\`\`\`bash
|
|
276
|
+
docker pull ${IMAGE}:${VERSION}
|
|
277
|
+
\`\`\`
|
|
278
|
+
|
|
279
|
+
Also published as \`${IMAGE}:latest\`.
|
|
280
|
+
|
|
281
|
+
Serve streamable HTTP on :8000 (the image default):
|
|
282
|
+
|
|
283
|
+
\`\`\`bash
|
|
284
|
+
docker run --rm -p 8000:8000 ${IMAGE}:${VERSION}
|
|
285
|
+
\`\`\`
|
|
286
|
+
|
|
287
|
+
Or stdio, for Claude Desktop/Code (\`--entrypoint\` clears the default HTTP args):
|
|
288
|
+
|
|
289
|
+
\`\`\`bash
|
|
290
|
+
docker run --rm -i --entrypoint pse-edge-mcp ${IMAGE}:${VERSION}
|
|
291
|
+
\`\`\`
|
|
292
|
+
|
|
293
|
+
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/v${VERSION}/CHANGELOG.md) for what changed.
|
|
294
|
+
EOF
|
|
295
|
+
)"
|
|
296
|
+
|
|
297
|
+
- name: Summarise the download
|
|
298
|
+
run: |
|
|
299
|
+
{
|
|
300
|
+
echo "## Published (linux/amd64 + linux/arm64)"
|
|
301
|
+
echo
|
|
302
|
+
echo '```bash'
|
|
303
|
+
echo "docker pull ${IMAGE}:latest"
|
|
304
|
+
if [ "${{ needs.version.outputs.bumped }}" = "true" ]; then
|
|
305
|
+
echo "docker pull ${IMAGE}:${{ needs.version.outputs.version }}"
|
|
306
|
+
fi
|
|
307
|
+
echo '```'
|
|
308
|
+
} >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
.venv/
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
!.env.example
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
dist/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
|
|
13
|
+
# Operator-local files that must never enter public history. ugreen.yaml is the
|
|
14
|
+
# operator's personal compose fork and carries a tunnel token inline; a blanket
|
|
15
|
+
# `git add -A` once swept it into pushed commits, forcing a token rotation and a
|
|
16
|
+
# history rewrite of develop. Never commit these.
|
|
17
|
+
ugreen.yaml
|
|
18
|
+
.DS_Store
|