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:
parent
2f3363cbce
commit
4896fb6856
13 changed files with 991 additions and 72 deletions
105
.gitea/workflows/ci.yml
Normal file
105
.gitea/workflows/ci.yml
Normal 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."
|
||||
211
.gitea/workflows/release.yml
Normal file
211
.gitea/workflows/release.yml
Normal 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"
|
||||
40
Makefile
40
Makefile
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "redflag-helper"
|
||||
version = "0.1.0"
|
||||
version = "0.2.7"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
257
scripts/release.sh
Executable 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
112
scripts/update-action-pins.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue