Watch
1
0
Fork
You've already forked RedFlag
0

ci: Gitea Actions pipeline — release gate, embedded UI build, guided release script

ci.yml: vet, race tests, clippy, full web build, AI-attribution and
action-pin enforcement. release.yml: gate job verifies tag against
versions.go/docker-compose/Cargo/CHANGELOG, forward-only and on public,
before anything builds; web UI staged into the embed path (gitignored
dist made a bare go build ship an empty dashboard); binaries and docker
image must self-report the tag; release created via Gitea's own API.
scripts/release.sh is the operator path: checks runner, secret, branch,
versions, changelog — asks before every mutation, watches the run after.
bump-version.sh gains current-version display, dirty-tree warning,
duplicate check, changelog check, confirmation. build-secure-agent.sh
retired (bare go build, no version injection, single Makefile caller).
This commit is contained in:
Fimeg 2026-06-10 20:04:32 -04:00
commit 4896fb6856
13 changed files with 991 additions and 72 deletions

105
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,105 @@
name: ci
on:
push:
branches: [main, public]
pull_request:
branches: [main, public]
jobs:
go-vet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: go vet (server)
run: cd server && go vet ./...
- name: go vet (agent)
run: cd agent && go vet ./...
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: go test -race (server)
run: cd server && go test -race -count=1 ./...
- name: go test -race (agent)
run: cd agent && go test -race -count=1 ./...
rust-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: cargo test
run: cd helper && cargo test
- name: cargo clippy
run: cd helper && cargo clippy -- -D warnings
web-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
# npm run build = tsc && vite build — type errors and bundling failures
# both surface here, not at release time.
- name: Build web UI
run: cd web && npm ci && npm run build
no-ai-attribution:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Check commit messages for AI attribution
run: |
# Check all commits in the push/PR range.
# On push: compare against the base branch.
# On PR: compare against the PR base.
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}"
else
RANGE="${{ github.event.before }}..${{ github.sha }}"
# First push — no before sha. Check last 10 commits.
if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
RANGE="HEAD~10..HEAD"
fi
fi
PATTERNS="Co-Authored-By:.*[Cc]laude|Co-Authored-By:.*OpenAI|Co-Authored-By:.*ChatGPT|Co-Authored-By:.*Copilot|Co-Authored-By:.*Letta|Co-Authored-By:.*Cursor|Generated by|Generated with|AI-assisted|Auto-generated by"
FAIL=0
while IFS= read -r msg; do
if echo "$msg" | grep -qiE "$PATTERNS"; then
echo "::error::AI attribution found in commit: $msg"
FAIL=1
fi
done < <(git log --format='%s%n%b' $RANGE 2>/dev/null)
if [ "$FAIL" -eq 1 ]; then
echo "::error::Commits contain AI attribution lines. Remove them before merging."
exit 1
fi
echo "No AI attribution found in commits."
action-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check for floating action refs
run: |
if grep -rE 'uses:.*@(v[0-9]+|stable|main|master)(\s|$)' .gitea/workflows/; then
echo "::error::Floating action refs found — run scripts/update-action-pins.sh"
exit 1
fi
echo "All action refs are SHA-pinned."

View file

