2
0

Compare commits

...

38 Commits

Author SHA1 Message Date
ee5fd838b8 feat(labels): support schema validation in runs-on matching
Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (amd64, darwin) (push) Successful in 53s
Release / build (amd64, linux) (push) Successful in 1m4s
Release / build (amd64, windows) (push) Successful in 50s
Release / build (arm64, darwin) (push) Successful in 48s
Release / build (arm64, linux) (push) Successful in 51s
Release / release (push) Successful in 21s
Enhances PickPlatform to validate schema when runs-on includes explicit mode (e.g., "linux:host" or "ubuntu:docker"). Parses schema suffix from runs-on values and ensures it matches the runner's configured schema for that label. Returns empty string on schema mismatch to prevent jobs from running in wrong environment (e.g., workflow requesting :host but runner only has :docker). Adds test coverage for schema matching, mismatches, and backward compatibility with schema-less runs-on values.
2026-02-09 02:30:24 -05:00
17f78a5e4c feat(runner): merge admin-added labels from server on declare
Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (amd64, darwin) (push) Successful in 49s
Release / build (amd64, linux) (push) Successful in 1m4s
Release / build (amd64, windows) (push) Successful in 52s
Release / build (arm64, darwin) (push) Successful in 54s
Release / build (arm64, linux) (push) Successful in 47s
Release / release (push) Successful in 21s
Adds MergeServerLabels method to sync labels added by admins in Gitea UI with runner's local configuration. Called after successful declare response to incorporate any server-side label changes. Skips duplicate labels and logs invalid entries. Enables dynamic label management without requiring runner restart or config file edits.
2026-02-09 02:15:23 -05:00
522ee44718 fix(labels): return empty string on label mismatch instead of fallback
Some checks failed
Release / build (amd64, darwin) (push) Successful in 53s
Release / build (amd64, linux) (push) Successful in 1m8s
Release / build (amd64, windows) (push) Successful in 50s
Release / build (arm64, darwin) (push) Successful in 1m1s
Release / build (arm64, linux) (push) Successful in 53s
Release / release (push) Successful in 21s
CI / build-and-test (push) Failing after 45s
Changes PickPlatform to return empty string when no matching label is found, causing the job to fail with a clear error rather than silently falling back to ubuntu-latest. This prevents jobs from running in incorrect environments when runner labels are edited in Gitea admin UI after registration. Adds comprehensive test coverage for label matching scenarios including exact matches, no matches, empty labels, and multiple runsOn values.
2026-02-09 01:56:57 -05:00
d87b08c559 Merge branch 'main' of https://git.marketally.com/gitcaddy/gitcaddy-runner
All checks were successful
CI / build-and-test (push) Successful in 57s
2026-01-27 22:50:26 -05:00
259238eedf docs(detached-note): add runner user guide and update deployment examples
Add comprehensive GUIDE.md (1000+ lines) covering GitCaddy Runner installation, registration, configuration, deployment options (Docker, Kubernetes, VM), workflow examples, artifact handling, cache server setup, and troubleshooting.

Update all deployment example READMEs with improved instructions and clarifications for Docker Compose, Kubernetes (DinD and rootless), and VM deployments. Enhance YAML configurations with better comments and security practices.
2026-01-27 22:50:23 -05:00
f33d0a54c4 refactor(client): simplify HTTP client and reporter daemon implementation
Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (amd64, darwin) (push) Successful in 57s
Release / build (amd64, linux) (push) Successful in 1m4s
Release / build (amd64, windows) (push) Successful in 1m13s
Release / build (arm64, linux) (push) Successful in 53s
Release / build (arm64, darwin) (push) Successful in 1m16s
Release / release (push) Successful in 24s
Use http.DefaultClient when TLS verification is not skipped, removing unnecessary custom transport configuration. Replace select-based daemon loop with time.AfterFunc for cleaner implementation and remove verbose error logging in RunDaemon.
2026-01-25 14:31:47 -05:00
899ca015b1 fix(poll): revert context inheritance to prevent deadlock
All checks were successful
CI / build-and-test (push) Successful in 1m0s
Release / build (amd64, linux) (push) Successful in 1m8s
Release / build (amd64, darwin) (push) Successful in 1m25s
Release / build (amd64, windows) (push) Successful in 51s
Release / build (arm64, darwin) (push) Successful in 1m0s
Release / build (arm64, linux) (push) Successful in 1m9s
Release / release (push) Successful in 26s
The v1.0.3 change that made poller contexts inherit from the parent
context caused a deadlock where runners would start but never poll
for tasks.

Reverted to using context.Background() for pollingCtx and jobsCtx.
Graceful shutdown still works via explicit Shutdown() call which
cancels the polling context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:51:47 -05:00
e1b9b277ee build(actions): update go version to 1.25.5
All checks were successful
CI / build-and-test (push) Successful in 1m2s
Release / build (amd64, linux) (push) Successful in 55s
Release / build (amd64, darwin) (push) Successful in 1m7s
Release / build (amd64, windows) (push) Successful in 1m16s
Release / build (arm64, darwin) (push) Successful in 1m3s
Release / build (arm64, linux) (push) Successful in 50s
Release / release (push) Successful in 17s
2026-01-25 12:48:20 -05:00
826ecfb433 chore(ci): clarify cache clearing and remove verbose flag
Some checks failed
CI / build-and-test (push) Failing after 46s
Update step name to better describe purpose and remove -x flag from go mod download to reduce log noise
2026-01-25 12:43:32 -05:00
5ac01b2dc9 ci(deps): clear module cache before downloading deps
Some checks failed
CI / build-and-test (push) Failing after 57s
Add module cache clearing step and enable verbose output for dependency downloads to help diagnose potential caching issues with private modules
2026-01-25 12:27:22 -05:00
f984198d4d ci(deps): add explicit dependency download step
Some checks failed
CI / build-and-test (push) Failing after 23s
Download Go modules before running vet to ensure all dependencies are available, especially private modules from git.marketally.com
2026-01-25 12:23:12 -05:00
607c332313 build(deps): sync go.sum with Go 1.25.0
Some checks failed
CI / build-and-test (push) Failing after 23s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 12:17:20 -05:00
50480c989c build(deps): update go version to 1.25.0
Some checks failed
CI / build-and-test (push) Failing after 23s
Remove explicit toolchain directive and update to Go 1.25.0
2026-01-25 12:12:49 -05:00
bf71b55cb7 style(service): improve comments and fix linter warnings
Some checks failed
CI / build-and-test (push) Failing after 24s
Release / build (amd64, darwin) (push) Failing after 38s
Release / build (amd64, linux) (push) Failing after 40s
Release / build (amd64, windows) (push) Failing after 32s
Release / build (arm64, darwin) (push) Failing after 31s
Release / build (arm64, linux) (push) Failing after 39s
Release / release (push) Has been skipped
- Add package documentation comments
- Use blank identifiers for unused parameters
- Add periods to comment sentences for consistency
- Fix naked return statement
2026-01-25 11:44:38 -05:00
26b4e7497f Merge branch 'main' of https://git.marketally.com/gitcaddy/gitcaddy-runner 2026-01-25 11:42:24 -05:00
b2922e332a feat(i18n): add windows service support and graceful shutdown
- Add native Windows service detection and signal handling
- Implement configurable shutdown timeout for graceful job completion
- Improve HTTP client with connection pooling and timeouts
- Propagate context through poller for proper shutdown coordination
- Add documentation for Windows service installation (NSSM and sc.exe)
- Add *.exe to .gitignore for Windows builds
2026-01-25 11:40:30 -05:00
d388ec5519 chore(scanner): add gitsecrets ignore file
Some checks failed
CI / build-and-test (push) Has been cancelled
Initializes .gitsecrets-ignore file to track false positives from secret scanning. Includes documentation header explaining the file format and usage.
2026-01-24 14:42:10 -05:00
cb1c1a3264 Add LICENSE.md (MIT)
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-23 00:53:55 +00:00
63967eb6fa style(ui): add package docs and mark unused parameters
Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
Adds package-level documentation comments across cmd and internal packages. Marks unused function parameters with underscore prefix to satisfy linter requirements. Replaces if-else chains with switch statements for better readability. Explicitly ignores os.Setenv return value where error handling is not needed.
2026-01-19 01:16:47 -05:00
22f1ea6e76 chore(ui): update golangci-lint config and cleanup package docs
Updates golangci-lint configuration to v2 format with Go 1.23, streamlines linter settings by removing deprecated options and unnecessary exclusions. Adds package documentation and renames CleanupResult to Result for consistency. Marks unused context parameter with underscore.
2026-01-19 01:03:07 -05:00
GitCaddy Bot
4d6900b7a3 Update README download URLs to v1.0.0
Some checks failed
Release / release (push) Has been cancelled
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:52:04 -05:00
GitCaddy Bot
898ef596ae Fix release workflow to use gitcaddy-runner naming
Some checks failed
CI / build-and-test (push) Successful in 1m15s
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, darwin) (push) Has been cancelled
- Update ldflags to use git.marketally.com/gitcaddy/gitcaddy-runner path
- Rename output binaries from act_runner to gitcaddy-runner
- Update artifact names to match new naming convention

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:49:46 -05:00
GitCaddy Bot
eb37073861 Fix project name in goreleaser config
All checks were successful
CI / build-and-test (push) Successful in 53s
Release / build (amd64, linux) (push) Successful in 1m7s
Release / build (amd64, darwin) (push) Successful in 1m8s
Release / build (amd64, windows) (push) Successful in 1m17s
Release / build (arm64, darwin) (push) Successful in 48s
Release / build (arm64, linux) (push) Successful in 54s
Release / release (push) Successful in 17s
- Add project_name: gitcaddy-runner so binaries are named correctly
- Update gitea_urls to point to git.marketally.com instead of gitea.com

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 10:41:56 -05:00
GitCaddy Bot
ec9b323318 Rebrand from act_runner to gitcaddy-runner v1.0.0
All checks were successful
Release / build (amd64, linux) (push) Successful in 1m15s
CI / build-and-test (push) Successful in 1m7s
Release / build (amd64, windows) (push) Successful in 1m3s
Release / build (amd64, darwin) (push) Successful in 1m8s
Release / build (arm64, darwin) (push) Successful in 46s
Release / build (arm64, linux) (push) Successful in 50s
Release / release (push) Successful in 26s
- Update module path: gitea.com/gitea/act_runner → git.marketally.com/gitcaddy/gitcaddy-runner
- Update all import paths across Go source files
- Update Makefile LDFLAGS and package references
- Update .goreleaser.yaml ldflags and S3 directory path
- Add comprehensive README with capacity configuration documentation
- Document troubleshooting for capacity feature and Docker detection
- Bump version to v1.0.0 for major rebrand
- All linting checks passed (fmt-check, go mod tidy, go vet)
2026-01-16 10:31:58 -05:00
GitCaddy
d955727863 Fix formatting (gofmt, remove BOM)
All checks were successful
CI / build-and-test (push) Successful in 1m13s
Release / build (amd64, darwin) (push) Successful in 57s
Release / build (amd64, linux) (push) Successful in 55s
Release / build (amd64, windows) (push) Successful in 54s
Release / build (arm64, darwin) (push) Successful in 53s
Release / build (arm64, linux) (push) Successful in 52s
Release / release (push) Successful in 19s
2026-01-15 13:09:06 +00:00
GitCaddy
3addd66efa Report runner capacity in capabilities JSON
Some checks failed
CI / build-and-test (push) Failing after 20s
2026-01-15 13:06:30 +00:00
GitCaddy
b6d700af60 fix: Use PowerShell instead of deprecated wmic for Windows CPU detection
Some checks failed
CI / build-and-test (push) Failing after 37s
Release / build (amd64, linux) (push) Successful in 1m6s
Release / build (amd64, darwin) (push) Successful in 1m22s
Release / build (amd64, windows) (push) Successful in 49s
Release / build (arm64, darwin) (push) Successful in 1m1s
Release / build (arm64, linux) (push) Successful in 49s
Release / release (push) Successful in 18s
wmic is deprecated in newer Windows versions and returns empty results.
Use Get-CimInstance Win32_Processor via PowerShell instead.
2026-01-14 18:00:21 +00:00
GitCaddy
7c0d11c353 chore: Reduce go-build cache retention to 3 days
Some checks failed
CI / build-and-test (push) Failing after 33s
2026-01-14 12:19:38 +00:00
GitCaddy
b9ae4d5f36 feat: Add auto-cleanup and fix container CPU detection
Some checks failed
CI / build-and-test (push) Failing after 37s
- Add automatic disk cleanup when usage exceeds 85%
- Fix false CPU readings in LXC containers (was showing host load)
- Add cross-platform cache cleanup (Linux, macOS, Windows)
- Extend temp file patterns for go-build, node-compile-cache, etc.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:12:34 +00:00
GitCaddy
3a66563c1e chore: Fix gofmt formatting in runner.go
All checks were successful
CI / build-and-test (push) Successful in 1m1s
Release / build (amd64, darwin) (push) Successful in 50s
Release / build (amd64, linux) (push) Successful in 1m0s
Release / build (amd64, windows) (push) Successful in 1m7s
Release / build (arm64, darwin) (push) Successful in 1m27s
Release / build (arm64, linux) (push) Successful in 1m2s
Release / release (push) Successful in 54s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:57:48 +00:00
GitCaddy
e0feb6bd4e chore: Remove gitea-vet from build process
Some checks failed
CI / build-and-test (push) Failing after 30s
Release / build (amd64, darwin) (push) Failing after 1m20s
Release / build (arm64, darwin) (push) Failing after 1m32s
Release / build (amd64, windows) (push) Failing after 1m40s
Release / build (arm64, linux) (push) Successful in 1m30s
Release / release (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Use standard go vet instead of gitea-vet for copyright checks.
This allows MarketAlly copyright headers in new files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 09:53:17 +00:00
GitCaddy
0db86bc6a4 chore: Fix linter issues and update copyrights
Some checks failed
CI / build-and-test (push) Failing after 55s
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
- Format Go files with gofmt
- Update copyrights to include MarketAlly
- Add MarketAlly copyright to files we created

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 09:41:16 +00:00
GitCaddy
f5b22c4149 feat: Add build cache cleanup and CLI cleanup command
Some checks failed
CI / build-and-test (push) Failing after 30s
- Add cleanup for common build tool caches (Go, npm, NuGet, Gradle, Maven, pip, Cargo)
- Build caches cleaned for files older than 7 days
- Add gitcaddy-runner cleanup CLI command for manual cleanup trigger
- Fixes disk space issues from accumulated CI build artifacts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 09:26:21 +00:00
GitCaddy
0ba2e0c3d5 feat: Add CPU load monitoring and cleanup support
Some checks failed
CI / build-and-test (push) Failing after 55s
- Add CPUInfo struct with load average and percentage
- Add detectCPULoad() for Linux, macOS, and Windows
- Add cleanup package for disk space management
- Handle RequestCleanup signal from server
- Report CPU load in capabilities to server

🤖 Generated with Claude Code
2026-01-14 08:48:54 +00:00
GitCaddy
8a54ec62d4 fix: Use linux-latest instead of ubuntu-latest
Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (amd64, darwin) (push) Has been cancelled
Release / build (amd64, linux) (push) Has been cancelled
Release / build (amd64, windows) (push) Has been cancelled
Release / build (arm64, darwin) (push) Has been cancelled
Release / build (arm64, linux) (push) Has been cancelled
Release / release (push) Has been cancelled
2026-01-14 07:39:18 +00:00
GitCaddy
587ac42be4 feat: Rebrand to gitcaddy-runner with upload helper
Some checks failed
Release / build (amd64, linux) (push) Successful in 1m12s
Release / build (amd64, darwin) (push) Successful in 1m16s
Release / build (arm64, darwin) (push) Successful in 1m0s
Release / build (amd64, windows) (push) Successful in 1m13s
Release / build (arm64, linux) (push) Successful in 45s
Release / release (push) Successful in 50s
CI / build-and-test (push) Has been cancelled
- Rename binary from act_runner to gitcaddy-runner
- Update all user-facing strings (Gitea → GitCaddy)
- Add gitcaddy-upload helper with automatic retry for large files
- Add upload helper package (internal/pkg/artifact)
- Update Docker image name to marketally/gitcaddy-runner

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-14 07:26:46 +00:00
GitCaddy
56dcda0d5e fix: remove binaries from git tracking
Some checks failed
CI / build-and-test (push) Has been cancelled
Release / build (amd64, darwin) (push) Successful in 1m22s
Release / build (arm64, darwin) (push) Successful in 2m9s
Release / build (amd64, linux) (push) Successful in 2m19s
Release / build (amd64, windows) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 1m9s
Release / release (push) Successful in 21s
2026-01-12 01:36:19 +00:00
GitCaddy
e44f0c403b fix: remove accidentally committed binaries and add to gitignore
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-01-12 01:35:38 +00:00
55 changed files with 2998 additions and 505 deletions

View File

@@ -44,14 +44,14 @@ jobs:
fi
echo "Building version: ${VERSION}"
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -a -ldflags "-X gitea.com/gitea/act_runner/internal/pkg/ver.version=${VERSION}" \
-o act_runner-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}
go build -a -ldflags "-X git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver.version=${VERSION}" \
-o gitcaddy-runner-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}${EXT}
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: act_runner-${{ matrix.goos }}-${{ matrix.goarch }}
path: act_runner-*
name: gitcaddy-runner-${{ matrix.goos }}-${{ matrix.goarch }}
path: gitcaddy-runner-*
release:
needs: build
@@ -67,7 +67,7 @@ jobs:
- name: Prepare release files
run: |
mkdir -p release
find artifacts -type f -name 'act_runner-*' -exec mv {} release/ \;
find artifacts -type f -name 'gitcaddy-runner-*' -exec mv {} release/ \;
cd release && sha256sum * > checksums.txt
- name: Create Release

