From eaa4ef32c018c5d948c39895ef79769fbe60a66a Mon Sep 17 00:00:00 2001 From: Ivan Kosyanenko Date: Fri, 27 Feb 2026 20:40:01 +0300 Subject: [PATCH] chore(release): add release workflow and prod docs --- .github/workflows/release.yml | 99 +++++++++++++++++++++++++ CHANGELOG.md | 35 ++++++++- CONTRIBUTING.md | 16 ++++ README.md | 28 +++++++ docs/README.md | 1 + docs/production.md | 136 ++++++++++++++++++++++++++++++++++ integration/README.md | 13 ++++ scripts/integration-local.sh | 116 ++++++++++++++++++++++++++--- scripts/release-notes.sh | 71 ++++++++++++++++++ 9 files changed, 502 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/production.md create mode 100755 scripts/release-notes.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4a6a2ed --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version tag (for example: v0.3.0)" + required: true + type: string + target: + description: "Target branch or commit-ish for the release tag" + required: false + default: "main" + type: string + +permissions: + contents: write + +jobs: + release: + name: Tag and Publish Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate release input + id: validate + env: + INPUT_VERSION: ${{ inputs.version }} + INPUT_TARGET: ${{ inputs.target }} + run: | + set -euo pipefail + version="${INPUT_VERSION}" + target="${INPUT_TARGET}" + if [ -z "$target" ]; then + target="main" + fi + + if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "invalid version format: $version" + echo "expected: v.. (optional pre-release/build suffix)" + exit 1 + fi + + git fetch --force --tags origin + git fetch --prune origin + if git rev-parse -q --verify "refs/tags/${version}" >/dev/null; then + echo "tag already exists: $version" + exit 1 + fi + + target_ref="$target" + if git rev-parse -q --verify "origin/$target^{commit}" >/dev/null; then + target_ref="origin/$target" + elif ! git rev-parse -q --verify "$target^{commit}" >/dev/null; then + echo "target commit-ish not found: $target" + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT" + + - name: Extract release notes from CHANGELOG + id: notes + env: + VERSION: ${{ steps.validate.outputs.version }} + run: | + set -euo pipefail + notes_file="$RUNNER_TEMP/release-notes.md" + ./scripts/release-notes.sh "$VERSION" > "$notes_file" + echo "notes_file=$notes_file" >> "$GITHUB_OUTPUT" + + - name: Create and push release tag + env: + VERSION: ${{ steps.validate.outputs.version }} + TARGET_REF: ${{ steps.validate.outputs.target_ref }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" -m "release $VERSION" "$TARGET_REF" + git push origin "refs/tags/$VERSION" + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.validate.outputs.version }} + TARGET: ${{ inputs.target }} + NOTES_FILE: ${{ steps.notes.outputs.notes_file }} + run: | + set -euo pipefail + args=(release create "$VERSION" --title "$VERSION" --target "$TARGET" --notes-file "$NOTES_FILE") + if [[ "$VERSION" == *-* ]]; then + args+=(--prerelease) + fi + gh "${args[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index b9910ed..92a6306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Added + +- release workflow (`.github/workflows/release.yml`) for tag+GitHub Release from merged PR state +- changelog note extractor script: `scripts/release-notes.sh` +- production deployment and data safety guide: `docs/production.md` + +### Changed + +- `scripts/integration-local.sh` now supports: + - `--no-start` + - `--port ` + - `--timeout ` + - `--verbose` + - explicit `--` passthrough for extra `go test` args +- docs expanded for harness options and release process (`README.md`, `CONTRIBUTING.md`, `integration/README.md`) + +## [v0.2.1] - 2026-02-27 + +### Added + +- local integration harness script: + - starts `simplex-chat` + - waits for websocket readiness + - runs integration tests + - cleans up process on exit +- documentation updates for local harness usage in README and integration docs + ## [v0.2.0] - 2026-02-27 ### Added @@ -43,5 +72,7 @@ All notable changes to this project are documented in this file. - rate limiting and safety validations - scaffold command with `basic` and `moderation` templates -[ v0.2.0 ]: https://github.com/Malomalsky/go-simplex/compare/v0.1.0...v0.2.0 -[ v0.1.0 ]: https://github.com/Malomalsky/go-simplex/releases/tag/v0.1.0 +[v0.2.1]: https://github.com/Malomalsky/go-simplex/compare/v0.2.0...v0.2.1 +[v0.2.0]: https://github.com/Malomalsky/go-simplex/compare/v0.1.0...v0.2.0 +[v0.1.0]: https://github.com/Malomalsky/go-simplex/releases/tag/v0.1.0 +[Unreleased]: https://github.com/Malomalsky/go-simplex/compare/v0.2.1...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a04383..19e5d74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,8 +60,24 @@ For local reproducible runs, use: ./scripts/integration-local.sh ``` +Example with custom options: + +```bash +./scripts/integration-local.sh --no-start --port 5225 --timeout 90 -- -run TestLiveContracts +``` + Additional fixture variables are documented in `integration/README.md`. +## Release process + +Releases are done from merged PR state: + +1. Add/update changelog section in `CHANGELOG.md` (`## [vX.Y.Z] - YYYY-MM-DD`). +2. Merge PR to `main`. +3. Run GitHub Actions workflow `Release` with input `version=vX.Y.Z`. + +The workflow validates the tag, extracts notes from `CHANGELOG.md`, creates/pushes the tag, and publishes GitHub Release. + ## Pull requests - keep PRs focused (one concern per PR) diff --git a/README.md b/README.md index a869e77..b649555 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ More details: `docs/security.md`. - Bot development guide: `docs/bot-development.md` - Compatibility and coverage: `docs/compatibility.md` - Security guide: `docs/security.md` +- Production deployment guide: `docs/production.md` - Vulnerability reporting: `SECURITY.md` - Contribution guide: `CONTRIBUTING.md` - Upstream API research notes: `docs/research/upstream-api.md` @@ -179,8 +180,35 @@ Local harness (auto-start `simplex-chat`, run integration tests, cleanup): ./scripts/integration-local.sh ``` +Harness options: + +- `--no-start` to run against existing endpoint +- `--port ` to override local websocket port +- `--timeout ` to adjust readiness wait +- `--verbose` for extra diagnostics + +Pass extra `go test` args after `--`, for example: + +```bash +./scripts/integration-local.sh --no-start -- -run TestLiveContracts +``` + Optional vulnerability scan: ```bash go run golang.org/x/vuln/cmd/govulncheck@latest ./... ``` + +## Release process + +Release from merged PR state: + +1. add a version section to `CHANGELOG.md` (`## [vX.Y.Z] - YYYY-MM-DD`) +2. merge to `main` +3. run GitHub Actions workflow `Release` with input `version=vX.Y.Z` + +Release notes are extracted from changelog via: + +```bash +./scripts/release-notes.sh vX.Y.Z +``` diff --git a/docs/README.md b/docs/README.md index ace6ced..fcc8539 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ - Bot development guide: `bot-development.md` - Compatibility and coverage: `compatibility.md` - Security and data safety: `security.md` +- Production deployment guide: `production.md` - Scaffold templates: `basic`, `moderation` via `cmd/simplexbot-init` - Runnable examples index: `../examples/README.md` - Live contract tests: `../integration/README.md` diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 0000000..683b7b1 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,136 @@ +# Production Deployment Guide + +This guide describes a practical baseline for running `go-simplex` bots in production with strong data-safety defaults. + +Official references: + +- SimpleX bot overview: https://github.com/simplex-chat/simplex-chat/tree/stable/bots +- Bot API commands: https://github.com/simplex-chat/simplex-chat/blob/stable/bots/api/COMMANDS.md +- Bot API events: https://github.com/simplex-chat/simplex-chat/blob/stable/bots/api/EVENTS.md + +## 1) Deployment model + +Recommended topology: + +- `simplex-chat` process exposing websocket API +- one or more Go bot processes using `go-simplex` client/runtime +- process supervisor (`systemd`) for restart and controlled rollout + +Keep bot runtime and SimpleX state on the same trusted host when possible. + +## 2) Service account and file permissions + +- create dedicated non-login user for services (for example `simplexbot`) +- ensure SimpleX state directory and bot config directories are owned by this user +- set strict permissions: + - directories: `0700` + - secrets/env files: `0600` +- avoid running either process as `root` + +## 3) Network and TLS + +- default to loopback binding for websocket (`ws://127.0.0.1:`) when bot runs on same host +- if remote access is required, terminate TLS at reverse proxy and expose only `wss://` +- restrict ingress by source IP/network; do not expose raw local websocket publicly + +In bot code, enforce transport hardening: + +- `ws.WithRequireWSS(true)` for remote links +- `ws.WithTLSMinVersion(...)` +- `ws.WithReadLimit(...)` + +## 4) Secrets and sensitive data + +- pass credentials via environment or secret manager, not command-line flags +- do not commit `.env`, private keys, websocket credentials, or fixture IDs +- avoid logging message payloads and raw command bodies in production +- rotate credentials and tokens on schedule and after incidents + +## 5) Runtime hardening in SDK + +Use these controls by default for production bots: + +- strict outbound raw-command policy: + - `client.WithRawCommandAllowPrefixes(...)` + - `client.WithRawCommandValidator(...)` + - `client.WithRawCommandMaxBytes(...)` +- bounded channels and backpressure: + - `client.WithEventOverflowPolicy(...)` + - `client.WithErrorOverflowPolicy(...)` + - `client.WithDropHandler(...)` +- forward compatibility: + - `client.WithStrictResponses(false)` during upstream migrations +- abuse mitigation: + - `router.EnablePerContactRateLimit(...)` + +## 6) Observability and incident response + +- collect process logs via `journald`/central log pipeline +- add restart alerts (unexpected exits, restart loops) +- track bot-level metrics: + - reconnect count + - command latency + - dropped events/errors +- keep `SECURITY.md` process visible for coordinated disclosure + +## 7) Backup and restore + +- backup SimpleX state directory and bot configuration daily (encrypted at rest) +- verify restore in a non-production environment on a schedule +- document RPO/RTO and operational owner +- during restore drills, validate: + - websocket bootstrap + - command send path + - event handling path + +## 8) Example systemd units + +`simplex-chat.service`: + +```ini +[Unit] +Description=SimpleX Chat API +After=network.target + +[Service] +User=simplexbot +Group=simplexbot +WorkingDirectory=/opt/go-simplex +ExecStart=/usr/local/bin/simplex-chat -p 5225 +Restart=always +RestartSec=2 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +`go-simplex-bot.service`: + +```ini +[Unit] +Description=Go SimpleX Bot +After=network.target simplex-chat.service +Requires=simplex-chat.service + +[Service] +User=simplexbot +Group=simplexbot +WorkingDirectory=/opt/go-simplex-bot +Environment="SIMPLEX_WS_URL=ws://127.0.0.1:5225" +ExecStart=/opt/go-simplex-bot/my-bot +Restart=always +RestartSec=2 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target +``` + +Adjust hardening options if your runtime needs additional filesystem access. diff --git a/integration/README.md b/integration/README.md index f6a7b88..73422bc 100644 --- a/integration/README.md +++ b/integration/README.md @@ -28,6 +28,19 @@ Use existing websocket instead of starting local process: SIMPLEX_WS_URL=ws://localhost:5225 ./scripts/integration-local.sh ``` +Or with explicit flags: + +```bash +./scripts/integration-local.sh --no-start --port 5225 --timeout 90 -- -run TestLiveContracts +``` + +Useful harness options: + +- `--no-start` - do not launch `simplex-chat`; run against existing endpoint +- `--port ` - local websocket port for auto-start and default endpoint +- `--timeout ` - readiness wait budget when auto-starting +- `--verbose` - extra diagnostics for readiness/wait steps + ## Optional fixture env vars for extended flows Set these to enable additional contract tests: diff --git a/scripts/integration-local.sh b/scripts/integration-local.sh index 8df8738..2be4921 100755 --- a/scripts/integration-local.sh +++ b/scripts/integration-local.sh @@ -4,9 +4,104 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" +NO_START=0 +VERBOSE=0 +WAIT_TIMEOUT="${SIMPLEX_WAIT_TIMEOUT:-60}" +SIMPLEX_BIN="${SIMPLEX_BIN:-simplex-chat}" +SIMPLEX_WS_PORT="${SIMPLEX_WS_PORT:-5225}" WS_URL="${SIMPLEX_WS_URL:-}" STARTED_PID="" LOG_FILE="" +TEST_ARGS=() + +usage() { + cat <<'EOF' +Usage: ./scripts/integration-local.sh [options] [-- go_test_args...] + +Options: + --no-start do not launch simplex-chat; use existing SIMPLEX_WS_URL or ws://localhost: + --port websocket port for local simplex-chat start/default URL (default: 5225) + --timeout websocket readiness timeout in seconds when auto-starting simplex-chat (default: 60) + --verbose print extra diagnostics while waiting/running tests + -h, --help show this help + +Environment: + SIMPLEX_WS_URL full websocket URL (if set, script does not auto-start simplex-chat) + SIMPLEX_BIN simplex binary name/path for auto-start (default: simplex-chat) + SIMPLEX_LOG_FILE log path for auto-started simplex process +EOF +} + +log() { + echo "[integration] $*" +} + +debug() { + if [[ "$VERBOSE" -eq 1 ]]; then + log "$*" + fi +} + +is_uint() { + [[ "$1" =~ ^[0-9]+$ ]] +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --no-start) + NO_START=1 + shift + ;; + --port) + if [[ $# -lt 2 ]]; then + echo "[integration] error: --port expects value" >&2 + exit 1 + fi + SIMPLEX_WS_PORT="$2" + shift 2 + ;; + --timeout) + if [[ $# -lt 2 ]]; then + echo "[integration] error: --timeout expects value" >&2 + exit 1 + fi + WAIT_TIMEOUT="$2" + shift 2 + ;; + --verbose) + VERBOSE=1 + shift + ;; + --) + shift + TEST_ARGS+=("$@") + break + ;; + *) + TEST_ARGS+=("$1") + shift + ;; + esac +done + +if ! is_uint "$SIMPLEX_WS_PORT"; then + echo "[integration] error: --port must be a non-negative integer (got '$SIMPLEX_WS_PORT')" >&2 + exit 1 +fi +if ! is_uint "$WAIT_TIMEOUT"; then + echo "[integration] error: --timeout must be a non-negative integer (got '$WAIT_TIMEOUT')" >&2 + exit 1 +fi + +if [[ -z "$WS_URL" ]]; then + WS_URL="ws://localhost:${SIMPLEX_WS_PORT}" +fi + +LOG_FILE="${SIMPLEX_LOG_FILE:-/tmp/go-simplex-integration-${SIMPLEX_WS_PORT}.log}" cleanup() { if [[ -n "$STARTED_PID" ]]; then @@ -18,29 +113,26 @@ cleanup() { } trap cleanup EXIT INT TERM -if [[ -z "$WS_URL" ]]; then - SIMPLEX_BIN="${SIMPLEX_BIN:-simplex-chat}" - SIMPLEX_WS_PORT="${SIMPLEX_WS_PORT:-5225}" - WS_URL="ws://localhost:${SIMPLEX_WS_PORT}" - LOG_FILE="${SIMPLEX_LOG_FILE:-/tmp/go-simplex-integration-${SIMPLEX_WS_PORT}.log}" - +if [[ "$NO_START" -eq 0 && -z "${SIMPLEX_WS_URL:-}" ]]; then if ! command -v "$SIMPLEX_BIN" >/dev/null 2>&1; then echo "[integration] error: '$SIMPLEX_BIN' not found" >&2 echo "[integration] install SimpleX CLI or set SIMPLEX_BIN/SIMPLEX_WS_URL" >&2 exit 1 fi - echo "[integration] starting $SIMPLEX_BIN -p $SIMPLEX_WS_PORT" + log "starting $SIMPLEX_BIN -p $SIMPLEX_WS_PORT" "$SIMPLEX_BIN" -p "$SIMPLEX_WS_PORT" >"$LOG_FILE" 2>&1 & STARTED_PID="$!" + debug "simplex log file: $LOG_FILE" - echo "[integration] waiting for websocket at $WS_URL" + log "waiting for websocket at $WS_URL (timeout ${WAIT_TIMEOUT}s)" ready=0 - for _ in $(seq 1 60); do + for attempt in $(seq 1 "$WAIT_TIMEOUT"); do if go run ./cmd/simplex-smoke --ws "$WS_URL" >/dev/null 2>&1; then ready=1 break fi + debug "websocket not ready yet (${attempt}/${WAIT_TIMEOUT})" sleep 1 done @@ -52,9 +144,11 @@ if [[ -z "$WS_URL" ]]; then fi exit 1 fi +elif [[ "$NO_START" -eq 1 ]]; then + log "--no-start enabled; using existing websocket at $WS_URL" fi export SIMPLEX_WS_URL="$WS_URL" -echo "[integration] running tests against $SIMPLEX_WS_URL" -go test -tags=integration ./integration/... -v "$@" +log "running tests against $SIMPLEX_WS_URL" +go test -tags=integration ./integration/... -v "${TEST_ARGS[@]}" diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..b5c1a4d --- /dev/null +++ b/scripts/release-notes.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/release-notes.sh [changelog_path] + +Extracts the changelog section body for a version heading: + ## [] - YYYY-MM-DD +EOF +} + +if [[ $# -lt 1 || $# -gt 2 ]]; then + usage + exit 1 +fi + +VERSION="$1" +CHANGELOG_PATH="${2:-CHANGELOG.md}" + +if [[ ! -f "$CHANGELOG_PATH" ]]; then + echo "error: changelog file not found: $CHANGELOG_PATH" >&2 + exit 1 +fi + +set +e +NOTES="$( + awk -v version="$VERSION" ' +BEGIN { + target = "^## \\[" version "\\]([[:space:]]|$)" + in_section = 0 + found = 0 +} +$0 ~ /^## \[/ { + if (in_section) { + exit + } + if ($0 ~ target) { + in_section = 1 + found = 1 + next + } +} +in_section { + print +} +END { + if (!found) { + exit 2 + } +} +' "$CHANGELOG_PATH" +)" +AWK_STATUS=$? +set -e + +if [[ "$AWK_STATUS" -ne 0 ]]; then + if [[ "$AWK_STATUS" -eq 2 ]]; then + echo "error: version section not found in $CHANGELOG_PATH: $VERSION" >&2 + exit 1 + fi + echo "error: failed to extract release notes from $CHANGELOG_PATH" >&2 + exit 1 +fi + +if [[ -z "${NOTES//[[:space:]]/}" ]]; then + echo "No changelog notes provided for $VERSION." + exit 0 +fi + +echo "$NOTES"