@ -0,0 +1,211 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
# Gate runs before any build. A tag that doesn't agree with the tree is a
# broken release waiting to happen — fail here, not after artifacts exist.
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Version gate
run: |
set -euo pipefail
TAG=${GITHUB_REF#refs/tags/v}
echo "Tag version: $TAG"
FAIL=0
# versions.go — anchored so MinAgentVersion does not match.
SERVER_VER=$(grep -P '^\s*AgentVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
CONFIG_VER=$(grep -P '^\s*ConfigVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
echo "versions.go: AgentVersion=$SERVER_VER ConfigVersion=$CONFIG_VER"
if [ "$TAG" != "$SERVER_VER" ]; then
echo "::error::tag=$TAG but versions.go AgentVersion=$SERVER_VER"
FAIL=1
fi
if [ "$TAG" != "$CONFIG_VER" ]; then
echo "::error::tag=$TAG but versions.go ConfigVersion=$CONFIG_VER"
FAIL=1
fi
# docker-compose.yml BUILD_VERSION default.
COMPOSE_VER=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' docker-compose.yml)
echo "docker-compose: BUILD_VERSION=$COMPOSE_VER"
if [ "$TAG" != "$COMPOSE_VER" ]; then
echo "::error::tag=$TAG but docker-compose BUILD_VERSION=$COMPOSE_VER"
FAIL=1
fi
# helper/Cargo.toml — 3-part semver, compare against first 3 octets.
CARGO_VER=$(grep -m1 '^version' helper/Cargo.toml | cut -d'"' -f2)
TAG_SEMVER=$(echo "$TAG" | cut -d. -f1-3)
echo "Cargo.toml: version=$CARGO_VER (tag semver=$TAG_SEMVER)"
if [ "$TAG_SEMVER" != "$CARGO_VER" ]; then
echo "::error::tag=$TAG (semver=$TAG_SEMVER) but Cargo.toml=$CARGO_VER"
FAIL=1
fi
# CHANGELOG must mention the version being released.
if ! grep -q "$TAG" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no entry for $TAG"
FAIL=1
fi
# Forward-only: the new tag must sort above every existing tag.
HIGHEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
echo "Highest tag: $HIGHEST"
if [ "$HIGHEST" != "v$TAG" ]; then
echo "::error::v$TAG does not sort above existing tags (highest=$HIGHEST) — version must move forward"
FAIL=1
fi
# The tagged commit must be on public — no releases from stray branches.
if ! git rev-parse --verify --quiet origin/public >/dev/null; then
echo "::error::origin/public not found in checkout — cannot verify tag ancestry"
FAIL=1
elif ! git merge-base --is-ancestor "$GITHUB_SHA" origin/public; then
echo "::error::tagged commit $GITHUB_SHA is not on public"
FAIL=1
fi
exit $FAIL
release:
runs-on: ubuntu-latest
needs: gate
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Create dist directory
run: mkdir -p dist
# The server binary embeds the dashboard via go:embed all:dist. The dist
# dir is gitignored (only .gitkeep is committed), so a bare go build
# produces a server with an empty UI. Build the web app and stage it
# into the embed path before compiling.
- name: Build web UI
run: cd web && npm ci && npm run build
- name: Stage web UI for embedding
run: |
rm -rf server/internal/webui/dist
cp -r web/dist server/internal/webui/dist
# Fail-closed: an empty embed must never reach a release binary.
test -s server/internal/webui/dist/index.html
test -d server/internal/webui/dist/assets
- name: Build server (amd64)
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd server && go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$VERSION \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$VERSION" \
-o ../dist/redflag-server-linux-amd64 cmd/server/main.go
- name: Build agent (amd64)
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd agent && go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o ../dist/redflag-agent-linux-amd64 ./cmd/agent/
- name: Build helper (Rust)
run: |
cd helper && cargo build --release
cp target/release/redflag-helper ../dist/redflag-helper-linux-amd64
# Did it make it in: the artifacts must report the tag version, not
# whatever a stale default or missed ldflag left behind.
- name: Verify binary versions
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
SERVER_OUT=$(./dist/redflag-server-linux-amd64 --version)
echo "$SERVER_OUT"
echo "$SERVER_OUT" | grep -q "v$VERSION" || { echo "::error::server binary reports wrong version"; exit 1; }
AGENT_OUT=$(./dist/redflag-agent-linux-amd64 --version)
echo "$AGENT_OUT"
echo "$AGENT_OUT" | grep -q "v$VERSION" || { echo "::error::agent binary reports wrong version"; exit 1; }
- name: Build Docker image
run: |
VERSION=${GITHUB_REF#refs/tags/v}
docker build \
--build-arg BUILD_VERSION=$VERSION \
-t 10.10.20.120:4455/fimeg/redflag:$VERSION \
-t 10.10.20.120:4455/fimeg/redflag:latest \
-f server/Dockerfile .
- name: Verify Docker image version
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
IMG_OUT=$(docker run --rm 10.10.20.120:4455/fimeg/redflag:$VERSION ./redflag-server --version)
echo "$IMG_OUT"
echo "$IMG_OUT" | grep -q "v$VERSION" || { echo "::error::docker image server reports wrong version"; exit 1; }
- name: Push Docker to Gitea
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "${{ secrets.GITEA_TOKEN }}" | docker login 10.10.20.120:4455 -u fimeg --password-stdin
docker push 10.10.20.120:4455/fimeg/redflag:$VERSION
docker push 10.10.20.120:4455/fimeg/redflag:latest
- name: Package binaries
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
tar czf redflag-$VERSION-linux-amd64.tar.gz redflag-*-linux-amd64
sha256sum redflag-$VERSION-linux-amd64.tar.gz > checksums-$VERSION.txt
# Native Gitea release via its own API. softprops/action-gh-release
# speaks the GitHub API and does not match Gitea's /api/v1 surface.
- name: Create Gitea release
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
API="${GITHUB_SERVER_URL}/api/v1"
RESPONSE=$(curl -sf -X POST "$API/repos/${GITHUB_REPOSITORY}/releases" \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v$VERSION\",\"name\":\"v$VERSION\",\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(echo "$RESPONSE" | grep -oP '"id":\s*\K[0-9]+' | head -1)
if [ -z "$RELEASE_ID" ]; then
echo "::error::failed to parse release id from API response: $RESPONSE"
exit 1
fi
echo "Created release id=$RELEASE_ID"
for f in dist/redflag-$VERSION-linux-amd64.tar.gz dist/checksums-$VERSION.txt; do
echo "Uploading $(basename "$f")"
curl -sf -X POST "$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@$f" > /dev/null
done
echo "Release v$VERSION published with $(ls dist/*.tar.gz dist/checksums-* | wc -l) assets"

View file

@ -1,4 +1,12 @@
.PHONY: help db-up db-down server agent clean kernel-enforcer
.PHONY: help db-up db-down server agent clean kernel-enforcer test lint version
VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "dev")
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
AGENT_LDFLAGS := -X github.com/Fimeg/RedFlag/agent/internal/version.Version=$(VERSION) \
-X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=$(VERSION) \
-X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(BUILD_TIME)
SERVER_LDFLAGS := -X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$(VERSION) \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$(VERSION)
help: ## Show this help message
@echo 'Usage: make [target]'
@ -6,6 +14,9 @@ help: ## Show this help message
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
version: ## Print current version
@echo "VERSION=$(VERSION)"
db-up: ## Start PostgreSQL database
docker-compose up -d postgres
@echo "Waiting for database to be ready..."
@ -21,26 +32,29 @@ agent: ## Build and run the agent
cd agent && go mod tidy && go run cmd/agent/main.go
build-server: ## Build server binary
cd server && go mod tidy && go build -o bin/server cmd/server/main.go
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o bin/server cmd/server/main.go
build-agent: ## Build agent binary with version injection
cd agent && go mod tidy && go build -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=0.2.0.7 -X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=0.2.0.7 -X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/agent ./cmd/agent/
build-agent-simple: ## Build agent binary with simple script
@./scripts/build-secure-agent.sh
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o bin/agent ./cmd/agent/
clean: ## Clean build artifacts
rm -rf server/bin agent/bin
build-all: ## Build with go mod tidy for fresh clones
@echo "Building all components with dependency cleanup..."
cd server && go mod tidy && go build -o redflag-server cmd/server/main.go
cd agent && go mod tidy && go build -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=0.2.0.7 -X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=0.2.0.7 -X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o redflag-agent ./cmd/agent/
build-all: ## Build all components with version from git tag
@echo "Building all components at version $(VERSION)..."
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o redflag-server cmd/server/main.go
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o redflag-agent ./cmd/agent/
@echo "Build complete!"
test: ## Run tests
cd server && go test ./...
cd agent && go test ./...
test: ## Run all tests
cd server && go test -race -count=1 ./...
cd agent && go test -race -count=1 ./...
cd helper && cargo test
lint: ## Run linters and vet
cd server && go vet ./...
cd agent && go vet ./...
cd helper && cargo clippy -- -D warnings
kernel-enforcer: ## Build eBPF kernel enforcer
@echo "Building eBPF kernel enforcer..."

View file

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
@ -32,7 +33,22 @@ func HandleCaptureScreenshot(apiClient *client.Client, cfg *config.Config, ackTr
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("redflag_screenshot_%d.png", time.Now().UnixNano()))
defer os.Remove(tmpPath)
if err := captureScreen(tmpPath); err != nil {
diag, err := captureScreen(tmpPath)
if err != nil {
// Report detailed diagnostics to the server instead of a generic message.
diagJSON, _ := json.Marshal(diag)
logReport := client.LogReport{
CommandID: commandID,
Action: "capture_screenshot",
Result: "failed",
Stderr: fmt.Sprintf("screen capture failed: %v", err),
ExitCode: 1,
Metadata: map[string]string{
"diagnostics": string(diagJSON),
},
DurationSeconds: int(time.Since(start).Seconds()),
}
_ = ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("screen capture failed: %w", err)
}
@ -61,14 +77,18 @@ func HandleCaptureScreenshot(apiClient *client.Client, cfg *config.Config, ackTr
}
// captureScreen writes a PNG screenshot to outputPath. Platform-specific.
func captureScreen(outputPath string) error {
// Returns diagnostics for health reporting on failure.
func captureScreen(outputPath string) (*screenshotDiagnostics, error) {
switch runtime.GOOS {
case "linux":
return captureScreenLinux(outputPath)
case "windows":
return captureScreenWindows(outputPath)
if err := captureScreenWindows(outputPath); err != nil {
return &screenshotDiagnostics{SessionType: "windows"}, err
}
return &screenshotDiagnostics{SessionType: "windows"}, nil
default:
return fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
return &screenshotDiagnostics{}, fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
}
}
@ -77,6 +97,29 @@ func captureScreen(outputPath string) error {
type sessionDisplayInfo struct {
env []string
sessionType string // "x11", "wayland", or ""
sourcePID int // pid whose environ provided the vars (0 = none)
}
// toolAttempt records one screenshot tool invocation for diagnostic reporting.
type toolAttempt struct {
Name string `json:"name"`
Found bool `json:"found"` // in PATH
Tried bool `json:"tried"` // actually executed
ExitCode int `json:"exit_code,omitempty"` // 0 = success, -1 = not tried
Stderr string `json:"stderr,omitempty"` // first 200 chars of output
}
// screenshotDiagnostics captures the full picture for the server when a
// screenshot fails. The generic "no tool found" message hid the real cause;
// this exposes it as structured health data.
type screenshotDiagnostics struct {
SessionType string `json:"session_type"`
SessionFound bool `json:"session_found"`
DisplayVars int `json:"display_vars"`
SessionPID int `json:"session_pid,omitempty"`
Tools []toolAttempt `json:"tools"`
CapSysPtrace bool `json:"cap_sys_ptrace"`
ProcReadable bool `json:"proc_readable"`
}
// captureScreenLinux captures the display. The agent service does not inherit
@ -89,37 +132,63 @@ type sessionDisplayInfo struct {
// Wayland: grim (wlroots) → gnome-screenshot (GNOME) → spectacle (KDE)
// → magick import (fallback, needs root on some compositors)
// Unknown: try all tools in order
func captureScreenLinux(outputPath string) error {
func captureScreenLinux(outputPath string) (*screenshotDiagnostics, error) {
info := discoverSessionDisplay()
env := append(os.Environ(), info.env...)
log.Printf("[INFO] [agent] [screenshot] session_type=%s display_vars=%d",
orDefault(info.sessionType, "unknown"), len(info.env))
diag := &screenshotDiagnostics{
SessionType: info.sessionType,
SessionFound: info.sessionType != "",
DisplayVars: len(info.env),
SessionPID: info.sourcePID,
CapSysPtrace: hasCapSysPtrace(),
ProcReadable: info.sourcePID != 0,
}
tryCmd := func(name string, args ...string) bool {
if _, err := exec.LookPath(name); err != nil {
log.Printf("[INFO] [agent] [screenshot] session_type=%s display_vars=%d session_pid=%d cap_sys_ptrace=%v",
orDefault(info.sessionType, "unknown"), len(info.env), info.sourcePID, diag.CapSysPtrace)
type cmdFunc func(string, ...string) bool
var tryCmd cmdFunc
tryCmd = func(name string, args ...string) bool {
_, lookErr := exec.LookPath(name)
attempt := toolAttempt{Name: name, Found: lookErr == nil, ExitCode: -1}
if lookErr != nil {
diag.Tools = append(diag.Tools, attempt)
return false
}
attempt.Tried = true
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = env
if out, err := cmd.CombinedOutput(); err != nil {
log.Printf("[WARN] [agent] [screenshot] %s_failed output=%q error=%v", name, string(out), err)
attempt.Stderr = truncate(string(out), 200)
if exitErr, ok := err.(*exec.ExitError); ok {
attempt.ExitCode = exitErr.ExitCode()
}
log.Printf("[WARN] [agent] [screenshot] %s_failed exit=%d output=%q error=%v", name, attempt.ExitCode, string(out), err)
diag.Tools = append(diag.Tools, attempt)
return false
}
attempt.ExitCode = 0
diag.Tools = append(diag.Tools, attempt)
return true
}
var err error
switch info.sessionType {
case "wayland":
return captureWayland(outputPath, env, tryCmd)
err = captureWayland(outputPath, env, tryCmd)
case "x11":
return captureX11(outputPath, tryCmd)
err = captureX11(outputPath, tryCmd)
default:
// Unknown session type — try everything.
return captureFallback(outputPath, tryCmd)
err = captureFallback(outputPath, tryCmd)
}
if err != nil {
return diag, err
}
return diag, nil
}
// captureX11 tries X11 screenshot tools in priority order.
@ -249,7 +318,8 @@ func discoverSessionDisplay() sessionDisplayInfo {
sessionType = "x11"
}
}
return sessionDisplayInfo{env: env, sessionType: sessionType}
pid, _ := strconv.Atoi(entry.Name())
return sessionDisplayInfo{env: env, sessionType: sessionType, sourcePID: pid}
}
}
return sessionDisplayInfo{}
@ -263,6 +333,27 @@ func orDefault(s, fallback string) string {
return fallback
}
// hasCapSysPtrace checks if the current process has CAP_SYS_PTRACE in its
// effective set. Reads /proc/self/status to avoid a cgo dependency on libcap.
func hasCapSysPtrace() bool {
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "CapEff:") {
// CAP_SYS_PTRACE is bit 19 = 0x80000.
fields := strings.Fields(line)
if len(fields) >= 2 {
var cap uint64
fmt.Sscanf(fields[1], "%x", &cap)
return cap&0x80000 != 0
}
}
}
return false
}
// captureScreenWindows captures the display using PowerShell + .NET
// System.Drawing. No extra tools required — always available on Windows.
func captureScreenWindows(outputPath string) error {

View file

@ -21,7 +21,7 @@ services:
context: .
dockerfile: ./server/Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-0.2.7.0}
BUILD_VERSION: ${BUILD_VERSION:-0.2.7.1}
container_name: redflag-server
volumes:
- server-config:/app/config

View file

@ -1,6 +1,6 @@
[package]
name = "redflag-helper"
version = "0.1.0"
version = "0.2.7"
edition = "2021"
publish = false

View file

@ -619,6 +619,10 @@ fn install_staged_agent_binary(staged: &str) -> Result<(), Denial> {
}
}
// Reconcile the unit drop-in before the restart so the restarted agent
// comes up with the capability grant in place.
reconcile_agent_unit_dropin();
// Enqueue the restart with --no-block and return. The agent process is the
// consumer blocked on this helper's stdout pipe; a synchronous restart would
// SIGTERM it before we emit our result (broken pipe). --no-block lets this
@ -640,6 +644,61 @@ fn install_staged_agent_binary(staged: &str) -> Result<(), Denial> {
Ok(())
}
// reconcile_agent_unit_dropin delivers the CAP_SYS_PTRACE grant to fleet units
// that predate the AmbientCapabilities line in the installer template. Self-
// upgrade swaps the binary but never rewrites the installer-owned service file,
// so without this the capability only reaches hosts that re-run the install
// script. A drop-in is additive and idempotent — it never clobbers the unit.
//
// AmbientCapabilities only, never CapabilityBoundingSet: restricting the
// bounding set would strip CAP_SETUID/CAP_SETGID from setuid binaries (sudo)
// inside the unit and break package discovery and the helper invocation path.
//
// Non-fatal on failure: the agent runs without the cap (loses screenshot and
// process discovery), and a failed upgrade is worse than a missing capability.
fn reconcile_agent_unit_dropin() {
const DROPIN_DIR: &str = "/etc/systemd/system/redflag-agent.service.d";
const DROPIN_PATH: &str = "/etc/systemd/system/redflag-agent.service.d/10-capabilities.conf";
const DROPIN_CONTENT: &str = "# Managed by redflag-helper (agent self-upgrade). Do not edit.\n\
# Grants /proc/<pid>/environ read for display/process discovery on units\n\
# installed before the template carried this line.\n\
[Service]\n\
AmbientCapabilities=CAP_SYS_PTRACE\n";
// Already reconciled — skip the write and the daemon-reload.
if fs::read_to_string(DROPIN_PATH).map(|c| c == DROPIN_CONTENT).unwrap_or(false) {
return;
}
if let Err(e) = fs::create_dir_all(DROPIN_DIR) {
log_security(&format!("unit_dropin_dir_failed path={} err={}", DROPIN_DIR, e));
return;
}
if let Err(e) = fs::write(DROPIN_PATH, DROPIN_CONTENT) {
log_security(&format!("unit_dropin_write_failed path={} err={}", DROPIN_PATH, e));
return;
}
// The drop-in only takes effect after a reload; the --no-block restart
// that follows self-upgrade then picks it up.
let status = Command::new("systemctl")
.args(["daemon-reload"])
.env_clear()
.env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
.status();
match status {
Ok(s) if s.success() => {
log_security(&format!("unit_dropin_reconciled path={}", DROPIN_PATH));
}
Ok(s) => {
log_security(&format!("unit_dropin_daemon_reload_failed exit={}", s));
}
Err(e) => {
log_security(&format!("unit_dropin_daemon_reload_error err={}", e));
}
}
}
// install_helper_binary replaces the running helper binary with a verified
// staged copy. This is a self-update: the current process is the helper, and
// we're replacing our own binary on disk. The new binary takes effect on the

View file

@ -1,23 +0,0 @@
#!/bin/bash
# RedFlag Agent Build Script
# Builds agent binary (public key fetched from server at runtime)
set -e
echo "[INFO] [build] RedFlag Agent Build"
echo "================================="
# Build agent
echo "[INFO] [build] Building agent..."
cd agent
go build \
-o redflag-agent \
./cmd/agent
cd ..
echo "[INFO] [build] Agent build complete!"
echo " Binary: agent/redflag-agent"
echo ""
echo "[INFO] [build] Note: Agent will fetch the server's public key automatically at startup"

View file

@ -1,33 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -ne 1 ]; then
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# --- Current version detection ---
# docker-compose.yml BUILD_VERSION is the read point; this script keeps
# versions.go, docker-compose.yml, and Cargo.toml in lockstep, and the
# release gate verifies all three against the tag.
CURRENT=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/docker-compose.yml" 2>/dev/null || echo "unknown")
CURRENT_TAG=$(git -C "$ROOT" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "none")
echo "=== RedFlag Version Bump ==="
echo ""
echo " Current (docker-compose): $CURRENT"
echo " Current (latest git tag): $CURRENT_TAG"
echo ""
# --- Dirty tree check ---
if ! git -C "$ROOT" diff --quiet 2>/dev/null; then
DIRTY=$(git -C "$ROOT" diff --stat --no-color 2>/dev/null | tail -1)
echo "WARNING: Working tree has uncommitted changes:"
echo " $DIRTY"
echo ""
read -rp "Continue anyway? [y/N] " confirm
if [[ ! "$confirm" =~ ^[yY]$ ]]; then
echo "Aborted."
exit 1
fi
fi
# --- Argument handling ---
if [ $# -eq 1 ]; then
NEW_VERSION="$1"
else
echo "Usage: $0 <new-version>"
echo "Example: $0 0.2.2.0"
echo "Example: $0 0.2.8.0"
echo ""
echo "Bump types for current $CURRENT:"
# Parse octets
IFS='.' read -r a b c d <<< "$CURRENT"
echo " patch: $a.$b.$c.$((d+1))"
echo " minor: $a.$b.$((c+1)).0"
echo " major: $a.$((b+1)).0.0"
exit 1
fi
NEW_VERSION="$1"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# Validate format: N.N.N.N
# --- Format validation ---
if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be N.N.N.N (got: $NEW_VERSION)"
exit 1
fi
FILES=(
"server/internal/version/versions.go"
"docker-compose.yml"
)
# --- Duplicate check ---
if [ "$NEW_VERSION" = "$CURRENT" ]; then
echo "Error: new version ($NEW_VERSION) is the same as current ($CURRENT)."
exit 1
fi
# --- CHANGELOG check ---
if ! grep -q "$NEW_VERSION" "$ROOT/CHANGELOG.md" 2>/dev/null; then
echo "WARNING: No entry for $NEW_VERSION in CHANGELOG.md"
echo ""
read -rp "Continue without changelog entry? [y/N] " confirm
if [[ ! "$confirm" =~ ^[yY]$ ]]; then
echo "Aborted. Add an entry to CHANGELOG.md first."
exit 1
fi
fi
# --- Confirmation ---
echo ""
echo "Bumping: $CURRENT -> $NEW_VERSION"
echo ""
echo "Files to modify:"
echo " [1] server/internal/version/versions.go AgentVersion + ConfigVersion"
echo " [2] docker-compose.yml BUILD_VERSION"
echo " [3] helper/Cargo.toml version"
echo ""
read -rp "Proceed? [y/N] " confirm
if [[ ! "$confirm" =~ ^[yY]$ ]]; then
echo "Aborted."
exit 1
fi
echo "Bumping to $NEW_VERSION"
echo ""
# 1. server/internal/version/versions.go — AgentVersion + ConfigVersion
# 1. server/internal/version/versions.go — AgentVersion + ConfigVersion (not MinAgentVersion)
FILE="$ROOT/server/internal/version/versions.go"
sed -i -E "s/(AgentVersion[[:space:]]*=[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\"/\1\"$NEW_VERSION\"/" "$FILE"
sed -i -E "s/(ConfigVersion[[:space:]]*=[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\"/\1\"$NEW_VERSION\"/" "$FILE"
sed -i -E "s/(^[[:space:]]+AgentVersion[[:space:]]*=[[:space:]]*)\"[a-zA-Z0-9.]+\"/\1\"$NEW_VERSION\"/" "$FILE"
sed -i -E "s/(^[[:space:]]+ConfigVersion[[:space:]]*=[[:space:]]*)\"[a-zA-Z0-9.]+\"/\1\"$NEW_VERSION\"/" "$FILE"
echo " [1] server/internal/version/versions.go AgentVersion -> $NEW_VERSION"
echo " [2] server/internal/version/versions.go ConfigVersion -> $NEW_VERSION"
@ -36,6 +97,24 @@ FILE="$ROOT/docker-compose.yml"
sed -i -E "s/(BUILD_VERSION: \\\$\{BUILD_VERSION:-)[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\}/\1$NEW_VERSION}/" "$FILE"
echo " [3] docker-compose.yml BUILD_VERSION -> $NEW_VERSION"
# 3. helper/Cargo.toml — version (semver: 3 parts only, drop 4th octet)
CARGO_VERSION=$(echo "$NEW_VERSION" | cut -d. -f1-3)
FILE="$ROOT/helper/Cargo.toml"
sed -i -E "s/(version[[:space:]]*=[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\"/\1\"$CARGO_VERSION\"/" "$FILE"
echo " [4] helper/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)"
echo ""
echo "Done. Verify with:"
echo " grep -n '$NEW_VERSION' server/internal/version/versions.go docker-compose.yml"
# Refresh action SHA pins so the workflow files ship with current hashes.
echo "--- Updating GitHub Action SHA pins ---"
"$ROOT/scripts/update-action-pins.sh" || true
echo ""
echo "Done. Next steps:"
echo " 1. Update CHANGELOG.md if you haven't already"
echo " 2. git add -A && git commit -m \"v$NEW_VERSION\""
echo " 3. git tag v$NEW_VERSION"
echo " 4. git push gitea-local public --tags"
echo ""
echo "Verify with:"
echo " grep -n '$NEW_VERSION' server/internal/version/versions.go docker-compose.yml helper/Cargo.toml"

257
scripts/release.sh Executable file
View file

@ -0,0 +1,257 @@
#!/usr/bin/env bash
# Guided release for RedFlag. Checks everything, asks before everything.
#
# scripts/release.sh # walks you through, suggests versions
# scripts/release.sh 0.2.8.0 # same, with the target version given
#
# What it verifies before anything is tagged or pushed:
# branch == public, tree clean, local == remote, version lockstep across
# versions.go / docker-compose.yml / Cargo.toml / CHANGELOG, tag is new and
# sorts above every existing tag, Gitea is reachable, Actions is enabled,
# a runner with the right label is registered, the GITEA_TOKEN secret exists
# (offers to create it). Every mutation is shown first and confirmed.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REMOTE="gitea-local"
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
fail() { printf ' \033[31m✗\033[0m %s\n' "$*"; }
ask() { # ask "question" -> returns 0 on yes
local reply
read -rp "$1 [y/N] " reply
[[ "$reply" =~ ^[yY]$ ]]
}
abort() { echo; echo "Aborted. Nothing was pushed."; exit 1; }
# ---------------------------------------------------------------- step 0: gitea
bold "[0/6] Gitea connection"
REMOTE_URL=$(git -C "$ROOT" remote get-url "$REMOTE")
# http://TOKEN@host:port/Owner/Repo.git
TOKEN=$(echo "$REMOTE_URL" | sed -E 's|https?://([^@]+)@.*|\1|')
BASE=$(echo "$REMOTE_URL" | sed -E 's|(https?://)[^@]+@|\1|; s|/[^/]+/[^/]+\.git$||')
OWNER_REPO=$(echo "$REMOTE_URL" | sed -E 's|.*[:/]([^/]+/[^/]+)\.git$|\1|')
API="$BASE/api/v1"
if ! GITEA_VER=$(curl -sf --max-time 5 -H "Authorization: token $TOKEN" "$API/version" | grep -oP '"version":\s*"\K[^"]+'); then
fail "Gitea unreachable at $BASE — is the box up?"
exit 1
fi
ok "Gitea $GITEA_VER at $BASE ($OWNER_REPO)"
if [ "$(curl -s -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO" | grep -oP '"has_actions":\s*\K(true|false)')" != "true" ]; then
fail "Actions is disabled on $OWNER_REPO — enable it in repo Settings → Units."
exit 1
fi
ok "Actions enabled on the repo"
# ------------------------------------------------------------- step 1: runners
bold "[1/6] Runner + secret preflight"
RUNNERS_JSON=$(curl -s -H "Authorization: token $TOKEN" "$API/admin/actions/runners" 2>/dev/null || echo "")
RUNNER_COUNT=$(echo "$RUNNERS_JSON" | grep -oP '"total_count":\s*\K[0-9]+' || echo 0)
if [ "${RUNNER_COUNT:-0}" -eq 0 ]; then
fail "No Actions runner is registered on this Gitea instance."
echo " A tag push will queue the release workflow forever — nothing will run it."
echo " To register one (on any docker-capable box that can reach $BASE):"
echo " 1. Get a registration token: $BASE/-/admin/actions/runners"
echo " 2. docker run -d --name act_runner --restart always \\"
echo " -v /var/run/docker.sock:/var/run/docker.sock \\"
echo " -e GITEA_INSTANCE_URL=$BASE \\"
echo " -e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \\"
echo " docker.io/gitea/act_runner:latest"
echo " The default runner config carries the 'ubuntu-latest' label the workflows need."
ask "Continue anyway (tag will sit queued until a runner exists)?" || abort
else
ok "$RUNNER_COUNT runner(s) registered"
# The workflows declare runs-on labels; make sure at least one runner carries each.
NEEDED=$(grep -rhoP 'runs-on:\s*\K\S+' "$ROOT/.gitea/workflows/" | sort -u)
for label in $NEEDED; do
if echo "$RUNNERS_JSON" | grep -q "\"$label\""; then
ok "runner label '$label' available"
else
warn "no runner advertises label '$label' — jobs declaring it will never start"
fi
done
fi
if curl -s -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO/actions/secrets" | grep -q '"GITEA_TOKEN"'; then
ok "repo secret GITEA_TOKEN exists"
else
warn "repo secret GITEA_TOKEN is missing — docker push and release creation will fail."
echo " The workflows authenticate with secrets.GITEA_TOKEN."
if ask "Create it now from the token in your $REMOTE remote?"; then
HTTP=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$API/repos/$OWNER_REPO/actions/secrets/GITEA_TOKEN" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"data\":\"$TOKEN\"}")
if [ "$HTTP" = "201" ] || [ "$HTTP" = "204" ]; then
ok "secret GITEA_TOKEN created"
else
fail "secret creation returned HTTP $HTTP"
abort
fi
else
ask "Continue without it (release job WILL fail at docker push)?" || abort
fi
fi
# ------------------------------------------------------------ step 2: git state
bold "[2/6] Git state"
BRANCH=$(git -C "$ROOT" branch --show-current)
if [ "$BRANCH" != "public" ]; then
fail "On branch '$BRANCH' — releases come from public."
abort
fi
ok "on public"
git -C "$ROOT" fetch "$REMOTE" public --tags --quiet
AHEAD=$(git -C "$ROOT" rev-list --count "$REMOTE/public..public")
BEHIND=$(git -C "$ROOT" rev-list --count "public..$REMOTE/public")
[ "$BEHIND" -gt 0 ] && { fail "public is $BEHIND commit(s) behind $REMOTE/public — pull/rebase first."; abort; }
[ "$AHEAD" -gt 0 ] && warn "public is $AHEAD commit(s) ahead of $REMOTE/public (they push with the tag)"
[ "$AHEAD" -eq 0 ] && ok "public matches $REMOTE/public"
if ! git -C "$ROOT" diff --quiet || ! git -C "$ROOT" diff --cached --quiet; then
warn "uncommitted changes:"
git -C "$ROOT" status --short | sed 's/^/ /' | head -20
UNTRACKED=$(git -C "$ROOT" status --short | grep -c '^??' || true)
[ "$UNTRACKED" -gt 0 ] && warn "$UNTRACKED untracked file(s) above will NOT be in the release unless added"
if ask "Commit everything (git add -A) before tagging?"; then
read -rp " → Commit message: " MSG
[ -z "$MSG" ] && { fail "empty commit message"; abort; }
git -C "$ROOT" add -A
git -C "$ROOT" commit -m "$MSG"
ok "committed: $MSG"
else
ask "Tag and release WITHOUT the uncommitted changes?" || abort
fi
else
ok "working tree clean"
fi
# -------------------------------------------------------------- step 3: version
bold "[3/6] Version lockstep"
V_SERVER=$(grep -P '^\s*AgentVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')
V_CONFIG=$(grep -P '^\s*ConfigVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')
V_COMPOSE=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' "$ROOT/docker-compose.yml")
V_CARGO=$(grep -m1 '^version' "$ROOT/helper/Cargo.toml" | cut -d'"' -f2)
V_TAG=$(git -C "$ROOT" tag --list 'v*' --sort=-v:refname | head -1 | sed 's/^v//')
echo " versions.go (Agent): $V_SERVER"
echo " versions.go (Config): $V_CONFIG"
echo " docker-compose.yml: $V_COMPOSE"
echo " helper/Cargo.toml: $V_CARGO"
echo " highest existing tag: ${V_TAG:-none}"
NEW_VERSION="${1:-}"
if [ -z "$NEW_VERSION" ]; then
if [ "$V_SERVER" = "$V_COMPOSE" ] && [ "$V_SERVER" != "${V_TAG:-}" ]; then
echo
echo " Tree is already bumped to $V_SERVER (no tag for it yet)."
ask "Release v$V_SERVER?" && NEW_VERSION="$V_SERVER"
fi
if [ -z "$NEW_VERSION" ]; then
IFS='.' read -r a b c d <<< "$V_COMPOSE"
echo
echo " Suggested bumps from $V_COMPOSE:"
echo " patch: $a.$b.$c.$((d+1))"
echo " minor: $a.$b.$((c+1)).0"
echo " major: $a.$((b+1)).0.0"
read -rp " → New version: " NEW_VERSION
fi
fi
[[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || { fail "version must be N.N.N.N (got: $NEW_VERSION)"; abort; }
# Bump the tree if it isn't already at the target.
if [ "$V_SERVER" != "$NEW_VERSION" ] || [ "$V_COMPOSE" != "$NEW_VERSION" ]; then
echo
warn "tree is not at $NEW_VERSION — running bump-version.sh"
"$ROOT/scripts/bump-version.sh" "$NEW_VERSION"
if ask "Commit the bump as 'v$NEW_VERSION'?"; then
git -C "$ROOT" add -A
git -C "$ROOT" commit -m "v$NEW_VERSION"
ok "committed v$NEW_VERSION"
else
fail "bump is uncommitted — the tag would point at a tree without it."
abort
fi
fi
# Same checks the CI gate runs — catch it here, not after the push.
LOCKSTEP_FAIL=0
for pair in "AgentVersion=$(grep -P '^\s*AgentVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')" \
"ConfigVersion=$(grep -P '^\s*ConfigVersion\s*=' "$ROOT/server/internal/version/versions.go" | grep -oP '"\K[^"]+')" \
"docker-compose=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' "$ROOT/docker-compose.yml")"; do
name="${pair%%=*}"; val="${pair#*=}"
if [ "$val" != "$NEW_VERSION" ]; then fail "$name is $val, expected $NEW_VERSION"; LOCKSTEP_FAIL=1; fi
done
CARGO_NOW=$(grep -m1 '^version' "$ROOT/helper/Cargo.toml" | cut -d'"' -f2)
[ "$CARGO_NOW" != "$(echo "$NEW_VERSION" | cut -d. -f1-3)" ] && { fail "Cargo.toml is $CARGO_NOW, expected $(echo "$NEW_VERSION" | cut -d. -f1-3)"; LOCKSTEP_FAIL=1; }
[ "$LOCKSTEP_FAIL" -eq 1 ] && abort
ok "all version sources agree on $NEW_VERSION"
if git -C "$ROOT" rev-parse "v$NEW_VERSION" >/dev/null 2>&1; then
fail "tag v$NEW_VERSION already exists"
abort
fi
HIGHEST=$(printf 'v%s\nv%s\n' "${V_TAG:-0.0.0.0}" "$NEW_VERSION" | sort -V | tail -1)
[ "$HIGHEST" != "v$NEW_VERSION" ] && { fail "v$NEW_VERSION does not sort above v$V_TAG — versions move forward only"; abort; }
ok "v$NEW_VERSION is new and sorts above v${V_TAG:-none}"
# ------------------------------------------------------------ step 4: changelog
bold "[4/6] CHANGELOG"
if grep -q "$NEW_VERSION" "$ROOT/CHANGELOG.md" 2>/dev/null; then
ok "CHANGELOG.md has an entry for $NEW_VERSION"
else
fail "no CHANGELOG.md entry for $NEW_VERSION — the CI gate will reject the tag."
echo " Add the entry, commit it, and rerun. (This script stops here on purpose:"
echo " a release without release notes is the 'what's new?' gap.)"
abort
fi
# ----------------------------------------------------------- step 5: tag + push
bold "[5/6] Tag and push"
echo " About to run:"
echo " git tag v$NEW_VERSION"
echo " git push $REMOTE public --tags"
echo " The tag push triggers the release workflow: gate → build → verify → publish."
ask "Proceed?" || abort
git -C "$ROOT" tag "v$NEW_VERSION"
git -C "$ROOT" push "$REMOTE" public --tags
ok "pushed public + v$NEW_VERSION to $REMOTE"
# ------------------------------------------------------------- step 6: watch CI
bold "[6/6] Watching the workflow"
echo " Polling for the release run (90s max) ..."
for i in $(seq 1 15); do
sleep 6
RUNS=$(curl -s -H "Authorization: token $TOKEN" "$API/repos/$OWNER_REPO/actions/tasks?limit=5")
STATUS=$(echo "$RUNS" | grep -oP '"status":\s*"\K[^"]+' | head -1 || true)
NAME=$(echo "$RUNS" | grep -oP '"name":\s*"\K[^"]+' | head -1 || true)
if [ -n "$STATUS" ]; then
echo " run '$NAME' → $STATUS"
case "$STATUS" in
success) ok "release pipeline finished"; break ;;
failure) fail "pipeline failed — see $BASE/$OWNER_REPO/actions"; exit 1 ;;
esac
else
echo " no run picked up yet (waiting on a runner?)"
fi
done
echo
echo "Done. Watch it live: $BASE/$OWNER_REPO/actions"
echo "Release lands at: $BASE/$OWNER_REPO/releases"

112
scripts/update-action-pins.sh Executable file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Resolves and updates pinned GitHub Action SHAs in Gitea workflow files.
#
# Reads lines matching: uses: org/repo@<40-hex-sha> # <ref>
# Queries GitHub API for the current SHA at <ref> (tag or branch).
# Updates the file in-place if the SHA has changed.
#
# Usage: scripts/update-action-pins.sh [--check]
# --check Report stale pins and exit non-zero if any found (CI mode).
#
# Depends on: curl, python3
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORKFLOWS_DIR="$ROOT/.gitea/workflows"
CHECK_ONLY=0
CHANGED=0
if [ "${1:-}" = "--check" ]; then
CHECK_ONLY=1
fi
resolve_sha() {
local repo="$1"
local ref="$2"
local result type sha
# Try as tag.
result=$(curl -sf "https://api.github.com/repos/$repo/git/ref/tags/$ref" 2>/dev/null || echo "")
type=$(echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('object',{}).get('type',''))" 2>/dev/null || echo "")
if [ "$type" = "commit" ]; then
echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['object']['sha'])"
return 0
elif [ "$type" = "tag" ]; then
# Annotated tag object — dereference to the commit it wraps.
sha=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['object']['sha'])")
curl -sf "https://api.github.com/repos/$repo/git/tags/$sha" 2>/dev/null \
| python3 -c "import sys,json; print(json.load(sys.stdin)['object']['sha'])"
return 0
fi
# Fall through: try as branch.
result=$(curl -sf "https://api.github.com/repos/$repo/git/ref/heads/$ref" 2>/dev/null || echo "")
type=$(echo "$result" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('object',{}).get('type',''))" 2>/dev/null || echo "")
if [ "$type" = "commit" ]; then
echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['object']['sha'])"
return 0
fi
echo ""
return 1
}
process_workflow() {
local file="$1"
while IFS= read -r line; do
# Match: uses: org/repo@<40-hex> # <ref>
if [[ "$line" =~ uses:[[:space:]]+([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)@([0-9a-f]{40})[[:space:]]+#[[:space:]]+([a-zA-Z0-9_./-]+) ]]; then
local repo="${BASH_REMATCH[1]}"
local current="${BASH_REMATCH[2]}"
local ref="${BASH_REMATCH[3]}"
local new
new=$(resolve_sha "$repo" "$ref") || {
echo " WARN $repo@$ref could not resolve"
continue
}
if [ -z "$new" ]; then
echo " WARN $repo@$ref empty response from API"
continue
fi
if [ "$current" != "$new" ]; then
if [ "$CHECK_ONLY" -eq 1 ]; then
echo " STALE $repo current=${current:0:12} latest=${new:0:12} (# $ref)"
else
sed -i "s|$repo@$current|$repo@$new|g" "$file"
echo " BUMP $repo ${current:0:12} -> ${new:0:12} (# $ref)"
fi
CHANGED=1
else
echo " OK $repo@${current:0:12} (# $ref)"
fi
fi
done < "$file"
}
echo "=== GitHub Action SHA pins ==="
if [ "$CHECK_ONLY" -eq 1 ]; then
echo "(check mode — no files modified)"
fi
echo ""
for workflow in "$WORKFLOWS_DIR"/*.yml; do
echo "$(basename "$workflow")"
process_workflow "$workflow"
echo ""
done
if [ "$CHANGED" -eq 1 ] && [ "$CHECK_ONLY" -eq 1 ]; then
echo "Stale pins found. Run scripts/update-action-pins.sh to update."
exit 1
elif [ "$CHANGED" -eq 1 ]; then
echo "Pins updated. Stage and commit the workflow files."
else
echo "All pins are current."
fi

View file

@ -851,6 +851,17 @@ RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
# CAP_SYS_PTRACE: lets the agent read /proc/<pid>/environ from the logged-in
# user's session processes. Required for display/Wayland discovery (screenshot
# capture) and per-process telemetry. AmbientCapabilities grants it to the
# process regardless of file caps, so self-upgrade (binary replacement) does
# not lose it.
# Deliberately no CapabilityBoundingSet here: restricting the bounding set to
# CAP_SYS_PTRACE would strip CAP_SETUID/CAP_SETGID from setuid binaries run
# inside the unit — sudo would fail, killing package discovery and the helper
# invocation path.
AmbientCapabilities=CAP_SYS_PTRACE
# Logging
StandardOutput=journal
StandardError=journal

View file

@ -10,10 +10,13 @@ import (
// Version coordination for Server Authority model
// The server is the single source of truth for all version information
// Build-time injected version information (SERVER AUTHORITY)
// Version information (SERVER AUTHORITY).
// Values are maintained by scripts/bump-version.sh and must match the release
// tag — the release gate enforces this. ldflags may override at build time;
// the release pipeline injects the tag so binaries and source agree.
var (
AgentVersion = "0.2.7.0"
ConfigVersion = "0.2.7.0"
AgentVersion = "0.2.7.1"
ConfigVersion = "0.2.7.1"
MinAgentVersion = "0.1.22"
)