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
25 changes: 10 additions & 15 deletions .github/wiki/plugins/Forgejo.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

<!-- END GENERATED:catalog -->

> Self-hosted Forgejo git forge plus a Forgejo Actions runner for zero-quota offline CI.
> Self-hosted Forgejo git forge with Forgejo Actions enabled for external runners.

## Install

Expand All @@ -25,28 +25,23 @@ No license key required.

## Description

Self-hosted Forgejo git forge + Forgejo Actions runner. Provides offline CI that executes .github/workflows/*.yml YAML on self-hosted compute, consuming zero GitHub Actions quota. Designed for the ops profile (ops server on staging/prod).
Self-hosted Forgejo git forge with Forgejo Actions enabled for external runners. There is no in-stack runner and no Docker socket mount: register runners on a CI node that is not a deploy host. Designed for the ops profile (ops server on staging/prod). See the [plugin README](https://github.com/nself-org/plugins/blob/main/free/forgejo/README.md) for isolation and trust, runner registration and migration from the removed runner.

This plugin runs as its own container in your nSelf stack (rebuild with `nself build && nself start` after install).

Category: `development`. Current version: `1.0.0`.
Category: `development`.

## Configuration

| Env Var | Default | Description |
|---------|---------|-------------|
| `NSELF_FORGEJO_ADMIN_USER` | *(required)* | Required. |
| `NSELF_FORGEJO_ADMIN_PASSWORD` | — | Required. |
| `NSELF_FORGEJO_ADMIN_EMAIL` | *(required)* | Required. |
| `FORGEJO_RUNNER_REGISTRATION_TOKEN` | — | Required. |
| `FORGEJO_HTTP_PORT` | *(see plugin.json)* | Optional. |
| `FORGEJO_SSH_PORT` | *(see plugin.json)* | Optional. |
| `FORGEJO_DOMAIN` | *(see plugin.json)* | Optional. |
| `FORGEJO_RUNNER_NAME` | *(see plugin.json)* | Optional. |
| `FORGEJO_RUNNER_LABELS` | *(see plugin.json)* | Optional. |
| `FORGEJO_RUNNER_CAPACITY` | *(see plugin.json)* | Optional. |
| `FORGEJO_RUNNER_MEMORY` | *(see plugin.json)* | Optional. |
| `FORGEJO_RUNNER_CPU` | *(see plugin.json)* | Optional. |
| `NSELF_FORGEJO_ADMIN_USER` | *(required)* | Admin account name, created on first boot. |
| `NSELF_FORGEJO_ADMIN_PASSWORD` | *(required)* | Admin account password. |
| `NSELF_FORGEJO_ADMIN_EMAIL` | *(required)* | Admin account email. |
| `FORGEJO_HTTP_PORT` | `3844` | Optional. HTTP port, published on 127.0.0.1 only. |
| `FORGEJO_SSH_PORT` | `3222` | Optional. SSH port for git over SSH. |
| `FORGEJO_DOMAIN` | `localhost` | Optional. Public hostname used in clone URLs and webhook callbacks. |
| `FORGEJO_DISABLE_REGISTRATION` | `true` | Optional. Self-service sign-up is closed by default; set `false` to open it. |

## Ports

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/forgejo-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,8 @@ jobs:
run: |
nself version
bash free/forgejo/tests/upgrade-smoke.sh

- name: Wiki env docs consistency
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq jq >/dev/null
./free/forgejo/tests/wiki-env-docs.sh
6 changes: 3 additions & 3 deletions free/ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,15 @@ The `nself-ci` binary takes the exact argv of core `nself ci` (P7-CANON-09), so
|---|---|
| `nself-ci [flags] [repo-root]` | The gate. Flags: `--check`, `--no-status`, `--no-gitleaks`, `--sha`, `--owner`, `--repo`, `-v`/`--verbose`, `--filesystem` (plus the existing `--env`, `--gateway`). Flags and the repo-root may be mixed. |
| `nself-ci build [flags] [dir]` | Local signed Android artifact build (see "Artifact builds"). `--artifact` defaults to `android`. |
| `nself-ci forgejo [--url U] [--runner R]` | Forgejo server and runner health for the ops profile (default URL `http://localhost:3844`). Always exits 0, as in core. |
| `nself-ci forgejo [--url U]` | Forgejo server health for the ops profile (default URL `http://localhost:3844`). Always exits 0, as in core. |
| `nself-ci serve [flags]` | Webhook listener daemon on port 3845 (HMAC-verified, Docker-sandboxed jobs, fail-closed defaults). Each job runs the gate by exec of this same executable. |
| `nself-ci run [flags] [search-root]` | Pipeline over `.ci.yaml` manifests (unchanged). |

`--help` of `ci`, `build`, `forgejo` and `serve` prints the text core prints; it is captured from a built core binary by `scripts/capture-core-surface.sh` into `testdata/core-surface/` and embedded (`internal/surface`). `ci eval` is not part of this binary (P7-CI-44/45).
`--help` of `ci`, `build`, `forgejo` and `serve` uses the core text captured by `scripts/capture-core-surface.sh` into `testdata/core-surface/` and embedded (`internal/surface`). The capture normalizes Forgejo help to remove the in-stack runner retired by P7-TRUST-21. `ci eval` is not part of this binary (P7-CI-44/45).

Core framing: with `--check` the binary prints core's section header first and `Error: gate failed` on a failing gate, so `nself-ci --no-status --check REPO` matches `nself ci --no-status --check REPO` byte for byte. The argv that deployed 1.4.x CLIs send (`--no-status` and friends, never `--check`) keeps its exact output, because those CLIs print the header themselves.

Parity proof: `bash scripts/parity-with-core.sh` (needs `$NSELF`, `$TMPDIR`, `gitleaks`) builds core `nself` from cli origin/main and the plugins origin/main and head `nself-ci`, then diffs stdout, stderr and exit codes for core-vs-head and for deployed-1.4.x (base cli with each binary). `--self-test` proves the comparator flags a planted difference.
Parity proof: `bash scripts/parity-with-core.sh` (needs `$NSELF`, `$TMPDIR`, `gitleaks`) builds core `nself` from cli origin/main and the plugins origin/main and head `nself-ci`, then diffs stdout, stderr and exit codes for core-vs-head and for deployed-1.4.x (base cli with each binary). `--self-test` proves the comparator flags a planted difference. Forgejo is excluded from frozen-core parity: its server-only health check and removal of `--runner` are covered by the `internal/forgejo` and `cmd` tests.

## Experimental placement-plan v1 cost totals

Expand Down
7 changes: 3 additions & 4 deletions free/ci/cmd/forgejo.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package main

// Purpose: the "forgejo" subcommand: Forgejo server and runner health, ported
// Purpose: the "forgejo" subcommand: Forgejo server health, ported
// from core `nself ci forgejo` (P7-CANON-09). The implementation lives in
// internal/forgejo; this file parses the flags only.
// Inputs: --url (default http://localhost:3844), --runner.
// Inputs: --url (default http://localhost:3844).
// Outputs: status table on stdout; exit 0 (core never failed this command).
// Constraints: output and exit codes identical to core on fixtures.
// Constraints: retain the legacy exit-zero contract; no in-stack runner probe.

import (
"flag"
Expand All @@ -22,7 +22,6 @@ func forgejoCmd(args []string, eh flag.ErrorHandling) int {
var cfg forgejo.Config
fs := flag.NewFlagSet("forgejo", eh)
fs.StringVar(&cfg.URL, "url", forgejo.DefaultURL, "Forgejo base URL")
fs.StringVar(&cfg.Runner, "runner", "", "Runner container name (default: <PROJECT>_forgejo_runner)")
if _, err := parseInterspersed(fs, args); err != nil {
return 2
}
Expand Down
2 changes: 1 addition & 1 deletion free/ci/cmd/forgejo_v15.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package main

// Purpose: `nself ci forgejo` in v1.5 (P7-HOST-12, Epic D14): the V15-only entry beside the frozen v1.4
// Purpose: `nself ci forgejo` in v1.5 (P7-HOST-12, Epic D14): the V15-only entry beside the v1.4
// one (cmd/forgejo.go). It prints one deprecation line per process, then runs the Forgejo check of
// internal/forgejo (server health and the optional admin API; no runner container, P7-TRUST-21
// removed that runner). Removal is v1.6 (D-0119); `nself ci doctor --providers` is the way forward.
Expand Down
24 changes: 18 additions & 6 deletions free/ci/cmd/forgejo_v15_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package main

// Purpose: tests of `nself ci forgejo` in v1.4 and v1.5 mode (P7-HOST-12): the v1.4 output equals the
// CANON-09 golden, the V15 alias prints one deprecation line per process and then the server and API
// Purpose: tests of `nself ci forgejo` in v1.4 and v1.5 mode (P7-HOST-12): both
// modes check the server without a runner. The V15 alias prints one deprecation
// line per process and then the server and API
// check (no runner line), and the URL comes from --url, then the policy key, then the default.
// Inputs: httptest servers on loopback, a stub docker on PATH for the v1.4 half.
// Outputs: pass or fail; no network beyond loopback.
Expand All @@ -11,6 +12,7 @@ import (
"bytes"
"context"
"encoding/json"
"flag"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -52,7 +54,7 @@ func captureStd(t *testing.T, fn func()) (string, string) {
return string(bo), string(be)
}

// TestForgejoAlias proves acceptance line 4: v1.4 `nself-ci forgejo` output equals the CANON-09 golden;
// TestForgejoAlias proves acceptance line 4: v1.4 `nself-ci forgejo` checks only the server;
// the V15 `nself ci forgejo` prints one deprecation line naming `nself ci doctor --providers` and removal
// v1.6, then the server and API check result.
func TestForgejoAlias(t *testing.T) {
Expand All @@ -66,15 +68,14 @@ func TestForgejoAlias(t *testing.T) {
t.Setenv("NSELF_FORGEJO_ADMIN_USER", "")
t.Setenv("NSELF_FORGEJO_ADMIN_PASSWORD", "")

// v1.4: the frozen table entry, byte for byte as the CANON-09 port prints it.
// v1.4: retain the table and exit contract without probing a removed runner.
t.Setenv("NSELF_V15", "")
golden := "\n→ Forgejo CI stack (ops profile)\n" +
" ✓ Forgejo server " + srv.URL + " healthy\n" +
" ✓ Forgejo runner container=my_runner state=running\n" +
" API auth n/a (set NSELF_FORGEJO_ADMIN_USER + NSELF_FORGEJO_ADMIN_PASSWORD)\n\n" +
"✓ Forgejo CI stack is healthy.\n"
out, errOut := captureStd(t, func() {
if code := run([]string{"forgejo", "--url", srv.URL, "--runner", "my_runner"}); code != 0 {
if code := run([]string{"forgejo", "--url", srv.URL}); code != 0 {
t.Errorf("v1.4 exit %d", code)
}
})
Expand Down Expand Up @@ -132,3 +133,14 @@ func TestForgejoAlias(t *testing.T) {
t.Fatalf("a stray argument is a usage error, got %d", code)
}
}

// TestForgejoRejectsRemovedRunner keeps the parser and advertised help aligned.
func TestForgejoRejectsRemovedRunner(t *testing.T) {
if code := forgejoCmd([]string{"--runner", "old-runner"}, flag.ContinueOnError); code != 2 {
t.Fatalf("removed flag returned %d, want usage error", code)
}
out, _ := captureStd(t, func() { run([]string{"forgejo", "--help"}) })
if strings.Contains(out, "--runner") || !strings.Contains(out, "external") {
t.Fatalf("stale help: %s", out)
}
}
2 changes: 1 addition & 1 deletion free/ci/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// nself-ci [flags] [repo-root] — single-repo gate
// nself-ci run [flags] [search-root] — pipeline: discover .ci.yaml + run stages
// nself-ci build --artifact android [dir] — local signed-APK build + gh release upload
// nself-ci forgejo [--url U] [--runner R] — Forgejo server + runner health
// nself-ci forgejo [--url U] — Forgejo server health
// nself-ci serve [flags] — webhook listener daemon (port 3845)
// nself ci run --env local — via nself CLI proxy
//
Expand Down
2 changes: 1 addition & 1 deletion free/ci/internal/forgejo/check_v15.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package forgejo

// Purpose: the v1.5 Forgejo check behind `nself ci forgejo` (P7-HOST-12, Epic D14): the server health
// endpoint and the optional admin-authenticated API probe, with the base URL taken from the operator
// policy key providers.forgejo.url. Unlike the v1.4 command (forgejo.go, frozen) it has no runner
// policy key providers.forgejo.url. Both command versions omit the runner
// container check: P7-TRUST-21 removed that runner.
// Inputs: CheckConfig (URL, optional admin user and password, HTTP client); the policy key.
// Outputs: Result, and the status lines on a writer; Deprecation, the one line the alias prints.
Expand Down
55 changes: 5 additions & 50 deletions free/ci/internal/forgejo/forgejo.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
// Package forgejo implements `nself ci forgejo`: a Forgejo server and runner
// health check for the ops profile.
//
// Purpose: behaviour-preserving port of cli cmd/commands/ci_forgejo.go
// (P7-CANON-09). Queries the Forgejo /-/health endpoint and the
// forgejo-runner container state (docker inspect) and prints a status table.
// Inputs: Config (URL, runner container name); optional
// NSELF_FORGEJO_ADMIN_USER and NSELF_FORGEJO_ADMIN_PASSWORD for the API probe.
// Outputs: the status table on stdout, warnings on stderr; never an error
// (core returned nil for every outcome, so the exit code is always 0).
// Constraints: no Forgejo token; docker found by PATH lookup as in core.
// Temporary duplicate of the core command, deleted with it at P7-SHIP-09.
// SPORT: F08-SERVICE-INVENTORY (forgejo, ops profile, port 3844)
// Package forgejo implements the Forgejo server health check for the ops profile.
// The plugin has no in-stack runner (P7-TRUST-21); external runner health is
// managed on its CI node. The legacy gate's exit-zero contract is retained.
package forgejo

import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"

Expand All @@ -30,8 +19,7 @@ const DefaultURL = "http://localhost:3844"

// Config holds the parsed flags.
type Config struct {
URL string // Forgejo base URL
Runner string // runner container name; empty probes the common names
URL string // Forgejo base URL
}

// healthResponse is the subset of /-/health we care about.
Expand All @@ -42,37 +30,26 @@ type healthResponse struct {
// Run prints the Forgejo stack status.
func Run(cfg Config) error {
baseURL := strings.TrimRight(cfg.URL, "/")
runnerContainer := cfg.Runner

client := &http.Client{Timeout: 10 * time.Second}

clui.Section("Forgejo CI stack (ops profile)")

serverOK, serverMsg := probeServer(client, baseURL)
runnerContainer, runnerStatus := probeRunner(runnerContainer)
jobsInfo := probeAPI(client, baseURL, serverOK)

serverIcon := "✗"
if serverOK {
serverIcon = "✓"
}
runnerIcon := "✗"
if runnerStatus == "running" {
runnerIcon = "✓"
}

fmt.Printf(" %s Forgejo server %s %s\n", serverIcon, baseURL, serverMsg)
fmt.Printf(" %s Forgejo runner container=%s state=%s\n", runnerIcon, runnerContainer, runnerStatus)
fmt.Printf(" API auth %s\n", jobsInfo)
fmt.Println()

if !serverOK {
clui.Warn("Forgejo server unreachable. Is the ops profile running? Try: nself start --profile ops")
}
if runnerStatus != "running" {
clui.Warn("Forgejo runner is not running. Check: docker logs " + runnerContainer)
}
if serverOK && runnerStatus == "running" {
if serverOK {
clui.Success("Forgejo CI stack is healthy.")
}
return nil
Expand All @@ -92,28 +69,6 @@ func probeServer(client *http.Client, baseURL string) (bool, string) {
return false, fmt.Sprintf("HTTP %d", resp.StatusCode)
}

// probeRunner returns the container name used and its state (docker inspect).
func probeRunner(runner string) (string, string) {
state := "unknown"
if runner == "" {
// Try common project prefix patterns.
for _, candidate := range []string{"nself_forgejo_runner", "app_forgejo_runner"} {
out, err := exec.Command("docker", "inspect", "--format", "{{.State.Status}}", candidate).Output()
if err == nil {
runner = candidate
state = strings.TrimSpace(string(out))
break
}
}
if runner == "" {
state = "not found (docker inspect failed — is Docker running?)"
}
} else if out, err := exec.Command("docker", "inspect", "--format", "{{.State.Status}}", runner).Output(); err == nil {
state = strings.TrimSpace(string(out))
}
return runner, state
}

// probeAPI reports the optional admin-authenticated API probe.
func probeAPI(client *http.Client, baseURL string, serverOK bool) string {
info := "n/a (set NSELF_FORGEJO_ADMIN_USER + NSELF_FORGEJO_ADMIN_PASSWORD)"
Expand Down
47 changes: 15 additions & 32 deletions free/ci/internal/forgejo/forgejo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,47 +50,30 @@ func stubDocker(t *testing.T, state string, ok bool) string {
return log
}

// TestHealthyStack: server healthy and runner running prints the success line.
// TestHealthyStack needs only the server; Docker must never be invoked.
func TestHealthyStack(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/-/health" {
_, _ = w.Write([]byte(`{"healthy": true}`))
return
}
http.NotFound(w, r)
_, _ = w.Write([]byte(`{"healthy": true}`))
}))
defer srv.Close()
log := stubDocker(t, "running", true)
out, errOut := run(t, Config{URL: srv.URL + "/", Runner: "my_runner"})
for _, want := range []string{"Forgejo CI stack (ops profile)", "✓ Forgejo server " + srv.URL + " healthy", "container=my_runner state=running", "Forgejo CI stack is healthy."} {
if !strings.Contains(out, want) {
t.Errorf("stdout lacks %q:\n%s", want, out)
}
}
if errOut != "" {
t.Errorf("stderr = %q", errOut)
log := stubDocker(t, "", false)
out, errOut := run(t, Config{URL: srv.URL + "/"})
if !strings.Contains(out, "Forgejo CI stack is healthy.") || strings.Contains(out, "runner") || errOut != "" {
t.Fatalf("healthy server without runner: stdout=%q stderr=%q", out, errOut)
}
b, _ := os.ReadFile(log)
if !strings.Contains(string(b), "inspect --format {{.State.Status}} my_runner") {
t.Errorf("docker argv = %q", b)
if _, err := os.Stat(log); !os.IsNotExist(err) {
t.Fatalf("Docker was invoked: %v", err)
}
}

// TestUnreachableAndCandidates: no server, docker finds none; both warnings print.
func TestUnreachableAndCandidates(t *testing.T) {
func TestUnreachable(t *testing.T) {
log := stubDocker(t, "", false)
out, errOut := run(t, Config{URL: "http://127.0.0.1:1"})
if !strings.Contains(out, "✗ Forgejo server http://127.0.0.1:1 unreachable") ||
!strings.Contains(out, "state=not found (docker inspect failed — is Docker running?)") {
t.Errorf("stdout:\n%s", out)
}
if !strings.Contains(errOut, "Forgejo server unreachable. Is the ops profile running?") ||
!strings.Contains(errOut, "Forgejo runner is not running. Check: docker logs") {
t.Errorf("stderr:\n%s", errOut)
if !strings.Contains(out, "unreachable") || !strings.Contains(errOut, "Forgejo server unreachable") || strings.Contains(out+errOut, "runner") {
t.Fatalf("unreachable server: stdout=%q stderr=%q", out, errOut)
}
b, _ := os.ReadFile(log)
if !strings.Contains(string(b), "nself_forgejo_runner") || !strings.Contains(string(b), "app_forgejo_runner") {
t.Errorf("both candidates must be probed, got %q", b)
if _, err := os.Stat(log); !os.IsNotExist(err) {
t.Fatalf("Docker was invoked: %v", err)
}
}

Expand All @@ -111,12 +94,12 @@ func TestAPIProbe(t *testing.T) {
stubDocker(t, "running", true)
t.Setenv("NSELF_FORGEJO_ADMIN_USER", "adm")
t.Setenv("NSELF_FORGEJO_ADMIN_PASSWORD", "pw")
out, _ := run(t, Config{URL: srv.URL, Runner: "r"})
out, _ := run(t, Config{URL: srv.URL})
if !strings.Contains(out, "API reachable (admin authenticated)") {
t.Errorf("stdout:\n%s", out)
}
t.Setenv("NSELF_FORGEJO_ADMIN_PASSWORD", "wrong")
out, _ = run(t, Config{URL: srv.URL, Runner: "r"})
out, _ = run(t, Config{URL: srv.URL})
if !strings.Contains(out, "API returned HTTP 401") {
t.Errorf("stdout:\n%s", out)
}
Expand Down
9 changes: 3 additions & 6 deletions free/ci/internal/surface/help/ci-forgejo.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
Query the Forgejo server health endpoint and the forgejo-runner
container status. Designed for the ops profile where Forgejo provides
offline CI that runs .github/workflows/*.yml with zero GitHub Actions
quota consumption.
Query the Forgejo server health endpoint. Workflow jobs run on external
runners registered on a CI node that is not a deploy host. This plugin
ships no runner container.

Env vars used (optional — for API queue depth):
NSELF_FORGEJO_ADMIN_USER Forgejo admin username
Expand All @@ -10,14 +9,12 @@ Env vars used (optional — for API queue depth):
Examples:
nself ci forgejo
nself ci forgejo --url http://ci.example.com:3844
nself ci forgejo --runner nself_forgejo_runner

Usage:
nself ci forgejo [flags]

Flags:
-h, --help help for forgejo
--runner string Runner container name (default: <PROJECT>_forgejo_runner)
--url string Forgejo base URL (default "http://localhost:3844")

Global Flags:
Expand Down
Loading
Loading