From 3eae88259df4efd9b60905dd40415139f87246a3 Mon Sep 17 00:00:00 2001 From: MLittleprince Date: Tue, 25 Aug 2026 09:54:45 +0800 Subject: [PATCH 1/3] ci: add fork-safe CLI test builds --- .github/workflows/fork-safe-test.yml | 137 +++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .github/workflows/fork-safe-test.yml diff --git a/.github/workflows/fork-safe-test.yml b/.github/workflows/fork-safe-test.yml new file mode 100644 index 0000000..497ec55 --- /dev/null +++ b/.github/workflows/fork-safe-test.yml @@ -0,0 +1,137 @@ +name: MemOS CLI — Safe Test & Build + +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: memos-cli-safe-test-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + +# This workflow is safe to run in a fork. It never receives release secrets and +# cannot create tags, GitHub Releases, npm packages, docs PRs, or deployments. +permissions: + contents: read + +jobs: + test: + name: Python tests and CLI smoke test + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install MemOS CLI + run: | + python -m pip install --upgrade pip + python -m pip install -e . + + - name: Run Python unit tests + run: python -m unittest discover -s tests -p "test_*.py" + + - name: Check installed CLI + run: | + memos --version + memos --help + + build: + name: Build and smoke test (${{ matrix.artifact_suffix }}) + needs: test + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + artifact_suffix: linux-x64 + - os: windows-2022 + artifact_suffix: windows-x64 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Build Linux archive + if: runner.os == 'Linux' + shell: bash + run: bash scripts/build-binary.sh + + - name: Build Windows archive + if: runner.os == 'Windows' + shell: pwsh + run: pwsh -File scripts/build-binary.ps1 + + - name: Smoke test Linux binary + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + mkdir -p "${RUNNER_TEMP}/memos-cli-smoke" + tar -xzf dist/memos-*-linux-x64.tar.gz -C "${RUNNER_TEMP}/memos-cli-smoke" + "${RUNNER_TEMP}/memos-cli-smoke/memos" --version + "${RUNNER_TEMP}/memos-cli-smoke/memos" --help + + - name: Smoke test Windows binary + if: runner.os == 'Windows' + shell: pwsh + run: | + $SmokeDir = Join-Path $env:RUNNER_TEMP "memos-cli-smoke" + New-Item -ItemType Directory -Force -Path $SmokeDir | Out-Null + tar -xzf (Get-ChildItem "dist/memos-*-windows-x64.tar.gz").FullName -C $SmokeDir + & (Join-Path $SmokeDir "memos.exe") --version + & (Join-Path $SmokeDir "memos.exe") --help + + - name: Upload test build + uses: actions/upload-artifact@v4 + with: + name: memos-cli-safe-${{ matrix.artifact_suffix }} + path: dist/*.tar.gz + if-no-files-found: error + retention-days: 7 + + - name: Explain artifact scope on Linux + if: runner.os == 'Linux' + shell: bash + run: | + { + echo "## Safe test build" + echo + echo "- repository: ${GITHUB_REPOSITORY}" + echo "- commit: ${GITHUB_SHA}" + echo "- artifact: memos-cli-safe-${{ matrix.artifact_suffix }}" + echo + echo "This is a temporary test artifact only. No tag, GitHub Release, npm package, Doc Agent request, docs PR, or deployment was created." + } >> "${GITHUB_STEP_SUMMARY}" + + - name: Explain artifact scope on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + @" + ## Safe test build + + - repository: $env:GITHUB_REPOSITORY + - commit: $env:GITHUB_SHA + - artifact: memos-cli-safe-${{ matrix.artifact_suffix }} + + This is a temporary test artifact only. No tag, GitHub Release, npm package, Doc Agent request, docs PR, or deployment was created. + "@ | Add-Content -Path $env:GITHUB_STEP_SUMMARY From 934c55c002d8431c854ff139f6cef8742cfc8685 Mon Sep 17 00:00:00 2001 From: MLittleprince Date: Tue, 25 Aug 2026 10:14:53 +0800 Subject: [PATCH 2/3] ci: harden Windows package smoke test --- .github/workflows/fork-safe-test.yml | 37 +++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fork-safe-test.yml b/.github/workflows/fork-safe-test.yml index 497ec55..c339c35 100644 --- a/.github/workflows/fork-safe-test.yml +++ b/.github/workflows/fork-safe-test.yml @@ -94,11 +94,42 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | + $ErrorActionPreference = "Stop" + $env:PYTHONUTF8 = "1" + $env:PYTHONIOENCODING = "utf-8" + [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false) + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + $SmokeDir = Join-Path $env:RUNNER_TEMP "memos-cli-smoke" New-Item -ItemType Directory -Force -Path $SmokeDir | Out-Null - tar -xzf (Get-ChildItem "dist/memos-*-windows-x64.tar.gz").FullName -C $SmokeDir - & (Join-Path $SmokeDir "memos.exe") --version - & (Join-Path $SmokeDir "memos.exe") --help + + $Archives = @(Get-ChildItem "dist/memos-*-windows-x64.tar.gz") + if ($Archives.Count -ne 1) { + throw "Expected exactly one Windows archive, found $($Archives.Count)." + } + + tar -xzf $Archives[0].FullName -C $SmokeDir + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract $($Archives[0].Name) (exit code $LASTEXITCODE)." + } + + $Binary = Join-Path $SmokeDir "memos.exe" + if (-not (Test-Path -LiteralPath $Binary)) { + throw "Extracted archive does not contain memos.exe." + } + + foreach ($Argument in @("--version", "--help")) { + Write-Host "Running packaged memos.exe $Argument" + $Output = @(& $Binary $Argument 2>&1) + $ExitCode = $LASTEXITCODE + $Output | ForEach-Object { Write-Host $_ } + + if ($ExitCode -ne 0) { + $Tail = (($Output | Select-Object -Last 20) -join " | ") + Write-Output "::error title=Windows binary smoke test failed::$Argument exited with code $ExitCode. $Tail" + exit $ExitCode + } + } - name: Upload test build uses: actions/upload-artifact@v4 From 9a79d7b895c43db07a153af329c991d4fed59212 Mon Sep 17 00:00:00 2001 From: MLittleprince Date: Tue, 25 Aug 2026 10:24:02 +0800 Subject: [PATCH 3/3] fix: make Windows CLI output Unicode-safe --- src/memos_cli/main.py | 4 ++++ src/memos_cli/stdio.py | 43 +++++++++++++++++++++++++++++++++++++++++ tests/test_stdio.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 src/memos_cli/stdio.py create mode 100644 tests/test_stdio.py diff --git a/src/memos_cli/main.py b/src/memos_cli/main.py index 7a343f8..cc44ca1 100644 --- a/src/memos_cli/main.py +++ b/src/memos_cli/main.py @@ -1,6 +1,10 @@ """Main CLI application — the entrypoint for `memos`.""" from __future__ import annotations +from memos_cli.stdio import configure_windows_stdio + +configure_windows_stdio() + import click import typer from rich.console import Console diff --git a/src/memos_cli/stdio.py b/src/memos_cli/stdio.py new file mode 100644 index 0000000..e33f8a1 --- /dev/null +++ b/src/memos_cli/stdio.py @@ -0,0 +1,43 @@ +"""Cross-platform standard-stream compatibility helpers.""" +from __future__ import annotations + +import os +import sys +from typing import Any + + +def _configure_stream(stream: Any) -> None: + """Keep Unicode CLI output from crashing on legacy Windows encodings.""" + if stream is None: + return + + reconfigure = getattr(stream, "reconfigure", None) + if not callable(reconfigure): + return + + try: + is_terminal = bool(stream.isatty()) + except (AttributeError, OSError, ValueError): + is_terminal = False + + options: dict[str, str] = {"errors": "backslashreplace"} + if not is_terminal: + # GitHub Actions and shell pipelines may expose a cp1252 stream even + # though CLI help contains Unicode. UTF-8 keeps redirected output safe. + options["encoding"] = "utf-8" + + try: + reconfigure(**options) + except (AttributeError, OSError, ValueError): + # Custom or already-detached streams may not support reconfiguration. + # Output should remain best-effort instead of breaking CLI startup. + return + + +def configure_windows_stdio() -> None: + """Configure Windows stdout/stderr before Rich or Click creates consoles.""" + if os.name != "nt": + return + + _configure_stream(sys.stdout) + _configure_stream(sys.stderr) diff --git a/tests/test_stdio.py b/tests/test_stdio.py new file mode 100644 index 0000000..e2e1925 --- /dev/null +++ b/tests/test_stdio.py @@ -0,0 +1,44 @@ +"""Tests for Windows standard-stream compatibility.""" +from __future__ import annotations + +import unittest + +from memos_cli.stdio import _configure_stream + + +class FakeStream: + def __init__(self, *, is_terminal: bool) -> None: + self.is_terminal = is_terminal + self.calls: list[dict[str, str]] = [] + + def isatty(self) -> bool: + return self.is_terminal + + def reconfigure(self, **options: str) -> None: + self.calls.append(options) + + +class StdioCompatibilityTests(unittest.TestCase): + def test_redirected_stream_uses_utf8(self) -> None: + stream = FakeStream(is_terminal=False) + + _configure_stream(stream) + + self.assertEqual( + stream.calls, + [{"errors": "backslashreplace", "encoding": "utf-8"}], + ) + + def test_terminal_keeps_native_encoding(self) -> None: + stream = FakeStream(is_terminal=True) + + _configure_stream(stream) + + self.assertEqual(stream.calls, [{"errors": "backslashreplace"}]) + + def test_missing_stream_is_ignored(self) -> None: + _configure_stream(None) + + +if __name__ == "__main__": + unittest.main()