diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bf5fa10 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,129 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.artifact }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + artifact: workmem-darwin-amd64 + archive_ext: tar.gz + - goos: darwin + goarch: arm64 + artifact: workmem-darwin-arm64 + archive_ext: tar.gz + - goos: linux + goarch: amd64 + artifact: workmem-linux-amd64 + archive_ext: tar.gz + - goos: linux + goarch: arm64 + artifact: workmem-linux-arm64 + archive_ext: tar.gz + - goos: windows + goarch: amd64 + artifact: workmem-windows-amd64 + binary_ext: .exe + archive_ext: zip + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build binary with version ldflags + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + set -euo pipefail + BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) + SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) + LDFLAGS="-s -w -X main.version=${GITHUB_REF_NAME} -X main.commit=${SHORT_SHA} -X main.buildDate=${BUILD_DATE}" + mkdir -p dist + go build -ldflags "${LDFLAGS}" -o "dist/workmem${{ matrix.binary_ext }}" ./cmd/workmem + - name: Package archive + id: package + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + # The top-level directory inside the archive includes the version + # suffix so `tar -xzf ...` produces `workmem--/`, + # matching the extraction path documented in README Install. + PACKAGE_DIR="${{ matrix.artifact }}-${REF_NAME}" + STAGING_DIR="staging/${PACKAGE_DIR}" + mkdir -p "$STAGING_DIR" + cp "dist/workmem${{ matrix.binary_ext }}" "$STAGING_DIR/" + cp LICENSE README.md "$STAGING_DIR/" + ARCHIVE="${PACKAGE_DIR}.${{ matrix.archive_ext }}" + if [ "${{ matrix.archive_ext }}" = "zip" ]; then + (cd staging && zip -r "../$ARCHIVE" "$PACKAGE_DIR") + else + tar -C staging -czf "$ARCHIVE" "$PACKAGE_DIR" + fi + echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }}-archive + path: ${{ steps.package.outputs.archive }} + if-no-files-found: error + + release: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts + pattern: "*-archive" + merge-multiple: true + - name: Generate SHA256SUMS + run: | + set -euo pipefail + cd artifacts + sha256sum * > SHA256SUMS + cat SHA256SUMS + - name: Detect pre-release + id: prerelease + env: + REF_NAME: ${{ github.ref_name }} + run: | + # Tags ending in -rc, -alpha, -beta, -pre, or -dev, optionally + # followed by digits (for example v0.1.0-rc1), are published as + # pre-releases. The $ anchor prevents false positives like + # "v1.0.0-preview" containing "-pre" in the middle of the tag. + if [[ "${REF_NAME}" =~ -(rc|alpha|beta|pre|dev)[0-9]*$ ]]; then + echo "flag=--prerelease" >> "$GITHUB_OUTPUT" + else + echo "flag=" >> "$GITHUB_OUTPUT" + fi + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REF_NAME: ${{ github.ref_name }} + PRERELEASE_FLAG: ${{ steps.prerelease.outputs.flag }} + run: | + set -euo pipefail + gh release create "${REF_NAME}" \ + --repo "${{ github.repository }}" \ + --title "${REF_NAME}" \ + --generate-notes \ + ${PRERELEASE_FLAG} \ + artifacts/* diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 8a634da..c0dc6f7 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -89,12 +89,12 @@ Brief description. **Gate:** the Go binary exposes the parity tool surface over ### Step 3.3: Release pipeline [🔧] -Brief description. **Gate:** release artifacts exist for major OS targets and install flow is simpler than the Node baseline. +Ship workmem as a single binary users can install without `go build`. **Gate:** tagging `vX.Y.Z` on main produces a GitHub release with cross-platform archives + SHA256SUMS, a Homebrew tap formula resolves to those archives, and a fresh-machine walkthrough of each install path succeeds end-to-end. -- [x] Add CI cross-builds -- [ ] Produce release binaries -- [ ] Draft Homebrew strategy -- [ ] Validate install path with fresh-machine assumptions +- [x] Add CI cross-builds (`.github/workflows/go-ci.yml` matrix over darwin amd64+arm64, linux amd64+arm64, windows amd64 — no windows/arm64 yet) +- [x] Produce release binaries (`.github/workflows/release.yml` tag-triggered, 5 platforms, tarball+zip, SHA256SUMS, `-ldflags` inject `version`/`commit`/`buildDate` for `workmem version`) +- [ ] Draft Homebrew strategy (tap repo `marlian/homebrew-tap` with `Formula/workmem.rb` resolving to the release tarball by OS/arch, SHA256 verified) +- [ ] Validate install path with fresh-machine assumptions (`brew install`, `curl | tar | install`, `go install` all succeed on macOS+Linux starting from a clean shell) **On Step Gate (all items [x]):** trigger release readiness review. diff --git a/README.md b/README.md index 016393b..778e9af 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,58 @@ LLMs forget everything between sessions. System prompts can't hold your project' ## Install -Download the binary for your platform from [releases](https://github.com/marlian/workmem/releases), or build from source: +### Homebrew (macOS / Linux) + +```bash +brew tap marlian/tap +brew install workmem +``` + +The tap is published once the first tagged release exists; until then use one of the methods below. + +### Direct download + +Download the archive for your platform from [releases](https://github.com/marlian/workmem/releases) and extract. Each archive contains the `workmem` binary plus `LICENSE` and `README.md`: + +```bash +# pick the archive that matches your OS/arch, e.g. darwin-arm64 / linux-amd64 +VER=v0.1.0 +curl -LO "https://github.com/marlian/workmem/releases/download/${VER}/workmem-darwin-arm64-${VER}.tar.gz" +tar -xzf workmem-darwin-arm64-${VER}.tar.gz +sudo install workmem-darwin-arm64-${VER}/workmem /usr/local/bin/workmem +workmem version +``` + +For integrity, download `SHA256SUMS` from the release page and verify only the archive you actually fetched, using the checksum tool that ships with your platform: + +```bash +curl -LO "https://github.com/marlian/workmem/releases/download/${VER}/SHA256SUMS" + +# macOS +grep "workmem-darwin-arm64-${VER}" SHA256SUMS | shasum -a 256 -c + +# Linux +grep "workmem-linux-amd64-${VER}" SHA256SUMS | sha256sum -c +``` + +On macOS, Gatekeeper will warn on first launch of an unsigned binary downloaded this way. Remove the quarantine attribute with `xattr -d com.apple.quarantine /usr/local/bin/workmem`, or install via Homebrew (which does not trigger the warning). + +### Build from source ```bash git clone https://github.com/marlian/workmem.git cd workmem go build -o workmem ./cmd/workmem +./workmem version # prints "workmem dev" without -ldflags +``` + +Or let Go fetch and install directly: + +```bash +go install github.com/marlian/workmem/cmd/workmem@latest ``` -No runtime. No dependencies. One file. +Go 1.26+ is required (see `go.mod` for the exact minimum). No CGO and no external runtime dependencies — the result is a single self-contained binary. Source builds report `workmem dev` for version; tagged release binaries carry the real `vX.Y.Z` plus commit SHA and build timestamp. ## Client configuration @@ -237,4 +280,4 @@ Only the global memory DB is included. Project-scoped DBs live in their own work ## License -MIT — see [LICENSE](LICENSE) for the full text. — see [LICENSE](LICENSE) for the full text. +MIT — see [LICENSE](LICENSE) for the full text. diff --git a/cmd/workmem/main.go b/cmd/workmem/main.go index 63bc93e..d12d5a1 100644 --- a/cmd/workmem/main.go +++ b/cmd/workmem/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/signal" + "runtime" "syscall" "workmem/internal/backup" @@ -15,7 +16,24 @@ import ( "workmem/internal/telemetry" ) +// Build metadata overridden at link time via -ldflags "-X main.version=..." +// in the release workflow. Defaults keep `go build` and `go run` usable +// without extra flags and clearly flag unreleased binaries as dev builds. +var ( + version = "dev" + commit = "none" + buildDate = "unknown" +) + func main() { + if len(os.Args) >= 2 { + switch os.Args[1] { + case "version", "--version", "-v": + printVersion() + return + } + } + if len(os.Args) < 2 { runMCP(nil) return @@ -38,6 +56,13 @@ func main() { } } +func printVersion() { + fmt.Printf("workmem %s\n", version) + fmt.Printf(" commit: %s\n", commit) + fmt.Printf(" built: %s\n", buildDate) + fmt.Printf(" go: %s\n", runtime.Version()) +} + // recipientFlag collects repeatable --age-recipient arguments. type recipientFlag []string @@ -106,7 +131,7 @@ func runMCP(args []string) { // New returns successfully. If New fails, the DB was already opened by // FromEnv and must be closed here — otherwise the handle leaks. tele := telemetry.FromEnv() - runtime, err := mcpserver.New(mcpserver.Config{ + rt, err := mcpserver.New(mcpserver.Config{ DBPath: *dbPath, Telemetry: tele, }) @@ -119,7 +144,7 @@ func runMCP(args []string) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - if err := runtime.RunStdio(ctx); err != nil { + if err := rt.RunStdio(ctx); err != nil { fmt.Fprintf(os.Stderr, "mcp server failed: %v\n", err) os.Exit(1) } @@ -169,7 +194,8 @@ func printUsage() { fmt.Fprintf(os.Stderr, "commands:\n") fmt.Fprintf(os.Stderr, " serve run the MCP server over stdio (default)\n") fmt.Fprintf(os.Stderr, " sqlite-canary prove schema init, FTS insert/match/delete, and persistence\n") - fmt.Fprintf(os.Stderr, " backup write an age-encrypted snapshot of memory.db\n\n") + fmt.Fprintf(os.Stderr, " backup write an age-encrypted snapshot of memory.db\n") + fmt.Fprintf(os.Stderr, " version print build metadata (also: --version / -v)\n\n") fmt.Fprintf(os.Stderr, "flags (serve, sqlite-canary, backup):\n") fmt.Fprintf(os.Stderr, " -db path to the SQLite database file\n") fmt.Fprintf(os.Stderr, " -env-file load variables from a .env file (process env takes precedence)\n\n")