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
3 changes: 3 additions & 0 deletions free/ci/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# compiled gate binary
nself-ci
nself-ci.exe
21 changes: 21 additions & 0 deletions free/ci/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024-2026 nSelf (https://nself.org)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
57 changes: 57 additions & 0 deletions free/ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# nself-ci plugin

Local CI gate runner for nSelf repositories. Detects the repo stack and runs lint, test, and build checks. Posts a `nself-ci` GitHub commit status via `gh` OAuth so branch protection can require this check instead of billing-blocked GitHub Actions.

## What it does

1. Detects which stacks are present: Go (`go.mod`), Node/TS (`package.json`), Flutter (`pubspec.yaml`)
2. Runs stack-specific gates:
- **Go:** `gofmt -l .` + `go vet ./...` + `go test ./...`
- **Node:** `pnpm run lint` + `pnpm run typecheck` + `pnpm run test` + `pnpm run build` (skips missing scripts)
- **Flutter:** `flutter analyze` + `flutter test`
3. Scans for secrets with `gitleaks` (uses repo `.github/gitleaks.toml` if present)
4. Posts a `nself-ci` commit status to GitHub so it appears in PR checks

## Usage

```bash
# Run gates + post nself-ci status to GitHub (standard usage)
nself-ci [repo-root]

# Run gates only, no status posted (local check)
nself-ci --check [repo-root]

# With explicit SHA / remote
nself-ci --owner nself-org --repo plugins --sha abc1234 .

# Skip gitleaks (if not installed)
nself-ci --no-gitleaks .

# Via nself CLI proxy (once registered)
nself ci [repo-root]
```

## Environment variables

| Var | Description |
|---|---|
| `NSELF_CI_REPO` | Repo root path (alternative to positional arg) |
| `NSELF_CI_SHA` | Commit SHA to report on (alternative to --sha) |
| `NSELF_CI_SKIP_STATUS` | Set to `1` to skip posting GitHub status |

## Prerequisites

- `gh` CLI with repo scope (`gh auth login`)
- `gitleaks` for secret scanning (`brew install gitleaks` or [releases](https://github.com/zricethezav/gitleaks/releases))
- Stack tools present: `go`, `pnpm`/`npm`, `flutter` as needed

## Build

```bash
cd plugins/free/ci
go build -o nself-ci ./cmd/
```

## Requiring nself-ci in branch protection

See the project's CI-LOCAL.md for the exact `gh api` command to configure branch protection to require `nself-ci`.
184 changes: 184 additions & 0 deletions free/ci/cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// nself-ci — nSelf CI gate runner.
//
// Purpose: Run the gate suite for a repo (lint/test/build + gitleaks) and
//
// optionally post a GitHub commit status so branch protection can require
// the "nself-ci" check instead of billing-blocked GitHub Actions.
//
// Usage:
//
// nself-ci [flags] [repo-root]
// nself ci (via nself CLI proxy)
//
// SPORT: PLUGINS-CI-000
package main

import (
"flag"
"fmt"
"os"
"strings"
"time"

"github.com/nself-org/plugins/free/ci/internal"
)

func main() {
var (
skipStatus = flag.Bool("no-status", false, "Run gates but do not post a GitHub commit status")
skipGitleaks = flag.Bool("no-gitleaks", false, "Skip gitleaks secret scan")
verbose = flag.Bool("v", false, "Print each gate command before running")
sha = flag.String("sha", "", "Commit SHA to report on (default: HEAD)")
owner = flag.String("owner", "", "GitHub owner (default: from git remote)")
repo = flag.String("repo", "", "GitHub repo name (default: from git remote)")
checkOnly = flag.Bool("check", false, "Check mode: run gates, print result, exit 0/1. No status posted.")
)
flag.Parse()

// repo-root is the optional positional argument.
repoRoot := "."
if flag.NArg() > 0 {
repoRoot = flag.Arg(0)
}
// Env override.
if v := os.Getenv("NSELF_CI_REPO"); v != "" && repoRoot == "." {
repoRoot = v
}
if v := os.Getenv("NSELF_CI_SKIP_STATUS"); v == "1" {
*skipStatus = true
}

cfg := internal.Config{
RepoRoot: repoRoot,
SkipGitleaks: *skipGitleaks,
Verbose: *verbose,
}

// Determine SHA and remote before running gates (fail early on config errors).
resolvedSHA := *sha
if v := os.Getenv("NSELF_CI_SHA"); v != "" && resolvedSHA == "" {
resolvedSHA = v
}

resolvedOwner := *owner
resolvedRepo := *repo

postStatus := !*skipStatus && !*checkOnly
if postStatus {
// Resolve SHA from git if not supplied.
if resolvedSHA == "" {
var err error
resolvedSHA, err = internal.HeadSHA(repoRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot resolve HEAD SHA: %v\n", err)
fmt.Fprintf(os.Stderr, "hint: pass --sha <sha> or use --no-status / --check\n")
os.Exit(1)
}
}

// Resolve owner/repo from git remote if not supplied.
if resolvedOwner == "" || resolvedRepo == "" {
o, r, err := internal.RepoOwnerName(repoRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot resolve GitHub remote: %v\n", err)
fmt.Fprintf(os.Stderr, "hint: pass --owner and --repo, or use --no-status / --check\n")
os.Exit(1)
}
if resolvedOwner == "" {
resolvedOwner = o
}
if resolvedRepo == "" {
resolvedRepo = r
}
}

// Post a "pending" status before running so GitHub shows the check immediately.
_ = internal.PostCommitStatus(internal.StatusConfig{
Owner: resolvedOwner,
Repo: resolvedRepo,
SHA: resolvedSHA,
State: "pending",
Description: "nself-ci gate running…",
})
}

// Run the gate suite.
result, err := internal.Run(cfg)
if err != nil {
msg := fmt.Sprintf("gate error: %v", err)
if postStatus {
_ = internal.PostCommitStatus(internal.StatusConfig{
Owner: resolvedOwner,
Repo: resolvedRepo,
SHA: resolvedSHA,
State: "error",
Description: msg,
})
}
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}

// Print results.
printResults(result)

// Post final commit status.
if postStatus {
state := "success"
if !result.Passed {
state = "failure"
}
if err := internal.PostCommitStatus(internal.StatusConfig{
Owner: resolvedOwner,
Repo: resolvedRepo,
SHA: resolvedSHA,
State: state,
Description: result.Summary(),
}); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not post commit status: %v\n", err)
} else {
fmt.Printf("\n✓ Posted nself-ci status %q to %s/%s@%s\n",
state, resolvedOwner, resolvedRepo, resolvedSHA[:min(7, len(resolvedSHA))])
}
}

if !result.Passed {
os.Exit(1)
}
}

// printResults prints a human-readable gate summary table.
func printResults(r *internal.Result) {
fmt.Printf("\nnself-ci gate results — %s\n", r.RepoRoot)
fmt.Printf("Stacks: %s\n", strings.Join(r.Stack, ", "))
fmt.Println(strings.Repeat("─", 60))

for _, g := range r.Gates {
mark := "PASS"
if !g.Passed {
mark = "FAIL"
}
fmt.Printf(" %-30s %s (%s)\n", g.Name, mark, g.Elapsed.Round(1*1000*1000))
if !g.Passed && g.Output != "" {
// Indent output for readability.
for _, line := range strings.SplitAfter(g.Output, "\n") {
fmt.Print(" ", line)
}
fmt.Println()
}
}

fmt.Println(strings.Repeat("─", 60))
overall := "PASSED"
if !r.Passed {
overall = "FAILED"
}
fmt.Printf(" Overall: %s (%s)\n\n", overall, r.Elapsed.Round(time.Second))
}

func min(a, b int) int {
if a < b {
return a
}
return b
}
3 changes: 3 additions & 0 deletions free/ci/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/nself-org/plugins/free/ci

go 1.23.0
Loading
Loading