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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ jobs:
- name: Test
run: go test -v -race -coverprofile coverage.out ./...

- name: Enforce coverage threshold (80%)
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.26'
run: |
COVERAGE=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%", "", $3); print $3}')
echo "Total coverage: ${COVERAGE}%"
awk -v cov="$COVERAGE" 'BEGIN { if (cov < 80) { exit 1 } }'

- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.26'
uses: actions/upload-artifact@v7
Expand All @@ -51,6 +58,17 @@ jobs:
with:
go-version: "1.26"

- name: Verify go.mod/go.sum are tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest

- name: Vulnerability scan
run: $(go env GOPATH)/bin/govulncheck ./...

- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ website/node_modules/
website/.next/
website/out/
website/next-env.d.ts
website/tsconfig.tsbuildinfo
website/.vercel/
website/.env*.local
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,32 @@

All notable changes to this project will be documented in this file.

## [v1.13.0] - 2026-04-02

### Added
- CI quality gates: module tidy drift check, 80% coverage threshold, and govulncheck scan
- Website validation job in GitHub Actions (lint, typecheck, build)
- Local development guide in `docs/DEVELOPMENT.md`
- Shared installer script for CI examples: `scripts/install-latest-xenvsync.sh`

### Changed
- `run` now preserves child process exit codes without double-printing errors
- `verify` duplicate-key checks now surface parse failures explicitly
- `.env` parser now supports unquoted inline comments while preserving literal `#` when not comment-prefixed
- Makefile includes `help`, `test-coverage`, and `ci-check` targets
- AUR package now builds from git metadata-derived version
- Nix package version now derives from git revision when available
- Minimum documented Go version is now 1.25+

### Security
- Expanded secret zeroization coverage across decrypt/encrypt flows and team-key operations
- `pull` now writes decrypted env files with mode `0600`
- `rotate` now rolls back vault/key files if writing the new key fails

### Fixed
- `diff` now handles missing env/vault files and parse errors with clearer diagnostics
- `init` gitignore updates now match exact entries to avoid false positives

## [v1.12.0] - 2026-04-01

### Security
Expand Down
18 changes: 16 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

Thank you for considering contributing to xenvsync! Here's how to get started.

Before coding, review:

- [Development Guide](docs/DEVELOPMENT.md)
- [Architecture](docs/ARCHITECTURE.md)

## Development Setup

1. **Fork and clone** the repository.
2. Ensure you have **Go 1.22+** installed.
2. Ensure you have **Go 1.25+** installed.
3. Install dependencies:
```bash
go mod download
Expand All @@ -14,6 +19,11 @@ Thank you for considering contributing to xenvsync! Here's how to get started.
```bash
go test -race ./...
```
5. (Recommended) Install quality tools used by CI:
```bash
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install golang.org/x/vuln/cmd/govulncheck@latest
```

## Making Changes

Expand All @@ -28,7 +38,11 @@ Thank you for considering contributing to xenvsync! Here's how to get started.
go test -race ./...
go vet ./...
```
5. Open a pull request against `main`.
5. Run the local CI-equivalent checks before pushing:
```bash
./scripts/ci-check.sh
```
6. Open a pull request against `main`.

## Code Style

Expand Down
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,19 @@ COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)

.PHONY: build test vet lint clean install tidy
.PHONY: help build test test-coverage vet lint clean install tidy ci-check

help:
@echo "xenvsync Makefile targets:"
@echo " build Build $(BINARY)"
@echo " install Install xenvsync to GOPATH/bin"
@echo " test Run tests with race detector"
@echo " test-coverage Run tests with race detector + coverage output"
@echo " vet Run go vet"
@echo " lint Run golangci-lint (if installed)"
@echo " tidy Run go mod tidy"
@echo " ci-check Run local CI-equivalent checks"
@echo " clean Remove local build artifacts"

build:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
Expand All @@ -16,6 +28,9 @@ install:
test:
go test -race ./...

test-coverage:
go test -v -race -coverprofile coverage.out ./...

vet:
go vet ./...

Expand All @@ -25,6 +40,9 @@ lint: vet
tidy:
go mod tidy

ci-check:
./scripts/ci-check.sh

clean:
rm -f $(BINARY)
rm -rf dist/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ npm install -g @nasimstg/xenvsync
# or run without installing
npx @nasimstg/xenvsync

# Go 1.22+
# Go 1.25+
go install github.com/nasimstg/xenvsync@latest

# Nix
Expand Down
17 changes: 16 additions & 1 deletion cmd/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"

"github.com/nasimstg/xenvsync/internal/env"