View File

@@ -8,7 +8,7 @@ on:
jobs:
build-and-test:
runs-on: ubuntu-latest
runs-on: linux-latest
steps:
- uses: actions/checkout@v4
@@ -17,6 +17,14 @@ jobs:
go-version-file: 'go.mod'
cache: false
- name: Clear stale module cache
run: go clean -modcache
- name: Download dependencies
run: go mod download
env:
GOPRIVATE: git.marketally.com
- name: Vet
run: make vet
env:

2
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/act_runner
*.exe
.env
.runner
coverage.txt
@@ -12,3 +13,4 @@ coverage.txt
__debug_bin
# gorelease binary folder
dist
act_runner-*

14
.gitsecrets-ignore Normal file
View File

@@ -0,0 +1,14 @@
# GitSecrets Ignore File
# This file tracks false positives identified by AI evaluation or manually marked.
# Each line is a JSON object with the following fields:
# - contentHash: SHA256 hash prefix of the secret content
# - patternId: The pattern that detected this secret
# - filePath: Relative path where the secret was found
# - reason: Why this was marked as a false positive
# - confidence: AI confidence level (if from AI evaluation)
# - addedAt: Timestamp when this entry was added
#
# You can safely commit this file to share false positive markers with your team.
# To remove an entry, simply delete the corresponding line.
{"contentHash":"5af30500c6463ec4","patternId":"password-assignment","filePath":"..\\gitcaddy\\internal\\app\\cmd\\register.go","reason":"Manually marked as false positive","addedAt":1769249840525}

View File

@@ -1,53 +1,42 @@
version: "2"
linters:
default: none
enable:
- gosimple
- typecheck
- govet
- errcheck
- staticcheck
- unused
- dupl
#- gocyclo # The cyclomatic complexety of a lot of functions is too high, we should refactor those another time.
- gofmt
- misspell
- gocritic
- bidichk
- ineffassign
- revive
- gofumpt
- depguard
- nakedret
- unconvert
- wastedassign
- nolintlint
- stylecheck
enable-all: false
disable-all: true
fast: false
formatters:
enable:
- gofmt
- gofumpt
run:
go: 1.18
go: "1.23"
timeout: 10m
skip-dirs:
- node_modules
- public
- web_src
linters-settings:
stylecheck:
checks: ["all", "-ST1005", "-ST1003"]
nakedret:
max-func-lines: 0
gocritic:
disabled-checks:
- ifElseChain
- singleCaseSwitch # Every time this occurred in the code, there was no other way.
- singleCaseSwitch
revive:
ignore-generated-header: false
severity: warning
confidence: 0.8
errorCode: 1
warningCode: 1
rules:
- name: blank-imports
- name: context-as-argument
@@ -72,94 +61,25 @@ linters-settings:
- name: modifies-value-receiver
gofumpt:
extra-rules: true
lang-version: "1.18"
depguard:
# TODO: use depguard to replace import checks in gitea-vet
list-type: denylist
# Check the list against standard lib.
include-go-root: true
packages-with-error-message:
- github.com/unknwon/com: "use gitea's util and replacements"
issues:
exclude-rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
- unparam
- staticcheck
- path: models/migrations/v
linters:
- gocyclo
- errcheck
- dupl
- gosec
- linters:
- dupl
text: "webhook"
- linters:
- gocritic
text: "`ID' should not be capitalized"
- path: modules/templates/helper.go
linters:
- gocritic
- linters:
- unused
text: "swagger"
- path: contrib/pr/checkout.go
linters:
- errcheck
- path: models/issue.go
linters:
- errcheck
- path: models/migrations/
linters:
- errcheck
- path: modules/log/
linters:
- errcheck
- path: routers/api/v1/repo/issue_subscription.go
linters:
- dupl
- path: routers/repo/view.go
linters:
- dupl
- path: models/migrations/
linters:
- unused
- linters:
- staticcheck
text: "argument x is overwritten before first use"
- path: modules/httplib/httplib.go
linters:
- staticcheck
# Enabling this would require refactoring the methods and how they are called.
- path: models/issue_comment_list.go
linters:
- dupl
- linters:
- misspell
text: '`Unknwon` is a misspelling of `Unknown`'
- path: models/update.go
linters:
- unused
- path: cmd/dump.go
linters:
- dupl
- text: "commentFormatting: put a space between `//` and comment text"
linters:
- gocritic
- text: "exitAfterDefer:"
linters:
- gocritic
- path: modules/graceful/manager_windows.go
linters:
- staticcheck
text: "svc.IsAnInteractiveSession is deprecated: Use IsWindowsService instead."
- path: models/user/openid.go
linters:
- golint

View File

