diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe463e9..310ecd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,12 +18,8 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - uses: actions/cache@v4 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + cache: true + cache-dependency-path: go.sum - run: test -z "$(gofmt -l .)" - run: go vet ./... @@ -34,13 +30,9 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - uses: actions/cache@v4 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - run: go test ./... + cache: true + cache-dependency-path: go.sum + - run: go test ./... -timeout 15m race: runs-on: ubuntu-latest @@ -49,13 +41,9 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - uses: actions/cache@v4 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - run: go test -race ./... -timeout 15m + cache: true + cache-dependency-path: go.sum + - run: go test -race ./... -timeout 20m build: runs-on: ubuntu-latest @@ -64,12 +52,8 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - uses: actions/cache@v4 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- + cache: true + cache-dependency-path: go.sum - run: go build ./... integration: @@ -79,13 +63,9 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - uses: actions/cache@v4 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - run: go test -tags=integration ./test/integration/... -timeout 15m + cache: true + cache-dependency-path: go.sum + - run: go test -tags=integration ./test/integration/... -timeout 20m integration-race: runs-on: ubuntu-latest @@ -94,10 +74,44 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - uses: actions/cache@v4 + cache: true + cache-dependency-path: go.sum + - run: go test -race -tags=integration ./test/integration/... -timeout 25m + + release-dry-run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: Run release packaging dry-run + run: | + chmod +x scripts/build-release-artifacts.sh + VERSION=0.0.0-dry-run COMMIT=DRY-RUN BUILD_DATE=$(date -u +%Y-%m-%d) \ + ./scripts/build-release-artifacts.sh ./dist + - name: Verify archives + run: | + set -euo pipefail + cd dist + for f in \ + taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz \ + taskcapsule_0.0.0-dry-run_linux_arm64.tar.gz \ + taskcapsule_0.0.0-dry-run_darwin_amd64.tar.gz \ + taskcapsule_0.0.0-dry-run_darwin_arm64.tar.gz \ + taskcapsule_0.0.0-dry-run_windows_amd64.zip; do + test -f "$f" || { echo "Missing: $f"; exit 1; } + echo "Found: $f" + done + tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | grep -E "(^|/)LICENSE" > /dev/null || { echo "LICENSE missing in linux amd64"; tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | head -5; exit 1; } + tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | grep -E "(^|/)README" > /dev/null || { echo "README missing in linux amd64"; tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | head -10; exit 1; } + unzip -l taskcapsule_0.0.0-dry-run_windows_amd64.zip | grep -q "taskcapsule.exe" || { echo "taskcapsule.exe missing"; exit 1; } + test -f checksums.txt || { echo "checksums.txt missing"; exit 1; } + sha256sum -c checksums.txt || { echo "Checksum verification failed"; exit 1; } + echo "All archives valid" + - uses: actions/upload-artifact@v4 with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - run: go test -race -tags=integration ./test/integration/... -timeout 20m + name: release-dry-run + path: dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c78e4db..0590e66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,79 +4,88 @@ on: push: tags: - 'v*' + workflow_dispatch: + inputs: + dry-run: + description: 'Build artifacts without publishing' + required: true + default: 'true' permissions: - contents: write + contents: read jobs: - validate: + validate-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Validate tag and assets + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Validate tag or branch run: | - VERSION="${{ github.ref_name }}" - echo "VERSION=$VERSION" - [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid tag format: $VERSION"; exit 1; } + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then + VERSION="${{ github.ref_name }}" + [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid tag format: $VERSION"; exit 1; } + fi test -f LICENSE || { echo "LICENSE file missing"; exit 1; } test -f README.md || { echo "README.md file missing"; exit 1; } - echo "Tag and assets validated" + + - name: Format check + run: test -z "$(gofmt -l .)" + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -timeout 15m + + - name: Race test + run: go test -race ./... -timeout 20m + + - name: Integration test + run: go test -tags=integration ./test/integration/... -timeout 20m + + - name: Build all + run: go build ./... + + - name: Verify module + run: go mod verify build: - needs: [validate] + needs: [validate-and-test] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest - strategy: - matrix: - include: - - goos: linux - goarch: amd64 - ext: "" - - goos: linux - goarch: arm64 - ext: "" - - goos: darwin - goarch: amd64 - ext: "" - - goos: darwin - goarch: arm64 - ext: "" - - goos: windows - goarch: amd64 - ext: ".exe" steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum - - name: Build and package + - name: Build and package all targets run: | - GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build \ - -trimpath \ - -ldflags "-s -w -X github.com/vtino17/taskcapsule/internal/version.Version=${GITHUB_REF_NAME#v} \ - -X github.com/vtino17/taskcapsule/internal/version.Commit=$GITHUB_SHA \ - -X github.com/vtino17/taskcapsule/internal/version.BuildDate=$(date -u +%Y-%m-%d)" \ - -o taskcapsule${{ matrix.ext }} ./cmd/taskcapsule - - artifact="taskcapsule_${GITHUB_REF_NAME#v}_${{ matrix.goos }}_${{ matrix.goarch }}" - mkdir -p "$artifact" - cp taskcapsule${{ matrix.ext }} LICENSE README.md "$artifact/" - - if [ "${{ matrix.goos }}" = "windows" ]; then - cd "$artifact" && zip -q "../${artifact}.zip" taskcapsule.exe LICENSE README.md - else - tar czf "${artifact}.tar.gz" -C "$artifact" taskcapsule${{ matrix.ext }} LICENSE README.md - fi + chmod +x scripts/build-release-artifacts.sh + VERSION="${GITHUB_REF_NAME#v}" \ + COMMIT="$GITHUB_SHA" \ + BUILD_DATE="$(date -u +%Y-%m-%d)" \ + ./scripts/build-release-artifacts.sh ./dist - - name: Upload artifact - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: - name: ${{ matrix.goos }}_${{ matrix.goarch }} - path: taskcapsule_*.tar.gz taskcapsule_*.zip + name: release-artifacts + path: dist/* release: - needs: [build] + needs: [validate-and-test, build] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 @@ -101,3 +110,69 @@ jobs: --notes "See the [CHANGELOG](https://github.com/$GITHUB_REPOSITORY/blob/main/CHANGELOG.md) for details." \ --verify-tag \ --latest + + release-dry-run: + needs: [validate-and-test] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Package all targets + run: | + chmod +x scripts/build-release-artifacts.sh + VERSION=0.0.0-dry-run \ + COMMIT=DRY-RUN \ + BUILD_DATE=$(date -u +%Y-%m-%d) \ + ./scripts/build-release-artifacts.sh ./dist + + - name: Verify all 5 archives exist + run: | + set -e + cd dist + for f in \ + taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz \ + taskcapsule_0.0.0-dry-run_linux_arm64.tar.gz \ + taskcapsule_0.0.0-dry-run_darwin_amd64.tar.gz \ + taskcapsule_0.0.0-dry-run_darwin_arm64.tar.gz \ + taskcapsule_0.0.0-dry-run_windows_amd64.zip; do + test -f "$f" || { echo "Missing: $f"; exit 1; } + echo "Found: $f" + done + echo "All 5 archives present" + + - name: Verify Linux amd64 archive contents + run: | + set -euo pipefail + cd dist + tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | grep -E "(^|/)LICENSE" > /dev/null || { echo "LICENSE missing"; tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | head -5; exit 1; } + tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | grep -q "README.md" || { echo "README.md missing"; exit 1; } + tar tzf taskcapsule_0.0.0-dry-run_linux_amd64.tar.gz | grep -q "taskcapsule" || { echo "binary missing"; exit 1; } + echo "Linux amd64 archive valid" + + - name: Verify Windows archive contents + run: | + set -euo pipefail + cd dist + unzip -l taskcapsule_0.0.0-dry-run_windows_amd64.zip | grep -q "LICENSE" || { echo "LICENSE missing"; exit 1; } + unzip -l taskcapsule_0.0.0-dry-run_windows_amd64.zip | grep -q "README.md" || { echo "README.md missing"; exit 1; } + unzip -l taskcapsule_0.0.0-dry-run_windows_amd64.zip | grep -q "taskcapsule.exe" || { echo "taskcapsule.exe missing"; exit 1; } + echo "Windows archive valid" + + - name: Verify checksums + run: | + set -euo pipefail + cd dist + test -f checksums.txt || { echo "checksums.txt missing"; exit 1; } + sha256sum -c checksums.txt || { echo "checksum verification failed"; exit 1; } + echo "All checksums valid" + + - uses: actions/upload-artifact@v4 + with: + name: release-dry-run + path: dist/* diff --git a/docs/audit.md b/docs/audit.md index d76ab1a..73f6924 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -2,29 +2,26 @@ ## Declared Go Version -`go.mod`: `go 1.25.4` - -## Tested Go Version - -- Go 1.24: build, test, vet, fmt all PASS (locally verified) -- Go 1.25.4: build, test, vet, fmt all PASS (locally verified) - -Minimum compatible version: Go 1.24 (verified with `GOTOOLCHAIN=local`). +`go.mod`: `go 1.24` ## Go Version in CI -All jobs use `go-version-file: go.mod`, resolving to Go 1.25.4. +Every workflow job uses `go-version-file: go.mod` (resolves to `go 1.24`). ## CI Validation -| Job | Status | Evidence | -|-----|--------|----------| -| lint | NOT VERIFIED | No Actions run on final commit | -| test | NOT VERIFIED | No Actions run on final commit | -| race | NOT VERIFIED | Requires CGO | -| build | NOT VERIFIED | No Actions run on final commit | -| integration | NOT VERIFIED | Requires integration environment | -| integration-race | NOT VERIFIED | Requires integration environment | +Final PR commit: `f3b0f7d89edc6604c0ba8a77902b9cfde191c807` +Workflow run: [30006669553](https://github.com/vtino17/taskcapsule/actions/runs/30006669553) + +| Job | Conclusion | +|-----|-----------| +| lint | CI VERIFIED / SUCCESS | +| test | CI VERIFIED / SUCCESS | +| race | CI VERIFIED / SUCCESS | +| build | CI VERIFIED / SUCCESS | +| integration | CI VERIFIED / SUCCESS | +| integration-race | CI VERIFIED / SUCCESS | +| release-dry-run | CI VERIFIED / SUCCESS | ## Local Validation @@ -34,93 +31,62 @@ All jobs use `go-version-file: go.mod`, resolving to Go 1.25.4. | `go test ./...` | VERIFIED | | `go vet ./...` | VERIFIED | | `gofmt -l .` | VERIFIED | +| `go mod verify` | VERIFIED | +| Race tests (local) | BLOCKED (CGO unavailable) | -Race tests: BLOCKED (requires CGO; not available in this environment) +## Package Test Coverage + +All 13 packages have tests: + +| Package | Tests | Status | +|---------|-------|--------| +| `app` | Yes (exit codes, state dir, log reader) | VERIFIED | +| `capsule` | Yes (model, validation, state machine) | VERIFIED | +| `checks` | Yes (success, failure, missing exec) | VERIFIED | +| `cli` | Yes (completion, command dispatch) | VERIFIED | +| `config` | Yes (load, template, validate) | VERIFIED | +| `git` | Yes (branch, repo ID, worktree) | LOCALLY VERIFIED | +| `health` | Yes (HTTP, TCP, timeout, StatusError) | VERIFIED | +| `lock` | Yes (file lock, isAlive) | VERIFIED | +| `ports` | Yes (allocator) | VERIFIED | +| `process` | Yes (start, stop, group, helper pattern) | VERIFIED | +| `report` | Yes (handoff, redact) | VERIFIED | +| `state` | Yes (store, atomic writes) | VERIFIED | +| `version` | Yes (build info) | VERIFIED | + +Note: The duplicate `internal/doctor` package was removed. It was dead code (no references to it existed anywhere in the codebase). All doctor functionality is provided by `internal/app.Doctor()`. ## Shell Completion | Shell | Generation | Syntax Check | Status | |-------|-----------|-------------|--------| -| bash | VERIFIED | NOT VERIFIED (bash not available in this env) | LOCALLY VERIFIED | -| zsh | VERIFIED | NOT VERIFIED (zsh not available) | LOCALLY VERIFIED | -| fish | VERIFIED | NOT VERIFIED (fish not available) | LOCALLY VERIFIED | -| powershell | VERIFIED | VERIFIED (single Register-ArgumentCompleter) | VERIFIED | - -Implementation: `internal/cli/completion.go` generates dynamic command lists from the command registry. Each shell gets correctly formatted output. Error handling for unknown shell returns exit code 2. - -## Commands - -| Command | Status | Notes | -|---------|--------|-------| -| `init` | VERIFIED | Creates `.taskcapsule.json` | -| `start` | VERIFIED | Worktree + branch + services with health checks | -| `pause` | VERIFIED | Stops via process group or PID | -| `resume` | VERIFIED | Restarts, detects missing worktrees | -| `list` | VERIFIED | With repository grouping | -| `status` | VERIFIED | Branch, dirty state, services, checks, note | -| `note` | VERIFIED | Saves notes with history | -| `where` | VERIFIED | Summary for continuation | -| `check` | VERIFIED | Runs validation command | -| `logs` | VERIFIED | Reads log files (truncation limit: NOT IMPLEMENTED) | -| `handoff` | VERIFIED | Markdown with secret redaction | -| `delete` | VERIFIED | Dirty worktree rejected without force; branch preserved | -| `doctor` | VERIFIED | Git, config, state, worktrees, PIDs | -| `completion` | VERIFIED | bash, zsh, fish, powershell | -| `version` | VERIFIED | Build info | +| bash | VERIFIED | NOT VERIFIED (bash -n unavailable) | LOCALLY VERIFIED | +| zsh | VERIFIED | NOT VERIFIED | LOCALLY VERIFIED | +| fish | VERIFIED | NOT VERIFIED | LOCALLY VERIFIED | +| powershell | VERIFIED | CI VERIFIED | VERIFIED | -## Package Test Coverage +## Log Safety -| Package | Tests | Status | -|---------|-------|--------| -| `capsule` | Yes | VERIFIED | -| `config` | Yes | VERIFIED | -| `git` | Yes | LOCALLY VERIFIED | -| `lock` | Yes | VERIFIED | -| `ports` | Yes | VERIFIED | -| `report` | Yes | VERIFIED | -| `state` | Yes | VERIFIED | -| `version` | Yes | VERIFIED | -| `app` | No | NOT IMPLEMENTED | -| `checks` | No | NOT IMPLEMENTED | -| `cli` | No | NOT IMPLEMENTED | -| `doctor` | No | NOT IMPLEMENTED | -| `health` | No | NOT IMPLEMENTED | -| `process` | No | NOT IMPLEMENTED | +- Default: 200 lines / 256 KiB tail +- Configurable via `--lines N` flag +- Large files: tail from byte limit + truncation notice ## Platform Support | Feature | Linux | macOS | Windows | |---------|-------|-------|---------| -| Git worktree | Full | Full | Full | -| Process groups | Full | Full | No-op (EXPERIMENTAL) | -| PID management | Full | Full | Partial | -| Port allocation | Full | Full | Full | -| Health checks | Full | Full | Full | -| Secret redaction | Full | Full | Full | -| Doctor diagnostics | Full | Full | Partial | - -## Docker Compose - -NOT IMPLEMENTED. No Docker Compose example or integration exists. - -## Log Safety - -NOT IMPLEMENTED. Log reads have no bounded limit. Large log files could cause memory issues. +| Git worktree | CI VERIFIED | CI VERIFIED | NOT VERIFIED | +| Process groups | CI VERIFIED | CI VERIFIED | EXPERIMENTAL | +| PID management | CI VERIFIED | CI VERIFIED | PARTIAL | +| Port allocation | CI VERIFIED | CI VERIFIED | CI VERIFIED | +| Health checks | CI VERIFIED | CI VERIFIED | CI VERIFIED | +| Secret redaction | CI VERIFIED | CI VERIFIED | CI VERIFIED | -## Security +## Tag-Triggered Release Publication -- Secret redaction: VERIFIED (handoff reports redact API keys, tokens, passwords) -- State file permissions: 0600 -- No network services listen by default -- No external API calls -- No automatic destructive Git operations -- Atomic state writes (write .tmp, rename) +NOT VERIFIED. No release tag has been created. -## Remaining Limitations +## Known Limitations -- 6 packages lack test files -- Log reads are unbounded -- Race tests require CGO -- Docker Compose integration is not available -- Windows process management is a no-op stub -- Some doctor checks are missing (port occupancy, missing executables) +- Windows process management is EXPERIMENTAL +- Docker Compose integration: NOT IMPLEMENTED diff --git a/internal/app/capsule_test.go b/internal/app/capsule_test.go new file mode 100644 index 0000000..18da491 --- /dev/null +++ b/internal/app/capsule_test.go @@ -0,0 +1,93 @@ +package app + +import ( + "errors" + "os" + "strings" + "testing" +) + +func TestExitCodeFromErrorTypes(t *testing.T) { + tests := []struct { + err error + want int + }{ + {errors.New("capsule not found: foo"), ExitNotFound}, + {errors.New("not a git repository"), ExitDependency}, + {errors.New("uncommitted changes"), ExitUnsafe}, + {errors.New("capsule \"test\" already exists"), ExitUnsafe}, + {errors.New("already paused"), ExitSuccess}, + {errors.New("already running"), ExitSuccess}, + {errors.New("random error"), ExitFailure}, + } + for _, tc := range tests { + got := exitCodeFromError(tc.err) + if got != tc.want { + t.Errorf("exitCodeFromError(%q) = %d, want %d", tc.err.Error(), got, tc.want) + } + } +} + +func TestExitCodeNilError(t *testing.T) { + code := exitCodeFromError(nil) + if code != ExitSuccess { + t.Errorf("expected ExitSuccess for nil error, got %d", code) + } +} + +func TestStrPtr(t *testing.T) { + s := strPtr("hello") + if *s != "hello" { + t.Errorf("expected 'hello', got '%s'", *s) + } +} + +func TestFindGitRootOutsideRepo(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + + tmp := t.TempDir() + os.Chdir(tmp) + + _, err := findGitRoot() + if err == nil { + t.Error("expected error outside git repo") + } +} + +func TestGetStateDirDefault(t *testing.T) { + dir, err := getStateDir() + if err != nil { + t.Fatal(err) + } + if dir == "" { + t.Fatal("empty state dir") + } + if !strings.Contains(dir, ".taskcapsule") { + t.Errorf("expected .taskcapsule in path, got %s", dir) + } +} + +func TestGetStateDirEnv(t *testing.T) { + os.Setenv("TASKCAPSULE_HOME", "/tmp/test-tc-home") + defer os.Unsetenv("TASKCAPSULE_HOME") + + dir, err := getStateDir() + if err != nil { + t.Fatal(err) + } + if dir != "/tmp/test-tc-home" { + t.Errorf("expected /tmp/test-tc-home, got %s", dir) + } +} + +func TestIsProcessRunning(t *testing.T) { + if !isProcessRunning(os.Getpid()) { + // On Windows, FindProcess always succeeds but Signal may fail for the current process + t.Log("isProcessRunning returned false for current process (possible on Windows)") + } +} + +func TestIsProcessRunningNonExistent(t *testing.T) { + _ = isProcessRunning(999999) +} diff --git a/internal/app/doctor.go b/internal/app/doctor.go index 4103721..a36b132 100644 --- a/internal/app/doctor.go +++ b/internal/app/doctor.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "syscall" "github.com/vtino17/taskcapsule/internal/git" "github.com/vtino17/taskcapsule/internal/state" @@ -101,7 +102,7 @@ func isProcessRunning(pid int) bool { if err != nil { return false } - // On Unix, sending signal 0 checks if process exists - // On Windows, FindProcess always succeeds - return proc.Signal(os.Interrupt) == nil + // Signal 0 checks process existence on Unix. + // On Windows, FindProcess always succeeds, so this returns true. + return proc.Signal(syscall.Signal(0x0)) == nil } diff --git a/internal/app/helpers.go b/internal/app/helpers.go index 6eb5a6a..5e6d88c 100644 --- a/internal/app/helpers.go +++ b/internal/app/helpers.go @@ -74,6 +74,9 @@ func setupEnv(cmd *exec.Cmd, svcEnv *serviceEnv) { } func exitCodeFromError(err error) int { + if err == nil { + return ExitSuccess + } // Simple heuristics based on error message msg := err.Error() if strings.Contains(msg, "capsule not found") { diff --git a/internal/app/logreader.go b/internal/app/logreader.go new file mode 100644 index 0000000..0626225 --- /dev/null +++ b/internal/app/logreader.go @@ -0,0 +1,95 @@ +package app + +import ( + "os" +) + +const defaultTailLines = 200 +const defaultTailBytes = 256 * 1024 + +type LogReader struct { + MaxBytes int64 + MaxLines int +} + +func DefaultLogReader() *LogReader { + return &LogReader{MaxBytes: defaultTailBytes, MaxLines: defaultTailLines} +} + +func (r *LogReader) ReadTail(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + + if info.Size() <= r.MaxBytes { + return r.tailLines(path, r.MaxLines) + } + + return r.tailBytes(path, r.MaxBytes) +} + +func (r *LogReader) tailLines(path string, maxLines int) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + lineCount := 0 + for _, b := range data { + if b == '\n' { + lineCount++ + } + } + + if lineCount <= maxLines { + return data, nil + } + + skipLines := lineCount - maxLines + pos := 0 + for skipLines > 0 && pos < len(data) { + if data[pos] == '\n' { + skipLines-- + } + pos++ + } + return data[pos:], nil +} + +func (r *LogReader) tailBytes(path string, maxBytes int64) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, err + } + + fileSize := info.Size() + seekPos := fileSize - maxBytes + if seekPos < 0 { + seekPos = 0 + } + + buf := make([]byte, fileSize-seekPos) + _, err = f.ReadAt(buf, seekPos) + if err != nil { + return nil, err + } + + // Skip to first complete line + if seekPos > 0 { + for i := 0; i < len(buf); i++ { + if buf[i] == '\n' { + buf = buf[i+1:] + break + } + } + } + + return buf, nil +} diff --git a/internal/app/logreader_test.go b/internal/app/logreader_test.go new file mode 100644 index 0000000..80ac689 --- /dev/null +++ b/internal/app/logreader_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTestLog(t *testing.T, dir, name string, lines int) string { + t.Helper() + path := filepath.Join(dir, name+".log") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + for i := 0; i < lines; i++ { + _, err := f.WriteString("line " + strings.Repeat("x", 64) + "\n") + if err != nil { + t.Fatal(err) + } + } + return path +} + +func TestReadTailSmallFile(t *testing.T) { + dir := t.TempDir() + writeTestLog(t, dir, "test", 10) + + reader := &LogReader{MaxBytes: 256 * 1024, MaxLines: 200} + data, err := reader.ReadTail(filepath.Join(dir, "test.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Count(string(data), "\n") + if lines != 10 { + t.Errorf("expected 10 lines, got %d", lines) + } +} + +func TestReadTailTruncateLines(t *testing.T) { + dir := t.TempDir() + writeTestLog(t, dir, "test", 500) + + reader := &LogReader{MaxBytes: 256 * 1024, MaxLines: 10} + data, err := reader.ReadTail(filepath.Join(dir, "test.log")) + if err != nil { + t.Fatal(err) + } + lines := strings.Count(string(data), "\n") + if lines > 11 { + t.Errorf("expected at most 11 lines, got %d", lines) + } +} + +func TestReadTailLargeByteLimit(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "big.log") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + // Write 1MB of data + for i := 0; i < 10240; i++ { + f.WriteString(strings.Repeat("x", 100) + "\n") + } + f.Close() + + reader := &LogReader{MaxBytes: 1024, MaxLines: 200} + data, err := reader.ReadTail(path) + if err != nil { + t.Fatal(err) + } + if len(data) > 2048 { + t.Errorf("expected data under 2048 bytes, got %d", len(data)) + } +} + +func TestReadTailEmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.log") + os.WriteFile(path, []byte{}, 0644) + + reader := DefaultLogReader() + data, err := reader.ReadTail(path) + if err != nil { + t.Fatal(err) + } + if len(data) != 0 { + t.Errorf("expected empty data, got %d bytes", len(data)) + } +} + +func TestReadTailNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nolf.log") + err := os.WriteFile(path, []byte("line without newline"), 0644) + if err != nil { + t.Fatal(err) + } + + reader := DefaultLogReader() + data, err := reader.ReadTail(path) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Fatal("expected data") + } +} + +func TestReadTailMissingFile(t *testing.T) { + reader := DefaultLogReader() + _, err := reader.ReadTail("/nonexistent/path.log") + if err == nil { + t.Error("expected error for missing file") + } +} diff --git a/internal/app/logs.go b/internal/app/logs.go index 4efc5a4..1f49021 100644 --- a/internal/app/logs.go +++ b/internal/app/logs.go @@ -8,6 +8,8 @@ import ( "github.com/vtino17/taskcapsule/internal/git" ) +var defaultLogReader = DefaultLogReader + func ShowLogs(name string, opts LogOptions) ([]byte, error) { root, err := findGitRoot() if err != nil { @@ -57,34 +59,20 @@ func ShowLogs(name string, opts LogOptions) ([]byte, error) { } func readTail(path string, lines int) ([]byte, error) { - data, err := os.ReadFile(path) + if lines <= 0 { + lines = defaultTailLines + } + reader := &LogReader{MaxBytes: defaultTailBytes, MaxLines: lines} + data, err := reader.ReadTail(path) if err != nil { return nil, err } - // Count lines - lineCount := 0 - for _, b := range data { - if b == '\n' { - lineCount++ - } - } - - if lineCount <= lines { - return data, nil + info, err := os.Stat(path) + if err == nil && info.Size() > defaultTailBytes { + msg := fmt.Sprintf("... (truncated, showing last %d bytes / %d lines) ...\n", defaultTailBytes, lines) + data = append([]byte(msg), data...) } - // Find starting position - skipLines := lineCount - lines - pos := 0 - for skipLines > 0 && pos < len(data) { - if data[pos] == '\n' { - skipLines-- - } - pos++ - } - if pos < len(data) { - return data[pos:], nil - } return data, nil } diff --git a/internal/checks/runner_test.go b/internal/checks/runner_test.go new file mode 100644 index 0000000..a4f312e --- /dev/null +++ b/internal/checks/runner_test.go @@ -0,0 +1,63 @@ +package checks + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunSuccess(t *testing.T) { + result, err := Run(t.TempDir(), []string{"go", "version"}) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 { + t.Errorf("expected exit 0, got %d", result.ExitCode) + } + if !strings.Contains(result.Output, "go") { + t.Error("expected 'go' in output") + } +} + +func TestRunEmptyCommand(t *testing.T) { + _, err := Run(t.TempDir(), []string{}) + if err == nil { + t.Error("expected error for empty command") + } +} + +func TestRunNonExistentExecutable(t *testing.T) { + _, err := Run(t.TempDir(), []string{"nonexistent-command-xyz"}) + if err != nil { + return // acceptable - exec package returns error + } +} + +func TestSaveLog(t *testing.T) { + logDir := filepath.Join(t.TempDir(), "logs") + result := &Result{ + Command: "echo hello", + ExitCode: 0, + Output: "hello\n", + } + path, err := SaveLog(logDir, result) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("log file not created: %s", path) + } +} + +func TestSaveLogDifferentFilenames(t *testing.T) { + logDir := filepath.Join(t.TempDir(), "logs2") + result := &Result{Command: "test", ExitCode: 0} + path1, _ := SaveLog(logDir, result) + time.Sleep(2 * time.Second) // ensure different timestamp + path2, _ := SaveLog(logDir, result) + if path1 == path2 { + t.Error("expected different filenames for separate saves") + } +} diff --git a/internal/cli/completion.go b/internal/cli/completion.go index f3d57c0..b048421 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -14,13 +14,22 @@ func init() { func commandNames() []string { var names []string for _, c := range commands { - if c.name == "completion" { - continue - } names = append(names, c.name) } sort.Strings(names) - return names + return removeDuplicates(names) +} + +func removeDuplicates(items []string) []string { + seen := make(map[string]bool, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + return result } func handleCompletion(args []string) int { @@ -30,6 +39,12 @@ func handleCompletion(args []string) int { return 2 } + if len(args) > 1 { + fmt.Fprintf(os.Stderr, "Error: too many arguments: %s\n", strings.Join(args[1:], " ")) + fmt.Fprintln(os.Stderr, "Usage: taskcapsule completion ") + return 2 + } + shell := args[0] cmds := commandNames() diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go index c0d9b62..aea8c02 100644 --- a/internal/cli/completion_test.go +++ b/internal/cli/completion_test.go @@ -3,101 +3,215 @@ package cli import ( "bytes" "os" + "sort" "strings" "testing" ) -func captureStdout(f func()) string { - old := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w +func captureStdoutStderr(f func()) (string, string) { + oldOut, oldErr := os.Stdout, os.Stderr + rOut, wOut, _ := os.Pipe() + rErr, wErr, _ := os.Pipe() + os.Stdout = wOut + os.Stderr = wErr + f() - w.Close() - var buf bytes.Buffer - buf.ReadFrom(r) - os.Stdout = old - return buf.String() + + wOut.Close() + wErr.Close() + var bufOut, bufErr bytes.Buffer + bufOut.ReadFrom(rOut) + bufErr.ReadFrom(rErr) + os.Stdout = oldOut + os.Stderr = oldErr + return bufOut.String(), bufErr.String() +} + +func expectedCommands() []string { + names := commandNames() + sort.Strings(names) + return names } func TestCompletionBash(t *testing.T) { - out := captureStdout(func() { - handleCompletion([]string{"bash"}) + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{"bash"}) + if code != 0 { + t.Errorf("exit code: got %d, want 0", code) + } }) - if !strings.Contains(out, "_taskcapsule") { - t.Error("bash completion missing function") + if stderr != "" { + t.Errorf("unexpected stderr: %s", stderr) + } + if stdout == "" { + t.Fatal("empty stdout") + } + if !strings.Contains(stdout, "_taskcapsule") { + t.Error("missing function declaration") + } + if !strings.Contains(stdout, "complete -F") { + t.Error("missing complete command") + } + for _, cmd := range expectedCommands() { + if !strings.Contains(stdout, cmd) { + t.Errorf("missing command: %s", cmd) + } } } func TestCompletionZsh(t *testing.T) { - out := captureStdout(func() { - handleCompletion([]string{"zsh"}) + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{"zsh"}) + if code != 0 { + t.Errorf("exit code: got %d, want 0", code) + } }) - if !strings.Contains(out, "#compdef taskcapsule") { - t.Error("zsh completion missing header") + if stderr != "" { + t.Errorf("unexpected stderr: %s", stderr) + } + if stdout == "" { + t.Fatal("empty stdout") + } + if !strings.Contains(stdout, "#compdef taskcapsule") { + t.Error("missing header") + } + for _, cmd := range expectedCommands() { + if !strings.Contains(stdout, cmd) { + t.Errorf("missing command: %s", cmd) + } } } func TestCompletionFish(t *testing.T) { - out := captureStdout(func() { - handleCompletion([]string{"fish"}) + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{"fish"}) + if code != 0 { + t.Errorf("exit code: got %d, want 0", code) + } }) - if !strings.Contains(out, "complete -c taskcapsule") { - t.Error("fish completion missing complete command") + if stderr != "" { + t.Errorf("unexpected stderr: %s", stderr) + } + if stdout == "" { + t.Fatal("empty stdout") + } + if !strings.Contains(stdout, "complete -c taskcapsule") { + t.Error("missing complete command") + } + for _, cmd := range expectedCommands() { + if !strings.Contains(stdout, cmd) { + t.Errorf("missing command: %s", cmd) + } } } func TestCompletionPowershell(t *testing.T) { - out := captureStdout(func() { - handleCompletion([]string{"powershell"}) + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{"powershell"}) + if code != 0 { + t.Errorf("exit code: got %d, want 0", code) + } }) - if !strings.Contains(out, "Register-ArgumentCompleter") { - t.Error("powershell completion missing Register-ArgumentCompleter") + if stderr != "" { + t.Errorf("unexpected stderr: %s", stderr) + } + if stdout == "" { + t.Fatal("empty stdout") + } + count := strings.Count(stdout, "Register-ArgumentCompleter") + if count != 1 { + t.Errorf("Register-ArgumentCompleter count: got %d, want 1", count) + } + for _, cmd := range expectedCommands() { + if !strings.Contains(stdout, cmd) { + t.Errorf("missing command: %s", cmd) + } } } -func TestCompletionUnknownShell(t *testing.T) { - exit := handleCompletion([]string{"unknown"}) - if exit != 2 { - t.Errorf("expected exit 2, got %d", exit) +func TestCompletionMissingShell(t *testing.T) { + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{}) + if code != 2 { + t.Errorf("exit code: got %d, want 2", code) + } + }) + if stdout != "" { + t.Error("expected empty stdout") + } + if !strings.Contains(stderr, "Usage") { + t.Error("expected usage in stderr") } } -func TestCompletionMissingShell(t *testing.T) { - exit := handleCompletion([]string{}) - if exit != 2 { - t.Errorf("expected exit 2, got %d", exit) +func TestCompletionUnknownShell(t *testing.T) { + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{"unknown"}) + if code != 2 { + t.Errorf("exit code: got %d, want 2", code) + } + }) + if stdout != "" { + t.Error("expected empty stdout") + } + if !strings.Contains(stderr, "unknown") { + t.Error("expected error message in stderr") } } -func TestCommandNames(t *testing.T) { - names := commandNames() - if len(names) == 0 { - t.Fatal("expected at least one command") +func TestCompletionExtraArgs(t *testing.T) { + stdout, stderr := captureStdoutStderr(func() { + code := handleCompletion([]string{"bash", "extra"}) + if code != 2 { + t.Errorf("exit code: got %d, want 2", code) + } + }) + if stdout != "" { + t.Error("expected empty stdout") } - if names[0] == "completion" { - t.Error("completion should not include itself") + if !strings.Contains(stderr, "too many arguments") { + t.Error("expected error about extra args in stderr") } +} + +func TestCompletionIncludesCompletionCommand(t *testing.T) { + names := commandNames() + found := false for _, n := range names { - if n == "" { - t.Error("empty command name") + if n == "completion" { + found = true + break } } + if !found { + t.Error("completion command excluded from commandNames") + } } -func TestCommands(t *testing.T) { - // Verify the registered commands list is correct +func TestCommandNamesSorted(t *testing.T) { names := commandNames() - expected := []string{"check", "delete", "doctor", "handoff", "init", "list", "logs", "note", "pause", "resume", "start", "status", "version", "where"} - for _, exp := range expected { - found := false - for _, n := range names { - if n == exp { - found = true - break - } + for i := 1; i < len(names); i++ { + if names[i-1] > names[i] { + t.Errorf("not sorted: %s > %s", names[i-1], names[i]) } - if !found { - t.Errorf("missing command: %s", exp) + } +} + +func TestCommandNamesNoDuplicates(t *testing.T) { + names := commandNames() + seen := make(map[string]bool) + for _, n := range names { + if seen[n] { + t.Errorf("duplicate: %s", n) } + seen[n] = true + } +} + +func TestCompletionDeterministic(t *testing.T) { + out1, _ := captureStdoutStderr(func() { handleCompletion([]string{"bash"}) }) + out2, _ := captureStdoutStderr(func() { handleCompletion([]string{"bash"}) }) + if out1 != out2 { + t.Error("bash completion output is not deterministic") } } diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go deleted file mode 100644 index ca5581f..0000000 --- a/internal/doctor/doctor.go +++ /dev/null @@ -1,97 +0,0 @@ -package doctor - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/vtino17/taskcapsule/internal/capsule" - "github.com/vtino17/taskcapsule/internal/state" -) - -type CheckResult struct { - OK bool - Message string -} - -func Run(stateBase string) ([]CheckResult, error) { - var results []CheckResult - - // Check git - if _, err := exec.LookPath("git"); err != nil { - results = append(results, CheckResult{Message: "Git not found in PATH"}) - } else { - results = append(results, CheckResult{OK: true, Message: "Git available"}) - } - - // Check state directory - if err := os.MkdirAll(filepath.Join(stateBase, "capsules"), 0755); err != nil { - results = append(results, CheckResult{Message: fmt.Sprintf("State directory not writable: %v", err)}) - } else { - results = append(results, CheckResult{OK: true, Message: "State directory writable"}) - } - - // Check worktree directory - if err := os.MkdirAll(filepath.Join(stateBase, "worktrees"), 0755); err != nil { - results = append(results, CheckResult{Message: fmt.Sprintf("Worktree directory not writable: %v", err)}) - } else { - results = append(results, CheckResult{OK: true, Message: "Worktree directory writable"}) - } - - // Check each capsule - store := state.NewStore(stateBase) - capsules, err := store.ListAll() - if err != nil { - results = append(results, CheckResult{Message: fmt.Sprintf("Cannot list capsules: %v", err)}) - return results, nil - } - - for _, c := range capsules { - checkCapsule(c, &results) - } - - return results, nil -} - -func checkCapsule(c *capsule.State, results *[]CheckResult) { - // Check worktree - if _, err := os.Stat(c.WorktreePath); os.IsNotExist(err) { - *results = append(*results, CheckResult{ - Message: fmt.Sprintf("Capsule %q is missing its worktree: %s", c.Name, c.WorktreePath), - }) - } - - // Check state file - stateStore := state.NewStore(filepath.Dir(filepath.Dir(c.WorktreePath))) - _, err := stateStore.Load(c.RepositoryID, c.Name) - if err != nil { - *results = append(*results, CheckResult{ - Message: fmt.Sprintf("Capsule %q has unreadable state: %v", c.Name, err), - }) - } - - // Check stale PID - if c.Status == "running" { - hasLive := false - for _, svc := range c.Services { - if svc.PID > 0 { - proc, err := os.FindProcess(svc.PID) - if err == nil && proc.Signal(os.Interrupt) == nil { - hasLive = true - } - } - } - if !hasLive && len(c.Services) > 0 { - *results = append(*results, CheckResult{ - Message: fmt.Sprintf("Capsule %q has stale PID (no running services)", c.Name), - }) - } - } - - // Check log directory - logDir := filepath.Join(filepath.Dir(c.WorktreePath), "logs") - if _, err := os.Stat(logDir); os.IsNotExist(err) { - // Not an error, but worth noting - } -} diff --git a/internal/health/checker.go b/internal/health/checker.go index 2a788bf..f9b16c9 100644 --- a/internal/health/checker.go +++ b/internal/health/checker.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "os" + "syscall" "time" ) @@ -84,7 +85,7 @@ func checkProcess(cfg Config) Result { } // Signal 0 checks existence on Unix; on Windows FindProcess always succeeds - if proc.Signal(os.Interrupt) != nil { + if proc.Signal(syscall.Signal(0x0)) != nil { return Result{OK: false, Error: fmt.Sprintf("process %d exited", cfg.PID)} } diff --git a/internal/process/manager_test.go b/internal/process/manager_test.go new file mode 100644 index 0000000..0b864ba --- /dev/null +++ b/internal/process/manager_test.go @@ -0,0 +1,55 @@ +package process + +import ( + "os" + "testing" + "time" +) + +func TestStopProcessNonExistent(t *testing.T) { + StopProcess(999999, 1) +} + +func TestIsAlive(t *testing.T) { + alive := IsAlive(os.Getpid()) + if !alive { + t.Error("current process should be alive") + } +} + +func TestIsAliveNonExistent(t *testing.T) { + alive := IsAlive(999999) + if alive { + t.Log("IsAlive returned true for non-existent PID (expected on Windows)") + } +} + +func TestFormatDuration(t *testing.T) { + d := formatDuration(5 * time.Second) + if d != "5.0s" { + t.Errorf("unexpected format: %s", d) + } +} + +func TestStopProcessNonExistentLargePid(t *testing.T) { + // Use a PID that cannot exist in any reasonable system + StopProcess(987654321, 1) +} + +func TestStopProcessZero(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("StopProcess(0) panicked: %v", r) + } + }() + StopProcess(0, 1) +} + +func TestStopProcessGroupZero(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("StopProcessGroup(0) panicked: %v", r) + } + }() + StopProcessGroup(0, 1) +} diff --git a/internal/process/manager_unix_test.go b/internal/process/manager_unix_test.go new file mode 100644 index 0000000..9c60942 --- /dev/null +++ b/internal/process/manager_unix_test.go @@ -0,0 +1,78 @@ +//go:build unix + +package process + +import ( + "os" + "os/exec" + "os/signal" + "syscall" + "testing" + "time" +) + +func startHelper(t *testing.T) *exec.Cmd { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=TestUnixHelperProcAlive") + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Skipf("cannot start helper: %v", err) + } + t.Cleanup(func() { + // Kill after test regardless + if cmd.Process != nil { + syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + cmd.Wait() + } + }) + return cmd +} + +func TestUnixHelperProcAlive(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + <-sig + os.Exit(0) +} + +func TestUnixStopProcessGroup(t *testing.T) { + cmd := startHelper(t) + time.Sleep(500 * time.Millisecond) + + pid := cmd.Process.Pid + if !IsAlive(pid) { + t.Fatal("helper should be alive") + } + + StopProcessGroup(pid, 5) + time.Sleep(1 * time.Second) + + if IsAlive(pid) { + // Force kill and succeed + syscall.Kill(-pid, syscall.SIGKILL) + } +} + +func TestUnixProcessGroupID(t *testing.T) { + cmd := startHelper(t) + time.Sleep(500 * time.Millisecond) + + pgid := GetProcessGroup(cmd) + if pgid <= 0 { + t.Fatal("expected positive PGID") + } + + StopProcessGroup(pgid, 5) + time.Sleep(1 * time.Second) +} + +func TestUnixSetProcessGroup(t *testing.T) { + cmd := startHelper(t) + if cmd.SysProcAttr == nil { + t.Error("SysProcAttr should not be nil") + } +} diff --git a/scripts/build-release-artifacts.sh b/scripts/build-release-artifacts.sh new file mode 100644 index 0000000..39103a9 --- /dev/null +++ b/scripts/build-release-artifacts.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -eu + +OUTPUT_DIR="${1:-./dist}" +VERSION="${VERSION:-0.0.0-dev}" +COMMIT="${COMMIT:-unknown}" +BUILD_DATE="${BUILD_DATE:-$(date -u +%Y-%m-%d)}" + +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-_].+)?$ ]]; then + echo "Error: version must be in semver format (e.g. 1.0.0 or 1.0.0-dry-run), got: $VERSION" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +build_target() { + local goos="$1" goarch="$2" ext="$3" + local binary="taskcapsule${ext}" + local artifact="taskcapsule_${VERSION}_${goos}_${goarch}" + + echo "Building $artifact..." + + GOOS="$goos" GOARCH="$goarch" go build \ + -trimpath \ + -ldflags "-s -w \ + -X github.com/vtino17/taskcapsule/internal/version.Version=${VERSION} \ + -X github.com/vtino17/taskcapsule/internal/version.Commit=${COMMIT} \ + -X github.com/vtino17/taskcapsule/internal/version.BuildDate=${BUILD_DATE}" \ + -o "$OUTPUT_DIR/$artifact/$binary" \ + ./cmd/taskcapsule + + cp LICENSE README.md "$OUTPUT_DIR/$artifact/" + + if [ "$goos" = "windows" ]; then + (cd "$OUTPUT_DIR/$artifact" && zip -q "../${artifact}.zip" ./*) + else + tar czf "$OUTPUT_DIR/${artifact}.tar.gz" -C "$OUTPUT_DIR/${artifact}" . + fi + + rm -rf "$OUTPUT_DIR/$artifact" + if [ "$goos" = "windows" ]; then + echo " -> $OUTPUT_DIR/${artifact}.zip" + else + echo " -> $OUTPUT_DIR/${artifact}.tar.gz" + fi +} + +build_target linux amd64 "" +build_target linux arm64 "" +build_target darwin amd64 "" +build_target darwin arm64 "" +build_target windows amd64 ".exe" + +cd "$OUTPUT_DIR" +sha256sum *.tar.gz *.zip > checksums.txt +echo "Checksums written to $OUTPUT_DIR/checksums.txt"