Expand Down Expand Up @@ -61,10 +62,24 @@ func runDiff(cmd *cobra.Command, args []string) error {
// Decrypt vault (may not exist).
vaultPairs, vaultErr := decryptVaultPairs(vFile)

if envErr != nil && vaultErr != nil {
if envErr != nil && vaultErr != nil && os.IsNotExist(envErr) && os.IsNotExist(vaultErr) {
return fmt.Errorf("neither %s nor %s found", eFile, vFile)
}

if envErr != nil && !os.IsNotExist(envErr) {
return fmt.Errorf("failed to parse %s: %w", eFile, envErr)
}
if vaultErr != nil && !os.IsNotExist(vaultErr) {
return fmt.Errorf("failed to read/decrypt %s: %w", vFile, vaultErr)
}

if envErr != nil && os.IsNotExist(envErr) {
envPairs = nil
}
if vaultErr != nil && os.IsNotExist(vaultErr) {
vaultPairs = nil
}

changes := computeKeyChanges(vaultPairs, envPairs)

if len(changes) == 0 {
Expand Down
2 changes: 2 additions & 0 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"runtime"
"strings"

"github.com/nasimstg/xenvsync/internal/crypto"
"github.com/nasimstg/xenvsync/internal/vault"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -216,6 +217,7 @@ func checkVaultDecrypt(vFile string) checkResult {
if err != nil {
return checkResult{"fail", fmt.Sprintf("Vault decrypt: %v", err)}
}
defer crypto.ZeroBytes(plaintext)
return checkResult{"pass", fmt.Sprintf("Vault decrypt: OK (%d bytes)", len(plaintext))}
}

Expand Down
16 changes: 6 additions & 10 deletions cmd/envname.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"sort"
"strings"

"github.com/nasimstg/xenvsync/internal/env"
Expand Down Expand Up @@ -101,7 +102,7 @@ func discoverEnvironments() []EnvInfo {
for k := range seen {
names = append(names, k)
}
sortStrings(names)
sort.Strings(names)
for _, n := range names {
result = append(result, *seen[n])
}
Expand Down Expand Up @@ -166,6 +167,8 @@ func loadMergedPairs(primaryFile string, noFallback bool) ([]env.Pair, error) {
merged[p.Key] = p.Value
orderedKeys = append(orderedKeys, p.Key)
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to parse %s: %w", sharedEnvFile, err)
}

// Layer 2: primary file (.env or .env.<name>)
Expand All @@ -188,6 +191,8 @@ func loadMergedPairs(primaryFile string, noFallback bool) ([]env.Pair, error) {
}
merged[p.Key] = p.Value
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to parse %s: %w", localEnvFile, err)
}

// Build result preserving key order
Expand All @@ -200,12 +205,3 @@ func loadMergedPairs(primaryFile string, noFallback bool) ([]env.Pair, error) {
}
return result, nil
}

func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}

63 changes: 63 additions & 0 deletions cmd/envs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package cmd

import (
"os"
"strings"
"testing"
)

func TestEnvs_ListsDefaultWhenNoFiles(t *testing.T) {
testInDir(t, func(_ string) {
resetAllFlags()
rootCmd.SetArgs([]string{"envs"})

output := captureStdout(t, func() {
if err := rootCmd.Execute(); err != nil {
t.Fatalf("envs failed: %v", err)
}
})

if !strings.Contains(output, "Discovered environments:") {
t.Fatalf("missing envs header, got: %q", output)
}
if !strings.Contains(output, "(default)") {
t.Fatalf("default environment should be listed, got: %q", output)
}
if !strings.Contains(output, "empty") {
t.Fatalf("default empty state should be shown, got: %q", output)
}
})
}

func TestEnvs_NamedEnvironmentsAreSorted(t *testing.T) {
testInDir(t, func(_ string) {
files := map[string]string{
".env.staging": "A=1\n",
".env.alpha.vault": "vault",
".env.production.vault": "vault",
}
for path, data := range files {
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatal(err)
}
}

resetAllFlags()
rootCmd.SetArgs([]string{"envs"})
output := captureStdout(t, func() {
if err := rootCmd.Execute(); err != nil {
t.Fatalf("envs failed: %v", err)
}
})

alpha := strings.Index(output, "alpha")
production := strings.Index(output, "production")
staging := strings.Index(output, "staging")
if alpha == -1 || production == -1 || staging == -1 {
t.Fatalf("expected all named envs in output, got: %q", output)
}
if alpha >= production || production >= staging {
t.Fatalf("named environments are not sorted alphabetically, got: %q", output)
}
})
}
37 changes: 37 additions & 0 deletions cmd/exitcode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cmd

import "fmt"

// ExitCodeCarrier is implemented by errors that map to a specific process exit code.
type ExitCodeCarrier interface {
ExitCode() int
}

// quietError marks errors that should not be printed by Execute.
type quietError interface {
Quiet() bool
}

type exitCodeError struct {
code int
quiet bool
}

func (e *exitCodeError) Error() string {
if e.quiet {
return ""
}
return fmt.Sprintf("process exited with code %d", e.code)
}

func (e *exitCodeError) ExitCode() int {
return e.code
}

func (e *exitCodeError) Quiet() bool {
return e.quiet
}

func newQuietExitCodeError(code int) error {
return &exitCodeError{code: code, quiet: true}
}
12 changes: 11 additions & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,11 @@ func runInit(cmd *cobra.Command, args []string) error {
func ensureGitignore(entries ...string) error {
existing, _ := os.ReadFile(gitignoreFile) // ignore error; file may not exist yet
content := string(existing)
lines := strings.Split(content, "\n")

var toAdd []string
for _, entry := range entries {
if !strings.Contains(content, entry) {
if !containsExactGitignoreEntry(lines, entry) {
toAdd = append(toAdd, entry)
}
}
Expand Down Expand Up @@ -119,3 +120,12 @@ func ensureGitignore(entries ...string) error {
}
return nil
}

func containsExactGitignoreEntry(lines []string, entry string) bool {
for _, line := range lines {
if strings.TrimSpace(line) == entry {
return true
}
}
return false
}
Loading
Loading