@@ -1,5 +1,7 @@
version: 2
project_name: gitcaddy-runner
before:
hooks:
- go mod tidy
@@ -63,7 +65,7 @@ builds:
flags:
- -trimpath
ldflags:
- -s -w -X gitea.com/gitea/act_runner/internal/pkg/ver.version={{ .Summary }}
- -s -w -X git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver.version={{ .Summary }}
binary: >-
{{ .ProjectName }}-
{{- .Version }}-
@@ -86,7 +88,7 @@ blobs:
provider: s3
bucket: "{{ .Env.S3_BUCKET }}"
region: "{{ .Env.S3_REGION }}"
directory: "act_runner/{{.Version}}"
directory: "gitcaddy-runner/{{.Version}}"
extra_files:
- glob: ./**.xz
- glob: ./**.sha256
@@ -108,8 +110,8 @@ nightly:
version_template: "nightly"
gitea_urls:
api: https://gitea.com/api/v1
download: https://gitea.com
api: https://git.marketally.com/api/v1
download: https://git.marketally.com
release:
extra_files:

1039
GUIDE.md Normal file
View File

File diff suppressed because it is too large Load Diff

18
LICENSE.md Normal file
View File

@@ -0,0 +1,18 @@
MIT License
Copyright (c) 2026 gitcaddy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,5 +1,5 @@
DIST := dist
EXECUTABLE := act_runner
EXECUTABLE := gitcaddy-runner
GOFMT ?= gofumpt -l
DIST_DIRS := $(DIST)/binaries $(DIST)/release
GO ?= go
@@ -15,7 +15,7 @@ WINDOWS_ARCHS ?= windows/amd64
GO_FMT_FILES := $(shell find . -type f -name "*.go" ! -name "generated.*")
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
DOCKER_IMAGE ?= gitea/act_runner
DOCKER_IMAGE ?= marketally/gitcaddy-runner
DOCKER_TAG ?= nightly
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
@@ -67,11 +67,11 @@ else
endif
endif
GO_PACKAGES_TO_VET ?= $(filter-out gitea.com/gitea/act_runner/internal/pkg/client/mocks,$(shell $(GO) list ./...))
GO_PACKAGES_TO_VET ?= $(filter-out git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client/mocks,$(shell $(GO) list ./...))
TAGS ?=
LDFLAGS ?= -X "gitea.com/gitea/act_runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
LDFLAGS ?= -X "git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver.version=v$(RELASE_VERSION)"
all: build
@@ -117,8 +117,7 @@ test: fmt-check security-check
.PHONY: vet
vet:
@echo "Running go vet..."
@$(GO) build code.gitea.io/gitea-vet
@$(GO) vet -vettool=gitea-vet $(GO_PACKAGES_TO_VET)
@$(GO) vet $(GO_PACKAGES_TO_VET)
install: $(GOFILES)
$(GO) install -v -tags '$(TAGS)' -ldflags '$(EXTLDFLAGS)-s -w $(LDFLAGS)'

590
README.md
View File

@@ -1,121 +1,81 @@
# GitCaddy Act Runner
# GitCaddy Runner
A Gitea Actions runner with enhanced capability detection and reporting for AI-friendly workflow generation.
> **This is a GitCaddy fork** of [gitea.com/gitea/act_runner](https://gitea.com/gitea/act_runner) with runner capability discovery features.
GitCaddy Runner is a hard fork of Gitea's act_runner, rebranded and enhanced with automated capability detection to enable AI tools to generate compatible workflows based on available resources.
## Overview
## Features
Act Runner executes Gitea Actions workflows using [act](https://github.com/nektos/act). This fork adds automatic capability detection, enabling Gitea to expose runner capabilities via API for AI tools to query before generating workflows.
## Key Features
- **Capability Detection**: Automatically detects OS, architecture, Docker support, available shells, and installed tools
- **Capability Reporting**: Reports capabilities to Gitea server during runner declaration
- **Full Compatibility**: Drop-in replacement for standard act_runner
- **Multi-Platform**: Supports Linux, macOS, and Windows
- **Automated Capability Detection**: Automatically identifies OS, architecture, installed tools, and available resources
- **Concurrent Job Execution**: Configure runner capacity to handle multiple jobs simultaneously
- **Docker Support**: Full support for Docker and Docker Compose workflows
- **Xcode Integration**: Detects Xcode installations, SDKs, and simulators on macOS
- **Tool Detection**: Identifies installed tools (Node.js, Python, .NET, Go, Ruby, Swift, etc.)
- **AI-Friendly API**: Exposes capabilities through Gitea's API for automated workflow generation
- **Cache Support**: Built-in workflow cache support for faster builds
## Installation
### Download Pre-built Binary
### Pre-built Binaries
Download from [Releases](https://git.marketally.com/gitcaddy/act_runner/releases):
Download the latest release for your platform from the [releases page](https://git.marketally.com/gitcaddy/gitcaddy-runner/releases):
**macOS:**
```bash
# Linux (amd64)
curl -L -o act_runner https://git.marketally.com/gitcaddy/act_runner/releases/download/v0.3.1-gitcaddy/act_runner-linux-amd64
chmod +x act_runner
# Apple Silicon (M1/M2/M3/M4)
curl -L -o gitcaddy-runner https://git.marketally.com/gitcaddy/gitcaddy-runner/releases/download/v1.0.0/gitcaddy-runner-1.0.0-darwin-arm64
chmod +x gitcaddy-runner
# macOS (Apple Silicon)
curl -L -o act_runner https://git.marketally.com/gitcaddy/act_runner/releases/download/v0.3.1-gitcaddy/act_runner-darwin-arm64
chmod +x act_runner
# Intel
curl -L -o gitcaddy-runner https://git.marketally.com/gitcaddy/gitcaddy-runner/releases/download/v1.0.0/gitcaddy-runner-1.0.0-darwin-amd64
chmod +x gitcaddy-runner
```
**Linux:**
```bash
# x86_64
curl -L -o gitcaddy-runner https://git.marketally.com/gitcaddy/gitcaddy-runner/releases/download/v1.0.0/gitcaddy-runner-1.0.0-linux-amd64
chmod +x gitcaddy-runner
# ARM64
curl -L -o gitcaddy-runner https://git.marketally.com/gitcaddy/gitcaddy-runner/releases/download/v1.0.0/gitcaddy-runner-1.0.0-linux-arm64
chmod +x gitcaddy-runner
```
**Windows:**
```powershell
# Download the Windows executable
# https://git.marketally.com/gitcaddy/gitcaddy-runner/releases/download/v1.0.0/gitcaddy-runner-1.0.0-windows-amd64.exe
```
### Build from Source
```bash
git clone https://git.marketally.com/gitcaddy/act_runner.git
cd act_runner
git clone https://git.marketally.com/gitcaddy/gitcaddy-runner.git
cd gitcaddy-runner
make build
```
## Quick Start
### 1. Enable Actions in Gitea
### 1. Enable Gitea Actions
Add to your Gitea `app.ini`:
In your Gitea `app.ini`:
```ini
[actions]
ENABLED = true
```
### 2. Register the Runner
### 2. Generate Configuration
```bash
./act_runner register \
--instance https://your-gitea-instance.com \
--token YOUR_RUNNER_TOKEN \
--name my-runner \
--labels ubuntu-latest,docker
./gitcaddy-runner generate-config > config.yaml
```
### 3. Start the Runner
### 3. Configure the Runner
```bash
./act_runner daemon
```
On startup, the runner will:
1. Detect system capabilities (OS, arch, Docker, shells, tools)
2. Report capabilities to Gitea via the Declare API
3. Begin polling for jobs
## Capability Detection
The runner automatically detects:
| Category | Examples |
|----------|----------|
| **OS/Arch** | linux/amd64, darwin/arm64, windows/amd64 |
| **Container Runtime** | Docker, Podman |
| **Shells** | bash, sh, zsh, powershell, cmd |
| **Tools** | Node.js, Go, Python, Java, .NET, Rust |
| **Features** | Cache support, Docker Compose |
### Example Capabilities JSON
```json
{
"os": "linux",
"arch": "amd64",
"docker": true,
"docker_compose": true,
"container_runtime": "docker",
"shell": ["bash", "sh"],
"tools": {
"node": ["18.19.0"],
"go": ["1.21.5"],
"python": ["3.11.6"]
},
"features": {
"cache": true,
"docker_services": true
},
"limitations": []
}
```
## Configuration
Create a config file or use command-line flags:
```bash
./act_runner generate-config > config.yaml
./act_runner -c config.yaml daemon
```
Example configuration:
Edit `config.yaml` to customize settings. **Important configuration options:**
```yaml
log:
@@ -123,73 +83,447 @@ log:
runner:
file: .runner
capacity: 1
capacity: 2 # Number of concurrent jobs (default: 1)
timeout: 3h
shutdown_timeout: 3m # Grace period for running jobs on shutdown
insecure: false
fetch_timeout: 5s
fetch_interval: 2s
labels:
- ubuntu-latest:docker://node:18-bullseye
- ubuntu-22.04:docker://ubuntu:22.04
container:
docker_host: ""
force_pull: false
privileged: false
- "ubuntu-latest:docker://node:16-bullseye"
- "ubuntu-22.04:docker://node:16-bullseye"
cache:
enabled: true
dir: ~/.cache/actcache
dir: ""
container:
network: ""
privileged: false
options: ""
valid_volumes: []
docker_host: ""
force_pull: false
host:
workdir_parent: ""
```
## Docker Deployment
#### Capacity Configuration
The `capacity` setting controls how many jobs the runner can execute simultaneously:
- **Default**: 1 (one job at a time)
- **Recommended**: 2-4 for multi-core systems
- **Considerations**:
- Each job consumes CPU, memory, and disk I/O
- iOS/macOS builds are resource-intensive (start with 2)
- Lighter builds (Node.js, Go) can handle higher capacity (4-6)
- Monitor system load and adjust accordingly
**Example for different workloads:**
```yaml
# Light builds (web apps, APIs)
runner:
capacity: 4
# Mixed builds
runner:
capacity: 2
# Heavy builds (iOS/macOS, large containers)
runner:
capacity: 1
```
### 4. Register the Runner
```bash
docker run -d \
--name act_runner \
-e GITEA_INSTANCE_URL=https://your-gitea.com \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ./data:/data \
gitcaddy/act_runner:latest
./gitcaddy-runner register \
--instance https://your-gitea-instance.com \
--token YOUR_REGISTRATION_TOKEN \
--name my-runner \
--labels ubuntu-latest:docker://node:16-bullseye
```
## GitCaddy Integration
The registration token can be obtained from:
- Gitea Admin Panel > Actions > Runners
- Or repository Settings > Actions > Runners
This runner is designed to work with the [GitCaddy Gitea fork](https://git.marketally.com/gitcaddy/gitea), which provides:
### 5. Start the Runner
- **Runner Capabilities API** (`/api/v2/repos/{owner}/{repo}/actions/runners/capabilities`)
- **Workflow Validation API** for pre-flight checks
- **Action Compatibility Database** for GitHub Actions mapping
### How It Works
**Important:** Always specify the config file path with `-c` flag:
```bash
./gitcaddy-runner daemon -c config.yaml
```
act_runner Gitea AI Tool
| | |
| Declare + Capabilities | |
|---------------------------->| |
| | |
| | GET /api/v2/.../caps |
| |<------------------------|
| | |
| | Runner capabilities |
| |------------------------>|
| | |
| | Generates workflow |
| | with correct config |
**Without the `-c` flag, the runner will use default settings and ignore your config.yaml!**
## Running as a Service
### macOS (launchd)
Create `~/Library/LaunchAgents/com.gitcaddy.runner.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.gitcaddy.runner</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/gitcaddy-runner</string>
<string>daemon</string>
<string>-c</string>
<string>/path/to/config.yaml</string>
</array>
<key>WorkingDirectory</key>
<string>/path/to/runner/directory</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/path/to/runner.log</string>
<key>StandardErrorPath</key>
<string>/path/to/runner.err</string>
</dict>
</plist>
```
## Related Projects
Load the service:
| Project | Description |
|---------|-------------|
| [gitcaddy/gitea](https://git.marketally.com/gitcaddy/gitea) | Gitea with AI-friendly enhancements |
| [gitcaddy/actions-proto-go](https://git.marketally.com/gitcaddy/actions-proto-go) | Protocol definitions with capability support |
```bash
launchctl load ~/Library/LaunchAgents/com.gitcaddy.runner.plist
```
## Upstream
### Linux (systemd)
This project is a fork of [gitea.com/gitea/act_runner](https://gitea.com/gitea/act_runner). We contribute enhancements back to upstream where appropriate.
Create `/etc/systemd/system/gitcaddy-runner.service`:
```ini
[Unit]
Description=GitCaddy Actions Runner
After=network.target
[Service]
Type=simple
User=runner
WorkingDirectory=/home/runner/gitcaddy-runner
ExecStart=/home/runner/gitcaddy-runner/gitcaddy-runner daemon -c /home/runner/gitcaddy-runner/config.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable gitcaddy-runner
sudo systemctl start gitcaddy-runner
```
### Windows (NSSM or Native Service)
GitCaddy Runner has native Windows service support. When running as a service, it automatically detects the Windows Service Control Manager (SCM) and handles stop/shutdown signals properly.
**Option 1: Using NSSM (Recommended)**
Install NSSM via Chocolatey:
```powershell
choco install nssm -y
```
Create the service:
```powershell
# Install the service
nssm install GiteaRunnerSvc C:\gitea-runner\gitcaddy-runner.exe daemon --config C:\gitea-runner\config.yaml
# Set working directory
nssm set GiteaRunnerSvc AppDirectory C:\gitea-runner
# Set environment variables
nssm set GiteaRunnerSvc AppEnvironmentExtra HOME=C:\gitea-runner USERPROFILE=C:\gitea-runner
# Configure auto-restart on failure
sc failure GiteaRunnerSvc reset=86400 actions=restart/60000/restart/60000/restart/60000
# Start the service
sc start GiteaRunnerSvc
```
**Option 2: Native sc.exe (requires wrapper)**
Create a wrapper batch file `C:\gitea-runner\start-runner.bat`:
```batch
@echo off
set HOME=C:\gitea-runner
set USERPROFILE=C:\gitea-runner
cd /d C:\gitea-runner
C:\gitea-runner\gitcaddy-runner.exe daemon --config C:\gitea-runner\config.yaml
```
**Service Management:**
```powershell
# Check service status
sc query GiteaRunnerSvc
# Start service
sc start GiteaRunnerSvc
# Stop service
sc stop GiteaRunnerSvc
# View service logs (if using NSSM with log rotation)
Get-Content C:\gitea-runner\logs\runner.log -Tail 50
```
**Environment Variables for Windows Services:**
| Variable | Description | Example |
|----------|-------------|---------|
| `GITEA_RUNNER_SERVICE_NAME` | Override service name detection | `GiteaRunnerSvc` |
## Capability Detection
GitCaddy Runner automatically detects and reports system capabilities:
### Platform Information
- Operating system (darwin, linux, windows)
- Architecture (amd64, arm64)
### Container Runtime
- Docker availability and version
- Docker Compose support
- Container runtime type
### Development Tools
- Node.js, npm, yarn
- Python, pip
- Go
- .NET
- Ruby
- Rust
- Java
- Swift (macOS)
- Git, Make
### macOS-Specific
- Xcode version and build
- Available SDKs (iOS, macOS, tvOS, watchOS, visionOS)
- Simulators
- Code signing tools (codesign, pkgbuild)
- Package managers (Homebrew, CocoaPods, Fastlane)
### System Resources
- CPU cores
- Load average
- Disk space and usage
- Network bandwidth
### Example Capabilities Output
```json
{
"os": "darwin",
"arch": "arm64",
"capacity": 2,
"docker": true,
"docker_compose": true,
"container_runtime": "docker",
"xcode": {
"version": "15.2",
"build": "15C500b",
"sdks": ["iOS 17.2", "macOS 14.2"]
},
"tools": {
"node": ["20.11"],
"python": ["3.11"],
"swift": ["5.9"]
},
"build_tools": ["fastlane", "cocoapods", "codesign"],
"cpu": {
"num_cpu": 10,
"load_percent": 25.5
},
"disk": {
"free_bytes": 54199226368,
"used_percent": 77.89
}
}
```
## Configuration Reference
### Runner Section
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `capacity` | int | 1 | Maximum concurrent jobs |
| `timeout` | duration | 3h | Maximum job execution time |
| `shutdown_timeout` | duration | 3m | Grace period for jobs to complete on shutdown |
| `insecure` | bool | false | Allow insecure HTTPS |
| `fetch_timeout` | duration | 5s | Timeout for fetching tasks |
| `fetch_interval` | duration | 2s | Interval between task fetches |
| `labels` | []string | [] | Runner labels for job matching |
| `env_file` | string | .env | Environment variables file |
### Cache Section
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | bool | true | Enable cache support |
| `dir` | string | "" | Cache directory path |
| `host` | string | "" | External cache server host |
| `port` | int | 0 | External cache server port |
### Container Section
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `network` | string | "" | Docker network for containers |
| `privileged` | bool | false | Run containers in privileged mode |
| `docker_host` | string | "" | Custom Docker host |
| `force_pull` | bool | false | Always pull latest images |
## Troubleshooting
### Capacity Not Being Applied
**Problem:** Runner shows `"capacity":1` even though config.yaml has `capacity: 2`
**Solution:** Ensure you're using the `-c` flag when starting the daemon:
```bash
# ✅ Correct
./gitcaddy-runner daemon -c /path/to/config.yaml
# ❌ Wrong - uses defaults
./gitcaddy-runner daemon
```
Verify the config is being loaded:
```bash
# Check runner process
ps aux | grep gitcaddy-runner
# Should show: gitcaddy-runner daemon -c /path/to/config.yaml
```
### Docker Not Detected
**Problem:** Capabilities show `"docker":false` but Docker is installed
**Solution:**
1. Ensure Docker Desktop/daemon is running:
```bash
docker ps
```
2. Restart the runner after starting Docker:
```bash
./gitcaddy-runner daemon -c config.yaml
```
3. Check Docker socket permissions:
```bash
ls -l /var/run/docker.sock
```
### Jobs Not Running Concurrently
**Problem:** Jobs queue instead of running in parallel
**Checklist:**
1. Verify capacity in capabilities output (check runner logs)
2. Confirm config.yaml has `capacity > 1`
3. Ensure runner was started with `-c config.yaml` flag
4. Check system resources aren't maxed out
5. Restart runner after config changes
### Runner Not Starting
**Problem:** Runner exits immediately or fails to start
**Common causes:**
1. Invalid config.yaml syntax
2. `.runner` file missing (run `register` first)
3. Permission issues on working directory
4. Invalid Gitea instance URL or token
**Debug steps:**
```bash
# Check config syntax
./gitcaddy-runner generate-config > test-config.yaml
diff config.yaml test-config.yaml
# Test with verbose logging
./gitcaddy-runner daemon -c config.yaml --log-level debug
# Verify registration
cat .runner
```
## Environment Variables
GitCaddy Runner supports environment variable configuration:
| Variable | Description | Example |
|----------|-------------|---------|
| `GITEA_RUNNER_CAPACITY` | Override capacity setting | `GITEA_RUNNER_CAPACITY=2` |
| `GITEA_RUNNER_ENV_FILE` | Custom env file path | `GITEA_RUNNER_ENV_FILE=.env.prod` |
## API Integration
Query runner capabilities via Gitea API:
```bash
curl -H "Authorization: token YOUR_TOKEN" \
https://your-gitea.com/api/v1/runners
```
Use capabilities to generate compatible workflows:
```yaml
# Example: Use capabilities to select appropriate runner
name: Build
on: [push]
jobs:
build:
runs-on: ${{ capabilities.os == 'darwin' && 'macos-latest' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v3
```
## Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
MIT License - see [LICENSE](LICENSE) for details.
## Support
- Issues: https://git.marketally.com/gitcaddy/gitcaddy-runner/issues
- Discussions: https://git.marketally.com/gitcaddy/gitcaddy-runner/discussions
## Acknowledgments
GitCaddy Runner is a hard fork of [Gitea's act_runner](https://gitea.com/gitea/act_runner), rebranded and enhanced with automated capability detection and reporting features for AI-friendly workflow generation.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0.0

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

39
cmd/upload-helper/main.go Normal file
View File

@@ -0,0 +1,39 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
// Package main provides the upload-helper CLI tool for reliable file uploads.
package main
import (
"flag"
"fmt"
"os"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/artifact"
)
func main() {
url := flag.String("url", "", "Upload URL")
token := flag.String("token", "", "Auth token")
file := flag.String("file", "", "File to upload")
retries := flag.Int("retries", 5, "Maximum retry attempts")
flag.Parse()
if *url == "" || *token == "" || *file == "" {
fmt.Fprintf(os.Stderr, "GitCaddy Upload Helper - Reliable file uploads with retry\n\n")
fmt.Fprintf(os.Stderr, "Usage: gitcaddy-upload -url URL -token TOKEN -file FILE\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
os.Exit(1)
}
helper := artifact.NewUploadHelper()
helper.MaxRetries = *retries
if err := helper.UploadWithRetry(*url, *token, *file); err != nil {
fmt.Fprintf(os.Stderr, "Upload failed: %v\n", err)
os.Exit(1)
}
fmt.Println("Upload succeeded!")
}

View File

@@ -1,12 +1,12 @@
# Usage Examples for `act_runner`
# Usage Examples for `gitcaddy-runner`
Welcome to our collection of usage and deployment examples specifically designed for Gitea setups. Whether you're a beginner or an experienced user, you'll find practical resources here that you can directly apply to enhance your Gitea experience. We encourage you to contribute your own insights and knowledge to make this collection even more comprehensive and valuable.
A collection of usage and deployment examples for GitCaddy Runner. Whether you're a beginner or an experienced user, you'll find practical resources here that you can directly apply to your GitCaddy setup. We encourage you to contribute your own insights and knowledge to make this collection even more comprehensive and valuable.
| Section | Description |
|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`docker`](docker) | This section provides you with scripts and instructions tailored for running containers on a workstation or server where Docker is installed. It simplifies the process of setting up and managing your Gitea deployment using Docker. |
| [`docker-compose`](docker-compose) | In this section, you'll discover examples demonstrating how to utilize docker-compose to efficiently handle your Gitea deployments. It offers a straightforward approach to managing multiple containerized components of your Gitea setup. |
| [`kubernetes`](kubernetes) | If you're utilizing Kubernetes clusters for your infrastructure, this section is specifically designed for you. It presents examples and guidelines for configuring Gitea deployments within Kubernetes clusters, enabling you to leverage the scalability and flexibility of Kubernetes. |
| [`vm`](vm) | This section is dedicated to examples that assist you in setting up Gitea on virtual or physical servers. Whether you're working with virtual machines or physical hardware, you'll find helpful resources to guide you through the deployment process. |
| [`docker`](docker) | Scripts and instructions for running GitCaddy Runner in a Docker container on a workstation or server where Docker is installed. |
| [`docker-compose`](docker-compose) | Examples demonstrating how to use docker-compose to run GitCaddy Runner alongside a GitCaddy server instance. |
| [`kubernetes`](kubernetes) | Examples and guidelines for deploying GitCaddy Runner within Kubernetes clusters, leveraging scalability and flexibility. |
| [`vm`](vm) | Examples for setting up GitCaddy Runner on virtual or physical servers directly, without containerization. |
We hope these resources provide you with valuable insights and solutions for your Gitea setup. Feel free to explore, contribute, and adapt these examples to suit your specific requirements.
Feel free to explore, contribute, and adapt these examples to suit your specific requirements.

View File

@@ -1,12 +1,12 @@
### Running `act_runner` using `docker-compose`
### Running `gitcaddy-runner` using `docker-compose`
```yml
...
gitea:
image: gitea/gitea
gitcaddy:
image: git.marketally.com/gitcaddy/server:latest
...
healthcheck:
# checks availability of Gitea's front-end with curl
# checks availability of GitCaddy's front-end with curl
test: ["CMD", "curl", "-f", "<instance_url>"]
interval: 10s
retries: 3
@@ -14,20 +14,19 @@
timeout: 10s
environment:
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
# The Gitea version must be v1.23 or higher.
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
runner:
image: gitea/act_runner
image: git.marketally.com/gitcaddy/gitcaddy-runner:latest
restart: always
depends_on:
gitea:
# required so runner can attach to gitea, see "healthcheck"
condition: service_healthy
gitcaddy:
# required so runner can attach to GitCaddy, see "healthcheck"
condition: service_healthy
restart: true
volumes:
- ./data/act_runner:/data
- ./data/gitcaddy-runner:/data
- /var/run/docker.sock:/var/run/docker.sock
environment:
- GITEA_INSTANCE_URL=<instance url>
@@ -38,18 +37,18 @@
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
```
### Running `act_runner` using Docker-in-Docker (DIND)
### Running `gitcaddy-runner` using Docker-in-Docker (DIND)
```yml
...
runner:
image: gitea/act_runner:latest-dind-rootless
image: git.marketally.com/gitcaddy/gitcaddy-runner:latest-dind-rootless
restart: always
privileged: true
depends_on:
- gitea
- gitcaddy
volumes:
- ./data/act_runner:/data
- ./data/gitcaddy-runner:/data
environment:
- GITEA_INSTANCE_URL=<instance url>
- DOCKER_HOST=unix:///var/run/user/1000/docker.sock

View File

@@ -1,7 +1,7 @@
### Run `act_runner` in a Docker Container
### Run `gitcaddy-runner` in a Docker Container
```sh
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/act_runner:nightly
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner git.marketally.com/gitcaddy/gitcaddy-runner:latest
```
The `/data` directory inside the docker container contains the runner API keys after registration.

View File

@@ -1,4 +1,4 @@
## Kubernetes Docker in Docker Deployment with `act_runner`
## Kubernetes Docker in Docker Deployment with `gitcaddy-runner`
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context.

View File

@@ -1,7 +1,7 @@
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: act-runner-vol
name: gitcaddy-runner-vol
spec:
accessModes:
- ReadWriteOnce
@@ -13,7 +13,7 @@ spec:
apiVersion: v1
data:
# The registration token can be obtained from the web UI, API or command-line.
# You can also set a pre-defined global runner registration token for the Gitea instance via
# You can also set a pre-defined global runner registration token for the GitCaddy instance via
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
token: << base64 encoded registration token >>
kind: Secret
@@ -25,19 +25,19 @@ apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: act-runner
name: act-runner
app: gitcaddy-runner
name: gitcaddy-runner
spec:
replicas: 1
selector:
matchLabels:
app: act-runner
app: gitcaddy-runner
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: act-runner
app: gitcaddy-runner
spec:
restartPolicy: Always
volumes:
@@ -45,10 +45,10 @@ spec:
emptyDir: {}
- name: runner-data
persistentVolumeClaim:
claimName: act-runner-vol
claimName: gitcaddy-runner-vol
containers:
- name: runner
image: gitea/act_runner:nightly
image: git.marketally.com/gitcaddy/gitcaddy-runner:latest
command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- run.sh"]
env:
- name: DOCKER_HOST
@@ -58,7 +58,7 @@ spec:
- name: DOCKER_TLS_VERIFY
value: "1"
- name: GITEA_INSTANCE_URL
value: http://gitea-http.gitea.svc.cluster.local:3000
value: http://gitcaddy-http.gitcaddy.svc.cluster.local:3000
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:

View File

@@ -1,7 +1,7 @@
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: act-runner-vol
name: gitcaddy-runner-vol
spec:
accessModes:
- ReadWriteOnce
@@ -13,7 +13,7 @@ spec:
apiVersion: v1
data:
# The registration token can be obtained from the web UI, API or command-line.
# You can also set a pre-defined global runner registration token for the Gitea instance via
# You can also set a pre-defined global runner registration token for the GitCaddy instance via
# `GITEA_RUNNER_REGISTRATION_TOKEN`/`GITEA_RUNNER_REGISTRATION_TOKEN_FILE` environment variable.
token: << base64 encoded registration token >>
kind: Secret
@@ -25,30 +25,30 @@ apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: act-runner
name: act-runner
app: gitcaddy-runner
name: gitcaddy-runner
spec:
replicas: 1
selector:
matchLabels:
app: act-runner
app: gitcaddy-runner
strategy: {}
template:
metadata:
creationTimestamp: null
labels:
app: act-runner
app: gitcaddy-runner
spec:
restartPolicy: Always
volumes:
- name: runner-data
persistentVolumeClaim:
claimName: act-runner-vol
claimName: gitcaddy-runner-vol
securityContext:
fsGroup: 1000
containers:
- name: runner
image: gitea/act_runner:nightly-dind-rootless
image: git.marketally.com/gitcaddy/gitcaddy-runner:latest-dind-rootless
imagePullPolicy: Always
# command: ["sh", "-c", "while ! nc -z localhost 2376 </dev/null; do echo 'waiting for docker daemon...'; sleep 5; done; /sbin/tini -- /opt/act/run.sh"]
env:
@@ -59,7 +59,7 @@ spec:
- name: DOCKER_TLS_VERIFY
value: "1"
- name: GITEA_INSTANCE_URL
value: http://gitea-http.gitea.svc.cluster.local:3000
value: http://gitcaddy-http.gitcaddy.svc.cluster.local:3000
- name: GITEA_RUNNER_REGISTRATION_TOKEN
valueFrom:
secretKeyRef:
@@ -70,4 +70,3 @@ spec:
volumeMounts:
- name: runner-data
mountPath: /data

View File

@@ -1,4 +1,4 @@
## `act_runner` on Virtual or Physical Servers
## `gitcaddy-runner` on Virtual or Physical Servers
Files in this directory:

View File

@@ -1,12 +1,12 @@
## Using Rootless Docker with`act_runner`
## Using Rootless Docker with `gitcaddy-runner`
Here is a simple example of how to set up `act_runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
Here is a simple example of how to set up `gitcaddy-runner` with rootless Docker. It has been created with Debian, but other Linux should work the same way.
Note: This procedure needs a real login shell -- using `sudo su` or other method of accessing the account will fail some of the steps below.
As `root`:
- Create a user to run both `docker` and `act_runner`. In this example, we use a non-privileged account called `rootless`.
- Create a user to run both `docker` and `gitcaddy-runner`. In this example, we use a non-privileged account called `rootless`.
```bash
useradd -m rootless
@@ -38,36 +38,36 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
```
- Reboot. Ensure that the Docker process is working.
- Create a directory for saving `act_runner` data between restarts
- Create a directory for saving `gitcaddy-runner` data between restarts
`mkdir /home/rootless/act_runner`
`mkdir /home/rootless/gitcaddy-runner`
- Register the runner from the data directory
```bash
cd /home/rootless/act_runner
act_runner register
cd /home/rootless/gitcaddy-runner
gitcaddy-runner register
```
- Generate a `act_runner` configuration file in the data directory. Edit the file to adjust for the system.
- Generate a `gitcaddy-runner` configuration file in the data directory. Edit the file to adjust for the system.
```bash
act_runner generate-config >/home/rootless/act_runner/config
gitcaddy-runner generate-config >/home/rootless/gitcaddy-runner/config
```
- Create a new user-level`systemd` unit file as `/home/rootless/.config/systemd/user/act_runner.service` with the following contents:
- Create a new user-level `systemd` unit file as `/home/rootless/.config/systemd/user/gitcaddy-runner.service` with the following contents:
```bash
Description=Gitea Actions runner
Documentation=https://gitea.com/gitea/act_runner
Description=GitCaddy Actions runner
Documentation=https://git.marketally.com/gitcaddy/gitcaddy-runner
After=docker.service
[Service]
Environment=PATH=/home/rootless/bin:/sbin:/usr/sbin:/home/rootless/bin:/home/rootless/bin:/home/rootless/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
Environment=DOCKER_HOST=unix:///run/user/1001/docker.sock
ExecStart=/usr/bin/act_runner daemon -c /home/rootless/act_runner/config
ExecStart=/usr/bin/gitcaddy-runner daemon -c /home/rootless/gitcaddy-runner/config
ExecReload=/bin/kill -s HUP $MAINPID
WorkingDirectory=/home/rootless/act_runner
WorkingDirectory=/home/rootless/gitcaddy-runner
TimeoutSec=0
RestartSec=2
Restart=always
@@ -88,8 +88,9 @@ export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
- Reboot
After the system restarts, check that the`act_runner` is working and that the runner is connected to Gitea.
After the system restarts, check that `gitcaddy-runner` is working and that the runner is connected to GitCaddy.
````bash
systemctl --user status act_runner
journalctl --user -xeu act_runner
```bash
systemctl --user status gitcaddy-runner
journalctl --user -xeu gitcaddy-runner
```

8
go.mod
View File

@@ -1,8 +1,6 @@
module gitea.com/gitea/act_runner
module git.marketally.com/gitcaddy/gitcaddy-runner
go 1.24.0
toolchain go1.24.11
go 1.25.5
require (
code.gitea.io/actions-proto-go v0.5.2
@@ -111,4 +109,4 @@ replace github.com/go-git/go-git/v5 => github.com/go-git/go-git/v5 v5.16.2
replace github.com/distribution/reference v0.6.0 => github.com/distribution/reference v0.5.0
// Use GitCaddy fork with capability support
replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.7
replace code.gitea.io/actions-proto-go => git.marketally.com/gitcaddy/actions-proto-go v0.5.8

4
go.sum
View File

@@ -6,8 +6,8 @@ cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
git.marketally.com/gitcaddy/actions-proto-go v0.5.7 h1:RUbafr3Vkw2l4WfSwa+oF+Ihakbm05W0FlAmXuQrDJc=
git.marketally.com/gitcaddy/actions-proto-go v0.5.7/go.mod h1:RPu21UoRD3zSAujoZR6LJwuVNa2uFRBveadslczCRfQ=
git.marketally.com/gitcaddy/actions-proto-go v0.5.8 h1:MBipeHvY6A0jcobvziUtzgatZTrV4fs/HE1rPQxREN4=
git.marketally.com/gitcaddy/actions-proto-go v0.5.8/go.mod h1:RPu21UoRD3zSAujoZR6LJwuVNa2uFRBveadslczCRfQ=
gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742 h1:ulcquQluJbmNASkh6ina70LvcHEa9eWYfQ+DeAZ0VEE=
gitea.com/gitea/act v0.261.7-0.20251202193638-5417d3ac6742/go.mod h1:Pg5C9kQY1CEA3QjthjhlrqOC/QOT5NyWNjOjRHw23Ok=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=

View File

@@ -9,7 +9,7 @@ import (
"os"
"os/signal"
"gitea.com/gitea/act_runner/internal/pkg/config"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
"github.com/nektos/act/pkg/artifactcache"
log "github.com/sirupsen/logrus"
@@ -22,8 +22,8 @@ type cacheServerArgs struct {
Port uint16
}
func runCacheServer(ctx context.Context, configFile *string, cacheArgs *cacheServerArgs) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
func runCacheServer(_ context.Context, configFile *string, cacheArgs *cacheServerArgs) func(cmd *cobra.Command, args []string) error {
return func(_ *cobra.Command, _ []string) error {
cfg, err := config.LoadDefault(*configFile)
if err != nil {
return fmt.Errorf("invalid configuration: %w", err)

View File

@@ -1,6 +1,7 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Copyright 2022 The Gitea Authors and MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
// Package cmd provides the CLI commands for gitcaddy-runner.
package cmd
import (
@@ -10,14 +11,16 @@ import (
"github.com/spf13/cobra"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/ver"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/cleanup"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver"
)
// Execute runs the root command for gitcaddy-runner CLI.
func Execute(ctx context.Context) {
// ./act_runner
// ./gitcaddy-runner
rootCmd := &cobra.Command{
Use: "act_runner [event name to run]\nIf no event name passed, will default to \"on: push\"",
Use: "gitcaddy-runner [event name to run]\nIf no event name passed, will default to \"on: push\"",
Short: "Run GitHub actions locally by specifying the event name (e.g. `push`) or an action name directly.",
Args: cobra.MaximumNArgs(1),
Version: ver.Version(),
@@ -26,7 +29,7 @@ func Execute(ctx context.Context) {
configFile := ""
rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Config file path")
// ./act_runner register
// ./gitcaddy-runner register
var regArgs registerArgs
registerCmd := &cobra.Command{
Use: "register",
@@ -35,14 +38,14 @@ func Execute(ctx context.Context) {
RunE: runRegister(ctx, &regArgs, &configFile), // must use a pointer to regArgs
}
registerCmd.Flags().BoolVar(&regArgs.NoInteractive, "no-interactive", false, "Disable interactive mode")
registerCmd.Flags().StringVar(&regArgs.InstanceAddr, "instance", "", "Gitea instance address")
registerCmd.Flags().StringVar(&regArgs.InstanceAddr, "instance", "", "GitCaddy instance address")
registerCmd.Flags().StringVar(&regArgs.Token, "token", "", "Runner token")
registerCmd.Flags().StringVar(&regArgs.RunnerName, "name", "", "Runner name")
registerCmd.Flags().StringVar(&regArgs.Labels, "labels", "", "Runner tags, comma separated")
registerCmd.Flags().BoolVar(&regArgs.Ephemeral, "ephemeral", false, "Configure the runner to be ephemeral and only ever be able to pick a single job (stricter than --once)")
rootCmd.AddCommand(registerCmd)
// ./act_runner daemon
// ./gitcaddy-runner daemon
var daemArgs daemonArgs
daemonCmd := &cobra.Command{
Use: "daemon",
@@ -53,10 +56,10 @@ func Execute(ctx context.Context) {
daemonCmd.Flags().BoolVar(&daemArgs.Once, "once", false, "Run one job then exit")
rootCmd.AddCommand(daemonCmd)
// ./act_runner exec
// ./gitcaddy-runner exec
rootCmd.AddCommand(loadExecCmd(ctx))
// ./act_runner config
// ./gitcaddy-runner config
rootCmd.AddCommand(&cobra.Command{
Use: "generate-config",
Short: "Generate an example config file",
@@ -66,7 +69,7 @@ func Execute(ctx context.Context) {
},
})
// ./act_runner cache-server
// ./gitcaddy-runner cache-server
var cacheArgs cacheServerArgs
cacheCmd := &cobra.Command{
Use: "cache-server",
@@ -79,6 +82,31 @@ func Execute(ctx context.Context) {
cacheCmd.Flags().Uint16VarP(&cacheArgs.Port, "port", "p", 0, "Port of the cache server")
rootCmd.AddCommand(cacheCmd)
// ./gitcaddy-runner cleanup
cleanupCmd := &cobra.Command{
Use: "cleanup",
Short: "Manually trigger cleanup to free disk space",
Args: cobra.MaximumNArgs(0),
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := config.LoadDefault(configFile)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
result, err := cleanup.RunCleanup(ctx, cfg)
if err != nil {
return fmt.Errorf("cleanup failed: %w", err)
}
fmt.Printf("Cleanup completed: freed %d bytes, deleted %d files in %s\n", result.BytesFreed, result.FilesDeleted, result.Duration)
if len(result.Errors) > 0 {
fmt.Printf("Warnings: %d errors occurred\n", len(result.Errors))
for _, e := range result.Errors {
fmt.Printf(" - %s\n", e)
}
}
return nil
},
}
rootCmd.AddCommand(cleanupCmd)
// hide completion command
rootCmd.CompletionOptions.HiddenDefaultCmd = true

View File

@@ -14,6 +14,7 @@ import (
"slices"
"strconv"
"strings"
"sync"
"time"
"connectrpc.com/connect"
@@ -21,13 +22,14 @@ import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gitea.com/gitea/act_runner/internal/app/poll"
"gitea.com/gitea/act_runner/internal/app/run"
"gitea.com/gitea/act_runner/internal/pkg/client"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/envcheck"
"gitea.com/gitea/act_runner/internal/pkg/labels"
"gitea.com/gitea/act_runner/internal/pkg/ver"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/poll"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/run"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/cleanup"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/envcheck"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/labels"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver"
)
const (
@@ -35,6 +37,10 @@ const (
DiskSpaceWarningThreshold = 85.0
// DiskSpaceCriticalThreshold is the percentage at which to log critical warnings
DiskSpaceCriticalThreshold = 95.0
// DiskSpaceAutoCleanupThreshold is the percentage at which to trigger automatic cleanup
DiskSpaceAutoCleanupThreshold = 85.0
// CleanupCooldown is the minimum time between automatic cleanups
CleanupCooldown = 10 * time.Minute
// CapabilitiesUpdateInterval is how often to update capabilities (including disk space)
CapabilitiesUpdateInterval = 5 * time.Minute
// BandwidthTestInterval is how often to run bandwidth tests (hourly)
@@ -44,13 +50,23 @@ const (
// Global bandwidth manager - accessible for triggering manual tests
var bandwidthManager *envcheck.BandwidthManager
// Global cleanup state
var (
lastCleanupTime time.Time
cleanupMutex sync.Mutex
globalConfig *config.Config
)
func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
return func(_ *cobra.Command, _ []string) error {
cfg, err := config.LoadDefault(*configFile)
if err != nil {
return fmt.Errorf("invalid configuration: %w", err)
}
// Store config globally for auto-cleanup
globalConfig = cfg
initLogging(cfg)
log.Infoln("Starting runner daemon")
@@ -116,7 +132,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
return err
}
// if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath
os.Setenv("DOCKER_HOST", dockerSocketPath)
_ = os.Setenv("DOCKER_HOST", dockerSocketPath)
// empty cfg.Container.DockerHost means act_runner need to find an available docker host automatically
// and assign the path to cfg.Container.DockerHost
if cfg.Container.DockerHost == "" {
@@ -163,26 +179,29 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
bandwidthManager.Start(ctx)
log.Infof("bandwidth manager started, testing against: %s", reg.Address)
capabilities := envcheck.DetectCapabilities(ctx, dockerHost, cfg.Container.WorkdirParent)
capabilities := envcheck.DetectCapabilities(ctx, dockerHost, cfg.Container.WorkdirParent, globalConfig.Runner.Capacity)
// Include initial bandwidth result if available
capabilities.Bandwidth = bandwidthManager.GetLastResult()
capabilitiesJson := capabilities.ToJSON()
log.Infof("detected capabilities: %s", capabilitiesJson)
capabilitiesJSON := capabilities.ToJSON()
log.Infof("detected capabilities: %s", capabilitiesJSON)
// Check disk space and warn if low
checkDiskSpaceWarnings(capabilities)
checkDiskSpaceAndCleanup(ctx, capabilities)
// declare the labels of the runner before fetching tasks
resp, err := runner.Declare(ctx, ls.Names(), capabilitiesJson)
if err != nil && connect.CodeOf(err) == connect.CodeUnimplemented {
log.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later")
resp, err := runner.Declare(ctx, ls.Names(), capabilitiesJSON)
switch {
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
log.Errorf("Your GitCaddy version is too old to support runner declare, please upgrade to v1.21 or later")
return err
} else if err != nil {
case err != nil:
log.WithError(err).Error("fail to invoke Declare")
return err
} else {
default:
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
resp.Msg.Runner.Name, resp.Msg.Runner.Version, resp.Msg.Runner.Labels)
// Merge any admin-added labels from the server
runner.MergeServerLabels(resp.Msg.Runner.Labels)
}
// Start periodic capabilities update goroutine
@@ -236,8 +255,8 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
}
}
// checkDiskSpaceWarnings logs warnings if disk space is low
func checkDiskSpaceWarnings(capabilities *envcheck.RunnerCapabilities) {
// checkDiskSpaceAndCleanup logs warnings if disk space is low and triggers cleanup if needed
func checkDiskSpaceAndCleanup(ctx context.Context, capabilities *envcheck.RunnerCapabilities) {
if capabilities.Disk == nil {
return
}
@@ -245,13 +264,54 @@ func checkDiskSpaceWarnings(capabilities *envcheck.RunnerCapabilities) {
usedPercent := capabilities.Disk.UsedPercent
freeGB := float64(capabilities.Disk.Free) / (1024 * 1024 * 1024)
if usedPercent >= DiskSpaceCriticalThreshold {
switch {
case usedPercent >= DiskSpaceCriticalThreshold:
log.Errorf("CRITICAL: Disk space critically low! %.1f%% used, only %.2f GB free. Runner may fail to execute jobs!", usedPercent, freeGB)
} else if usedPercent >= DiskSpaceWarningThreshold {
// Always try cleanup at critical level
triggerAutoCleanup(ctx)
case usedPercent >= DiskSpaceAutoCleanupThreshold:
log.Warnf("WARNING: Disk space at %.1f%% used (%.2f GB free). Triggering automatic cleanup.", usedPercent, freeGB)
triggerAutoCleanup(ctx)
case usedPercent >= DiskSpaceWarningThreshold:
log.Warnf("WARNING: Disk space running low. %.1f%% used, %.2f GB free. Consider cleaning up disk space.", usedPercent, freeGB)
}
}
// triggerAutoCleanup runs cleanup if cooldown has passed
func triggerAutoCleanup(ctx context.Context) {
cleanupMutex.Lock()
defer cleanupMutex.Unlock()
// Check cooldown (except for first run)
if !lastCleanupTime.IsZero() && time.Since(lastCleanupTime) < CleanupCooldown {
log.Debugf("Skipping auto-cleanup, cooldown not expired (last cleanup: %s ago)", time.Since(lastCleanupTime))
return
}
if globalConfig == nil {
log.Warn("Cannot run auto-cleanup: config not available")
return
}
log.Info("Starting automatic disk cleanup...")
lastCleanupTime = time.Now()
go func() {
result, err := cleanup.RunCleanup(ctx, globalConfig)
if err != nil {
log.WithError(err).Error("Auto-cleanup failed")
return
}
log.Infof("Auto-cleanup completed: freed %d bytes, deleted %d files in %s",
result.BytesFreed, result.FilesDeleted, result.Duration)
if len(result.Errors) > 0 {
for _, e := range result.Errors {
log.WithError(e).Warn("Cleanup error")
}
}
}()
}
// periodicCapabilitiesUpdate periodically updates capabilities including disk space and bandwidth
func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNames []string, dockerHost string, workingDir string) {
ticker := time.NewTicker(CapabilitiesUpdateInterval)
@@ -267,20 +327,20 @@ func periodicCapabilitiesUpdate(ctx context.Context, runner *run.Runner, labelNa
return
case <-ticker.C:
// Detect updated capabilities (disk space changes over time)
capabilities := envcheck.DetectCapabilities(ctx, dockerHost, workingDir)
capabilities := envcheck.DetectCapabilities(ctx, dockerHost, workingDir, globalConfig.Runner.Capacity)
// Include latest bandwidth result
if bandwidthManager != nil {
capabilities.Bandwidth = bandwidthManager.GetLastResult()
}
capabilitiesJson := capabilities.ToJSON()
capabilitiesJSON := capabilities.ToJSON()
// Check for disk space warnings
checkDiskSpaceWarnings(capabilities)
checkDiskSpaceAndCleanup(ctx, capabilities)
// Send updated capabilities to server
_, err := runner.Declare(ctx, labelNames, capabilitiesJson)
_, err := runner.Declare(ctx, labelNames, capabilitiesJSON)
if err != nil {
log.WithError(err).Debug("failed to update capabilities")
} else {

View File

@@ -264,7 +264,7 @@ func printList(plan *model.Plan) error {
return nil
}
func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *executeArgs) error {
func runExecList(_ context.Context, planner model.WorkflowPlanner, execArgs *executeArgs) error {
// plan with filtered jobs - to be used for filtering only
var filterPlan *model.Plan
@@ -286,19 +286,20 @@ func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *e
}
var err error
if execArgs.job != "" {
switch {
case execArgs.job != "":
log.Infof("Preparing plan with a job: %s", execArgs.job)
filterPlan, err = planner.PlanJob(execArgs.job)
if err != nil {
return err
}
} else if filterEventName != "" {
case filterEventName != "":
log.Infof("Preparing plan for a event: %s", filterEventName)
filterPlan, err = planner.PlanEvent(filterEventName)
if err != nil {
return err
}
} else {
default:
log.Infof("Preparing plan with all jobs")
filterPlan, err = planner.PlanAll()
if err != nil {
@@ -312,7 +313,7 @@ func runExecList(ctx context.Context, planner model.WorkflowPlanner, execArgs *e
}
func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
return func(_ *cobra.Command, _ []string) error {
planner, err := model.NewWorkflowPlanner(execArgs.WorkflowsPath(), execArgs.noWorkflowRecurse)
if err != nil {
return err
@@ -331,18 +332,19 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
// collect all events from loaded workflows
events := planner.GetEvents()
if len(execArgs.event) > 0 {
switch {
case len(execArgs.event) > 0:
log.Infof("Using chosed event for filtering: %s", execArgs.event)
eventName = execArgs.event
} else if len(events) == 1 && len(events[0]) > 0 {
case len(events) == 1 && len(events[0]) > 0:
log.Infof("Using the only detected workflow event: %s", events[0])
eventName = events[0]
} else if execArgs.autodetectEvent && len(events) > 0 && len(events[0]) > 0 {
case execArgs.autodetectEvent && len(events) > 0 && len(events[0]) > 0:
// set default event type to first event from many available
// this way user dont have to specify the event.
log.Infof("Using first detected workflow event: %s", events[0])
eventName = events[0]
} else {
default:
log.Infof("Using default workflow event: push")
eventName = "push"
}
@@ -388,7 +390,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
if err != nil {
fmt.Println(err)
}
defer os.RemoveAll(tempDir)
defer func() { _ = os.RemoveAll(tempDir) }()
execArgs.artifactServerPath = tempDir
}
@@ -454,7 +456,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command
log.Debugf("artifacts server started at %s:%s", execArgs.artifactServerPath, execArgs.artifactServerPort)
ctx = common.WithDryrun(ctx, execArgs.dryrun)
executor := r.NewPlanExecutor(plan).Finally(func(ctx context.Context) error {
executor := r.NewPlanExecutor(plan).Finally(func(_ context.Context) error {
artifactCancel()
return nil
})
@@ -505,7 +507,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", "docker.gitea.com/runner-images:ubuntu-latest", "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "GitCaddy instance to use.")
return execCmd
}

View File

@@ -20,15 +20,15 @@ import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gitea.com/gitea/act_runner/internal/pkg/client"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/labels"
"gitea.com/gitea/act_runner/internal/pkg/ver"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/labels"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver"
)
// runRegister registers a runner to the server
func runRegister(ctx context.Context, regArgs *registerArgs, configFile *string) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
return func(_ *cobra.Command, _ []string) error {
log.SetReportCaller(false)
isTerm := isatty.IsTerminal(os.Stdout.Fd())
log.SetFormatter(&log.TextFormatter{
@@ -80,6 +80,7 @@ type registerArgs struct {
type registerStage int8
// Register stage constants define the steps in the registration workflow.
const (
StageUnknown registerStage = -1
StageOverwriteLocalConfig registerStage = iota + 1
@@ -250,7 +251,7 @@ func registerInteractive(ctx context.Context, configFile string, regArgs *regist
if stage == StageWaitingForRegistration {
log.Infof("Registering runner, name=%s, instance=%s, labels=%v.", inputs.RunnerName, inputs.InstanceAddr, inputs.Labels)
if err := doRegister(ctx, cfg, inputs); err != nil {
return fmt.Errorf("Failed to register runner: %w", err)
return fmt.Errorf("failed to register runner: %w", err)
}
log.Infof("Runner registered successfully.")
return nil
@@ -272,7 +273,7 @@ func printStageHelp(stage registerStage) {
case StageOverwriteLocalConfig:
log.Infoln("Runner is already registered, overwrite local config? [y/N]")
case StageInputInstance:
log.Infoln("Enter the Gitea instance URL (for example, https://gitea.com/):")
log.Infoln("Enter the GitCaddy instance URL (for example, https://gitea.com/):")
case StageInputToken:
log.Infoln("Enter the runner token:")
case StageInputRunnerName:
@@ -311,7 +312,7 @@ func registerNoInteractive(ctx context.Context, configFile string, regArgs *regi
return err
}
if err := doRegister(ctx, cfg, inputs); err != nil {
return fmt.Errorf("Failed to register runner: %w", err)
return fmt.Errorf("failed to register runner: %w", err)
}
log.Infof("Runner registered successfully.")
return nil
@@ -341,7 +342,7 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
}
if err != nil {
log.WithError(err).
Errorln("Cannot ping the Gitea instance server")
Errorln("Cannot ping the GitCaddy instance server")
// TODO: if ping failed, retry or exit
time.Sleep(time.Second)
} else {

View File

@@ -1,6 +1,7 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// Copyright 2023 The Gitea Authors and MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
// Package poll provides task polling functionality for CI runners.
package poll
import (
@@ -15,12 +16,14 @@ import (
log "github.com/sirupsen/logrus"
"golang.org/x/time/rate"
"gitea.com/gitea/act_runner/internal/app/run"
"gitea.com/gitea/act_runner/internal/pkg/client"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/envcheck"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/run"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/cleanup"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/envcheck"
)
// Poller handles task polling from the Gitea server.
type Poller struct {
client client.Client
runner *run.Runner
@@ -37,9 +40,10 @@ type Poller struct {
done chan struct{}
}
// New creates a new Poller instance.
func New(cfg *config.Config, client client.Client, runner *run.Runner) *Poller {
// Use independent contexts - shutdown is handled explicitly via Shutdown()
pollingCtx, shutdownPolling := context.WithCancel(context.Background())
jobsCtx, shutdownJobs := context.WithCancel(context.Background())
done := make(chan struct{})
@@ -64,6 +68,7 @@ func (p *Poller) SetBandwidthManager(bm *envcheck.BandwidthManager) {
p.bandwidthManager = bm
}
// Poll starts polling for tasks with the configured capacity.
func (p *Poller) Poll() {
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
wg := &sync.WaitGroup{}
@@ -77,6 +82,7 @@ func (p *Poller) Poll() {
close(p.done)
}
// PollOnce polls for a single task and then exits.
func (p *Poller) PollOnce() {
limiter := rate.NewLimiter(rate.Every(p.cfg.Runner.FetchInterval), 1)
@@ -86,18 +92,19 @@ func (p *Poller) PollOnce() {
close(p.done)
}
// Shutdown gracefully stops the poller.
func (p *Poller) Shutdown(ctx context.Context) error {
p.shutdownPolling()
select {
// graceful shutdown completed succesfully
// graceful shutdown completed successfully
case <-p.done:
return nil
// our timeout for shutting down ran out
case <-ctx.Done():
// when both the timeout fires and the graceful shutdown
// completed succsfully, this branch of the select may
// completed successfully, this branch of the select may
// fire. Do a non-blocking check here against the graceful
// shutdown status to avoid sending an error if we don't need to.
_, ok := <-p.done
@@ -109,7 +116,7 @@ func (p *Poller) Shutdown(ctx context.Context) error {
p.shutdownJobs()
// wait for running jobs to report their status to Gitea
_, _ = <-p.done
<-p.done
return ctx.Err()
}
@@ -165,20 +172,20 @@ func (p *Poller) fetchTask(ctx context.Context) (*runnerv1.Task, bool) {
defer cancel()
// Detect capabilities including current disk space
caps := envcheck.DetectCapabilities(ctx, p.cfg.Container.DockerHost, p.cfg.Container.WorkdirParent)
caps := envcheck.DetectCapabilities(ctx, p.cfg.Container.DockerHost, p.cfg.Container.WorkdirParent, p.cfg.Runner.Capacity)
// Include latest bandwidth result if available
if p.bandwidthManager != nil {
caps.Bandwidth = p.bandwidthManager.GetLastResult()
}
capsJson := caps.ToJSON()
capsJSON := caps.ToJSON()
// Load the version value that was in the cache when the request was sent.
v := p.tasksVersion.Load()
fetchReq := &runnerv1.FetchTaskRequest{
TasksVersion: v,
CapabilitiesJson: capsJson,
CapabilitiesJson: capsJSON,
}
resp, err := p.client.FetchTask(reqCtx, connect.NewRequest(fetchReq))
if errors.Is(err, context.DeadlineExceeded) {
@@ -205,6 +212,20 @@ func (p *Poller) fetchTask(ctx context.Context) (*runnerv1.Task, bool) {
}()
}
// Check if server requested a cleanup
if resp.Msg.RequestCleanup {
log.Info("Server requested cleanup, running now...")
go func() {
result, err := cleanup.RunCleanup(ctx, p.cfg)
if err != nil {
log.Errorf("Cleanup failed: %v", err)
} else if result != nil {
log.Infof("Cleanup completed: freed %d bytes, deleted %d files in %s",
result.BytesFreed, result.FilesDeleted, result.Duration)
}
}()
}
if resp.Msg.TasksVersion > v {
p.tasksVersion.CompareAndSwap(v, resp.Msg.TasksVersion)
}

View File

@@ -1,6 +1,7 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package run provides the core runner functionality for executing tasks.
package run
import (

View File

@@ -22,11 +22,11 @@ import (
"github.com/nektos/act/pkg/runner"
log "github.com/sirupsen/logrus"
"gitea.com/gitea/act_runner/internal/pkg/client"
"gitea.com/gitea/act_runner/internal/pkg/config"
"gitea.com/gitea/act_runner/internal/pkg/labels"
"gitea.com/gitea/act_runner/internal/pkg/report"
"gitea.com/gitea/act_runner/internal/pkg/ver"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/labels"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/report"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver"
)
// Runner runs the pipeline.
@@ -84,6 +84,8 @@ func (r *Runner) CleanStaleJobCaches(maxAge time.Duration) {
}
}
}
// NewRunner creates a new Runner with the given configuration, registration, and client.
func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) *Runner {
ls := labels.Labels{}
for _, v := range reg.Labels {
@@ -132,6 +134,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
}
}
// Run executes a task from the server.
func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
if _, ok := r.runningTasks.Load(task.Id); ok {
return fmt.Errorf("task %d is already running", task.Id)
@@ -160,7 +163,7 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error {
// getDefaultActionsURL
// when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank,
// it should be set to GithubMirror first.
func (r *Runner) getDefaultActionsURL(ctx context.Context, task *runnerv1.Task) string {
func (r *Runner) getDefaultActionsURL(_ context.Context, task *runnerv1.Task) string {
giteaDefaultActionsURL := task.Context.Fields["gitea_default_actions_url"].GetStringValue()
if giteaDefaultActionsURL == "https://github.com" && r.cfg.Runner.GithubMirror != "" {
return r.cfg.Runner.GithubMirror
@@ -218,8 +221,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
preset.Token = t
}
if actionsIdTokenRequestUrl := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIdTokenRequestUrl != "" {
r.envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIdTokenRequestUrl
if actionsIDTokenRequestURL := taskContext["actions_id_token_request_url"].GetStringValue(); actionsIDTokenRequestURL != "" {
r.envs["ACTIONS_ID_TOKEN_REQUEST_URL"] = actionsIDTokenRequestURL
r.envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = taskContext["actions_id_token_request_token"].GetStringValue()
task.Secrets["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = r.envs["ACTIONS_ID_TOKEN_REQUEST_TOKEN"]
}
@@ -304,10 +307,32 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
return execErr
}
func (r *Runner) Declare(ctx context.Context, labels []string, capabilitiesJson string) (*connect.Response[runnerv1.DeclareResponse], error) {
// Declare sends the runner's labels and capabilities to the server.
func (r *Runner) Declare(ctx context.Context, declareLabels []string, capabilitiesJSON string) (*connect.Response[runnerv1.DeclareResponse], error) {
return r.client.Declare(ctx, connect.NewRequest(&runnerv1.DeclareRequest{
Version: ver.Version(),
Labels: labels,
CapabilitiesJson: capabilitiesJson,
Labels: declareLabels,
CapabilitiesJson: capabilitiesJSON,
}))
}
// MergeServerLabels merges labels returned from the server (which may include admin-added labels)
// with the runner's existing labels. This allows admins to add labels via the Gitea UI.
func (r *Runner) MergeServerLabels(serverLabels []string) {
existing := make(map[string]bool)
for _, l := range r.labels {
existing[l.Name] = true
}
for _, labelStr := range serverLabels {
label, err := labels.Parse(labelStr)
if err != nil {
log.Warnf("ignoring invalid server label %q: %v", labelStr, err)
continue
}
if !existing[label.Name] {
r.labels = append(r.labels, label)
log.Infof("merged server label: %s", labelStr)
}
}
}

View File

@@ -0,0 +1,146 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
// Package artifact provides utilities for handling artifact uploads.
package artifact
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"time"
log "github.com/sirupsen/logrus"
)
// UploadHelper handles reliable file uploads with retry logic
type UploadHelper struct {
MaxRetries int
RetryDelay time.Duration
ChunkSize int64
ConnectTimeout time.Duration
MaxTimeout time.Duration
}
// NewUploadHelper creates a new upload helper with sensible defaults
func NewUploadHelper() *UploadHelper {
return &UploadHelper{
MaxRetries: 5,
RetryDelay: 10 * time.Second,
ChunkSize: 10 * 1024 * 1024, // 10MB
ConnectTimeout: 120 * time.Second,
MaxTimeout: 3600 * time.Second,
}
}
// UploadWithRetry uploads a file with automatic retry on failure
func (u *UploadHelper) UploadWithRetry(url, token, filepath string) error {
client := &http.Client{
Timeout: u.MaxTimeout,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false, // Keep connections alive
ForceAttemptHTTP2: false, // Use HTTP/1.1 for large uploads
},
}
var lastErr error
for attempt := 0; attempt < u.MaxRetries; attempt++ {
if attempt > 0 {
delay := u.RetryDelay * time.Duration(attempt)
log.Infof("Upload attempt %d/%d, waiting %v before retry...", attempt+1, u.MaxRetries, delay)
time.Sleep(delay)
}
// Pre-resolve DNS / warm connection
if err := u.prewarmConnection(url); err != nil {
lastErr = fmt.Errorf("connection prewarm failed: %w", err)
log.Warnf("Prewarm failed: %v", err)
continue
}
// Attempt upload
if err := u.doUpload(client, url, token, filepath); err != nil {
lastErr = err
log.Warnf("Upload attempt %d failed: %v", attempt+1, err)
continue
}
log.Infof("Upload succeeded on attempt %d", attempt+1)
return nil // Success
}
return fmt.Errorf("upload failed after %d attempts: %w", u.MaxRetries, lastErr)
}
// prewarmConnection establishes a connection to help with DNS and TCP setup
func (u *UploadHelper) prewarmConnection(url string) error {
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
return nil
}
// doUpload performs the actual file upload
func (u *UploadHelper) doUpload(client *http.Client, url, token, filepath string) error {
file, err := os.Open(filepath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer func() { _ = file.Close() }()
stat, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
log.Infof("Uploading %s (%d bytes) to %s", filepath, stat.Size(), url)
// Create multipart form
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("attachment", stat.Name())
if err != nil {
return fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return fmt.Errorf("failed to copy file to form: %w", err)
}
_ = writer.Close()
req, err := http.NewRequest("POST", url, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Connection", "keep-alive")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("upload request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
}
log.Infof("Upload completed successfully, status: %d", resp.StatusCode)
return nil
}

View File

@@ -0,0 +1,390 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
// Package cleanup provides disk cleanup utilities for CI runners.
package cleanup
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/config"
log "github.com/sirupsen/logrus"
)
// Result contains the results of a cleanup operation.
type Result struct {
BytesFreed int64
FilesDeleted int
Errors []error
Duration time.Duration
}
// RunCleanup performs cleanup operations to free disk space.
func RunCleanup(_ context.Context, cfg *config.Config) (*Result, error) {
start := time.Now()
result := &Result{}
log.Info("Starting runner cleanup...")
// 1. Clean old cache directories
cacheDir := filepath.Join(cfg.Cache.Dir, "_cache")
if cacheDir != "" {
if bytes, files, err := cleanOldDir(cacheDir, 24*time.Hour); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("cache cleanup: %w", err))
} else {
result.BytesFreed += bytes
result.FilesDeleted += files
log.Infof("Cleaned cache: freed %d bytes, deleted %d files", bytes, files)
}
}
// 2. Clean old work directories
workDir := cfg.Container.WorkdirParent
if workDir != "" {
if bytes, files, err := cleanOldWorkDirs(workDir, 48*time.Hour); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("workdir cleanup: %w", err))
} else {
result.BytesFreed += bytes
result.FilesDeleted += files
log.Infof("Cleaned work dirs: freed %d bytes, deleted %d files", bytes, files)
}
}
// 3. Clean old artifact staging directories
artifactDir := cfg.Cache.Dir
if bytes, files, err := cleanOldArtifacts(artifactDir, 72*time.Hour); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("artifact cleanup: %w", err))
} else {
result.BytesFreed += bytes
result.FilesDeleted += files
log.Infof("Cleaned artifacts: freed %d bytes, deleted %d files", bytes, files)
}
// 4. Clean system temp files (older than 24h)
if bytes, files, err := cleanTempDir(24 * time.Hour); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("temp cleanup: %w", err))
} else {
result.BytesFreed += bytes
result.FilesDeleted += files
log.Infof("Cleaned temp: freed %d bytes, deleted %d files", bytes, files)
}
// 5. Clean build tool caches (older than 7 days)
// These can grow very large from Go, npm, nuget, gradle, maven builds
if bytes, files, err := cleanBuildCaches(7 * 24 * time.Hour); err != nil {
result.Errors = append(result.Errors, fmt.Errorf("build cache cleanup: %w", err))
} else {
result.BytesFreed += bytes
result.FilesDeleted += files
log.Infof("Cleaned build caches: freed %d bytes, deleted %d files", bytes, files)
}
result.Duration = time.Since(start)
log.Infof("Cleanup completed: freed %s in %s", formatBytes(result.BytesFreed), result.Duration)
return result, nil
}
// cleanOldDir removes files older than maxAge from a directory
func cleanOldDir(dir string, maxAge time.Duration) (int64, int, error) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return 0, 0, nil
}
var bytesFreed int64
var filesDeleted int
cutoff := time.Now().Add(-maxAge)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
}
if info.IsDir() {
return nil
}
if info.ModTime().Before(cutoff) {
size := info.Size()
if err := os.Remove(path); err == nil {
bytesFreed += size
filesDeleted++
}
}
return nil
})
return bytesFreed, filesDeleted, err
}
// cleanOldWorkDirs removes work directories older than maxAge
func cleanOldWorkDirs(baseDir string, maxAge time.Duration) (int64, int, error) {
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
return 0, 0, nil
}
var bytesFreed int64
var filesDeleted int
cutoff := time.Now().Add(-maxAge)
entries, err := os.ReadDir(baseDir)
if err != nil {
return 0, 0, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
path := filepath.Join(baseDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
size := dirSize(path)
if err := os.RemoveAll(path); err == nil {
bytesFreed += size
filesDeleted++
log.Debugf("Removed old work dir: %s", path)
}
}
}
return bytesFreed, filesDeleted, nil
}
// cleanOldArtifacts removes artifact staging files older than maxAge
func cleanOldArtifacts(baseDir string, maxAge time.Duration) (int64, int, error) {
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
return 0, 0, nil
}
var bytesFreed int64
var filesDeleted int
cutoff := time.Now().Add(-maxAge)
// Look for artifact staging dirs
patterns := []string{"artifact-*", "upload-*", "download-*"}
for _, pattern := range patterns {
matches, _ := filepath.Glob(filepath.Join(baseDir, pattern))
for _, path := range matches {
info, err := os.Stat(path)
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
var size int64
if info.IsDir() {
size = dirSize(path)
err = os.RemoveAll(path)
} else {
size = info.Size()
err = os.Remove(path)
}
if err == nil {
bytesFreed += size
filesDeleted++
}
}
}
}
return bytesFreed, filesDeleted, nil
}
// cleanTempDir removes old files from system temp directory
func cleanTempDir(maxAge time.Duration) (int64, int, error) {
tmpDir := os.TempDir()
var bytesFreed int64
var filesDeleted int
cutoff := time.Now().Add(-maxAge)
entries, err := os.ReadDir(tmpDir)
if err != nil {
return 0, 0, err
}
// Only clean files/dirs that look like runner/act artifacts or build tool temp files
runnerPatterns := []string{
"act-", "runner-", "gitea-", "workflow-",
"go-build", "go-link",
"node-compile-cache", "npm-", "yarn-", "yarn--", "pnpm-",
"ts-node-", "tsx-", "jiti", "v8-compile-cache",
"text-diff-expansion-test", "DiagOutputDir",
"dugite-native-", "reorderCommitMessage-", "squashCommitMessage-",
}
for _, entry := range entries {
name := entry.Name()
isRunner := false
for _, p := range runnerPatterns {
if len(name) >= len(p) && name[:len(p)] == p {
isRunner = true
break
}
}
if !isRunner {
continue
}
path := filepath.Join(tmpDir, name)
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
var size int64
if info.IsDir() {
size = dirSize(path)
err = os.RemoveAll(path)
} else {
size = info.Size()
err = os.Remove(path)
}
if err == nil {
bytesFreed += size
filesDeleted++
}
}
}
return bytesFreed, filesDeleted, nil
}
// dirSize calculates the total size of a directory.
func dirSize(path string) int64 {
var size int64
_ = filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if !info.IsDir() {
size += info.Size()
}
return nil
})
return size
}
// cleanBuildCaches removes old build tool caches that accumulate from CI jobs
// These are cleaned more aggressively (files older than 7 days) since they can grow very large
func cleanBuildCaches(maxAge time.Duration) (int64, int, error) {
home := os.Getenv("HOME")
if home == "" {
home = os.Getenv("USERPROFILE") // Windows
}
if home == "" {
home = "/root" // fallback for runners typically running as root
}
var totalBytesFreed int64
var totalFilesDeleted int
// Build cache directories to clean
// Format: {path, description, maxAge (0 = use default)}
// Go build cache cleaned more aggressively (3 days) as it grows very fast
goBuildMaxAge := 3 * 24 * time.Hour
cacheDirs := []struct {
path string
desc string
maxAge time.Duration
}{
// Linux paths
{filepath.Join(home, ".cache", "go-build"), "Go build cache", goBuildMaxAge},
{filepath.Join(home, ".cache", "golangci-lint"), "golangci-lint cache", 0},
{filepath.Join(home, ".npm", "_cacache"), "npm cache", 0},
{filepath.Join(home, ".cache", "pnpm"), "pnpm cache", 0},
{filepath.Join(home, ".cache", "yarn"), "yarn cache", 0},
{filepath.Join(home, ".nuget", "packages"), "NuGet cache", 0},
{filepath.Join(home, ".gradle", "caches"), "Gradle cache", 0},
{filepath.Join(home, ".m2", "repository"), "Maven cache", 0},
{filepath.Join(home, ".cache", "pip"), "pip cache", 0},
{filepath.Join(home, ".cargo", "registry", "cache"), "Cargo cache", 0},
{filepath.Join(home, ".rustup", "tmp"), "Rustup temp", 0},
// macOS paths (Library/Caches)
{filepath.Join(home, "Library", "Caches", "go-build"), "Go build cache (macOS)", goBuildMaxAge},
{filepath.Join(home, "Library", "Caches", "Yarn"), "Yarn cache (macOS)", 0},
{filepath.Join(home, "Library", "Caches", "pip"), "pip cache (macOS)", 0},
{filepath.Join(home, "Library", "Caches", "Homebrew"), "Homebrew cache (macOS)", 0},
// Windows paths (LOCALAPPDATA)
{filepath.Join(os.Getenv("LOCALAPPDATA"), "go-build"), "Go build cache (Windows)", goBuildMaxAge},
{filepath.Join(os.Getenv("LOCALAPPDATA"), "npm-cache"), "npm cache (Windows)", 0},
{filepath.Join(os.Getenv("LOCALAPPDATA"), "pnpm"), "pnpm cache (Windows)", 0},
{filepath.Join(os.Getenv("LOCALAPPDATA"), "Yarn", "Cache"), "Yarn cache (Windows)", 0},
{filepath.Join(os.Getenv("LOCALAPPDATA"), "NuGet", "v3-cache"), "NuGet cache (Windows)", 0},
{filepath.Join(os.Getenv("LOCALAPPDATA"), "pip", "Cache"), "pip cache (Windows)", 0},
// Windows custom paths used by some CI setups
{"C:\\L\\Yarn", "Yarn global cache (Windows)", 0},
{filepath.Join(os.TempDir(), "chocolatey"), "Chocolatey temp cache", 0},
}
for _, cache := range cacheDirs {
if _, err := os.Stat(cache.path); os.IsNotExist(err) {
continue
}
// Use cache-specific maxAge if set, otherwise use default
cacheMaxAge := cache.maxAge
if cacheMaxAge == 0 {
cacheMaxAge = maxAge
}
cutoff := time.Now().Add(-cacheMaxAge)
var bytesFreed int64
var filesDeleted int
err := filepath.Walk(cache.path, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // Skip errors
}
if info.IsDir() {
return nil
}
if info.ModTime().Before(cutoff) {
size := info.Size()
if err := os.Remove(path); err == nil {
bytesFreed += size
filesDeleted++
}
}
return nil
})
if err == nil && (bytesFreed > 0 || filesDeleted > 0) {
log.Infof("Cleaned %s: freed %s, deleted %d files", cache.desc, formatBytes(bytesFreed), filesDeleted)
totalBytesFreed += bytesFreed
totalFilesDeleted += filesDeleted
}
// Also remove empty directories
_ = filepath.Walk(cache.path, func(path string, info os.FileInfo, err error) error {
if err != nil || !info.IsDir() || path == cache.path {
return nil
}
entries, _ := os.ReadDir(path)
if len(entries) == 0 {
_ = os.Remove(path)
}
return nil
})
}
return totalBytesFreed, totalFilesDeleted, nil
}
// formatBytes formats bytes into human readable string
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

View File

@@ -1,6 +1,7 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package client provides the HTTP client for communicating with the runner API.
package client
import (

View File

@@ -3,6 +3,7 @@
package client
// HTTP header constants for runner authentication and identification.
const (
UUIDHeader = "x-runner-uuid"
TokenHeader = "x-runner-token"

View File

@@ -63,10 +63,12 @@ func New(endpoint string, insecure bool, uuid, token, version string, opts ...co
}
}
// Address returns the endpoint URL of the client.
func (c *HTTPClient) Address() string {
return c.endpoint
}
// Insecure returns whether TLS verification is disabled.
func (c *HTTPClient) Insecure() bool {
return c.insecure
}

View File

@@ -1,6 +1,7 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package config provides configuration loading and management for the runner.
package config
import (
@@ -137,6 +138,9 @@ func LoadDefault(file string) (*Config, error) {
if cfg.Runner.FetchInterval <= 0 {
cfg.Runner.FetchInterval = 2 * time.Second
}
if cfg.Runner.ShutdownTimeout <= 0 {
cfg.Runner.ShutdownTimeout = 3 * time.Minute
}
// although `container.network_mode` will be deprecated, but we have to be compatible with it for now.
if cfg.Container.NetworkMode != "" && cfg.Container.Network == "" {

View File

@@ -5,5 +5,7 @@ package config
import _ "embed"
// Example contains the example configuration file content.
//
//go:embed config.example.yaml
var Example []byte

View File

@@ -23,12 +23,13 @@ type Registration struct {
Ephemeral bool `json:"ephemeral"`
}
// LoadRegistration loads the runner registration from a JSON file.
func LoadRegistration(file string) (*Registration, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
defer func() { _ = f.Close() }()
var reg Registration
if err := json.NewDecoder(f).Decode(&reg); err != nil {
@@ -40,12 +41,13 @@ func LoadRegistration(file string) (*Registration, error) {
return &reg, nil
}
// SaveRegistration saves the runner registration to a JSON file.
func SaveRegistration(file string, reg *Registration) error {
f, err := os.Create(file)
if err != nil {
return err
}
defer f.Close()
defer func() { _ = f.Close() }()
reg.Warning = registrationWarning

View File

@@ -84,7 +84,7 @@ func (bm *BandwidthManager) GetLastResult() *BandwidthInfo {
return bm.lastResult
}
// TestBandwidth tests network bandwidth to the Gitea server
// TestBandwidth tests network bandwidth to the GitCaddy server
func TestBandwidth(ctx context.Context, serverURL string) *BandwidthInfo {
if serverURL == "" {
return nil
@@ -121,7 +121,7 @@ func testLatency(ctx context.Context, serverURL string) float64 {
if err != nil {
return 0
}
resp.Body.Close()
_ = resp.Body.Close()
latency := time.Since(start).Seconds() * 1000 // Convert to ms
return float64(int(latency*100)) / 100 // Round to 2 decimals
@@ -169,7 +169,7 @@ func testDownloadSpeed(ctx context.Context, serverURL string) float64 {
}
n, _ := io.Copy(io.Discard, resp.Body)
resp.Body.Close()
_ = resp.Body.Close()
cancel()
duration := time.Since(start)

View File

@@ -1,4 +1,4 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package envcheck
@@ -19,13 +19,22 @@ import (
// DiskInfo holds disk space information
type DiskInfo struct {
Path string `json:"path,omitempty"` // Path being checked (working directory)
Path string `json:"path,omitempty"` // Path being checked (working directory)
Total uint64 `json:"total_bytes"`
Free uint64 `json:"free_bytes"`
Used uint64 `json:"used_bytes"`
UsedPercent float64 `json:"used_percent"`
}
// CPUInfo holds CPU load information
type CPUInfo struct {
NumCPU int `json:"num_cpu"` // Number of logical CPUs
LoadAvg1m float64 `json:"load_avg_1m"` // 1-minute load average
LoadAvg5m float64 `json:"load_avg_5m"` // 5-minute load average
LoadAvg15m float64 `json:"load_avg_15m"` // 15-minute load average
LoadPercent float64 `json:"load_percent"` // (load_avg_1m / num_cpu) * 100
}
// DistroInfo holds Linux distribution information
type DistroInfo struct {
ID string `json:"id,omitempty"` // e.g., "ubuntu", "debian", "fedora"
@@ -37,7 +46,7 @@ type DistroInfo struct {
type XcodeInfo struct {
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
SDKs []string `json:"sdks,omitempty"` // e.g., ["iOS 17.0", "macOS 14.0"]
SDKs []string `json:"sdks,omitempty"` // e.g., ["iOS 17.0", "macOS 14.0"]
Simulators []string `json:"simulators,omitempty"` // Available iOS simulators
}
@@ -52,13 +61,15 @@ type RunnerCapabilities struct {
ContainerRuntime string `json:"container_runtime,omitempty"`
Shell []string `json:"shell,omitempty"`
Tools map[string][]string `json:"tools,omitempty"`
BuildTools []string `json:"build_tools,omitempty"` // Available build/installer tools
BuildTools []string `json:"build_tools,omitempty"` // Available build/installer tools
PackageManagers []string `json:"package_managers,omitempty"`
Features *CapabilityFeatures `json:"features,omitempty"`
Limitations []string `json:"limitations,omitempty"`
Disk *DiskInfo `json:"disk,omitempty"`
CPU *CPUInfo `json:"cpu,omitempty"`
Bandwidth *BandwidthInfo `json:"bandwidth,omitempty"`
SuggestedLabels []string `json:"suggested_labels,omitempty"`
Capacity int `json:"capacity,omitempty"` // Number of concurrent jobs this runner can handle
}
// CapabilityFeatures represents feature support flags
@@ -71,8 +82,9 @@ type CapabilityFeatures struct {
// DetectCapabilities detects the runner's capabilities
// workingDir is the directory where builds will run (for disk space detection)
func DetectCapabilities(ctx context.Context, dockerHost string, workingDir string) *RunnerCapabilities {
cap := &RunnerCapabilities{
func DetectCapabilities(ctx context.Context, dockerHost string, workingDir string, capacity int) *RunnerCapabilities {
caps := &RunnerCapabilities{
Capacity: capacity,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Tools: make(map[string][]string),
@@ -93,37 +105,40 @@ func DetectCapabilities(ctx context.Context, dockerHost string, workingDir strin
// Detect Linux distribution
if runtime.GOOS == "linux" {
cap.Distro = detectLinuxDistro()
caps.Distro = detectLinuxDistro()
}
// Detect macOS Xcode/iOS
if runtime.GOOS == "darwin" {
cap.Xcode = detectXcode(ctx)
caps.Xcode = detectXcode(ctx)
}
// Detect Docker
cap.Docker, cap.ContainerRuntime = detectDocker(ctx, dockerHost)
if cap.Docker {
cap.DockerCompose = detectDockerCompose(ctx)
cap.Features.Services = true
caps.Docker, caps.ContainerRuntime = detectDocker(ctx, dockerHost)
if caps.Docker {
caps.DockerCompose = detectDockerCompose(ctx)
caps.Features.Services = true
}
// Detect common tools
detectTools(ctx, cap)
detectTools(ctx, caps)
// Detect build tools
detectBuildTools(ctx, cap)
detectBuildTools(ctx, caps)
// Detect package managers
detectPackageManagers(ctx, cap)
detectPackageManagers(ctx, caps)
// Detect disk space on the working directory's filesystem
cap.Disk = detectDiskSpace(workingDir)
caps.Disk = detectDiskSpace(workingDir)
// Detect CPU load
caps.CPU = detectCPULoad()
// Generate suggested labels based on detected capabilities
cap.SuggestedLabels = generateSuggestedLabels(cap)
caps.SuggestedLabels = generateSuggestedLabels(caps)
return cap
return caps
}
// detectXcode detects Xcode and iOS development capabilities on macOS
@@ -212,18 +227,19 @@ func detectLinuxDistro() *DistroInfo {
if err != nil {
return nil
}
defer file.Close()
defer func() { _ = file.Close() }()
distro := &DistroInfo{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "ID=") {
switch {
case strings.HasPrefix(line, "ID="):
distro.ID = strings.Trim(strings.TrimPrefix(line, "ID="), "\"")
} else if strings.HasPrefix(line, "VERSION_ID=") {
case strings.HasPrefix(line, "VERSION_ID="):
distro.VersionID = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"")
} else if strings.HasPrefix(line, "PRETTY_NAME=") {
case strings.HasPrefix(line, "PRETTY_NAME="):
distro.PrettyName = strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), "\"")
}
}
@@ -236,7 +252,7 @@ func detectLinuxDistro() *DistroInfo {
}
// generateSuggestedLabels creates industry-standard labels based on capabilities
func generateSuggestedLabels(cap *RunnerCapabilities) []string {
func generateSuggestedLabels(caps *RunnerCapabilities) []string {
labels := []string{}
seen := make(map[string]bool)
@@ -248,7 +264,7 @@ func generateSuggestedLabels(cap *RunnerCapabilities) []string {
}
// OS labels
switch cap.OS {
switch caps.OS {
case "linux":
addLabel("linux")
addLabel("linux-latest")
@@ -261,17 +277,17 @@ func generateSuggestedLabels(cap *RunnerCapabilities) []string {
}
// Distro labels (Linux only)
if cap.Distro != nil && cap.Distro.ID != "" {
distro := strings.ToLower(cap.Distro.ID)
if caps.Distro != nil && caps.Distro.ID != "" {
distro := strings.ToLower(caps.Distro.ID)
addLabel(distro)
addLabel(distro + "-latest")
}
// Xcode/iOS labels (macOS only)
if cap.Xcode != nil {
if caps.Xcode != nil {
addLabel("xcode")
// Check for SDKs
for _, sdk := range cap.Xcode.SDKs {
for _, sdk := range caps.Xcode.SDKs {
sdkLower := strings.ToLower(sdk)
if strings.Contains(sdkLower, "ios") {
addLabel("ios")
@@ -287,24 +303,24 @@ func generateSuggestedLabels(cap *RunnerCapabilities) []string {
}
}
// If simulators available, add simulator label
if len(cap.Xcode.Simulators) > 0 {
if len(caps.Xcode.Simulators) > 0 {
addLabel("ios-simulator")
}
}
// Tool-based labels
if _, ok := cap.Tools["dotnet"]; ok {
if _, ok := caps.Tools["dotnet"]; ok {
addLabel("dotnet")
}
if _, ok := cap.Tools["java"]; ok {
if _, ok := caps.Tools["java"]; ok {
addLabel("java")
}
if _, ok := cap.Tools["node"]; ok {
if _, ok := caps.Tools["node"]; ok {
addLabel("node")
}
// Build tool labels
for _, tool := range cap.BuildTools {
for _, tool := range caps.BuildTools {
switch tool {
case "msbuild":
addLabel("msbuild")
@@ -369,7 +385,7 @@ func detectDocker(ctx context.Context, dockerHost string) (bool, string) {
if err != nil {
return false, ""
}
defer cli.Close()
defer func() { _ = cli.Close() }()
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
@@ -405,7 +421,7 @@ func detectDockerCompose(ctx context.Context) bool {
return false
}
func detectTools(ctx context.Context, cap *RunnerCapabilities) {
func detectTools(ctx context.Context, caps *RunnerCapabilities) {
toolDetectors := map[string]func(context.Context) []string{
"node": detectNodeVersions,
"go": detectGoVersions,
@@ -424,7 +440,7 @@ func detectTools(ctx context.Context, cap *RunnerCapabilities) {
for tool, detector := range toolDetectors {
if versions := detector(ctx); len(versions) > 0 {
cap.Tools[tool] = versions
caps.Tools[tool] = versions
}
}
@@ -445,23 +461,23 @@ func detectTools(ctx context.Context, cap *RunnerCapabilities) {
for name, cmd := range simpleTools {
if v := detectSimpleToolVersion(ctx, cmd); v != "" {
cap.Tools[name] = []string{v}
caps.Tools[name] = []string{v}
}
}
}
func detectBuildTools(ctx context.Context, cap *RunnerCapabilities) {
func detectBuildTools(ctx context.Context, caps *RunnerCapabilities) {
switch runtime.GOOS {
case "windows":
detectWindowsBuildTools(ctx, cap)
detectWindowsBuildTools(ctx, caps)
case "darwin":
detectMacOSBuildTools(ctx, cap)
detectMacOSBuildTools(caps)
case "linux":
detectLinuxBuildTools(ctx, cap)
detectLinuxBuildTools(caps)
}
}
func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
func detectWindowsBuildTools(ctx context.Context, caps *RunnerCapabilities) {
// Check for Visual Studio via vswhere
vswherePaths := []string{
`C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe`,
@@ -471,7 +487,7 @@ func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
if _, err := os.Stat(vswhere); err == nil {
cmd := exec.CommandContext(ctx, vswhere, "-latest", "-property", "displayName")
if output, err := cmd.Output(); err == nil && len(output) > 0 {
cap.BuildTools = append(cap.BuildTools, "visual-studio")
caps.BuildTools = append(caps.BuildTools, "visual-studio")
break
}
}
@@ -488,7 +504,7 @@ func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
}
for _, msbuild := range msbuildPaths {
if _, err := os.Stat(msbuild); err == nil {
cap.BuildTools = append(cap.BuildTools, "msbuild")
caps.BuildTools = append(caps.BuildTools, "msbuild")
break
}
}
@@ -502,14 +518,14 @@ func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
}
for _, iscc := range innoSetupPaths {
if _, err := os.Stat(iscc); err == nil {
cap.BuildTools = append(cap.BuildTools, "inno-setup")
caps.BuildTools = append(caps.BuildTools, "inno-setup")
break
}
}
// Also check PATH
if _, err := exec.LookPath("iscc"); err == nil {
if !contains(cap.BuildTools, "inno-setup") {
cap.BuildTools = append(cap.BuildTools, "inno-setup")
if !contains(caps.BuildTools, "inno-setup") {
caps.BuildTools = append(caps.BuildTools, "inno-setup")
}
}
@@ -520,13 +536,13 @@ func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
}
for _, nsis := range nsisPaths {
if _, err := os.Stat(nsis); err == nil {
cap.BuildTools = append(cap.BuildTools, "nsis")
caps.BuildTools = append(caps.BuildTools, "nsis")
break
}
}
if _, err := exec.LookPath("makensis"); err == nil {
if !contains(cap.BuildTools, "nsis") {
cap.BuildTools = append(cap.BuildTools, "nsis")
if !contains(caps.BuildTools, "nsis") {
caps.BuildTools = append(caps.BuildTools, "nsis")
}
}
@@ -537,7 +553,7 @@ func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
}
for _, wix := range wixPaths {
if _, err := os.Stat(wix); err == nil {
cap.BuildTools = append(cap.BuildTools, "wix")
caps.BuildTools = append(caps.BuildTools, "wix")
break
}
}
@@ -545,63 +561,63 @@ func detectWindowsBuildTools(ctx context.Context, cap *RunnerCapabilities) {
// Check for signtool (Windows SDK)
signtoolPaths, _ := filepath.Glob(`C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe`)
if len(signtoolPaths) > 0 {
cap.BuildTools = append(cap.BuildTools, "signtool")
caps.BuildTools = append(caps.BuildTools, "signtool")
}
}
func detectMacOSBuildTools(ctx context.Context, cap *RunnerCapabilities) {
func detectMacOSBuildTools(caps *RunnerCapabilities) {
// Check for xcpretty
if _, err := exec.LookPath("xcpretty"); err == nil {
cap.BuildTools = append(cap.BuildTools, "xcpretty")
caps.BuildTools = append(caps.BuildTools, "xcpretty")
}
// Check for fastlane
if _, err := exec.LookPath("fastlane"); err == nil {
cap.BuildTools = append(cap.BuildTools, "fastlane")
caps.BuildTools = append(caps.BuildTools, "fastlane")
}
// Check for CocoaPods
if _, err := exec.LookPath("pod"); err == nil {
cap.BuildTools = append(cap.BuildTools, "cocoapods")
caps.BuildTools = append(caps.BuildTools, "cocoapods")
}
// Check for Carthage
if _, err := exec.LookPath("carthage"); err == nil {
cap.BuildTools = append(cap.BuildTools, "carthage")
caps.BuildTools = append(caps.BuildTools, "carthage")
}
// Check for SwiftLint
if _, err := exec.LookPath("swiftlint"); err == nil {
cap.BuildTools = append(cap.BuildTools, "swiftlint")
caps.BuildTools = append(caps.BuildTools, "swiftlint")
}
// Check for create-dmg or similar
if _, err := exec.LookPath("create-dmg"); err == nil {
cap.BuildTools = append(cap.BuildTools, "create-dmg")
caps.BuildTools = append(caps.BuildTools, "create-dmg")
}
// Check for Packages (packagesbuild)
if _, err := exec.LookPath("packagesbuild"); err == nil {
cap.BuildTools = append(cap.BuildTools, "packages")
caps.BuildTools = append(caps.BuildTools, "packages")
}
// Check for pkgbuild (built-in)
if _, err := exec.LookPath("pkgbuild"); err == nil {
cap.BuildTools = append(cap.BuildTools, "pkgbuild")
caps.BuildTools = append(caps.BuildTools, "pkgbuild")
}
// Check for codesign (built-in)
if _, err := exec.LookPath("codesign"); err == nil {
cap.BuildTools = append(cap.BuildTools, "codesign")
caps.BuildTools = append(caps.BuildTools, "codesign")
}
// Check for notarytool (built-in with Xcode)
if _, err := exec.LookPath("notarytool"); err == nil {
cap.BuildTools = append(cap.BuildTools, "notarytool")
caps.BuildTools = append(caps.BuildTools, "notarytool")
}
}
func detectLinuxBuildTools(ctx context.Context, cap *RunnerCapabilities) {
func detectLinuxBuildTools(caps *RunnerCapabilities) {
// Check for common Linux build tools
tools := []string{
"gcc", "g++", "clang", "clang++",
@@ -613,54 +629,54 @@ func detectLinuxBuildTools(ctx context.Context, cap *RunnerCapabilities) {
for _, tool := range tools {
if _, err := exec.LookPath(tool); err == nil {
cap.BuildTools = append(cap.BuildTools, tool)
caps.BuildTools = append(caps.BuildTools, tool)
}
}
}
func detectPackageManagers(ctx context.Context, cap *RunnerCapabilities) {
func detectPackageManagers(_ context.Context, caps *RunnerCapabilities) {
switch runtime.GOOS {
case "windows":
if _, err := exec.LookPath("choco"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "chocolatey")
caps.PackageManagers = append(caps.PackageManagers, "chocolatey")
}
if _, err := exec.LookPath("scoop"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "scoop")
caps.PackageManagers = append(caps.PackageManagers, "scoop")
}
if _, err := exec.LookPath("winget"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "winget")
caps.PackageManagers = append(caps.PackageManagers, "winget")
}
case "darwin":
if _, err := exec.LookPath("brew"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "homebrew")
caps.PackageManagers = append(caps.PackageManagers, "homebrew")
}
if _, err := exec.LookPath("port"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "macports")
caps.PackageManagers = append(caps.PackageManagers, "macports")
}
case "linux":
if _, err := exec.LookPath("apt"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "apt")
caps.PackageManagers = append(caps.PackageManagers, "apt")
}
if _, err := exec.LookPath("yum"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "yum")
caps.PackageManagers = append(caps.PackageManagers, "yum")
}
if _, err := exec.LookPath("dnf"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "dnf")
caps.PackageManagers = append(caps.PackageManagers, "dnf")
}
if _, err := exec.LookPath("pacman"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "pacman")
caps.PackageManagers = append(caps.PackageManagers, "pacman")
}
if _, err := exec.LookPath("zypper"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "zypper")
caps.PackageManagers = append(caps.PackageManagers, "zypper")
}
if _, err := exec.LookPath("apk"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "apk")
caps.PackageManagers = append(caps.PackageManagers, "apk")
}
if _, err := exec.LookPath("snap"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "snap")
caps.PackageManagers = append(caps.PackageManagers, "snap")
}
if _, err := exec.LookPath("flatpak"); err == nil {
cap.PackageManagers = append(cap.PackageManagers, "flatpak")
caps.PackageManagers = append(caps.PackageManagers, "flatpak")
}
}
}
@@ -798,13 +814,8 @@ func detectPwshVersion(ctx context.Context, cmd string) string {
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Use -Command to get version
var c *exec.Cmd
if cmd == "pwsh" {
c = exec.CommandContext(timeoutCtx, cmd, "-Command", "$PSVersionTable.PSVersion.ToString()")
} else {
c = exec.CommandContext(timeoutCtx, cmd, "-Command", "$PSVersionTable.PSVersion.ToString()")
}
// Use -Command to get version (same command works for both pwsh and powershell)
c := exec.CommandContext(timeoutCtx, cmd, "-Command", "$PSVersionTable.PSVersion.ToString()")
output, err := c.Output()
if err != nil {
@@ -887,3 +898,157 @@ func contains(slice []string, item string) bool {
}
return false
}
// detectCPULoad detects the current CPU load
func detectCPULoad() *CPUInfo {
numCPU := runtime.NumCPU()
info := &CPUInfo{
NumCPU: numCPU,
}
switch runtime.GOOS {
case "linux":
// Check if running in a container (LXC/Docker)
// Containers share /proc/loadavg with host, giving inaccurate readings
inContainer := isInContainer()
if inContainer {
// Try to get CPU usage from cgroups (more accurate for containers)
if cgroupCPU := getContainerCPUUsage(); cgroupCPU >= 0 {
info.LoadPercent = cgroupCPU
info.LoadAvg1m = cgroupCPU * float64(numCPU) / 100.0
return info
}
// If cgroup reading failed, report 0 - better than host's load
info.LoadPercent = 0
info.LoadAvg1m = 0
return info
}
// Not in container - use traditional /proc/loadavg
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return info
}
parts := strings.Fields(string(data))
if len(parts) >= 3 {
if load, err := parseFloat(parts[0]); err == nil {
info.LoadAvg1m = load
}
if load, err := parseFloat(parts[1]); err == nil {
info.LoadAvg5m = load
}
if load, err := parseFloat(parts[2]); err == nil {
info.LoadAvg15m = load
}
}
case "darwin":
// Use sysctl on macOS
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sysctl", "-n", "vm.loadavg")
output, err := cmd.Output()
if err == nil {
// Output format: "{ 1.23 4.56 7.89 }"
line := strings.Trim(string(output), "{ }\n")
parts := strings.Fields(line)
if len(parts) >= 3 {
if load, err := parseFloat(parts[0]); err == nil {
info.LoadAvg1m = load
}
if load, err := parseFloat(parts[1]); err == nil {
info.LoadAvg5m = load
}
if load, err := parseFloat(parts[2]); err == nil {
info.LoadAvg15m = load
}
}
}
case "windows":
// Windows doesn't have load average, use PowerShell to get CPU usage
// wmic is deprecated, use Get-CimInstance instead
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
"(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average")
output, err := cmd.Output()
if err == nil {
line := strings.TrimSpace(string(output))
if load, err := parseFloat(line); err == nil {
info.LoadPercent = load
info.LoadAvg1m = load * float64(numCPU) / 100.0
return info
}
}
}
// Calculate load percent (load_avg_1m / num_cpu * 100)
if info.LoadAvg1m > 0 && numCPU > 0 {
info.LoadPercent = (info.LoadAvg1m / float64(numCPU)) * 100.0
}
return info
}
// isInContainer checks if we're running inside a container (LXC/Docker)
func isInContainer() bool {
// Check for Docker
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check PID 1's environment for container type (works for LXC on Proxmox)
if data, err := os.ReadFile("/proc/1/environ"); err == nil {
// environ uses null bytes as separators
content := string(data)
if strings.Contains(content, "container=lxc") || strings.Contains(content, "container=docker") {
return true
}
}
// Check for LXC/Docker in cgroup path (cgroup v1)
if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
content := string(data)
if strings.Contains(content, "/lxc/") || strings.Contains(content, "/docker/") {
return true
}
}
// Check for container environment variable in current process
if os.Getenv("container") != "" {
return true
}
// Check for systemd-nspawn or other containers
if _, err := os.Stat("/run/.containerenv"); err == nil {
return true
}
return false
}
// getContainerCPUUsage tries to get CPU usage from cgroups
// Returns -1 if unable to determine
func getContainerCPUUsage() float64 {
// Try cgroup v2 first
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil {
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "usage_usec ") {
// This gives total CPU time, not current usage
// For now, we can't easily calculate percentage without storing previous value
// Return -1 to fall back to reporting 0
break
}
}
}
// Note: Reading /proc/self/stat could give us utime and stime (fields 14 and 15),
// but these are cumulative values, not instantaneous. For containers, we report 0
// rather than misleading host data.
return -1 // Unable to determine - caller should handle
}
// parseFloat parses a string to float64
func parseFloat(s string) (float64, error) {
s = strings.TrimSpace(s)
var f float64
err := json.Unmarshal([]byte(s), &f)
return f, err
}

View File

@@ -1,8 +1,8 @@
//go:build unix
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build unix
package envcheck
import (

View File

@@ -1,8 +1,8 @@
//go:build windows
// Copyright 2026 The Gitea Authors. All rights reserved.
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build windows
package envcheck
import (

View File

@@ -10,6 +10,7 @@ import (
"github.com/docker/docker/client"
)
// CheckIfDockerRunning verifies that the Docker daemon is running and accessible.
func CheckIfDockerRunning(ctx context.Context, configDockerHost string) error {
opts := []client.Opt{
client.FromEnv,
@@ -23,7 +24,7 @@ func CheckIfDockerRunning(ctx context.Context, configDockerHost string) error {
if err != nil {
return err
}
defer cli.Close()
defer func() { _ = cli.Close() }()
_, err = cli.Ping(ctx)
if err != nil {

View File

@@ -1,6 +1,7 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package labels provides utilities for parsing and managing runner labels.
package labels
import (
@@ -8,17 +9,20 @@ import (
"strings"
)
// Label scheme constants define the execution environments.
const (
SchemeHost = "host"
SchemeDocker = "docker"
)
// Label represents a parsed runner label with name, schema, and optional argument.
type Label struct {
Name string
Schema string
Arg string
}
// Parse parses a label string in the format "name:schema:arg" and returns a Label.
func Parse(str string) (*Label, error) {
splits := strings.SplitN(str, ":", 3)
label := &Label{
@@ -38,8 +42,10 @@ func Parse(str string) (*Label, error) {
return label, nil
}
// Labels is a slice of Label pointers.
type Labels []*Label
// RequireDocker returns true if any label uses the docker schema.
func (l Labels) RequireDocker() bool {
for _, label := range l {
if label.Schema == SchemeDocker {
@@ -49,39 +55,56 @@ func (l Labels) RequireDocker() bool {
return false
}
// PickPlatform selects the appropriate platform based on the runsOn requirements.
// Returns empty string if no matching label is found, which will cause the job to fail.
// If runs-on includes a schema (e.g., "linux:host" or "linux:docker"), it must match
// the runner's configured schema for that label.
func (l Labels) PickPlatform(runsOn []string) string {
// Build maps for both platform values and schemas
platforms := make(map[string]string, len(l))
schemas := make(map[string]string, len(l))
for _, label := range l {
switch label.Schema {
case SchemeDocker:
// "//" will be ignored
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
schemas[label.Name] = SchemeDocker
case SchemeHost:
platforms[label.Name] = "-self-hosted"
schemas[label.Name] = SchemeHost
default:
// It should not happen, because Parse has checked it.
continue
}
}
for _, v := range runsOn {
if v, ok := platforms[v]; ok {
return v
name := v
requestedSchema := ""
// Parse schema if present (e.g., "germany-linux:host" -> name="germany-linux", schema="host")
if idx := strings.Index(v, ":"); idx != -1 {
name = v[:idx]
requestedSchema = v[idx+1:]
// Handle docker:// prefix
if strings.HasPrefix(requestedSchema, "docker") {
requestedSchema = SchemeDocker
}
}
if platform, ok := platforms[name]; ok {
// If schema was specified, validate it matches
if requestedSchema != "" && requestedSchema != schemas[name] {
// Schema mismatch - workflow asked for different mode than runner provides
continue
}
return platform
}
}
// TODO: support multiple labels
// like:
// ["ubuntu-22.04"] => "ubuntu:22.04"
// ["with-gpu"] => "linux:with-gpu"
// ["ubuntu-22.04", "with-gpu"] => "ubuntu:22.04_with-gpu"
// return default.
// So the runner receives a task with a label that the runner doesn't have,
// it happens when the user have edited the label of the runner in the web UI.
// TODO: it may be not correct, what if the runner is used as host mode only?
return "docker.gitea.com/runner-images:ubuntu-latest"
// No matching label found
return ""
}
// Names returns the names of all labels.
func (l Labels) Names() []string {
names := make([]string, 0, len(l))
for _, label := range l {
@@ -90,6 +113,7 @@ func (l Labels) Names() []string {
return names
}
// ToStrings converts labels back to their string representation.
func (l Labels) ToStrings() []string {
ls := make([]string, 0, len(l))
for _, label := range l {

View File

@@ -10,6 +10,94 @@ import (
"gotest.tools/v3/assert"
)
func TestPickPlatform(t *testing.T) {
tests := []struct {
name string
labels []string
runsOn []string
want string
}{
{
name: "exact match host label",
labels: []string{"linux-latest:host", "ubuntu:host"},
runsOn: []string{"linux-latest"},
want: "-self-hosted",
},
{
name: "exact match docker label",
labels: []string{"ubuntu:docker://node:18"},
runsOn: []string{"ubuntu"},
want: "node:18",
},
{
name: "no match returns empty string to fail job",
labels: []string{"linux:host", "debian:host"},
runsOn: []string{"unknown-label"},
want: "",
},
{
name: "no match on docker runner returns empty string",
labels: []string{"ubuntu:docker://node:18", "linux:host"},
runsOn: []string{"unknown-label"},
want: "",
},
{
name: "empty labels returns empty string",
labels: []string{},
runsOn: []string{"anything"},
want: "",
},
{
name: "multiple runsOn matches first available",
labels: []string{"linux:host", "ubuntu:docker://node:18"},
runsOn: []string{"windows", "ubuntu"},
want: "node:18",
},
{
name: "runsOn with :host suffix matches host label",
labels: []string{"germany-linux:host", "linux:host"},
runsOn: []string{"germany-linux:host"},
want: "-self-hosted",
},
{
name: "runsOn with :docker suffix matches docker label",
labels: []string{"ubuntu:docker://node:18"},
runsOn: []string{"ubuntu:docker"},
want: "node:18",
},
{
name: "runsOn without suffix matches any schema",
labels: []string{"linux:host"},
runsOn: []string{"linux"},
want: "-self-hosted",
},
{
name: "runsOn :docker does not match host label",
labels: []string{"linux:host"},
runsOn: []string{"linux:docker"},
want: "",
},
{
name: "runsOn :host does not match docker label",
labels: []string{"ubuntu:docker://node:18"},
runsOn: []string{"ubuntu:host"},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ls := Labels{}
for _, l := range tt.labels {
label, err := Parse(l)
require.NoError(t, err)
ls = append(ls, label)
}
got := ls.PickPlatform(tt.runsOn)
assert.Equal(t, got, tt.want)
})
}
}
func TestParse(t *testing.T) {
tests := []struct {
args string

View File

@@ -1,6 +1,7 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package report provides task reporting functionality for communicating with the server.
package report
import (
@@ -18,9 +19,10 @@ import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"gitea.com/gitea/act_runner/internal/pkg/client"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client"
)
// Reporter handles logging and state reporting for running tasks.
type Reporter struct {
ctx context.Context
cancel context.CancelFunc
@@ -42,6 +44,7 @@ type Reporter struct {
stopCommandEndToken string
}
// NewReporter creates a new Reporter for the given task.
func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task) *Reporter {
var oldnew []string
if v := task.Context.Fields["token"].GetStringValue(); v != "" {
@@ -72,6 +75,7 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C
return rv
}
// ResetSteps initializes the step states with the given number of steps.
func (r *Reporter) ResetSteps(l int) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
@@ -82,6 +86,7 @@ func (r *Reporter) ResetSteps(l int) {
}
}
// Levels returns all log levels that this hook should fire for.
func (r *Reporter) Levels() []log.Level {
return log.AllLevels
}
@@ -93,6 +98,7 @@ func appendIfNotNil[T any](s []*T, v *T) []*T {
return s
}
// Fire processes a log entry and updates the task state accordingly.
func (r *Reporter) Fire(entry *log.Entry) error {
r.stateMu.Lock()
defer r.stateMu.Unlock()
@@ -175,6 +181,7 @@ func (r *Reporter) Fire(entry *log.Entry) error {
return nil
}
// RunDaemon starts the periodic reporting of logs and state.
func (r *Reporter) RunDaemon() {
if r.closed {
return
@@ -189,6 +196,7 @@ func (r *Reporter) RunDaemon() {
time.AfterFunc(time.Second, r.RunDaemon)
}
// Logf adds a formatted log message to the report.
func (r *Reporter) Logf(format string, a ...interface{}) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
@@ -205,6 +213,7 @@ func (r *Reporter) logf(format string, a ...interface{}) {
}
}
// SetOutputs stores the job outputs to be reported to the server.
func (r *Reporter) SetOutputs(outputs map[string]string) {
r.stateMu.Lock()
defer r.stateMu.Unlock()
@@ -225,6 +234,7 @@ func (r *Reporter) SetOutputs(outputs map[string]string) {
}
}
// Close finalizes the report and sends any remaining logs and state.
func (r *Reporter) Close(lastWords string) error {
r.closed = true
@@ -260,6 +270,7 @@ func (r *Reporter) Close(lastWords string) error {
}, retry.Context(r.ctx))
}
// ReportLog sends accumulated log rows to the server.
func (r *Reporter) ReportLog(noMore bool) error {
r.clientM.Lock()
defer r.clientM.Unlock()
@@ -295,6 +306,7 @@ func (r *Reporter) ReportLog(noMore bool) error {
return nil
}
// ReportState sends the current task state to the server.
func (r *Reporter) ReportState() error {
r.clientM.Lock()
defer r.clientM.Unlock()
@@ -373,7 +385,7 @@ func (r *Reporter) parseResult(result interface{}) (runnerv1.Result, bool) {
var cmdRegex = regexp.MustCompile(`^::([^ :]+)( .*)?::(.*)$`)
func (r *Reporter) handleCommand(originalContent, command, parameters, value string) *string {
func (r *Reporter) handleCommand(originalContent, command, _ /* parameters */, value string) *string {
if r.stopCommandEndToken != "" && command != r.stopCommandEndToken {
return &originalContent
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
"gitea.com/gitea/act_runner/internal/pkg/client/mocks"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/client/mocks"
)
func TestReporter_parseLogRow(t *testing.T) {

View File

@@ -0,0 +1,27 @@
// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build !windows
// Package service provides Windows service integration for the runner.
// On non-Windows platforms, these functions are no-ops.
package service
import (
"context"
)
// IsWindowsService returns false on non-Windows platforms.
func IsWindowsService() bool {
return false
}
// RunAsService is a no-op on non-Windows platforms.
func RunAsService(_ string, _ func(ctx context.Context)) error {
return nil
}
// GetServiceName returns empty on non-Windows platforms.
func GetServiceName() string {
return ""
}

View File

@@ -0,0 +1,103 @@
// Copyright 2024 The Gitea Authors and MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build windows
// Package service provides Windows service integration for the runner.
package service
import (
"context"
"os"
"strings"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows/svc"
)
// runnerService implements svc.Handler for Windows service management.
type runnerService struct {
ctx context.Context
cancel context.CancelFunc
}
// Execute is called by the Windows Service Control Manager.
func (s *runnerService) Execute(_ []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
log.Info("Windows service started")
loop:
for {
select {
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
// Windows wants two responses for interrogate
time.Sleep(100 * time.Millisecond)
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
log.Info("Windows service stop/shutdown requested")
s.cancel()
break loop
default:
log.Warnf("unexpected control request #%d", c)
}
case <-s.ctx.Done():
break loop
}
}
changes <- svc.Status{State: svc.StopPending}
return false, 0
}
// IsWindowsService returns true if the process is running as a Windows service.
func IsWindowsService() bool {
// Check if we're running interactively
isInteractive, err := svc.IsWindowsService()
if err != nil {
log.WithError(err).Debug("failed to detect if running as Windows service")
return false
}
return isInteractive
}
// RunAsService runs the application as a Windows service.
func RunAsService(serviceName string, run func(ctx context.Context)) error {
ctx, cancel := context.WithCancel(context.Background())
// Start the actual runner in a goroutine
go run(ctx)
// Run the service handler - this blocks until service stops
err := svc.Run(serviceName, &runnerService{ctx: ctx, cancel: cancel})
if err != nil {
log.WithError(err).Error("Windows service run failed")
return err
}
return nil
}
// GetServiceName returns the service name from environment or default.
func GetServiceName() string {
if name := os.Getenv("GITEA_RUNNER_SERVICE_NAME"); name != "" {
return name
}
// Try to detect from executable name
exe, err := os.Executable()
if err == nil {
base := strings.TrimSuffix(exe, ".exe")
if idx := strings.LastIndex(base, string(os.PathSeparator)); idx >= 0 {
return base[idx+1:]
}
return base
}
return "GiteaRunnerSvc"
}

View File

@@ -1,11 +1,13 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package ver provides version information for the runner.
package ver
// go build -ldflags "-X gitea.com/gitea/act_runner/internal/pkg/ver.version=1.2.3"
// go build -ldflags "-X git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/ver.version=1.2.3"
var version = "dev"
// Version returns the current runner version.
func Version() string {
return version
}

14
main.go
View File

@@ -1,6 +1,7 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// GitCaddy Runner is a CI/CD runner for Gitea Actions.
package main
import (
@@ -8,10 +9,21 @@ import (
"os/signal"
"syscall"
"gitea.com/gitea/act_runner/internal/app/cmd"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/app/cmd"
"git.marketally.com/gitcaddy/gitcaddy-runner/internal/pkg/service"
)
func main() {
// Check if running as Windows service
if service.IsWindowsService() {
// Run as Windows service with proper SCM handling
_ = service.RunAsService(service.GetServiceName(), func(ctx context.Context) {
cmd.Execute(ctx)
})
return
}
// Normal interactive mode with signal handling
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// run the command