mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 02:37:44 +00:00
ci: publish the MCP server from build.yml (#8049)
* ci: publish @simplex-chat/mcp from build.yml * ci: publish MCP after PyPI, notice for a version held back * ci: test the MCP server as a scope of build-linux
This commit is contained in:
1 parent
f3de496006
commit
4f6fc5fb3a
5 files changed
+159
-19
No files matched your search
@@ -127,6 +127,7 @@ jobs:
|
||||
publish_types: ${{ steps.libs.outputs.tag && steps.publish.outputs.types_version || '' }}
|
||||
publish_nodejs: ${{ steps.publish.outputs.nodejs_version }}
|
||||
publish_python: ${{ steps.publish.outputs.python_version }}
|
||||
publish_mcp: ${{ steps.publish.outputs.mcp_version }}
|
||||
publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }}
|
||||
packages: ${{ steps.changes.outputs.packages }}
|
||||
scopes: ${{ steps.changes.outputs.scopes }}
|
||||
@@ -1731,7 +1732,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
needs: [maybe-release, build-linux, build-linux-full, build-linux-push, build-macos, build-ios, build-windows, releases]
|
||||
if: ${{ !cancelled() && (needs.maybe-release.outputs.publish || needs.maybe-release.outputs.publish_types) }}
|
||||
if: ${{ !cancelled() && (needs.maybe-release.outputs.publish || needs.maybe-release.outputs.publish_types || needs.maybe-release.outputs.publish_mcp) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -1740,6 +1741,7 @@ jobs:
|
||||
outputs:
|
||||
types: ${{ steps.types.outputs.version }}
|
||||
nodejs: ${{ steps.nodejs.outputs.version }}
|
||||
mcp: ${{ steps.mcp.outputs.version }}
|
||||
python: ${{ steps.python.outputs.version }}
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1778,7 +1780,7 @@ jobs:
|
||||
run: python3 scripts/ci/packages_release.py publish-plan >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version
|
||||
if: steps.plan.outputs.types_version || steps.plan.outputs.nodejs_version || steps.plan.outputs.mcp_version
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -1850,9 +1852,29 @@ jobs:
|
||||
VERSION: ${{ steps.plan.outputs.python_version }}
|
||||
run: echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and test @simplex-chat/mcp
|
||||
if: steps.plan.outputs.mcp_version
|
||||
working-directory: packages/simplex-chat-mcp
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npx tsc
|
||||
npx tsc -p test/tsconfig.json --noEmit
|
||||
npm test
|
||||
|
||||
- name: Publish @simplex-chat/mcp
|
||||
id: mcp
|
||||
if: steps.plan.outputs.mcp_version
|
||||
working-directory: packages/simplex-chat-mcp
|
||||
env:
|
||||
VERSION: ${{ steps.plan.outputs.mcp_version }}
|
||||
DIST_TAG: ${{ steps.plan.outputs.mcp_dist_tag }}
|
||||
run: |
|
||||
npm publish --provenance --access public --tag "$DIST_TAG"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
record:
|
||||
needs: publish
|
||||
if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.python) }}
|
||||
if: ${{ !cancelled() && (needs.publish.outputs.types || needs.publish.outputs.nodejs || needs.publish.outputs.mcp || needs.publish.outputs.python) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
@@ -1864,7 +1886,8 @@ jobs:
|
||||
LIBS_REPOSITORY: ${{ github.repository }}-libs
|
||||
TYPES: ${{ needs.publish.outputs.types }}
|
||||
NODEJS: ${{ needs.publish.outputs.nodejs }}
|
||||
MCP: ${{ needs.publish.outputs.mcp }}
|
||||
PYTHON: ${{ needs.publish.outputs.python }}
|
||||
run: |
|
||||
python3 scripts/ci/packages_release.py record --commit "$GITHUB_SHA" \
|
||||
--types "$TYPES" --nodejs "$NODEJS" --python "$PYTHON"
|
||||
--types "$TYPES" --nodejs "$NODEJS" --mcp "$MCP" --python "$PYTHON"
|
||||
@@ -8,6 +8,7 @@
|
||||
| Node.js add-on | simplex-chat-libs release `nodejs-addon-<7 characters of the commit>` | | push to master or stable with unreleased add-on sources |
|
||||
| `@simplex-chat/types` | npm | `version` of `packages/simplex-chat-client/types/typescript/package.json` | library release with an unpublished types version |
|
||||
| `simplex-chat` | npm | `version` of `packages/simplex-chat-nodejs/package.json` | push to master or stable with an unpublished version |
|
||||
| `@simplex-chat/mcp` | npm | `version` of `packages/simplex-chat-mcp/package.json` | push to master or stable with an unpublished version, when the versions it pins for `simplex-chat` and `@simplex-chat/types` are on npm |
|
||||
| `simplex-chat` | PyPI | `__version__` of `packages/simplex-chat-python/src/simplex_chat/_version.py` | push to master or stable with an unpublished version |
|
||||
|
||||
Add-on sources:
|
||||
@@ -111,6 +112,24 @@ Incomplete draft: completed by a re-run or by the next push with the same add-on
|
||||
|
||||
Versions unpublished after a failure: published by the next push to the branch.
|
||||
|
||||
## MCP server release
|
||||
|
||||
1. On master or stable, in `packages/simplex-chat-mcp`: `version` in `package.json` set, the pins of `simplex-chat` and `@simplex-chat/types` updated when needed, then:
|
||||
|
||||
```
|
||||
npm install --package-lock-only
|
||||
```
|
||||
|
||||
2. Commit, push to the same branch.
|
||||
|
||||
3. Run of `build.yml`:
|
||||
- `publish`, with the pinned versions on npm: package installed by `npm ci` from the published packages, type-checked and unit tested, then published with trusted publishing
|
||||
- `record`: published version recorded
|
||||
|
||||
The package tests of 5 platforms are not required.
|
||||
|
||||
A version pinned in the same commit that bumps `simplex-chat` or `@simplex-chat/types` is on npm after that run: the MCP version is published by a later push to the branch, and the run shows a notice that names the pinned version.
|
||||
|
||||
## Local publication
|
||||
|
||||
1. Step 1 of the package release.
|
||||
@@ -142,7 +161,7 @@ Versions unpublished after a failure: published by the next push to the branch.
|
||||
- simplex-chat-libs: release immutability enabled
|
||||
- secret `NODEJS_REPO_TOKEN`: contents of simplex-chat-libs, read and write
|
||||
- secrets `APPLE_SIMPLEX_SIGNING_KEYCHAIN`, `APPLE_SIMPLEX_NOTARIZATION_APPLE_ID` and `APPLE_SIMPLEX_NOTARIZATION_PASSWORD`: signing and notarization of the macOS libraries, shared with the desktop app build
|
||||
- trusted publisher of `simplex-chat` and `@simplex-chat/types` on npm and of `simplex-chat` on PyPI: workflow `build.yml`
|
||||
- trusted publisher of `simplex-chat`, `@simplex-chat/types` and `@simplex-chat/mcp` on npm and of `simplex-chat` on PyPI: workflow `build.yml`
|
||||
- secrets `NPM_TOKEN` and `PYPI_API_TOKEN`: used instead of trusted publishing when set
|
||||
- variables `NPM_SCOPE`, `PYPI_PROJECT`, `PYPI_URL` and `PYPI_UPLOAD_URL`: publication under other names, for test repositories
|
||||
|
||||
|
||||
@@ -142,7 +142,8 @@ The reduced build runs one Linux job, build-linux (ubuntu-22.04 x86_64), for eve
|
||||
- `npm ci`, then both installed into the MCP package with `npm install --no-save`;
|
||||
- `npx tsc` and `npx tsc -p test/tsconfig.json --noEmit`;
|
||||
- `npm test`, the unit tests;
|
||||
- `npm run test:e2e`, the end-to-end tests. They download libsimplex and the add-on pinned in packages/simplex-chat-nodejs/src/release.ts, and smp-server and xftp-server of the simplexmq release in test/e2e/smpServer.ts.
|
||||
- `npm run test:e2e`, the end-to-end tests. They download libsimplex and the add-on pinned in packages/simplex-chat-nodejs/src/release.ts, and smp-server and xftp-server of the simplexmq release in test/e2e/smpServer.ts;
|
||||
- before a publication: the package with its published dependencies, type-checked and unit tested in job `publish` of build.yml (docs/contributing/RELEASES.md, "MCP server release").
|
||||
- Local: `scripts/ci/scope-tests.sh mcp`.
|
||||
|
||||
#### Scopes lacking CI tests
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Release automation for simplex-chat and @simplex-chat/types on npm and simplex-chat on PyPI.
|
||||
"""Release automation for simplex-chat, @simplex-chat/types and @simplex-chat/mcp on npm and simplex-chat on PyPI.
|
||||
|
||||
core-release set the core version and the types version for a library release
|
||||
claim-core claim the library release of the core version for a commit
|
||||
@@ -43,6 +43,8 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
NODEJS_DIR = "packages/simplex-chat-nodejs"
|
||||
TYPES_DIR = "packages/simplex-chat-client/types/typescript"
|
||||
MCP_DIR = "packages/simplex-chat-mcp"
|
||||
MCP_PINNED = ["simplex-chat", "@simplex-chat/types"]
|
||||
PYTHON_DIR = "packages/simplex-chat-python"
|
||||
RELEASE_FILE = f"{NODEJS_DIR}/src/release.ts"
|
||||
PYTHON_VERSION_FILE = f"{PYTHON_DIR}/src/simplex_chat/_version.py"
|
||||
@@ -579,6 +581,15 @@ def npm_published(name: str, version: str) -> bool:
|
||||
return http_json(f"{npm_package_url(name)}/{version}") is not None
|
||||
|
||||
|
||||
def npm_released(releases: dict, name: str, version: str) -> bool:
|
||||
return recorded_commit(releases, f"npm/{name}", version) is not None or npm_published(name, version)
|
||||
|
||||
|
||||
def unreleased_pins(releases: dict, package: dict, pinned: list[str]) -> list[str]:
|
||||
versions = [(name, package["dependencies"][name].removeprefix("^")) for name in pinned]
|
||||
return [f"{name} {version}" for name, version in versions if not npm_released(releases, name, version)]
|
||||
|
||||
|
||||
def python_project() -> str:
|
||||
return read_field(f"{PYTHON_DIR}/pyproject.toml", r'^name = "([^"]+)"')
|
||||
|
||||
@@ -710,7 +721,7 @@ def types_problem(version: str, releases: dict, branch: str) -> str | None:
|
||||
if not types_changed(releases, branch):
|
||||
return None
|
||||
types = read_json(f"{TYPES_DIR}/package.json")
|
||||
if recorded_commit(releases, npm_key(TYPES_DIR), types["version"]) or npm_published(types["name"], types["version"]):
|
||||
if npm_released(releases, types["name"], types["version"]):
|
||||
return f"{types['name']} changed, and its version {types['version']} is published"
|
||||
if types.get("simplexChatCore") != version:
|
||||
return f"simplexChatCore of {types['name']} is {types.get('simplexChatCore')}, expected {version}"
|
||||
@@ -850,12 +861,14 @@ def prepare(args: argparse.Namespace) -> None:
|
||||
def publish_plan() -> None:
|
||||
"""A recorded version is published: npm and PyPI list a new version minutes after it is published."""
|
||||
releases, _ = read_releases()
|
||||
for key, directory in [("types", TYPES_DIR), ("nodejs", NODEJS_DIR)]:
|
||||
for key, directory, pinned in [("types", TYPES_DIR, []), ("nodejs", NODEJS_DIR, []), ("mcp", MCP_DIR, MCP_PINNED)]:
|
||||
package = read_json(f"{directory}/package.json")
|
||||
published = recorded_commit(releases, npm_key(directory), package["version"]) or npm_published(
|
||||
package["name"], package["version"]
|
||||
)
|
||||
print(f"{key}_version={'' if published else package['version']}")
|
||||
published = npm_released(releases, package["name"], package["version"])
|
||||
unreleased = [] if published else unreleased_pins(releases, package, pinned)
|
||||
if unreleased:
|
||||
waits_for = ", ".join(unreleased)
|
||||
print(f"::notice title=Package release::{package['name']} {package['version']} waits for {waits_for}", file=sys.stderr)
|
||||
print(f"{key}_version={'' if published or unreleased else package['version']}")
|
||||
print(f"{key}_dist_tag={dist_tag(package['version'])}")
|
||||
version, _ = parse_python_version(read_text(PYTHON_VERSION_FILE))
|
||||
published = recorded_commit(releases, pypi_key(), version) or pypi_published(version)
|
||||
@@ -867,6 +880,7 @@ def record(args: argparse.Namespace) -> None:
|
||||
(core_key(args.branch), args.core),
|
||||
(npm_key(TYPES_DIR), args.types),
|
||||
(npm_key(NODEJS_DIR), args.nodejs),
|
||||
(npm_key(MCP_DIR), args.mcp),
|
||||
(pypi_key(), args.python),
|
||||
]
|
||||
entries = [(key, version) for key, version in versions if version]
|
||||
@@ -895,6 +909,17 @@ def test_names() -> None:
|
||||
"dependencies": {**nodejs["dependencies"], types["name"]: alias},
|
||||
},
|
||||
)
|
||||
mcp = read_json(f"{MCP_DIR}/package.json")
|
||||
aliases = {name: f"npm:{registry_name(name, scope)}@{mcp['dependencies'][name]}" for name in MCP_PINNED}
|
||||
write_json(
|
||||
f"{MCP_DIR}/package.json",
|
||||
{
|
||||
**mcp,
|
||||
"name": registry_name(mcp["name"], scope),
|
||||
"repository": {**mcp["repository"], "url": url},
|
||||
"dependencies": {**mcp["dependencies"], **aliases},
|
||||
},
|
||||
)
|
||||
if project := os.environ.get("PYPI_PROJECT"):
|
||||
pyproject = ROOT / PYTHON_DIR / "pyproject.toml"
|
||||
pyproject.write_text(
|
||||
@@ -991,7 +1016,7 @@ def main(argv: list[str]) -> None:
|
||||
record_parser = commands.add_parser("record")
|
||||
record_parser.add_argument("--commit", required=True, help="the simplex-chat commit released or published from")
|
||||
record_parser.add_argument("--branch", default="master", help="the branch of the core version")
|
||||
for name in ["core", "types", "nodejs", "python"]:
|
||||
for name in ["core", "types", "nodejs", "mcp", "python"]:
|
||||
record_parser.add_argument(f"--{name}", default="", help=f"{name} version, recorded when not empty")
|
||||
commands.add_parser("test-names")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@@ -19,6 +19,7 @@ import packages_release
|
||||
from packages_release import (
|
||||
ADDON_ASSETS,
|
||||
CABAL_FILE,
|
||||
MCP_DIR,
|
||||
NODEJS_DIR,
|
||||
PYTHON_DIR,
|
||||
PYTHON_VERSION_FILE,
|
||||
@@ -556,33 +557,104 @@ class PackageStateTest(GitRepoTest):
|
||||
self.assertFalse(self.state(registry_published=False).published)
|
||||
self.assertTrue(self.state(registry_published=True).published)
|
||||
|
||||
def test_publish_plan_skips_recorded_versions_before_the_registries_list_them(self):
|
||||
def plan_output(self, releases, npm_versions):
|
||||
mcp = {
|
||||
"name": "@simplex-chat/mcp",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {"simplex-chat": "^1.0.0-beta.1.1", "@simplex-chat/types": "^0.1.0", "qrcode": "^1.5"},
|
||||
}
|
||||
self.write(f"{TYPES_DIR}/package.json", '{"name": "@simplex-chat/types", "version": "0.1.0"}')
|
||||
self.write(f"{NODEJS_DIR}/package.json", '{"name": "simplex-chat", "version": "1.0.0-beta.1.1"}')
|
||||
self.write(f"{MCP_DIR}/package.json", json.dumps(mcp))
|
||||
self.write(PYTHON_VERSION_FILE, render_python_version("1.0.0b1.post1", Pins("core-1", {})))
|
||||
self.write("pyproject.toml", "")
|
||||
self.commit("packages")
|
||||
releases = {"npm": {"simplex-chat": {"1.0.0-beta.1.1": "c"}}, "pypi": {"simplex-chat": {"1.0.0b1.post1": "c"}}}
|
||||
output = io.StringIO()
|
||||
notices = io.StringIO()
|
||||
with (
|
||||
mock.patch.object(packages_release, "ROOT", self.repo),
|
||||
mock.patch.object(packages_release, "read_releases", return_value=(releases, "blob")),
|
||||
mock.patch.object(packages_release, "python_project", return_value="simplex-chat"),
|
||||
mock.patch.object(packages_release, "npm_published", return_value=False),
|
||||
mock.patch.object(packages_release, "npm_published", lambda name, version: (name, version) in npm_versions),
|
||||
mock.patch.object(packages_release, "pypi_published", return_value=False),
|
||||
contextlib.redirect_stdout(output),
|
||||
contextlib.redirect_stderr(notices),
|
||||
):
|
||||
publish_plan()
|
||||
self.notices = notices.getvalue()
|
||||
return output.getvalue().splitlines()
|
||||
|
||||
def test_publish_plan_skips_recorded_versions_before_the_registries_list_them(self):
|
||||
releases = {
|
||||
"npm": {"simplex-chat": {"1.0.0-beta.1.1": "c"}, "@simplex-chat/mcp": {"0.2.0": "c"}},
|
||||
"pypi": {"simplex-chat": {"1.0.0b1.post1": "c"}},
|
||||
}
|
||||
self.assertEqual(
|
||||
output.getvalue().splitlines(),
|
||||
self.plan_output(releases, set()),
|
||||
[
|
||||
"types_version=0.1.0",
|
||||
"types_dist_tag=latest",
|
||||
"nodejs_version=",
|
||||
"nodejs_dist_tag=beta",
|
||||
"mcp_version=",
|
||||
"mcp_dist_tag=latest",
|
||||
"python_version=",
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.notices, "")
|
||||
|
||||
def test_publish_plan_lists_mcp_when_its_pinned_versions_are_on_npm(self):
|
||||
pins = {("simplex-chat", "1.0.0-beta.1.1"), ("@simplex-chat/types", "0.1.0")}
|
||||
self.assertIn("mcp_version=0.2.0", self.plan_output({}, pins))
|
||||
self.assertEqual(self.notices, "")
|
||||
|
||||
def test_publish_plan_counts_a_recorded_pinned_version_as_published(self):
|
||||
releases = {"npm": {"simplex-chat": {"1.0.0-beta.1.1": "c"}}}
|
||||
self.assertIn("mcp_version=0.2.0", self.plan_output(releases, {("@simplex-chat/types", "0.1.0")}))
|
||||
|
||||
def test_publish_plan_leaves_mcp_for_a_later_push_while_a_pinned_version_is_not_on_npm(self):
|
||||
notice = "::notice title=Package release::@simplex-chat/mcp 0.2.0 waits for "
|
||||
self.assertIn("mcp_version=", self.plan_output({}, {("simplex-chat", "1.0.0-beta.1.1")}))
|
||||
self.assertEqual(self.notices, f"{notice}@simplex-chat/types 0.1.0\n")
|
||||
self.assertIn("mcp_version=", self.plan_output({}, {("@simplex-chat/types", "0.1.0")}))
|
||||
self.assertEqual(self.notices, f"{notice}simplex-chat 1.0.0-beta.1.1\n")
|
||||
|
||||
def test_record_adds_the_mcp_version(self):
|
||||
self.write(f"{TYPES_DIR}/package.json", '{"name": "@simplex-chat/types"}')
|
||||
self.write(f"{NODEJS_DIR}/package.json", '{"name": "simplex-chat"}')
|
||||
self.write(f"{MCP_DIR}/package.json", '{"name": "@simplex-chat/mcp"}')
|
||||
with (
|
||||
mock.patch.object(packages_release, "ROOT", self.repo),
|
||||
mock.patch.object(packages_release, "python_project", return_value="simplex-chat"),
|
||||
mock.patch.object(packages_release, "record_releases", return_value="libs1") as record,
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
):
|
||||
packages_release.main(["record", "--commit", "sx1", "--mcp", "0.2.0"])
|
||||
record.assert_called_once_with([("npm/@simplex-chat/mcp", "0.2.0")], "sx1")
|
||||
|
||||
def test_test_names_rename_mcp_and_its_pinned_dependencies(self):
|
||||
repository = {"type": "git", "url": "git+https://github.com/simplex-chat/simplex-chat.git"}
|
||||
types = {"name": "@simplex-chat/types", "repository": repository}
|
||||
nodejs = {"name": "simplex-chat", "repository": repository, "dependencies": {"@simplex-chat/types": "^0.1.0"}}
|
||||
dependencies = {"simplex-chat": "^1.0.0-beta.1.1", "@simplex-chat/types": "^0.1.0", "qrcode": "^1.5"}
|
||||
mcp = {"name": "@simplex-chat/mcp", "repository": repository, "dependencies": dependencies}
|
||||
for directory, package in [(TYPES_DIR, types), (NODEJS_DIR, nodejs), (MCP_DIR, mcp)]:
|
||||
self.write(f"{directory}/package.json", json.dumps(package))
|
||||
with (
|
||||
mock.patch.object(packages_release, "ROOT", self.repo),
|
||||
mock.patch.dict("os.environ", {"NPM_SCOPE": "@test", "GITHUB_REPOSITORY": "a/b", "PYPI_PROJECT": ""}),
|
||||
):
|
||||
packages_release.test_names()
|
||||
renamed = json.loads((self.repo / MCP_DIR / "package.json").read_text())
|
||||
self.assertEqual(renamed["name"], "@test/mcp")
|
||||
self.assertEqual(renamed["repository"]["url"], "git+https://github.com/a/b.git")
|
||||
self.assertEqual(
|
||||
renamed["dependencies"],
|
||||
{
|
||||
"simplex-chat": "npm:@test/simplex-chat@^1.0.0-beta.1.1",
|
||||
"@simplex-chat/types": "npm:@test/types@^0.1.0",
|
||||
"qrcode": "^1.5",
|
||||
},
|
||||
)
|
||||
|
||||
def test_unknown_recorded_commit_falls_back_to_the_version_commit(self):
|
||||
self.write("pkg/src.txt", "b")
|
||||
|
||||
Reference in new issue
Block a user