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
168 changes: 168 additions & 0 deletions .github/workflows/fork-safe-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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: |
$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

$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
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
4 changes: 4 additions & 0 deletions src/memos_cli/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/memos_cli/stdio.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions tests/test_stdio.py
Original file line number Diff line number Diff line change
@@ -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()
Loading